From 45baccbb04f365b18bdd51989c52e9137c1fa5d9 Mon Sep 17 00:00:00 2001 From: Timo Hissink <49754279+timohissink@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:33:17 +0100 Subject: [PATCH 1/2] feat(baileys): add sendPollVote and sendEventResponse endpoints Allow voting on existing polls and responding to event invitations by encrypting the vote/response with the stored messageSecret (inverse of Baileys decryptPollVote and decryptEventResponse). Uses the LID domain for creator/voter when the chat uses LID addressing or the message was sent by this account; jidMode can override (auto|lid|pn). Unsupported on the Business API and Evolution channels. --- src/api/controllers/sendMessage.controller.ts | 10 + src/api/dto/sendMessage.dto.ts | 13 + .../evolution/evolution.channel.service.ts | 6 + .../channel/meta/whatsapp.business.service.ts | 6 + .../whatsapp/whatsapp.baileys.service.ts | 235 +++++++++++++++++- src/api/routes/sendMessage.router.ts | 24 ++ src/validate/message.schema.ts | 34 +++ 7 files changed, 327 insertions(+), 1 deletion(-) diff --git a/src/api/controllers/sendMessage.controller.ts b/src/api/controllers/sendMessage.controller.ts index 8414c67db8..3d9f8e4147 100644 --- a/src/api/controllers/sendMessage.controller.ts +++ b/src/api/controllers/sendMessage.controller.ts @@ -4,10 +4,12 @@ import { SendButtonsDto, SendCarouselDto, SendContactDto, + SendEventResponseDto, SendListDto, SendLocationDto, SendMediaDto, SendPollDto, + SendPollVoteDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -95,6 +97,14 @@ export class SendMessageController { return await this.waMonitor.waInstances[instanceName].contactMessage(data); } + public async sendPollVote({ instanceName }: InstanceDto, data: SendPollVoteDto) { + return await this.waMonitor.waInstances[instanceName].pollVoteMessage(data); + } + + public async sendEventResponse({ instanceName }: InstanceDto, data: SendEventResponseDto) { + return await this.waMonitor.waInstances[instanceName].eventResponseMessage(data); + } + public async sendReaction({ instanceName }: InstanceDto, data: SendReactionDto) { if (!isEmoji(data.reaction)) { throw new BadRequestException('Reaction must be a single emoji or empty string'); diff --git a/src/api/dto/sendMessage.dto.ts b/src/api/dto/sendMessage.dto.ts index b3d87e5e08..42583ebc26 100644 --- a/src/api/dto/sendMessage.dto.ts +++ b/src/api/dto/sendMessage.dto.ts @@ -169,6 +169,19 @@ export class SendContactDto extends Metadata { contact: ContactMessage[]; } +export class SendPollVoteDto { + key: proto.IMessageKey; + options: string[]; + jidMode?: 'auto' | 'lid' | 'pn'; +} + +export class SendEventResponseDto { + key: proto.IMessageKey; + response: 'going' | 'maybe' | 'not_going'; + extraGuestCount?: number; + jidMode?: 'auto' | 'lid' | 'pn'; +} + export class SendReactionDto { key: proto.IMessageKey; reaction: string; diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index e32e658810..46778fc67e 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -781,6 +781,12 @@ export class EvolutionStartupService extends ChannelStartupService { public async pollMessage() { throw new BadRequestException('Method not available on Evolution Channel'); } + public async pollVoteMessage() { + throw new BadRequestException('Method not available on Evolution Channel'); + } + public async eventResponseMessage() { + throw new BadRequestException('Method not available on Evolution Channel'); + } public async statusMessage() { throw new BadRequestException('Method not available on Evolution Channel'); } diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 098654c2d0..1b8659d205 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -1760,6 +1760,12 @@ export class BusinessStartupService extends ChannelStartupService { public async pollMessage() { throw new BadRequestException('Method not available on WhatsApp Business API'); } + public async pollVoteMessage() { + throw new BadRequestException('Method not available on WhatsApp Business API'); + } + public async eventResponseMessage() { + throw new BadRequestException('Method not available on WhatsApp Business API'); + } public async statusMessage() { throw new BadRequestException('Method not available on WhatsApp Business API'); } diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 22839fd451..bf53b55bc7 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -44,10 +44,12 @@ import { SendButtonsDto, SendCarouselDto, SendContactDto, + SendEventResponseDto, SendListDto, SendLocationDto, SendMediaDto, SendPollDto, + SendPollVoteDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -139,7 +141,7 @@ import { Label } from 'baileys/lib/Types/Label'; import { LabelAssociation } from 'baileys/lib/Types/LabelAssociation'; import { spawn } from 'child_process'; import { isArray, isBase64, isURL } from 'class-validator'; -import { createHash } from 'crypto'; +import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'; import EventEmitter2 from 'eventemitter2'; import ffmpeg from 'fluent-ffmpeg'; import FormData from 'form-data'; @@ -3961,6 +3963,237 @@ export class BaileysStartupService extends ChannelStartupService { return await this.sendMessageWithTyping(data.number, { ...message }, {}); } + /** + * Loads a poll/event creation message from the database together with its messageSecret and resolves the JIDs + * used for the message-secret encryption (see Baileys decryptPollVote / whatsmeow encryptMsgSecret). + * + * WhatsApp clients derive the key from the creator and sender in the LID domain when the chat uses LID + * addressing; for messages sent by this account (including the self-chat) the own LID is used. + */ + private async getMessageSecretContext( + key: proto.IMessageKey, + messageTypes: string[], + jidMode: 'auto' | 'lid' | 'pn' = 'auto', + ) { + const stored = await this.prismaRepository.message.findFirst({ + where: { instanceId: this.instanceId, key: { path: ['id'], equals: key.id } }, + }); + + if (!stored) { + throw new NotFoundException('Message not found'); + } + + const storedKey = stored.key as proto.IMessageKey & { remoteJidAlt?: string; participantAlt?: string }; + const storedMessage = stored.message as Record; + const requestedJid = jidNormalizedUser(key.remoteJid); + + if ( + requestedJid !== jidNormalizedUser(storedKey.remoteJid) && + requestedJid !== jidNormalizedUser(storedKey.remoteJidAlt || '') + ) { + throw new BadRequestException('remoteJid does not match the message'); + } + + const messageType = messageTypes.find((type) => storedMessage?.[type]); + if (!messageType) { + throw new BadRequestException(`Message is not a ${messageTypes.join(' or ')}`); + } + + let secret: any = storedMessage?.messageContextInfo?.messageSecret; + if (typeof secret === 'string') { + secret = Buffer.from(secret, 'base64'); + } else if (secret?.type === 'Buffer' && Array.isArray(secret.data)) { + secret = Buffer.from(secret.data); + } else if (secret && !Buffer.isBuffer(secret)) { + secret = Buffer.from(Object.values(secret) as number[]); + } + if (!Buffer.isBuffer(secret) || secret.length !== 32) { + throw new BadRequestException('messageSecret not available for this message'); + } + + const ownLid = this.client.user?.lid ? jidNormalizedUser(this.client.user.lid) : undefined; + const ownPn = jidNormalizedUser(this.client.user?.id || this.instance.wuid); + const isGroup = isJidGroup(storedKey.remoteJid); + const author = storedKey.fromMe ? undefined : isGroup ? storedKey.participant : storedKey.remoteJid; + + let useLid = storedKey.fromMe ? !!ownLid : String(author || '').endsWith('@lid'); + if (jidMode === 'lid') useLid = !!ownLid; + if (jidMode === 'pn') useLid = false; + + let creatorJid: string; + let senderJid: string; + if (useLid) { + senderJid = ownLid; + creatorJid = storedKey.fromMe ? ownLid : jidNormalizedUser(author); + } else { + senderJid = ownPn; + const authorPn = String(author || '').endsWith('@lid') + ? storedKey.participantAlt || storedKey.remoteJidAlt || author + : author; + creatorJid = storedKey.fromMe ? ownPn : jidNormalizedUser(authorPn); + } + + // Mirror the primary device: in LID-addressed chats (including the self-chat) both the creation key and the + // relay target use the LID chat JID. + let keyRemoteJid = storedKey.remoteJid; + if (useLid && !isGroup && jidNormalizedUser(storedKey.remoteJid) === ownPn) { + keyRemoteJid = ownLid; + } else if (useLid && !isGroup && storedKey.remoteJidAlt?.endsWith('@lid')) { + keyRemoteJid = storedKey.remoteJidAlt; + } + + const creationKey: proto.IMessageKey = { remoteJid: keyRemoteJid, fromMe: !!storedKey.fromMe, id: storedKey.id }; + if (isGroup && storedKey.participant) { + creationKey.participant = storedKey.participant; + } + + return { storedKey, storedMessage, messageType, secret: secret as Buffer, creatorJid, senderJid, creationKey }; + } + + private encryptMessageSecretPayload( + secret: Buffer, + messageId: string, + creatorJid: string, + senderJid: string, + useCase: 'Poll Vote' | 'Event Response', + plaintext: Uint8Array, + ) { + const useCaseSecret = Buffer.concat([ + Buffer.from(messageId), + Buffer.from(creatorJid), + Buffer.from(senderJid), + Buffer.from(useCase), + Buffer.from([1]), + ]); + const baseKey = createHmac('sha256', Buffer.alloc(32)).update(secret).digest(); + const encKey = createHmac('sha256', baseKey).update(useCaseSecret).digest(); + const encIv = randomBytes(12); + + const cipher = createCipheriv('aes-256-gcm', encKey, encIv); + cipher.setAAD(Buffer.from(`${messageId}\u0000${senderJid}`)); + const encPayload = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]); + + return { encPayload, encIv }; + } + + public async pollVoteMessage(data: SendPollVoteDto) { + const context = await this.getMessageSecretContext( + data.key, + ['pollCreationMessage', 'pollCreationMessageV2', 'pollCreationMessageV3'], + data.jidMode, + ); + + const poll = context.storedMessage[context.messageType]; + const optionNames: string[] = (poll.options || []).map((option: any) => option.optionName); + const unknownOptions = (data.options || []).filter((option) => !optionNames.includes(option)); + if (unknownOptions.length) { + throw new BadRequestException({ + error: 'Unknown poll option(s)', + unknown: unknownOptions, + available: optionNames, + }); + } + + const selectableCount = poll.selectableOptionsCount || 0; + if (selectableCount && data.options.length > selectableCount) { + throw new BadRequestException(`A maximum of ${selectableCount} option(s) can be selected`); + } + + const plaintext = proto.Message.PollVoteMessage.encode({ + selectedOptions: data.options.map((option) => createHash('sha256').update(option).digest()), + }).finish(); + const vote = this.encryptMessageSecretPayload( + context.secret, + context.storedKey.id, + context.creatorJid, + context.senderJid, + 'Poll Vote', + plaintext, + ); + + const message: proto.IMessage = { + pollUpdateMessage: { + pollCreationMessageKey: context.creationKey, + vote, + metadata: {}, + senderTimestampMs: Date.now(), + }, + }; + + try { + const messageId = await this.client.relayMessage(context.creationKey.remoteJid, message, { + additionalAttributes: { type: 'poll' }, + // Same markers as a vote sent by the phone; without content_type the server acks but does not deliver the vote. + additionalNodes: [{ tag: 'meta', attrs: { polltype: 'vote', content_type: 'add_on' } }], + }); + + this.logger.verbose(`Poll vote sent for poll ${context.storedKey.id}`); + + return { + status: 'SENT', + voteMessageId: messageId, + poll: { id: context.storedKey.id, remoteJid: context.storedKey.remoteJid }, + options: data.options, + crypto: { creator: context.creatorJid, voter: context.senderJid, keyRemoteJid: context.creationKey.remoteJid }, + }; + } catch (error) { + this.logger.error(`Failed to send poll vote: ${error?.message || error}`); + throw new BadRequestException('Failed to send poll vote', error?.message); + } + } + + public async eventResponseMessage(data: SendEventResponseDto) { + const context = await this.getMessageSecretContext(data.key, ['eventMessage'], data.jidMode); + + const responseTypes = { + going: proto.Message.EventResponseMessage.EventResponseType.GOING, + not_going: proto.Message.EventResponseMessage.EventResponseType.NOT_GOING, + maybe: proto.Message.EventResponseMessage.EventResponseType.MAYBE, + }; + + const plaintext = proto.Message.EventResponseMessage.encode({ + response: responseTypes[data.response], + timestampMs: Date.now(), + extraGuestCount: data.extraGuestCount || 0, + }).finish(); + const encrypted = this.encryptMessageSecretPayload( + context.secret, + context.storedKey.id, + context.creatorJid, + context.senderJid, + 'Event Response', + plaintext, + ); + + const message: proto.IMessage = { + encEventResponseMessage: { eventCreationMessageKey: context.creationKey, ...encrypted }, + }; + + try { + const messageId = await this.client.relayMessage(context.creationKey.remoteJid, message, { + additionalAttributes: { type: 'event' }, + additionalNodes: [{ tag: 'meta', attrs: { event_type: 'response' } }], + }); + + this.logger.verbose(`Event response sent for event ${context.storedKey.id}`); + + return { + status: 'SENT', + responseMessageId: messageId, + event: { id: context.storedKey.id, remoteJid: context.storedKey.remoteJid }, + response: data.response, + crypto: { + creator: context.creatorJid, + responder: context.senderJid, + keyRemoteJid: context.creationKey.remoteJid, + }, + }; + } catch (error) { + this.logger.error(`Failed to send event response: ${error?.message || error}`); + throw new BadRequestException('Failed to send event response', error?.message); + } + } + public async reactionMessage(data: SendReactionDto) { return await this.sendMessageWithTyping(data.key.remoteJid, { reactionMessage: { key: data.key, text: data.reaction }, diff --git a/src/api/routes/sendMessage.router.ts b/src/api/routes/sendMessage.router.ts index 51876483d0..abca9dbaaa 100644 --- a/src/api/routes/sendMessage.router.ts +++ b/src/api/routes/sendMessage.router.ts @@ -4,10 +4,12 @@ import { SendButtonsDto, SendCarouselDto, SendContactDto, + SendEventResponseDto, SendListDto, SendLocationDto, SendMediaDto, SendPollDto, + SendPollVoteDto, SendPtvDto, SendReactionDto, SendStatusDto, @@ -21,10 +23,12 @@ import { buttonsMessageSchema, carouselMessageSchema, contactMessageSchema, + eventResponseMessageSchema, listMessageSchema, locationMessageSchema, mediaMessageSchema, pollMessageSchema, + pollVoteMessageSchema, ptvMessageSchema, reactionMessageSchema, statusMessageSchema, @@ -154,6 +158,26 @@ export class MessageRouter extends RouterBroker { return res.status(HttpStatus.CREATED).json(response); }) + .post(this.routerPath('sendPollVote'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: pollVoteMessageSchema, + ClassRef: SendPollVoteDto, + execute: (instance, data) => sendMessageController.sendPollVote(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }) + .post(this.routerPath('sendEventResponse'), ...guards, async (req, res) => { + const response = await this.dataValidate({ + request: req, + schema: eventResponseMessageSchema, + ClassRef: SendEventResponseDto, + execute: (instance, data) => sendMessageController.sendEventResponse(instance, data), + }); + + return res.status(HttpStatus.CREATED).json(response); + }) .post(this.routerPath('sendPoll'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, diff --git a/src/validate/message.schema.ts b/src/validate/message.schema.ts index db76fe1c85..35b3e0ce58 100644 --- a/src/validate/message.schema.ts +++ b/src/validate/message.schema.ts @@ -313,6 +313,40 @@ export const reactionMessageSchema: JSONSchema7 = { required: ['key', 'reaction'], }; +const messageKeySchema: JSONSchema7 = { + type: 'object', + properties: { + id: { type: 'string' }, + remoteJid: { type: 'string' }, + fromMe: { type: 'boolean', enum: [true, false] }, + }, + required: ['id', 'remoteJid'], + ...isNotEmpty('id', 'remoteJid'), +}; + +export const pollVoteMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + key: messageKeySchema, + options: { type: 'array', items: { type: 'string' }, maxItems: 12 }, + jidMode: { type: 'string', enum: ['auto', 'lid', 'pn'] }, + }, + required: ['key', 'options'], +}; + +export const eventResponseMessageSchema: JSONSchema7 = { + $id: v4(), + type: 'object', + properties: { + key: messageKeySchema, + response: { type: 'string', enum: ['going', 'maybe', 'not_going'] }, + extraGuestCount: { type: 'integer', minimum: 0, maximum: 50 }, + jidMode: { type: 'string', enum: ['auto', 'lid', 'pn'] }, + }, + required: ['key', 'response'], +}; + export const pollMessageSchema: JSONSchema7 = { $id: v4(), type: 'object', From 302ed53b16cb66719c1f91eaeb05073f9a11bd61 Mon Sep 17 00:00:00 2001 From: Timo Hissink <49754279+timohissink@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:23:54 +0100 Subject: [PATCH 2/2] fix(baileys): use Portuguese for new user-facing error messages --- .../evolution/evolution.channel.service.ts | 4 ++-- .../channel/meta/whatsapp.business.service.ts | 4 ++-- .../channel/whatsapp/whatsapp.baileys.service.ts | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/api/integrations/channel/evolution/evolution.channel.service.ts b/src/api/integrations/channel/evolution/evolution.channel.service.ts index 46778fc67e..a6eaa73eb1 100644 --- a/src/api/integrations/channel/evolution/evolution.channel.service.ts +++ b/src/api/integrations/channel/evolution/evolution.channel.service.ts @@ -782,10 +782,10 @@ export class EvolutionStartupService extends ChannelStartupService { throw new BadRequestException('Method not available on Evolution Channel'); } public async pollVoteMessage() { - throw new BadRequestException('Method not available on Evolution Channel'); + throw new BadRequestException('Método não disponível no Evolution Channel'); } public async eventResponseMessage() { - throw new BadRequestException('Method not available on Evolution Channel'); + throw new BadRequestException('Método não disponível no Evolution Channel'); } public async statusMessage() { throw new BadRequestException('Method not available on Evolution Channel'); diff --git a/src/api/integrations/channel/meta/whatsapp.business.service.ts b/src/api/integrations/channel/meta/whatsapp.business.service.ts index 1b8659d205..e1fcebcde5 100644 --- a/src/api/integrations/channel/meta/whatsapp.business.service.ts +++ b/src/api/integrations/channel/meta/whatsapp.business.service.ts @@ -1761,10 +1761,10 @@ export class BusinessStartupService extends ChannelStartupService { throw new BadRequestException('Method not available on WhatsApp Business API'); } public async pollVoteMessage() { - throw new BadRequestException('Method not available on WhatsApp Business API'); + throw new BadRequestException('Método não disponível na WhatsApp Business API'); } public async eventResponseMessage() { - throw new BadRequestException('Method not available on WhatsApp Business API'); + throw new BadRequestException('Método não disponível na WhatsApp Business API'); } public async statusMessage() { throw new BadRequestException('Method not available on WhatsApp Business API'); diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index bf53b55bc7..bfc031bb76 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -3980,7 +3980,7 @@ export class BaileysStartupService extends ChannelStartupService { }); if (!stored) { - throw new NotFoundException('Message not found'); + throw new NotFoundException('Mensagem não encontrada'); } const storedKey = stored.key as proto.IMessageKey & { remoteJidAlt?: string; participantAlt?: string }; @@ -3991,12 +3991,12 @@ export class BaileysStartupService extends ChannelStartupService { requestedJid !== jidNormalizedUser(storedKey.remoteJid) && requestedJid !== jidNormalizedUser(storedKey.remoteJidAlt || '') ) { - throw new BadRequestException('remoteJid does not match the message'); + throw new BadRequestException('O remoteJid não corresponde à mensagem'); } const messageType = messageTypes.find((type) => storedMessage?.[type]); if (!messageType) { - throw new BadRequestException(`Message is not a ${messageTypes.join(' or ')}`); + throw new BadRequestException(`A mensagem não é do tipo ${messageTypes.join(' ou ')}`); } let secret: any = storedMessage?.messageContextInfo?.messageSecret; @@ -4008,7 +4008,7 @@ export class BaileysStartupService extends ChannelStartupService { secret = Buffer.from(Object.values(secret) as number[]); } if (!Buffer.isBuffer(secret) || secret.length !== 32) { - throw new BadRequestException('messageSecret not available for this message'); + throw new BadRequestException('messageSecret não disponível para esta mensagem'); } const ownLid = this.client.user?.lid ? jidNormalizedUser(this.client.user.lid) : undefined; @@ -4088,7 +4088,7 @@ export class BaileysStartupService extends ChannelStartupService { const unknownOptions = (data.options || []).filter((option) => !optionNames.includes(option)); if (unknownOptions.length) { throw new BadRequestException({ - error: 'Unknown poll option(s)', + error: 'Opção(ões) de enquete desconhecida(s)', unknown: unknownOptions, available: optionNames, }); @@ -4096,7 +4096,7 @@ export class BaileysStartupService extends ChannelStartupService { const selectableCount = poll.selectableOptionsCount || 0; if (selectableCount && data.options.length > selectableCount) { - throw new BadRequestException(`A maximum of ${selectableCount} option(s) can be selected`); + throw new BadRequestException(`É possível selecionar no máximo ${selectableCount} opção(ões)`); } const plaintext = proto.Message.PollVoteMessage.encode({ @@ -4138,7 +4138,7 @@ export class BaileysStartupService extends ChannelStartupService { }; } catch (error) { this.logger.error(`Failed to send poll vote: ${error?.message || error}`); - throw new BadRequestException('Failed to send poll vote', error?.message); + throw new BadRequestException('Falha ao enviar o voto na enquete', error?.message); } } @@ -4190,7 +4190,7 @@ export class BaileysStartupService extends ChannelStartupService { }; } catch (error) { this.logger.error(`Failed to send event response: ${error?.message || error}`); - throw new BadRequestException('Failed to send event response', error?.message); + throw new BadRequestException('Falha ao enviar a resposta ao evento', error?.message); } }