From f51be2a6ae8f4396d54869c95836d64db573177f Mon Sep 17 00:00:00 2001 From: caitano28 Date: Sat, 24 Jan 2026 12:02:25 -0400 Subject: [PATCH 01/13] Add reaction and sticker send support --- README.md | 35 +++++++++++++ src/services/client_baileys.ts | 92 +++++++++++++++++++++++++++++++--- src/utils/sticker_convert.ts | 13 +++++ 3 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 src/utils/sticker_convert.ts diff --git a/README.md b/README.md index 198b174a..ff2c786d 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,41 @@ http://localhost:9876/v15.0/5549988290955/messages \ }' ``` +To react to a message + +```sh +curl -i -X POST \ +http://localhost:9876/v15.0/5549988290955/messages \ +-H 'Content-Type: application/json' \ +-H 'Authorization: 1' \ +-d '{ + "messaging_product": "whatsapp", + "to": "5549988290955", + "type": "reaction", + "reaction": { + "message_id": "MESSAGE_ID", + "emoji": "👍" + } +}' +``` + +To send a sticker (PNG/JPG/GIF are auto-converted to WEBP) + +```sh +curl -i -X POST \ +http://localhost:9876/v15.0/5549988290955/messages \ +-H 'Content-Type: application/json' \ +-H 'Authorization: 1' \ +-d '{ + "messaging_product": "whatsapp", + "to": "5549988290955", + "type": "sticker", + "sticker": { + "link": "https://example.com/sticker.png" + } +}' +``` + ## Media To test media diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 8c2f6caf..43877c6a 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -37,6 +37,7 @@ import { t } from '../i18n' import { ClientForward } from './client_forward' import { SendError } from './send_error' import audioConverter from '../utils/audio_converter' +import { convertToWebpSticker } from '../utils/sticker_convert' const attempts = 3 @@ -465,9 +466,60 @@ export class ClientBaileys implements Client { throw new Error(`Unknow message status ${status}`) } } else if (type) { - if (['text', 'image', 'audio', 'sticker', 'document', 'video', 'template', 'interactive', 'contacts'].includes(type)) { + if (['text', 'image', 'audio', 'sticker', 'document', 'video', 'template', 'interactive', 'contacts', 'reaction'].includes(type)) { let content - if ('template' === type) { + let targetTo = to + const extraSendOptions: any = {} + if ('reaction' === type) { + const reaction = payload?.reaction || {} + const messageId = + reaction?.message_id || + reaction?.messageId || + payload?.message_id || + payload?.context?.message_id || + payload?.context?.id + if (!messageId) { + throw new SendError(400, 'invalid_reaction_payload: missing message_id') + } + const dataStore = this.store?.dataStore + let key = await dataStore?.loadKey(messageId) + if (!key) { + const unoId = await dataStore?.loadUnoId(messageId) + if (unoId) { + key = await dataStore?.loadKey(unoId) + } + } + if (!key || !key.id || !key.remoteJid) { + throw new SendError(404, `reaction_message_not_found: ${messageId}`) + } + const emojiRaw = typeof reaction?.emoji !== 'undefined' + ? reaction.emoji + : (typeof reaction?.text !== 'undefined' ? reaction.text : reaction?.value) + const emoji = `${emojiRaw ?? ''}` + let reactionKey = key + try { + const original = await dataStore?.loadMessage?.(reactionKey.remoteJid, reactionKey.id) + if (original?.key) { + reactionKey = { ...original.key, id: reactionKey.id } + if (typeof reactionKey.participant === 'string' && reactionKey.participant.trim() === '') { + delete (reactionKey as any).participant + } + } + } catch {} + try { + logger.info( + 'REACTION send: msgId=%s key.id=%s key.remoteJid=%s key.participant=%s', + messageId, + reactionKey?.id || '', + reactionKey?.remoteJid || '', + (reactionKey as any)?.participant || '', + ) + } catch {} + content = { react: { text: emoji, key: reactionKey } } + targetTo = reactionKey.remoteJid + extraSendOptions.forceRemoteJid = reactionKey.remoteJid + extraSendOptions.skipBrSendOrder = true + } else if ('template' === type) { const template = new Template(this.getConfig) content = await template.bind(this.phone, payload.template.name, payload.template.components) } else { @@ -481,6 +533,30 @@ export class ClientBaileys implements Client { } } content = toBaileysMessageContent(payload, this.config.customMessageCharactersFunction) + if (type === 'sticker') { + try { + const stickerPayload: any = payload?.sticker || {} + const stickerLink = stickerPayload?.link || (content as any)?.sticker?.url + const cleanLink = `${stickerLink || ''}`.split('?')[0].split('#')[0] + const stickerMimeRaw = `${stickerPayload?.mime_type || stickerPayload?.mimetype || (content as any)?.mimetype || ''}`.toLowerCase() + const isWebp = stickerMimeRaw.includes('webp') || cleanLink.toLowerCase().endsWith('.webp') + if (stickerLink && !isWebp && typeof (content as any)?.sticker === 'object' && (content as any)?.sticker?.url) { + const resp = await fetch(stickerLink, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), method: 'GET' }) + if (!resp?.ok) { + throw new Error(`sticker_download_failed: ${resp?.status || 0}`) + } + const contentType = `${resp.headers.get('content-type') || ''}`.toLowerCase() + const isAnimated = contentType.includes('gif') || cleanLink.toLowerCase().endsWith('.gif') + const buf = Buffer.from(await resp.arrayBuffer()) + const webp = await convertToWebpSticker(buf, { animated: isAnimated }) + ;(content as any).sticker = webp + ;(content as any).mimetype = 'image/webp' + logger.debug('Sticker converted to webp for %s', stickerLink) + } + } catch (err) { + logger.warn(err, 'Ignore error converting sticker to webp sending original') + } + } } let quoted: WAMessage | undefined = undefined let disappearingMessagesInChat: boolean | number = false @@ -490,7 +566,7 @@ export class ClientBaileys implements Client { const key = await this.store?.dataStore?.loadKey(messageId) logger.debug('Quoted message key %s!', key?.id) if (key?.id) { - const remoteJid = phoneNumberToJid(to) + const remoteJid = phoneNumberToJid(targetTo) quoted = await this.store?.dataStore.loadMessage(remoteJid, key?.id) if (!quoted) { const unoId = await this.store?.dataStore?.loadUnoId(key?.id) @@ -527,12 +603,12 @@ export class ClientBaileys implements Client { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const sockDelays = delays.get(this.phone) || (delays.set(this.phone, new Map()) && delays.get(this.phone)!) // eslint-disable-next-line @typescript-eslint/no-unused-vars - const toDelay = sockDelays.get(to) || (async (_phone: string, to) => sockDelays.set(to, this.delayBeforeSecondMessage)) - await toDelay(this.phone, to) + const toDelay = sockDelays.get(targetTo) || (async (_phone: string, to) => sockDelays.set(to, this.delayBeforeSecondMessage)) + await toDelay(this.phone, targetTo) let response if (content?.listMessage) { response = await this.sendMessage( - to, + targetTo, { forward: { key: { @@ -548,14 +624,16 @@ export class ClientBaileys implements Client { composing: this.config.composingMessage, quoted, disappearingMessagesInChat, + ...extraSendOptions, ...options, }, ) } else { - response = await this.sendMessage(to, content, { + response = await this.sendMessage(targetTo, content, { composing: this.config.composingMessage, quoted, disappearingMessagesInChat, + ...extraSendOptions, ...options, }) } diff --git a/src/utils/sticker_convert.ts b/src/utils/sticker_convert.ts new file mode 100644 index 00000000..7dd2a4a8 --- /dev/null +++ b/src/utils/sticker_convert.ts @@ -0,0 +1,13 @@ +import sharp from 'sharp' + +type StickerConvertOptions = { + animated?: boolean +} + +export const convertToWebpSticker = async (input: Buffer, opts: StickerConvertOptions = {}) => { + const image = sharp(input, { animated: !!opts.animated }) + return image + .resize(512, 512, { fit: 'inside', withoutEnlargement: true }) + .webp({ lossless: !opts.animated, quality: 80, effort: 4 }) + .toBuffer() +} From 044cde185b2b0ec87c631d14f4f3fbee319aa525 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Sat, 24 Jan 2026 12:10:14 -0400 Subject: [PATCH 02/13] Guard sticker conversion size --- src/services/client_baileys.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 43877c6a..55bb9164 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -545,9 +545,18 @@ export class ClientBaileys implements Client { if (!resp?.ok) { throw new Error(`sticker_download_failed: ${resp?.status || 0}`) } + const MAX_STICKER_BYTES = 2 * 1024 * 1024 + const contentLength = Number(resp.headers.get('content-length') || 0) + if (contentLength && contentLength > MAX_STICKER_BYTES) { + throw new Error(`sticker_too_large: ${contentLength}`) + } const contentType = `${resp.headers.get('content-type') || ''}`.toLowerCase() const isAnimated = contentType.includes('gif') || cleanLink.toLowerCase().endsWith('.gif') - const buf = Buffer.from(await resp.arrayBuffer()) + const arrayBuffer = await resp.arrayBuffer() + if (arrayBuffer.byteLength > MAX_STICKER_BYTES) { + throw new Error(`sticker_too_large: ${arrayBuffer.byteLength}`) + } + const buf = Buffer.from(arrayBuffer) const webp = await convertToWebpSticker(buf, { animated: isAnimated }) ;(content as any).sticker = webp ;(content as any).mimetype = 'image/webp' From 2de470d90a980280f7dd5185f75658b486b007b7 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Sat, 24 Jan 2026 12:12:52 -0400 Subject: [PATCH 03/13] Increase sticker size limit --- src/services/client_baileys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 55bb9164..2bca93d1 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -545,7 +545,7 @@ export class ClientBaileys implements Client { if (!resp?.ok) { throw new Error(`sticker_download_failed: ${resp?.status || 0}`) } - const MAX_STICKER_BYTES = 2 * 1024 * 1024 + const MAX_STICKER_BYTES = 8 * 1024 * 1024 const contentLength = Number(resp.headers.get('content-length') || 0) if (contentLength && contentLength > MAX_STICKER_BYTES) { throw new Error(`sticker_too_large: ${contentLength}`) From d60aab74404483eeaba4f76228567ad454c10fde Mon Sep 17 00:00:00 2001 From: caitano28 Date: Sat, 24 Jan 2026 12:19:35 -0400 Subject: [PATCH 04/13] Fix reaction recipient resolution --- src/services/client_baileys.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 2bca93d1..29ba7851 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -414,7 +414,8 @@ export class ClientBaileys implements Client { // eslint-disable-next-line @typescript-eslint/no-explicit-any async send(payload: any, options: any = {}) { - const { status, type, to } = payload + const { status, type } = payload + let { to } = payload try { if (status) { if (['sent', 'delivered', 'failed', 'progress', 'read', 'deleted'].includes(status)) { @@ -517,6 +518,7 @@ export class ClientBaileys implements Client { } catch {} content = { react: { text: emoji, key: reactionKey } } targetTo = reactionKey.remoteJid + to = targetTo extraSendOptions.forceRemoteJid = reactionKey.remoteJid extraSendOptions.skipBrSendOrder = true } else if ('template' === type) { From 150ae64396ca147e3455826e60ce0906da9f42a7 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 13:33:02 -0400 Subject: [PATCH 05/13] Refactor reaction/sticker payload build --- __tests__/services/transformer.ts | 45 +++++++- src/services/client_baileys.ts | 172 +++++++++++++++++------------- src/services/transformer.ts | 15 +++ 3 files changed, 154 insertions(+), 78 deletions(-) diff --git a/__tests__/services/transformer.ts b/__tests__/services/transformer.ts index 06dee71b..254968fc 100644 --- a/__tests__/services/transformer.ts +++ b/__tests__/services/transformer.ts @@ -1652,6 +1652,49 @@ describe('service transformer', () => { expect(result).toEqual(output) }) + test('toBaileysMessageContent reaction', async () => { + const input = { + type: 'reaction', + reaction: { + emoji: 'ok', + key: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + } + const output = { + react: { + text: 'ok', + key: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + } + const result = toBaileysMessageContent(input) + expect(result).toEqual(output) + }) + + test('toBaileysMessageContent sticker', async () => { + const input = { + type: 'sticker', + sticker: { + link: 'https://example.com/sticker.png', + }, + } + const output = { + mimetype: 'image/png', + sticker: { + url: 'https://example.com/sticker.png', + }, + } + const result = toBaileysMessageContent(input) + expect(result).toEqual(output) + }) + test('fromBaileysMessageContent participant outside key', async () => { const phoneNumer = '5549998093075' const remotePhoneNumber = '11115551212' @@ -2467,4 +2510,4 @@ describe('service transformer', () => { // } // } // } -// } \ No newline at end of file +// } diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 29ba7851..371ef3a2 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -240,6 +240,89 @@ export class ClientBaileys implements Client { await this.connect(time) } + // Resolve reaction target and key before building Baileys payload + private async resolveReactionPayload(payload: any) { + const reaction = payload?.reaction || {} + const messageId = + reaction?.message_id || + reaction?.messageId || + payload?.message_id || + payload?.context?.message_id || + payload?.context?.id + if (!messageId) { + throw new SendError(400, 'invalid_reaction_payload: missing message_id') + } + const dataStore = this.store?.dataStore + let key = await dataStore?.loadKey(messageId) + if (!key) { + const unoId = await dataStore?.loadUnoId(messageId) + if (unoId) { + key = await dataStore?.loadKey(unoId) + } + } + if (!key || !key.id || !key.remoteJid) { + throw new SendError(404, `reaction_message_not_found: ${messageId}`) + } + const emojiRaw = typeof reaction?.emoji !== 'undefined' + ? reaction.emoji + : (typeof reaction?.text !== 'undefined' ? reaction.text : reaction?.value) + const emoji = `${emojiRaw ?? ''}` + let reactionKey = key + try { + const original = await dataStore?.loadMessage?.(reactionKey.remoteJid, reactionKey.id) + if (original?.key) { + reactionKey = { ...original.key, id: reactionKey.id } + if (typeof reactionKey.participant === 'string' && reactionKey.participant.trim() === '') { + delete (reactionKey as any).participant + } + } + } catch {} + try { + logger.info( + 'REACTION send: msgId=%s key.id=%s key.remoteJid=%s key.participant=%s', + messageId, + reactionKey?.id || '', + reactionKey?.remoteJid || '', + (reactionKey as any)?.participant || '', + ) + } catch {} + return { reactionKey, emoji, targetTo: reactionKey.remoteJid } + } + + private async maybeConvertStickerToWebp(content: any, payload: any) { + try { + const stickerPayload: any = payload?.sticker || {} + const stickerLink = stickerPayload?.link || (content as any)?.sticker?.url + const cleanLink = `${stickerLink || ''}`.split('?')[0].split('#')[0] + const stickerMimeRaw = `${stickerPayload?.mime_type || stickerPayload?.mimetype || (content as any)?.mimetype || ''}`.toLowerCase() + const isWebp = stickerMimeRaw.includes('webp') || cleanLink.toLowerCase().endsWith('.webp') + if (stickerLink && !isWebp && typeof (content as any)?.sticker === 'object' && (content as any)?.sticker?.url) { + const resp = await fetch(stickerLink, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), method: 'GET' }) + if (!resp?.ok) { + throw new Error(`sticker_download_failed: ${resp?.status || 0}`) + } + const MAX_STICKER_BYTES = 8 * 1024 * 1024 + const contentLength = Number(resp.headers.get('content-length') || 0) + if (contentLength && contentLength > MAX_STICKER_BYTES) { + throw new Error(`sticker_too_large: ${contentLength}`) + } + const contentType = `${resp.headers.get('content-type') || ''}`.toLowerCase() + const isAnimated = contentType.includes('gif') || cleanLink.toLowerCase().endsWith('.gif') + const arrayBuffer = await resp.arrayBuffer() + if (arrayBuffer.byteLength > MAX_STICKER_BYTES) { + throw new Error(`sticker_too_large: ${arrayBuffer.byteLength}`) + } + const buf = Buffer.from(arrayBuffer) + const webp = await convertToWebpSticker(buf, { animated: isAnimated }) + ;(content as any).sticker = webp + ;(content as any).mimetype = 'image/webp' + logger.debug('Sticker converted to webp for %s', stickerLink) + } + } catch (err) { + logger.warn(err, 'Ignore error converting sticker to webp sending original') + } + } + private delayBeforeSecondMessage: Delay = async (phone, to) => { const time = 2000 logger.debug(`Sleep for ${time} before second message ${phone} => ${to}`) @@ -472,54 +555,19 @@ export class ClientBaileys implements Client { let targetTo = to const extraSendOptions: any = {} if ('reaction' === type) { - const reaction = payload?.reaction || {} - const messageId = - reaction?.message_id || - reaction?.messageId || - payload?.message_id || - payload?.context?.message_id || - payload?.context?.id - if (!messageId) { - throw new SendError(400, 'invalid_reaction_payload: missing message_id') - } - const dataStore = this.store?.dataStore - let key = await dataStore?.loadKey(messageId) - if (!key) { - const unoId = await dataStore?.loadUnoId(messageId) - if (unoId) { - key = await dataStore?.loadKey(unoId) - } - } - if (!key || !key.id || !key.remoteJid) { - throw new SendError(404, `reaction_message_not_found: ${messageId}`) + const resolved = await this.resolveReactionPayload(payload) + const resolvedPayload = { + ...payload, + reaction: { + ...(payload?.reaction || {}), + emoji: resolved.emoji, + key: resolved.reactionKey, + }, } - const emojiRaw = typeof reaction?.emoji !== 'undefined' - ? reaction.emoji - : (typeof reaction?.text !== 'undefined' ? reaction.text : reaction?.value) - const emoji = `${emojiRaw ?? ''}` - let reactionKey = key - try { - const original = await dataStore?.loadMessage?.(reactionKey.remoteJid, reactionKey.id) - if (original?.key) { - reactionKey = { ...original.key, id: reactionKey.id } - if (typeof reactionKey.participant === 'string' && reactionKey.participant.trim() === '') { - delete (reactionKey as any).participant - } - } - } catch {} - try { - logger.info( - 'REACTION send: msgId=%s key.id=%s key.remoteJid=%s key.participant=%s', - messageId, - reactionKey?.id || '', - reactionKey?.remoteJid || '', - (reactionKey as any)?.participant || '', - ) - } catch {} - content = { react: { text: emoji, key: reactionKey } } - targetTo = reactionKey.remoteJid + content = toBaileysMessageContent(resolvedPayload, this.config.customMessageCharactersFunction) + targetTo = resolved.targetTo to = targetTo - extraSendOptions.forceRemoteJid = reactionKey.remoteJid + extraSendOptions.forceRemoteJid = resolved.targetTo extraSendOptions.skipBrSendOrder = true } else if ('template' === type) { const template = new Template(this.getConfig) @@ -536,37 +584,7 @@ export class ClientBaileys implements Client { } content = toBaileysMessageContent(payload, this.config.customMessageCharactersFunction) if (type === 'sticker') { - try { - const stickerPayload: any = payload?.sticker || {} - const stickerLink = stickerPayload?.link || (content as any)?.sticker?.url - const cleanLink = `${stickerLink || ''}`.split('?')[0].split('#')[0] - const stickerMimeRaw = `${stickerPayload?.mime_type || stickerPayload?.mimetype || (content as any)?.mimetype || ''}`.toLowerCase() - const isWebp = stickerMimeRaw.includes('webp') || cleanLink.toLowerCase().endsWith('.webp') - if (stickerLink && !isWebp && typeof (content as any)?.sticker === 'object' && (content as any)?.sticker?.url) { - const resp = await fetch(stickerLink, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), method: 'GET' }) - if (!resp?.ok) { - throw new Error(`sticker_download_failed: ${resp?.status || 0}`) - } - const MAX_STICKER_BYTES = 8 * 1024 * 1024 - const contentLength = Number(resp.headers.get('content-length') || 0) - if (contentLength && contentLength > MAX_STICKER_BYTES) { - throw new Error(`sticker_too_large: ${contentLength}`) - } - const contentType = `${resp.headers.get('content-type') || ''}`.toLowerCase() - const isAnimated = contentType.includes('gif') || cleanLink.toLowerCase().endsWith('.gif') - const arrayBuffer = await resp.arrayBuffer() - if (arrayBuffer.byteLength > MAX_STICKER_BYTES) { - throw new Error(`sticker_too_large: ${arrayBuffer.byteLength}`) - } - const buf = Buffer.from(arrayBuffer) - const webp = await convertToWebpSticker(buf, { animated: isAnimated }) - ;(content as any).sticker = webp - ;(content as any).mimetype = 'image/webp' - logger.debug('Sticker converted to webp for %s', stickerLink) - } - } catch (err) { - logger.warn(err, 'Ignore error converting sticker to webp sending original') - } + await this.maybeConvertStickerToWebp(content, payload) } } let quoted: WAMessage | undefined = undefined diff --git a/src/services/transformer.ts b/src/services/transformer.ts index 4b0a22ec..57bbe8ef 100644 --- a/src/services/transformer.ts +++ b/src/services/transformer.ts @@ -464,6 +464,7 @@ export const toBaileysMessageContent = (payload: any, customMessageCharactersFun case 'audio': case 'document': case 'video': + case 'sticker': const link = payload[type].link if (link) { let mimetype: string = getMimetype(payload) @@ -502,6 +503,20 @@ export const toBaileysMessageContent = (payload: any, customMessageCharactersFun case 'template': throw new BindTemplateError() + case 'reaction': { + const reaction = payload?.reaction || {} + const key = reaction?.key + if (!key) { + throw new Error('invalid_reaction_payload: missing key') + } + const emojiRaw = typeof reaction?.emoji !== 'undefined' + ? reaction.emoji + : (typeof reaction?.text !== 'undefined' ? reaction.text : reaction?.value) + const emoji = `${emojiRaw ?? ''}` + response.react = { text: emoji, key } + break + } + default: throw new Error(`Unknow message type ${type}`) } From 192f890995a87b7afa1252b7e2375a6a719c0ed7 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 13:34:44 -0400 Subject: [PATCH 06/13] Add reaction missing-key test --- __tests__/services/transformer.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/__tests__/services/transformer.ts b/__tests__/services/transformer.ts index 254968fc..a2a74367 100644 --- a/__tests__/services/transformer.ts +++ b/__tests__/services/transformer.ts @@ -1678,6 +1678,16 @@ describe('service transformer', () => { expect(result).toEqual(output) }) + test('toBaileysMessageContent reaction without key', async () => { + const input = { + type: 'reaction', + reaction: { + emoji: 'ok', + }, + } + expect(() => toBaileysMessageContent(input)).toThrow('invalid_reaction_payload: missing key') + }) + test('toBaileysMessageContent sticker', async () => { const input = { type: 'sticker', From 53556e5f7b794ba592bf8a808aadc2c76d42981e Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 13:43:40 -0400 Subject: [PATCH 07/13] Add reaction empty-emoji tests --- __tests__/services/transformer.ts | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/__tests__/services/transformer.ts b/__tests__/services/transformer.ts index a2a74367..83ce5b42 100644 --- a/__tests__/services/transformer.ts +++ b/__tests__/services/transformer.ts @@ -1678,6 +1678,57 @@ describe('service transformer', () => { expect(result).toEqual(output) }) + test('toBaileysMessageContent reaction without emoji', async () => { + const input = { + type: 'reaction', + reaction: { + key: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + } + const output = { + react: { + text: '', + key: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + } + const result = toBaileysMessageContent(input) + expect(result).toEqual(output) + }) + + test('toBaileysMessageContent reaction with empty emoji', async () => { + const input = { + type: 'reaction', + reaction: { + emoji: '', + key: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + } + const output = { + react: { + text: '', + key: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + } + const result = toBaileysMessageContent(input) + expect(result).toEqual(output) + }) + test('toBaileysMessageContent reaction without key', async () => { const input = { type: 'reaction', From 3bc3b887681ab3e9bb83a144210c518401433fa0 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 13:48:59 -0400 Subject: [PATCH 08/13] Extract reaction helper --- __tests__/services/reaction_helper.ts | 33 ++++++++++++++++ src/services/client_baileys.ts | 52 +------------------------ src/services/reaction_helper.ts | 56 +++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 50 deletions(-) create mode 100644 __tests__/services/reaction_helper.ts create mode 100644 src/services/reaction_helper.ts diff --git a/__tests__/services/reaction_helper.ts b/__tests__/services/reaction_helper.ts new file mode 100644 index 00000000..df8b121c --- /dev/null +++ b/__tests__/services/reaction_helper.ts @@ -0,0 +1,33 @@ +import { resolveReactionPayload } from '../../src/services/reaction_helper' +import { SendError } from '../../src/services/send_error' + +describe('resolveReactionPayload', () => { + test('resolves reaction key and target', async () => { + const loadKey = jest.fn(async (id: string) => { + if (id === 'UNO_ID') { + return { + id: 'BAILEYS_ID', + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + } + } + return undefined + }) + const loadUnoId = jest.fn(async (id: string) => (id === 'MSG_ID' ? 'UNO_ID' : undefined)) + const loadMessage = jest.fn(async () => ({ key: { id: 'BAILEYS_ID', remoteJid: '554988189915@s.whatsapp.net', fromMe: true } })) + const dataStore = { loadKey, loadUnoId, loadMessage } + const payload = { + type: 'reaction', + reaction: { message_id: 'MSG_ID', emoji: '👍' }, + } + const result = await resolveReactionPayload(payload, dataStore) + expect(result.emoji).toEqual('👍') + expect(result.targetTo).toEqual('554988189915@s.whatsapp.net') + expect(result.reactionKey).toMatchObject({ id: 'BAILEYS_ID', remoteJid: '554988189915@s.whatsapp.net' }) + }) + + test('throws on missing message_id', async () => { + const dataStore = {} + await expect(resolveReactionPayload({ type: 'reaction', reaction: { emoji: 'ok' } }, dataStore)).rejects.toBeInstanceOf(SendError) + }) +}) diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 371ef3a2..992d2669 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -38,6 +38,7 @@ import { ClientForward } from './client_forward' import { SendError } from './send_error' import audioConverter from '../utils/audio_converter' import { convertToWebpSticker } from '../utils/sticker_convert' +import { resolveReactionPayload } from './reaction_helper' const attempts = 3 @@ -240,55 +241,6 @@ export class ClientBaileys implements Client { await this.connect(time) } - // Resolve reaction target and key before building Baileys payload - private async resolveReactionPayload(payload: any) { - const reaction = payload?.reaction || {} - const messageId = - reaction?.message_id || - reaction?.messageId || - payload?.message_id || - payload?.context?.message_id || - payload?.context?.id - if (!messageId) { - throw new SendError(400, 'invalid_reaction_payload: missing message_id') - } - const dataStore = this.store?.dataStore - let key = await dataStore?.loadKey(messageId) - if (!key) { - const unoId = await dataStore?.loadUnoId(messageId) - if (unoId) { - key = await dataStore?.loadKey(unoId) - } - } - if (!key || !key.id || !key.remoteJid) { - throw new SendError(404, `reaction_message_not_found: ${messageId}`) - } - const emojiRaw = typeof reaction?.emoji !== 'undefined' - ? reaction.emoji - : (typeof reaction?.text !== 'undefined' ? reaction.text : reaction?.value) - const emoji = `${emojiRaw ?? ''}` - let reactionKey = key - try { - const original = await dataStore?.loadMessage?.(reactionKey.remoteJid, reactionKey.id) - if (original?.key) { - reactionKey = { ...original.key, id: reactionKey.id } - if (typeof reactionKey.participant === 'string' && reactionKey.participant.trim() === '') { - delete (reactionKey as any).participant - } - } - } catch {} - try { - logger.info( - 'REACTION send: msgId=%s key.id=%s key.remoteJid=%s key.participant=%s', - messageId, - reactionKey?.id || '', - reactionKey?.remoteJid || '', - (reactionKey as any)?.participant || '', - ) - } catch {} - return { reactionKey, emoji, targetTo: reactionKey.remoteJid } - } - private async maybeConvertStickerToWebp(content: any, payload: any) { try { const stickerPayload: any = payload?.sticker || {} @@ -555,7 +507,7 @@ export class ClientBaileys implements Client { let targetTo = to const extraSendOptions: any = {} if ('reaction' === type) { - const resolved = await this.resolveReactionPayload(payload) + const resolved = await resolveReactionPayload(payload, this.store?.dataStore) const resolvedPayload = { ...payload, reaction: { diff --git a/src/services/reaction_helper.ts b/src/services/reaction_helper.ts new file mode 100644 index 00000000..fb5ca0c1 --- /dev/null +++ b/src/services/reaction_helper.ts @@ -0,0 +1,56 @@ +import { SendError } from './send_error' +import logger from './logger' + +export type ReactionResolveResult = { + emoji: string + reactionKey: any + targetTo: string + messageId: string +} + +export const resolveReactionPayload = async (payload: any, dataStore: any): Promise => { + const reaction = payload?.reaction || {} + const messageId = + reaction?.message_id || + reaction?.messageId || + payload?.message_id || + payload?.context?.message_id || + payload?.context?.id + if (!messageId) { + throw new SendError(400, 'invalid_reaction_payload: missing message_id') + } + let key = await dataStore?.loadKey(messageId) + if (!key) { + const unoId = await dataStore?.loadUnoId(messageId) + if (unoId) { + key = await dataStore?.loadKey(unoId) + } + } + if (!key || !key.id || !key.remoteJid) { + throw new SendError(404, `reaction_message_not_found: ${messageId}`) + } + const emojiRaw = typeof reaction?.emoji !== 'undefined' + ? reaction.emoji + : (typeof reaction?.text !== 'undefined' ? reaction.text : reaction?.value) + const emoji = `${emojiRaw ?? ''}` + let reactionKey = key + try { + const original = await dataStore?.loadMessage?.(reactionKey.remoteJid, reactionKey.id) + if (original?.key) { + reactionKey = { ...original.key, id: reactionKey.id } + if (typeof reactionKey.participant === 'string' && reactionKey.participant.trim() === '') { + delete (reactionKey as any).participant + } + } + } catch {} + try { + logger.info( + 'REACTION send: msgId=%s key.id=%s key.remoteJid=%s key.participant=%s', + messageId, + reactionKey?.id || '', + reactionKey?.remoteJid || '', + (reactionKey as any)?.participant || '', + ) + } catch {} + return { emoji, reactionKey, targetTo: reactionKey.remoteJid, messageId } +} From 2ead8dfd3e7b529cf66b69e241c3212c353df633 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 18:03:06 -0400 Subject: [PATCH 09/13] Add cloud reaction flow test --- __tests__/services/reaction_helper.ts | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/__tests__/services/reaction_helper.ts b/__tests__/services/reaction_helper.ts index df8b121c..fa3fd079 100644 --- a/__tests__/services/reaction_helper.ts +++ b/__tests__/services/reaction_helper.ts @@ -1,5 +1,6 @@ import { resolveReactionPayload } from '../../src/services/reaction_helper' import { SendError } from '../../src/services/send_error' +import { toBaileysMessageContent } from '../../src/services/transformer' describe('resolveReactionPayload', () => { test('resolves reaction key and target', async () => { @@ -30,4 +31,50 @@ describe('resolveReactionPayload', () => { const dataStore = {} await expect(resolveReactionPayload({ type: 'reaction', reaction: { emoji: 'ok' } }, dataStore)).rejects.toBeInstanceOf(SendError) }) + + test('cloud api reaction payload to baileys content', async () => { + const dataStore = { + loadKey: jest.fn(async (id: string) => { + if (id === '3EB0778F74E14FF7B1FCA4') { + return { + id: 'BAILEYS_ID', + remoteJid: '556696269251@s.whatsapp.net', + fromMe: true, + } + } + return undefined + }), + loadUnoId: jest.fn(async () => undefined), + loadMessage: jest.fn(async () => ({ key: { id: 'BAILEYS_ID', remoteJid: '556696269251@s.whatsapp.net', fromMe: true } })), + } + const cloudInput = { + messaging_product: 'whatsapp', + to: '556696269251', + type: 'reaction', + reaction: { + message_id: '3EB0778F74E14FF7B1FCA4', + emoji: '👍', + }, + } + const resolved = await resolveReactionPayload(cloudInput, dataStore) + const resolvedPayload = { + ...cloudInput, + reaction: { + ...(cloudInput as any).reaction, + emoji: resolved.emoji, + key: resolved.reactionKey, + }, + } + const result = toBaileysMessageContent(resolvedPayload) + expect(result).toEqual({ + react: { + text: '👍', + key: { + id: 'BAILEYS_ID', + remoteJid: '556696269251@s.whatsapp.net', + fromMe: true, + }, + }, + }) + }) }) From dba72c213cd8ed4d781f98debe6b8cd1dcb4bc46 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 18:04:23 -0400 Subject: [PATCH 10/13] Clarify reaction transformer input --- __tests__/services/transformer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/__tests__/services/transformer.ts b/__tests__/services/transformer.ts index 83ce5b42..c6d2c9ad 100644 --- a/__tests__/services/transformer.ts +++ b/__tests__/services/transformer.ts @@ -1652,6 +1652,7 @@ describe('service transformer', () => { expect(result).toEqual(output) }) + // Reaction here expects a pre-resolved payload (reaction.key already set by helper) test('toBaileysMessageContent reaction', async () => { const input = { type: 'reaction', From 2e9fa13e7ddd894038c95527cf24f5e8582a807d Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 18:12:58 -0400 Subject: [PATCH 11/13] Use cloud inputs in reaction transformer tests --- __tests__/services/transformer.ts | 101 ++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/__tests__/services/transformer.ts b/__tests__/services/transformer.ts index c6d2c9ad..80f737a6 100644 --- a/__tests__/services/transformer.ts +++ b/__tests__/services/transformer.ts @@ -22,6 +22,7 @@ import { extractFromPhone, extractTypeMessage, } from '../../src/services/transformer' +import { resolveReactionPayload } from '../../src/services/reaction_helper' const key = { remoteJid: 'XXXX@s.whatsapp.net', id: 'abc' } const documentMessage: proto.Message.IDocumentMessage = { @@ -1652,20 +1653,42 @@ describe('service transformer', () => { expect(result).toEqual(output) }) - // Reaction here expects a pre-resolved payload (reaction.key already set by helper) - test('toBaileysMessageContent reaction', async () => { - const input = { + const buildReactionDataStore = () => ({ + loadKey: jest.fn(async (id: string) => { + if (id === 'REACTION_MESSAGE_ID') { + return { + id: 'REACTION_KEY_ID', + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + } + } + return undefined + }), + loadUnoId: jest.fn(async () => undefined), + loadMessage: jest.fn(async () => ({ key: { id: 'REACTION_KEY_ID', remoteJid: '554988189915@s.whatsapp.net', fromMe: true } })), + }) + + test('toBaileysMessageContent reaction (cloud input)', async () => { + const cloudInput = { + messaging_product: 'whatsapp', + to: '554988189915', type: 'reaction', reaction: { + message_id: 'REACTION_MESSAGE_ID', emoji: 'ok', - key: { - remoteJid: '554988189915@s.whatsapp.net', - fromMe: true, - id: 'REACTION_KEY_ID', - }, }, } - const output = { + const resolved = await resolveReactionPayload(cloudInput, buildReactionDataStore()) + const resolvedPayload = { + ...cloudInput, + reaction: { + ...(cloudInput as any).reaction, + emoji: resolved.emoji, + key: resolved.reactionKey, + }, + } + const result = toBaileysMessageContent(resolvedPayload) + expect(result).toEqual({ react: { text: 'ok', key: { @@ -1674,23 +1697,29 @@ describe('service transformer', () => { id: 'REACTION_KEY_ID', }, }, - } - const result = toBaileysMessageContent(input) - expect(result).toEqual(output) + }) }) - test('toBaileysMessageContent reaction without emoji', async () => { - const input = { + test('toBaileysMessageContent reaction without emoji (cloud input)', async () => { + const cloudInput = { + messaging_product: 'whatsapp', + to: '554988189915', type: 'reaction', reaction: { - key: { - remoteJid: '554988189915@s.whatsapp.net', - fromMe: true, - id: 'REACTION_KEY_ID', - }, + message_id: 'REACTION_MESSAGE_ID', }, } - const output = { + const resolved = await resolveReactionPayload(cloudInput, buildReactionDataStore()) + const resolvedPayload = { + ...cloudInput, + reaction: { + ...(cloudInput as any).reaction, + emoji: resolved.emoji, + key: resolved.reactionKey, + }, + } + const result = toBaileysMessageContent(resolvedPayload) + expect(result).toEqual({ react: { text: '', key: { @@ -1699,24 +1728,30 @@ describe('service transformer', () => { id: 'REACTION_KEY_ID', }, }, - } - const result = toBaileysMessageContent(input) - expect(result).toEqual(output) + }) }) - test('toBaileysMessageContent reaction with empty emoji', async () => { - const input = { + test('toBaileysMessageContent reaction with empty emoji (cloud input)', async () => { + const cloudInput = { + messaging_product: 'whatsapp', + to: '554988189915', type: 'reaction', reaction: { + message_id: 'REACTION_MESSAGE_ID', emoji: '', - key: { - remoteJid: '554988189915@s.whatsapp.net', - fromMe: true, - id: 'REACTION_KEY_ID', - }, }, } - const output = { + const resolved = await resolveReactionPayload(cloudInput, buildReactionDataStore()) + const resolvedPayload = { + ...cloudInput, + reaction: { + ...(cloudInput as any).reaction, + emoji: resolved.emoji, + key: resolved.reactionKey, + }, + } + const result = toBaileysMessageContent(resolvedPayload) + expect(result).toEqual({ react: { text: '', key: { @@ -1725,9 +1760,7 @@ describe('service transformer', () => { id: 'REACTION_KEY_ID', }, }, - } - const result = toBaileysMessageContent(input) - expect(result).toEqual(output) + }) }) test('toBaileysMessageContent reaction without key', async () => { From f09f33cfa7875d66657b0d55cb8124b1b57a5796 Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 18:18:47 -0400 Subject: [PATCH 12/13] Guard reaction error responses when to is absent --- src/services/client_baileys.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/client_baileys.ts b/src/services/client_baileys.ts index 992d2669..ec4cea42 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -451,6 +451,7 @@ export class ClientBaileys implements Client { async send(payload: any, options: any = {}) { const { status, type } = payload let { to } = payload + let safeTo = to || this.phone try { if (status) { if (['sent', 'delivered', 'failed', 'progress', 'read', 'deleted'].includes(status)) { @@ -519,6 +520,7 @@ export class ClientBaileys implements Client { content = toBaileysMessageContent(resolvedPayload, this.config.customMessageCharactersFunction) targetTo = resolved.targetTo to = targetTo + safeTo = targetTo extraSendOptions.forceRemoteJid = resolved.targetTo extraSendOptions.skipBrSendOrder = true } else if ('template' === type) { @@ -670,7 +672,7 @@ export class ClientBaileys implements Client { messaging_product: 'whatsapp', contacts: [ { - wa_id: jidToPhoneNumber(to, ''), + wa_id: jidToPhoneNumber(safeTo, ''), }, ], messages: [ @@ -695,7 +697,7 @@ export class ClientBaileys implements Client { statuses: [ { id, - recipient_id: jidToPhoneNumber(to || this.phone, ''), + recipient_id: jidToPhoneNumber(safeTo, ''), status: 'failed', timestamp: Math.floor(Date.now() / 1000), errors: [ From 5876d12ab6eb114afaaa4a323d37cef93ef4426a Mon Sep 17 00:00:00 2001 From: caitano28 Date: Mon, 26 Jan 2026 18:19:46 -0400 Subject: [PATCH 13/13] Fail fast on missing media link --- src/services/transformer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/transformer.ts b/src/services/transformer.ts index 57bbe8ef..25980602 100644 --- a/src/services/transformer.ts +++ b/src/services/transformer.ts @@ -483,6 +483,7 @@ export const toBaileysMessageContent = (payload: any, customMessageCharactersFun response[type] = { url: link } break } + throw new Error(`invalid_media_payload: missing link for ${type}`) case 'contacts': const contact = payload[type][0]