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);