diff --git a/package.json b/package.json index 43dbd9b3..0801d64d 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,9 @@ "ts" ], "rootDir": "src", + "moduleNameMapper": { + "^@waha/(.*)$": "/$1" + }, "testRegex": ".test.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" diff --git a/src/apps/app_sdk/jest/JestLogger.ts b/src/apps/app_sdk/jest/JestLogger.ts new file mode 100644 index 00000000..f363ebc1 --- /dev/null +++ b/src/apps/app_sdk/jest/JestLogger.ts @@ -0,0 +1,12 @@ +import { ILogger } from '@waha/apps/app_sdk/ILogger'; + +export function JestLogger(): ILogger { + return { + fatal: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + trace: jest.fn(), + }; +} diff --git a/src/apps/chatwoot/consumers/waha/message.any.ts b/src/apps/chatwoot/consumers/waha/message.any.ts index 48e53b84..acb56d5c 100644 --- a/src/apps/chatwoot/consumers/waha/message.any.ts +++ b/src/apps/chatwoot/consumers/waha/message.any.ts @@ -20,6 +20,7 @@ import { PinoLogger } from 'nestjs-pino'; import { FacebookAdMessage, EventMessage, + PixMessage, LocationMessage, MessageToChatWootConverter, PollMessage, @@ -109,6 +110,12 @@ class MessageAnyHandler extends MessageBaseHandler { return msg; } + converter = new PixMessage(this.l, this.logger); + msg = await converter.convert(payload, protoMessage); + if (msg) { + return msg; + } + converter = new UnsupportedMessage(this.l, this.job); msg = await converter.convert(payload, protoMessage); return msg; diff --git a/src/apps/chatwoot/i18n/locale.ts b/src/apps/chatwoot/i18n/locale.ts index e12fb051..b053faf6 100644 --- a/src/apps/chatwoot/i18n/locale.ts +++ b/src/apps/chatwoot/i18n/locale.ts @@ -6,6 +6,14 @@ import { ensureNumber } from '@waha/core/engines/noweb/utils'; export class Locale { constructor(private readonly strings: Record) {} + /** + * Return Base Locale + * For number, datetime, currency formats + */ + get locale(): string { + return this.strings['locale.base'] || 'en'; + } + key(key: K): Template { return new Template(this.strings[key] || key); } @@ -18,13 +26,37 @@ export class Locale { return new Locale({ ...this.strings, ...strings }); } - FormatDate(date: Date | null): string | null { + FormatCurrency( + currency: string, + value: number | null, + offset: number = 1, + ): string | null { + if (value == null) { + return null; + } + if (!currency) { + return null; + } + + offset = offset || 1; + try { + const fmt = new Intl.NumberFormat(this.locale, { + style: 'currency', + currency: currency, + }); + return fmt.format(value / offset); + } catch (err) { + return `${currency} ${value}`; + } + } + + FormatDatetime(date: Date | null): string | null { if (!date) { return null; } const options: any = this.strings['datetime'] || {}; - options.timezone = options.timeZone ?? process.env.TZ; - return date.toLocaleString(options.locales, options); + options.timeZone = options.timeZone || options.timezone || process.env.TZ; + return date.toLocaleString(this.locale, options); } FormatTimestamp(timestamp: Long | string | number | null): string | null { @@ -40,7 +72,7 @@ export class Locale { if (Number.isNaN(date.getTime())) { return undefined; } - return this.FormatDate(date); + return this.FormatDatetime(date); } } diff --git a/src/apps/chatwoot/i18n/locales/en-US.yaml b/src/apps/chatwoot/i18n/locales/en-US.yaml index 0fba99d3..f75bf00b 100644 --- a/src/apps/chatwoot/i18n/locales/en-US.yaml +++ b/src/apps/chatwoot/i18n/locales/en-US.yaml @@ -1,6 +1,11 @@ # English translations for ChatWoot integration locale.name: '🇺🇸 English (English)' +# Base locale code - must be a valid BCP 47 locale +# https://en.wikipedia.org/wiki/IETF_language_tag +# WAHA uses it for number, currency and datetime representation +locale.base: 'en-US' + app.inbox.contact.name: 'WhatsApp Integration (WAHA)' app.inbox.contact.avatar.url: 'https://raw.githubusercontent.com/devlikeapro/waha/core/logo.png' @@ -177,6 +182,35 @@ whatsapp.to.chatwoot.message.event: |- {{/eventMessage}} {{/message}} +whatsapp.to.chatwoot.message.pix: |- + 💳 **PIX - Copy and Paste** + + {{#pixData}} + {{#merchantName}} + **{{merchantName}}** + {{/merchantName}} + + {{#key}} + **PIX Key:** {{key}} + {{/key}} + {{#keyType}} + **Type:** {{keyType}} + {{/keyType}} + {{#totalAmount}} + **Amount:** {{currency}} {{totalAmount}} + {{/totalAmount}} + {{^totalAmount}} + {{#currency}} + **Currency:** {{currency}} + {{/currency}} + {{/totalAmount}} + {{#referenceId}} + **Reference:** {{referenceId}} + {{/referenceId}} + + 💳 **PIX Copy and Paste sent** + {{/pixData}} + whatsapp.to.chatwoot.message.unsupported: |- ⚠️ **This message type is not supported in ChatWoot.** 📱 Please open **WhatsApp** to view it. diff --git a/src/apps/chatwoot/i18n/locales/pt-BR.yaml b/src/apps/chatwoot/i18n/locales/pt-BR.yaml index a21d41d1..f7f6b613 100644 --- a/src/apps/chatwoot/i18n/locales/pt-BR.yaml +++ b/src/apps/chatwoot/i18n/locales/pt-BR.yaml @@ -1,5 +1,11 @@ # Portuguese translations for ChatWoot integration locale.name: '🇧🇷🇵🇹 Português (Portuguese)' + +# Base locale code - must be a valid BCP 47 locale +# https://en.wikipedia.org/wiki/IETF_language_tag +# WAHA uses it for number, currency and datetime representation +locale.base: 'pt-BR' + app.inbox.contact.name: 'Integração WhatsApp (WAHA)' app.inbox.contact.avatar.url: 'https://raw.githubusercontent.com/devlikeapro/waha/core/logo.png' @@ -301,6 +307,24 @@ whatsapp.to.chatwoot.message.event: |- {{/eventMessage}} {{/message}} +whatsapp.to.chatwoot.message.pix: |- + 💳 **PIX - Copia e Cola** + + {{#pixData}} + **{{merchantName}}** + + **Chave PIX:** {{key}} + **Tipo:** {{keyType}} + {{#totalAmount}} + **Valor:** {{totalAmountFormatted}} + {{/totalAmount}} + {{#referenceId}} + **Referência:** {{referenceId}} + {{/referenceId}} + + 💳 **PIX Copia e Cola enviado** + {{/pixData}} + whatsapp.to.chatwoot.message.unsupported: |- ⚠️ **Esse tipo de mensagem não é suportado no ChatWoot.** 📱 Por favor, abra o **WhatsApp** para visualizá-la. diff --git a/src/apps/chatwoot/i18n/templates.ts b/src/apps/chatwoot/i18n/templates.ts index ab29dae3..e444c5d6 100644 --- a/src/apps/chatwoot/i18n/templates.ts +++ b/src/apps/chatwoot/i18n/templates.ts @@ -53,6 +53,7 @@ export enum TKey { WA_TO_CW_MESSAGE_LOCATION = 'whatsapp.to.chatwoot.message.location', WA_TO_CW_MESSAGE_POLL = 'whatsapp.to.chatwoot.message.poll', WA_TO_CW_MESSAGE_EVENT = 'whatsapp.to.chatwoot.message.event', + WA_TO_CW_MESSAGE_PIX = 'whatsapp.to.chatwoot.message.pix', WA_TO_CW_MESSAGE_UNSUPPORTED = 'whatsapp.to.chatwoot.message.unsupported', WA_TO_CW_MESSAGE_FACEBOOK_AD = 'whatsapp.to.chatwoot.message.facebook.ad', @@ -120,6 +121,15 @@ export interface PollCreationMessage { options?: Array; } +export interface PixTemplatePayload { + merchantName?: string; + key?: string; + keyType?: string; + currency?: string; + totalAmount?: number; + referenceId?: string; +} + export type TemplatePayloads = { [TKey.LOCALE_NAME]: void; [TKey.APP_INBOX_CONTACT_NAME]: void; @@ -163,6 +173,11 @@ export type TemplatePayloads = { eventMessage: Record; }; }; + [TKey.WA_TO_CW_MESSAGE_PIX]: { + payload: WAMessage; + message: proto.Message | null; + pixData: PixTemplatePayload; + }; [TKey.WA_TO_CW_MESSAGE_UNSUPPORTED]: { details: Link }; [TKey.WA_TO_CW_MESSAGE_FACEBOOK_AD]: { payload: WAMessage; diff --git a/src/apps/chatwoot/messages/to/chatwoot/PixMessage.test.ts b/src/apps/chatwoot/messages/to/chatwoot/PixMessage.test.ts new file mode 100644 index 00000000..be25ad71 --- /dev/null +++ b/src/apps/chatwoot/messages/to/chatwoot/PixMessage.test.ts @@ -0,0 +1,250 @@ +import { PixMessage } from '@waha/apps/chatwoot/messages/to/chatwoot/PixMessage'; +import { resolveProtoMessage } from '@waha/apps/chatwoot/messages/to/chatwoot'; +import { WAMessage } from '@waha/structures/responses.dto'; +import { JestLogger } from '@waha/apps/app_sdk/jest/JestLogger'; +import { i18n } from '@waha/apps/chatwoot/i18n'; + +describe('PixMessage - pt-BR', () => { + const locale = i18n.locale('pt-BR'); + + it('GOWS - no total amount', () => { + // Setup + const buttonParamsJson = JSON.stringify({ + currency: 'BRL', + total_amount: { + value: 0, + offset: 100, + }, + reference_id: '4TB11111111', + type: 'physical-goods', + order: { + status: 'pending', + subtotal: { + value: 0, + offset: 100, + }, + order_type: 'ORDER', + items: [ + { + name: '', + amount: { + value: 0, + offset: 100, + }, + quantity: 0, + sale_amount: { + value: 0, + offset: 100, + }, + }, + ], + }, + payment_settings: [ + { + type: 'pix_static_code', + pix_static_code: { + merchant_name: 'Merchant Name Here', + key: '+5000000000000', + key_type: 'PHONE', + }, + }, + ], + share_payment_status: false, + referral: 'chat_attachment', + }); + + const payload = { + id: 'false_559900000001@c.us_FAKEPIXORDER1', + timestamp: 1899003201, + from: '559900000001@c.us', + fromMe: false, + source: 'app', + to: null, + participant: null, + hasMedia: false, + ack: 2, + ackName: 'DEVICE', + body: '', + _data: { + Message: { + messageContextInfo: { + deviceListMetadata: { + senderKeyHash: 'aFakeSenderKeyHash==', + senderTimestamp: 1880001234, + recipientKeyHash: 'anotherFakeRecipientKeyHash==', + recipientTimestamp: 1880005678, + }, + deviceListMetadataVersion: 2, + messageSecret: 'fakeMessageSecret==', + }, + interactiveMessage: { + InteractiveMessage: { + NativeFlowMessage: { + buttons: [ + { + name: 'payment_info', + buttonParamsJSON: buttonParamsJson, + }, + ], + }, + }, + }, + contextInfo: { + expiration: 0, + ephemeralSettingTimestamp: 1880006789, + disappearingMode: { + initiator: 0, + trigger: 0, + }, + }, + }, + }, + } as unknown as WAMessage; + + // Test + const logger = JestLogger(); + const protoMessage = resolveProtoMessage(payload); + const converter = new PixMessage(locale, logger); + const result = converter.convert(payload, protoMessage); + + // Assert + const expected = `💳 ****PIX - Copia e Cola**** + +****Merchant Name Here**** + +****Chave PIX:**** +5000000000000 +****Tipo:**** PHONE +****Referência:**** 4TB11111111 + +💳 ****PIX Copia e Cola enviado**** +`; + + expect(protoMessage).not.toBeNull(); + expect(result).not.toBeNull(); + expect(result.content).toEqual(expected); + expect(logger.fatal).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('GOWS - with total amount', () => { + // Setup + const buttonParamsJson = JSON.stringify({ + currency: 'BRL', + total_amount: { + value: 2512, + offset: 100, + }, + reference_id: '4TB11111111', + type: 'physical-goods', + order: { + status: 'pending', + subtotal: { + value: 2512, + offset: 100, + }, + order_type: 'ORDER', + items: [ + { + name: '', + amount: { + value: 2512, + offset: 100, + }, + quantity: 1, + sale_amount: { + value: 2512, + offset: 100, + }, + }, + ], + }, + payment_settings: [ + { + type: 'pix_static_code', + pix_static_code: { + merchant_name: 'Merchant Name Here', + key: '+5000000000000', + key_type: 'PHONE', + }, + }, + ], + share_payment_status: false, + referral: 'chat_attachment', + }); + + const payload = { + id: 'false_559900000001@c.us_FAKEPIXORDER1', + timestamp: 1899003201, + from: '559900000001@c.us', + fromMe: false, + source: 'app', + to: null, + participant: null, + hasMedia: false, + ack: 2, + ackName: 'DEVICE', + body: '', + _data: { + Message: { + messageContextInfo: { + deviceListMetadata: { + senderKeyHash: 'aFakeSenderKeyHash==', + senderTimestamp: 1880001234, + recipientKeyHash: 'anotherFakeRecipientKeyHash==', + recipientTimestamp: 1880005678, + }, + deviceListMetadataVersion: 2, + messageSecret: 'fakeMessageSecret==', + }, + interactiveMessage: { + InteractiveMessage: { + NativeFlowMessage: { + buttons: [ + { + name: 'payment_info', + buttonParamsJSON: buttonParamsJson, + }, + ], + }, + }, + }, + contextInfo: { + expiration: 0, + ephemeralSettingTimestamp: 1880006789, + disappearingMode: { + initiator: 0, + trigger: 0, + }, + }, + }, + }, + } as unknown as WAMessage; + + // Test + const logger = JestLogger(); + const protoMessage = resolveProtoMessage(payload); + const converter = new PixMessage(locale, logger); + const result = converter.convert(payload, protoMessage); + + // Assert + const expected = `💳 ****PIX - Copia e Cola**** + +****Merchant Name Here**** + +****Chave PIX:**** +5000000000000 +****Tipo:**** PHONE +****Valor:**** R$\u00A025,12 +****Referência:**** 4TB11111111 + +💳 ****PIX Copia e Cola enviado**** +`; + + expect(protoMessage).not.toBeNull(); + expect(result).not.toBeNull(); + expect(result.content).toEqual(expected); + expect(logger.fatal).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/apps/chatwoot/messages/to/chatwoot/PixMessage.ts b/src/apps/chatwoot/messages/to/chatwoot/PixMessage.ts new file mode 100644 index 00000000..6ffdcaf2 --- /dev/null +++ b/src/apps/chatwoot/messages/to/chatwoot/PixMessage.ts @@ -0,0 +1,140 @@ +import { ILogger } from '@waha/apps/app_sdk/ILogger'; +import { ChatWootMessagePartial } from '@waha/apps/chatwoot/consumers/waha/base'; +import { Locale } from '@waha/apps/chatwoot/i18n/locale'; +import { TKey } from '@waha/apps/chatwoot/i18n/templates'; +import { MessageToChatWootConverter } from '@waha/apps/chatwoot/messages/to/chatwoot'; +import { + camelCaseKeysDeep, + isEmptyString, +} from '@waha/apps/chatwoot/messages/to/chatwoot/utils/proto'; +import { WhatsappToMarkdown } from '@waha/apps/chatwoot/messages/to/chatwoot/utils/markdown'; +import * as lodash from 'lodash'; +import type { proto } from '@adiwajshing/baileys'; +import { WAMessage } from '@waha/structures/responses.dto'; + +interface PixTemplateData { + merchantName?: string; + key?: string; + keyType?: string; + currency?: string; + totalAmount?: number; + totalAmountFormatted?: string; + referenceId?: string; +} + +interface NativeFlowButton { + name?: string; + buttonParamsJson?: string; +} + +export class PixMessage implements MessageToChatWootConverter { + constructor( + private readonly l: Locale, + private readonly logger: ILogger, + ) {} + + convert( + payload: WAMessage, + protoMessage: proto.Message | null, + ): ChatWootMessagePartial | null { + if (!protoMessage) { + return null; + } + const pixData = this.extractPixData(protoMessage); + if (!pixData) { + return null; + } + + const content = this.l.key(TKey.WA_TO_CW_MESSAGE_PIX).r({ + payload: payload, + message: protoMessage, + pixData: pixData, + }); + + if (isEmptyString(content)) { + return null; + } + + return { + content: WhatsappToMarkdown(content), + attachments: [], + private: undefined, + }; + } + + private extractPixData(protoMessage: proto.Message): PixTemplateData | null { + const buttons = this.resolveNativeFlowButtons(protoMessage); + if (!buttons || buttons.length === 0) { + return null; + } + + const paymentButton = buttons.find( + (button) => button?.name === 'payment_info' && button?.buttonParamsJson, + ); + + if (!paymentButton?.buttonParamsJson) { + return null; + } + + let params: any; + try { + params = JSON.parse(paymentButton.buttonParamsJson); + } catch (error) { + this.logger.warn(`Failed to parse PIX button params: ${error}`); + return null; + } + + const pixSettings = params?.payment_settings?.find( + (setting: any) => setting?.type === 'pix_static_code', + ); + const pixCode = pixSettings?.pix_static_code; + if (!pixCode) { + return null; + } + + const totalAmountFormatted = this.l.FormatCurrency( + params?.currency, + params?.total_amount?.value, + params?.total_amount?.offset, + ); + const data: PixTemplateData = { + merchantName: pixCode.merchant_name, + key: pixCode.key, + keyType: pixCode.key_type, + currency: params?.currency, + totalAmount: params?.total_amount?.value, + totalAmountFormatted: totalAmountFormatted, + referenceId: params?.reference_id, + }; + + if (lodash.isEmpty(data)) { + return null; + } + return data; + } + + private resolveNativeFlowButtons( + protoMessage: proto.Message, + ): + | proto.Message.InteractiveMessage.NativeFlowMessage.INativeFlowButton[] + | null { + let interactiveMessage: proto.Message.IInteractiveMessage = + protoMessage?.interactiveMessage; + if (lodash.isEmpty(interactiveMessage)) { + return null; + } + // GOWS has weird oneOf protobuf behaviour, + // so we need to double interactiveMessage + if ( + !lodash.isEmpty( + (interactiveMessage as proto.Message.InteractiveMessage) + .interactiveMessage, + ) + ) { + interactiveMessage = ( + interactiveMessage as proto.Message.InteractiveMessage + ).interactiveMessage as proto.Message.IInteractiveMessage; + } + return interactiveMessage?.nativeFlowMessage?.buttons; + } +} diff --git a/src/apps/chatwoot/messages/to/chatwoot/index.ts b/src/apps/chatwoot/messages/to/chatwoot/index.ts index b0753862..2b779a3c 100644 --- a/src/apps/chatwoot/messages/to/chatwoot/index.ts +++ b/src/apps/chatwoot/messages/to/chatwoot/index.ts @@ -11,6 +11,7 @@ export { MessageEdited } from './MessageEdited'; export { FacebookAdMessage } from './FacebookAdMessage'; export { PollMessage } from './PollMessage'; export { EventMessage } from './EventMessage'; +export { PixMessage } from './PixMessage'; export type Awaitable = T | Promise;