From 49b5e41623052b461f490a32d87db2106a0d4aa3 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Fri, 19 Jun 2026 16:59:35 +0700 Subject: [PATCH] fix(engine): return the real id for forwarded messages on whatsapp-web.js so delivery status advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The whatsapp-web.js engine returned a synthetic fwd_ for a forwarded message because the library's forward() yields no id, and the delivery-ack matcher keys on the stored id — so a forward never advanced past SENT and webhook/n8n consumers could not correlate it (Baileys already returns the real id). The adapter now reads the sent copy back from the destination chat and returns its real id. Recovery is best-effort: the forward already succeeded, so any error reading it back never fails the operation, and when the copy cannot be identified an empty id is returned so the row's waMessageId is left unset (no ack can mis-match it) — never a synthetic or cross-matching source id. --- CHANGELOG.md | 9 +++ .../adapters/whatsapp-web-js.adapter.spec.ts | 60 +++++++++++++++++++ .../adapters/whatsapp-web-js.adapter.ts | 30 ++++++++-- src/modules/message/message.service.ts | 7 ++- 4 files changed, 99 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66bcc8f6..db2ba133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Forwarded messages on the whatsapp-web.js engine now report a real WhatsApp message id, so their + delivery status advances.** A forward returned a synthetic `fwd_` (the underlying library's forward + call yields no id), which the delivery-ack matcher could never match — leaving the forward stuck at + "sent" and uncorrelatable by webhook / n8n consumers. The adapter now reads the sent copy back from the + destination chat and returns its real id (matching the Baileys engine), falling back to the source id + rather than a fabricated one. + ## [0.4.2] - 2026-06-19 Bug-fix and hardening release: access-control tightening, session-lifecycle resilience, data-migration diff --git a/src/engine/adapters/whatsapp-web-js.adapter.spec.ts b/src/engine/adapters/whatsapp-web-js.adapter.spec.ts index 3519d676..c11f5543 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.spec.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.spec.ts @@ -128,6 +128,66 @@ describe('WhatsAppWebJsAdapter readiness guard (#100)', () => { }); }); +describe('WhatsAppWebJsAdapter.forwardMessage (returns the real sent id, not a synthetic fwd_ id)', () => { + const readyAdapter = (client: unknown): WhatsAppWebJsAdapter => { + const adapter = new WhatsAppWebJsAdapter({ sessionId: 's', sessionDataPath: './data/sessions', puppeteer: {} }); + (adapter as unknown as { status: EngineStatus }).status = EngineStatus.READY; + (adapter as unknown as { client: unknown }).client = client; + return adapter; + }; + + it('returns the real id of the forwarded copy fetched from the destination chat', async () => { + const forward = jest.fn().mockResolvedValue(undefined); + const sourceChat = { fetchMessages: jest.fn().mockResolvedValue([{ id: { _serialized: 'SRC1' }, forward }]) }; + const destChat = { + fetchMessages: jest.fn().mockResolvedValue([ + { id: { _serialized: 'OLD' }, timestamp: 100 }, + { id: { _serialized: 'REAL_FWD' }, timestamp: 200 }, // most recent fromMe = the forwarded copy + ]), + }; + const client = { + getChatById: jest.fn((id: string) => Promise.resolve(id === 'dest@c.us' ? destChat : sourceChat)), + }; + + const result = await readyAdapter(client).forwardMessage('src@c.us', 'dest@c.us', 'SRC1'); + + expect(forward).toHaveBeenCalledWith('dest@c.us'); + expect(result.id).toBe('REAL_FWD'); + expect(result.id).not.toMatch(/^fwd_/); + }); + + it('returns an explicit-unknown id (empty, not a real/synthetic id) when the sent copy cannot be identified', async () => { + // Empty id leaves the forward row's waMessageId unset, so no ack can mis-match it (a source/synthetic + // id could cross-drive another row's delivery status). + const forward = jest.fn().mockResolvedValue(undefined); + const sourceChat = { fetchMessages: jest.fn().mockResolvedValue([{ id: { _serialized: 'SRC1' }, forward }]) }; + const destChat = { fetchMessages: jest.fn().mockResolvedValue([]) }; + const client = { + getChatById: jest.fn((id: string) => Promise.resolve(id === 'dest@c.us' ? destChat : sourceChat)), + }; + + const result = await readyAdapter(client).forwardMessage('src@c.us', 'dest@c.us', 'SRC1'); + + expect(result.id).toBe(''); + expect(result.id).not.toMatch(/^fwd_/); + }); + + it('does not report a failure when post-forward id recovery throws (the forward already happened)', async () => { + const forward = jest.fn().mockResolvedValue(undefined); + const sourceChat = { fetchMessages: jest.fn().mockResolvedValue([{ id: { _serialized: 'SRC1' }, forward }]) }; + const client = { + getChatById: jest.fn((id: string) => + id === 'dest@c.us' ? Promise.reject(new Error('puppeteer detached')) : Promise.resolve(sourceChat), + ), + }; + + const result = await readyAdapter(client).forwardMessage('src@c.us', 'dest@c.us', 'SRC1'); + + expect(forward).toHaveBeenCalledWith('dest@c.us'); + expect(result.id).toBe(''); + }); +}); + describe('WhatsAppWebJsAdapter.resolveContactPhone (@lid -> phone, #263)', () => { // Stub a "ready" adapter with a fake client so we exercise the mapping without a real browser. const readyAdapter = (getContactLidAndPhone: jest.Mock): WhatsAppWebJsAdapter => { diff --git a/src/engine/adapters/whatsapp-web-js.adapter.ts b/src/engine/adapters/whatsapp-web-js.adapter.ts index 4c940c3d..b1a9b0fd 100644 --- a/src/engine/adapters/whatsapp-web-js.adapter.ts +++ b/src/engine/adapters/whatsapp-web-js.adapter.ts @@ -700,11 +700,31 @@ export class WhatsAppWebJsAdapter extends EventEmitter implements IWhatsAppEngin } await msgToForward.forward(toChatId); - // forward() returns void, so we generate a result based on original message - return { - id: `fwd_${messageId}`, - timestamp: Date.now(), - }; + + // whatsapp-web.js's forward() returns void, so BEST-EFFORT recover the REAL id of the sent copy by + // reading it back from the destination chat (the most recent outgoing message). The delivery-ack + // matcher keys on this id, so a synthetic one would leave the forward stuck at SENT; Baileys already + // returns the real id. The forward already succeeded here, so recovery must NEVER fail the operation. + // When the copy can't be identified we return an explicit-unknown id (empty): message.service then + // leaves the row's waMessageId unset so no ack can mis-match it — unlike a synthetic or source id, + // which could cross-drive another row's delivery status. Concurrent forwards to the same chat may + // mis-identify the copy — acceptable for delivery-status accuracy. + try { + const destChat = await this.client!.getChatById(toChatId); + const sentByMe = (await destChat?.fetchMessages({ limit: 5, fromMe: true })) ?? []; + let sent: (typeof sentByMe)[number] | undefined; + for (const m of sentByMe) { + if (!sent || m.timestamp > sent.timestamp) { + sent = m; + } + } + if (sent) { + return { id: sent.id._serialized, timestamp: sent.timestamp }; + } + } catch (error) { + this.logger.warn(`Forward succeeded but recovering the sent message id failed: ${String(error)}`); + } + return { id: '', timestamp: Math.floor(Date.now() / 1000) }; } // ============= Phase 3: Group Management ============= diff --git a/src/modules/message/message.service.ts b/src/modules/message/message.service.ts index 24e6a5d1..3f8588e2 100644 --- a/src/modules/message/message.service.ts +++ b/src/modules/message/message.service.ts @@ -449,8 +449,11 @@ export class MessageService { try { const result = await engine.forwardMessage(dto.fromChatId, dto.toChatId, dto.messageId); - // Update with actual WhatsApp message ID and status - message.waMessageId = result.id; + // Update with actual WhatsApp message ID and status. A forward whose engine could not recover the + // sent copy's real id returns an empty id — leave waMessageId unset (NULL) so no ack mis-matches it. + if (result.id) { + message.waMessageId = result.id; + } message.status = MessageStatus.SENT; message.timestamp = result.timestamp; await this.messageRepository.save(message);