Skip to content
Closed
Show file tree
Hide file tree
Changes from 9 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
80 changes: 80 additions & 0 deletions __tests__/services/reaction_helper.ts
Original file line number Diff line number Diff line change
@@ -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,
},
},
})
})
})
106 changes: 105 additions & 1 deletion __tests__/services/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1652,6 +1652,110 @@ 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 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',
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'
Expand Down Expand Up @@ -2467,4 +2571,4 @@ describe('service transformer', () => {
// }
// }
// }
// }
// }
75 changes: 67 additions & 8 deletions src/services/client_baileys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -413,7 +449,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 +502,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 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
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 +535,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 +547,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 +584,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 +605,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
Loading