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/__tests__/services/reaction_helper.ts b/__tests__/services/reaction_helper.ts new file mode 100644 index 00000000..fa3fd079 --- /dev/null +++ b/__tests__/services/reaction_helper.ts @@ -0,0 +1,80 @@ +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 () => { + 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) + }) + + 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, + }, + }, + }) + }) +}) diff --git a/__tests__/services/transformer.ts b/__tests__/services/transformer.ts index 06dee71b..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,6 +1653,143 @@ describe('service transformer', () => { expect(result).toEqual(output) }) + 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', + }, + } + 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: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + }) + }) + + test('toBaileysMessageContent reaction without emoji (cloud input)', async () => { + const cloudInput = { + messaging_product: 'whatsapp', + to: '554988189915', + type: 'reaction', + reaction: { + message_id: 'REACTION_MESSAGE_ID', + }, + } + 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: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + }) + }) + + 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: '', + }, + } + 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: { + remoteJid: '554988189915@s.whatsapp.net', + fromMe: true, + id: 'REACTION_KEY_ID', + }, + }, + }) + }) + + 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', + 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 +2605,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 8c2f6caf..ec4cea42 100644 --- a/src/services/client_baileys.ts +++ b/src/services/client_baileys.ts @@ -37,6 +37,8 @@ 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' +import { resolveReactionPayload } from './reaction_helper' const attempts = 3 @@ -239,6 +241,40 @@ export class ClientBaileys implements Client { await this.connect(time) } + 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}`) @@ -413,7 +449,9 @@ 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 + let safeTo = to || this.phone try { if (status) { if (['sent', 'delivered', 'failed', 'progress', 'read', 'deleted'].includes(status)) { @@ -465,9 +503,27 @@ 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 resolved = await resolveReactionPayload(payload, this.store?.dataStore) + const resolvedPayload = { + ...payload, + reaction: { + ...(payload?.reaction || {}), + emoji: resolved.emoji, + key: resolved.reactionKey, + }, + } + content = toBaileysMessageContent(resolvedPayload, this.config.customMessageCharactersFunction) + targetTo = resolved.targetTo + to = targetTo + safeTo = targetTo + extraSendOptions.forceRemoteJid = resolved.targetTo + 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 +537,9 @@ export class ClientBaileys implements Client { } } content = toBaileysMessageContent(payload, this.config.customMessageCharactersFunction) + if (type === 'sticker') { + await this.maybeConvertStickerToWebp(content, payload) + } } let quoted: WAMessage | undefined = undefined let disappearingMessagesInChat: boolean | number = false @@ -490,7 +549,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 +586,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 +607,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, }) } @@ -611,7 +672,7 @@ export class ClientBaileys implements Client { messaging_product: 'whatsapp', contacts: [ { - wa_id: jidToPhoneNumber(to, ''), + wa_id: jidToPhoneNumber(safeTo, ''), }, ], messages: [ @@ -636,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: [ 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 } +} diff --git a/src/services/transformer.ts b/src/services/transformer.ts index 4b0a22ec..25980602 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) @@ -482,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] @@ -502,6 +504,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}`) } 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() +}