Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<id>` (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
Expand Down
60 changes: 60 additions & 0 deletions src/engine/adapters/whatsapp-web-js.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
30 changes: 25 additions & 5 deletions src/engine/adapters/whatsapp-web-js.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =============
Expand Down
7 changes: 5 additions & 2 deletions src/modules/message/message.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading