From 2a83120d8fe1358c6559bb21410f06e1b323f4b5 Mon Sep 17 00:00:00 2001 From: Muhammed Mustafa AKSAM Date: Sat, 20 Dec 2025 20:00:03 +0300 Subject: [PATCH] feat(api): add reactions field to WAMessage and reactions endpoint - Add WAReactionInfo DTO with reaction, senderId, timestamp - Extract reactions in all engines (noweb, gows, webjs) - Add GET /api/{session}/chats/{chatId}/messages/{messageId}/reactions --- src/api/chats.controller.ts | 18 +++++ src/core/engines/gows/session.gows.core.ts | 67 +++++++++------ src/core/engines/noweb/session.noweb.core.ts | 12 ++- src/core/engines/webjs/session.webjs.core.ts | 85 ++++++++++++++------ src/structures/responses.dto.ts | 27 +++++++ 5 files changed, 157 insertions(+), 52 deletions(-) diff --git a/src/api/chats.controller.ts b/src/api/chats.controller.ts index f06d67122..a74d4dfc4 100644 --- a/src/api/chats.controller.ts +++ b/src/api/chats.controller.ts @@ -161,6 +161,24 @@ class ChatsController { return message; } + @Get(':chatId/messages/:messageId/reactions') + @SessionApiParam + @ApiOperation({ summary: 'Gets reactions for a message' }) + @ChatIdApiParam + async getMessageReactions( + @WorkingSessionParam session: WhatsappSession, + @Param('chatId') chatId: string, + @Param('messageId') messageId: string, + ) { + const message = await session.getChatMessage(chatId, messageId, { + downloadMedia: false, + }); + if (!message) { + throw new NotFoundException('Message not found'); + } + return message.reactions || []; + } + @Post(':chatId/messages/:messageId/pin') @SessionApiParam @ApiOperation({ summary: 'Pins a message in the chat' }) diff --git a/src/core/engines/gows/session.gows.core.ts b/src/core/engines/gows/session.gows.core.ts index 5a9fa91eb..30cc99809 100644 --- a/src/core/engines/gows/session.gows.core.ts +++ b/src/core/engines/gows/session.gows.core.ts @@ -122,6 +122,7 @@ import { MessageSource, WAMessage, WAMessageReaction, + WAReactionInfo, } from '@waha/structures/responses.dto'; import { CallData } from '@waha/structures/calls.dto'; import { MeInfo, ProxyConfig } from '@waha/structures/sessions.dto'; @@ -509,38 +510,42 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { msg?.Message?.protocolMessage?.key !== undefined ); }), - mergeMap(async (message): Promise => { - const afterMessage = await this.toWAMessage(message); - // Extract the revoked message ID from protocolMessage.key - const revokedMessageId = message.Message.protocolMessage.key?.ID; - return { - after: afterMessage, - before: null, - revokedMessageId: revokedMessageId, - _data: message, - }; - }), + mergeMap( + async (message): Promise => { + const afterMessage = await this.toWAMessage(message); + // Extract the revoked message ID from protocolMessage.key + const revokedMessageId = message.Message.protocolMessage.key?.ID; + return { + after: afterMessage, + before: null, + revokedMessageId: revokedMessageId, + _data: message, + }; + }, + ), ); this.events2.get(WAHAEvents.MESSAGE_REVOKED).switch(messagesRevoked$); // Handle edited messages const messagesEdited$ = messages$.pipe( filter((message) => IsEditedMessage(message.Message)), - mergeMap(async (message): Promise => { - const waMessage = await this.toWAMessage(message); - const content = normalizeMessageContent(message.Message); - // Extract the body from editedMessage using extractBody function - const body = extractBody(content.protocolMessage.editedMessage) || ''; - // Extract the original message ID from protocolMessage.key - // @ts-ignore - const editedMessageId = content.protocolMessage.key?.ID; - return { - ...waMessage, - body: body, - editedMessageId: editedMessageId, - _data: message, - }; - }), + mergeMap( + async (message): Promise => { + const waMessage = await this.toWAMessage(message); + const content = normalizeMessageContent(message.Message); + // Extract the body from editedMessage using extractBody function + const body = extractBody(content.protocolMessage.editedMessage) || ''; + // Extract the original message ID from protocolMessage.key + // @ts-ignore + const editedMessageId = content.protocolMessage.key?.ID; + return { + ...waMessage, + body: body, + editedMessageId: editedMessageId, + _data: message, + }; + }, + ), ); this.events2.get(WAHAEvents.MESSAGE_EDITED).switch(messagesEdited$); @@ -2209,10 +2214,20 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { vCards: extractVCards(waproto), ackName: WAMessageAck[ack] || ACK_UNKNOWN, replyTo: replyTo, + reactions: this.extractReactions(message.Reactions), _data: message, }; } + protected extractReactions(reactions: any[]): WAReactionInfo[] { + if (!reactions || !Array.isArray(reactions)) return []; + return reactions.map((r) => ({ + reaction: r.Text || '', + senderId: toCusFormat(r.Sender), + timestamp: r.Timestamp ? new Date(r.Timestamp).getTime() / 1000 : 0, + })); + } + private toPollVotePayload(event: any): PollVotePayload { // Extract event creation message key from the message const creationKey = event.Message?.pollUpdateMessage.pollCreationMessageKey; diff --git a/src/core/engines/noweb/session.noweb.core.ts b/src/core/engines/noweb/session.noweb.core.ts index 10c91a63e..be2ddfb8e 100644 --- a/src/core/engines/noweb/session.noweb.core.ts +++ b/src/core/engines/noweb/session.noweb.core.ts @@ -154,7 +154,7 @@ import { WAHAChatPresences, WAHAPresenceData, } from '@waha/structures/presence.dto'; -import { WAMessage, WAMessageReaction } from '@waha/structures/responses.dto'; +import { WAMessage, WAMessageReaction, WAReactionInfo } from '@waha/structures/responses.dto'; import { MeInfo } from '@waha/structures/sessions.dto'; import { BROADCAST_ID, @@ -2432,10 +2432,20 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { location: extractWALocation(waproto), vCards: extractVCards(waproto), replyTo: replyTo, + reactions: this.extractReactions(message.reactions), _data: message, }; } + protected extractReactions(reactions: any[]): WAReactionInfo[] { + if (!reactions || !Array.isArray(reactions)) return []; + return reactions.map((r) => ({ + reaction: r.text || '', + senderId: toCusFormat(r.key?.participant || r.key?.remoteJid), + timestamp: ensureNumber(r.senderTimestampMs) || 0, + })); + } + protected extractReplyTo(message): ReplyToMessage | null { const msgType = getContentType(message); const contextInfo = message[msgType]?.contextInfo; diff --git a/src/core/engines/webjs/session.webjs.core.ts b/src/core/engines/webjs/session.webjs.core.ts index 95bf31631..842c8ded0 100644 --- a/src/core/engines/webjs/session.webjs.core.ts +++ b/src/core/engines/webjs/session.webjs.core.ts @@ -118,6 +118,7 @@ import { WALocation, WAMessage, WAMessageReaction, + WAReactionInfo, } from '@waha/structures/responses.dto'; import { BrowserTraceQuery } from '@waha/structures/server.debug.dto'; import { MeInfo } from '@waha/structures/sessions.dto'; @@ -711,7 +712,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { const message = this.recreateMessage(messageId); const options = { // It's fine to sent just ids instead of Contact object - mentions: request.mentions as unknown as string[], + mentions: (request.mentions as unknown) as string[], linkPreview: request.linkPreview, }; return message.edit(request.text, options); @@ -1652,19 +1653,23 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { filter((evt: any) => this.jids.include(evt?.after?.id?.remote || evt?.before?.id?.remote), ), - map((event): WAMessageRevokedBody => { - const afterMessage = event.after ? this.toWAMessage(event.after) : null; - const beforeMessage = event.before - ? this.toWAMessage(event.before) - : null; - // Extract the revoked message ID from the protocolMessageKey.id field - const revokedMessageId = afterMessage?._data?.protocolMessageKey?.id; - return { - after: afterMessage, - before: beforeMessage, - revokedMessageId: revokedMessageId, - }; - }), + map( + (event): WAMessageRevokedBody => { + const afterMessage = event.after + ? this.toWAMessage(event.after) + : null; + const beforeMessage = event.before + ? this.toWAMessage(event.before) + : null; + // Extract the revoked message ID from the protocolMessageKey.id field + const revokedMessageId = afterMessage?._data?.protocolMessageKey?.id; + return { + after: afterMessage, + before: beforeMessage, + revokedMessageId: revokedMessageId, + }; + }, + ), ); this.events2.get(WAHAEvents.MESSAGE_REVOKED).switch(messagesRevoked$); @@ -1685,15 +1690,17 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { ); const messagesEdit$ = messageEdit$.pipe( filter((event: any) => this.jids.include(event?.message?.id?.remote)), - map((event): WAMessageEditedBody => { - const message = this.toWAMessage(event.message); - return { - ...message, - body: event.newBody, - editedMessageId: message._data?.id?.id, - _data: event, - }; - }), + map( + (event): WAMessageEditedBody => { + const message = this.toWAMessage(event.message); + return { + ...message, + body: event.newBody, + editedMessageId: message._data?.id?.id, + _data: event, + }; + }, + ), ); this.events2.get(WAHAEvents.MESSAGE_EDITED).switch(messagesEdit$); @@ -1864,6 +1871,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { ) { // Convert const wamessage = this.toWAMessage(message); + // Reactions - use getReactions() API if message has reactions + if (message.hasReaction) { + const reactionLists = await message.getReactions().catch((e) => { + this.logger.error( + { error: e, msg: message.id._serialized }, + 'Failed to get reactions', + ); + return null; + }); + if (reactionLists) { + wamessage.reactions = this.convertReactionLists(reactionLists); + } + } // Media if (downloadMedia) { const media = await this.downloadMediaSafe(message); @@ -1872,6 +1892,22 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { return wamessage; } + protected convertReactionLists(reactionLists: any[]): WAReactionInfo[] { + if (!reactionLists || !Array.isArray(reactionLists)) return []; + const reactions: WAReactionInfo[] = []; + for (const reactionList of reactionLists) { + if (!reactionList.senders) continue; + for (const sender of reactionList.senders) { + reactions.push({ + reaction: sender.reaction || reactionList.aggregateEmoji || '', + senderId: toCusFormat(sender.senderId), + timestamp: sender.timestamp || 0, + }); + } + } + return reactions; + } + private toRejectedCallData(peerJid: string, id: string): CallData { const timestamp = Math.floor(Date.now() / 1000); return { @@ -2105,8 +2141,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { } export class WEBJSEngineMediaProcessor - implements IMediaEngineProcessor -{ + implements IMediaEngineProcessor { hasMedia(message: Message): boolean { if (!message.hasMedia) { return false; diff --git a/src/structures/responses.dto.ts b/src/structures/responses.dto.ts index 34f5957a2..0d2cebe1c 100644 --- a/src/structures/responses.dto.ts +++ b/src/structures/responses.dto.ts @@ -125,6 +125,33 @@ export class WAMessage extends WAMessageBase { 'Message in a raw format that we get from WhatsApp. May be changed anytime, use it with caution! It depends a lot on the underlying backend.', }) _data?: any; + + @ApiProperty({ + description: 'Reactions to this message', + type: () => [WAReactionInfo], + required: false, + }) + reactions?: WAReactionInfo[]; +} + +export class WAReactionInfo { + @ApiProperty({ + description: 'Emoji reaction', + example: '👍', + }) + reaction: string; + + @ApiProperty({ + description: 'Who sent the reaction', + example: '1234567890@c.us', + }) + senderId: string; + + @ApiProperty({ + description: 'Unix timestamp when reaction was sent', + example: 1666943582, + }) + timestamp: number; } export class WAReaction {