Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 44 additions & 1 deletion __tests__/services/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -2467,4 +2510,4 @@ describe('service transformer', () => {
// }
// }
// }
// }
// }
123 changes: 115 additions & 8 deletions src/services/client_baileys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -239,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 || '<none>',
reactionKey?.remoteJid || '<none>',
(reactionKey as any)?.participant || '<none>',
)
} 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}`)
Expand Down Expand Up @@ -413,7 +497,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)) {
Expand Down Expand Up @@ -465,9 +550,26 @@ 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 this.resolveReactionPayload(payload)
const resolvedPayload = {
...payload,
reaction: {
...(payload?.reaction || {}),
emoji: resolved.emoji,
key: resolved.reactionKey,
},
}
content = toBaileysMessageContent(resolvedPayload, this.config.customMessageCharactersFunction)
targetTo = resolved.targetTo
to = targetTo
extraSendOptions.forceRemoteJid = resolved.targetTo
extraSendOptions.skipBrSendOrder = true
} else if ('template' === type) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const template = new Template(this.getConfig)
content = await template.bind(this.phone, payload.template.name, payload.template.components)
} else {
Expand All @@ -481,6 +583,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
Expand All @@ -490,7 +595,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)
Expand Down Expand Up @@ -527,12 +632,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<string, Delay>()) && 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: {
Expand All @@ -548,14 +653,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,
})
}
Expand Down
15 changes: 15 additions & 0 deletions src/services/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}`)
}
Expand Down
13 changes: 13 additions & 0 deletions src/utils/sticker_convert.ts
Original file line number Diff line number Diff line change
@@ -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()
}