From b50fa6466b9086bb422a3f30ef711c3354bcd794 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 14:00:18 +0700 Subject: [PATCH 1/7] refactor(engine): lazy-load Baileys so there is no global Node floor (Approach B) Switch @whiskeysockets/baileys from static value-imports to a private cached dynamic import() in both BaileysAdapter and BaileysMessageStoreService. The package now loads only when ENGINE_TYPE=baileys is selected at runtime; the default whatsapp-web.js path never touches it. Changes: - baileys.adapter.ts: remove static value-import; add private `lib` field + `loadLib()` async loader; update connect() to call loadLib() and reference all Baileys values (default/useMultiFileAuthState/fetchLatestBaileysVersion/ DisconnectReason/getContentType) via the loaded module. - baileys-message-store.service.ts: remove static BufferJSON import; add `baileysLib` + `loadLib()` loader; call loadLib() inside put()/getMessage(). - package.json: remove the engines.node >=20.19.0 floor (no longer required; Baileys loads lazily so there is no boot-time ESM require() constraint). - package.json jest.transform: override ts-jest tsconfig to CommonJS so dynamic import() is downleveled for the CJS jest runner, making jest.mock/@whiskeysockets/baileys resolve correctly in unit tests. - CHANGELOG.md: replace the Node-floor warning with a lazy-load note. Verified: dist files contain import('@whiskeysockets/baileys') not require(), 627 unit tests pass, 6 e2e tests pass, lint 0 errors. --- CHANGELOG.md | 2 +- package.json | 5 +-- .../adapters/baileys-message-store.service.ts | 14 ++++++- src/engine/adapters/baileys.adapter.ts | 38 ++++++++++++------- 4 files changed, 39 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4034b5f..abea34c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ plugins instead of in core (#265). ### Changed -- ⚠️ **Node.js ≥ 20.19 now required.** The Baileys engine dependency (`@whiskeysockets/baileys`) is ESM-only and is loaded at startup, so OpenWA now requires Node ≥ 20.19 (for `require()` of ESM). Operators on older Node must upgrade. (#299) +- **Baileys engine loads lazily** — `@whiskeysockets/baileys` is imported via a dynamic `import()` only when the Baileys engine is actually used (`ENGINE_TYPE=baileys`). Operators using the default whatsapp-web.js engine are unaffected and there is no global Node version floor. (#299) - Engine config is now **opaque per-engine**: `EngineFactory` passes only engine-neutral fields (`sessionId`/`proxyUrl`/`proxyType`) to an engine plugin and supplies engine-specific config (Puppeteer for whatsapp-web.js) as a blob via the plugin context, so a non-browser engine can be added without the diff --git a/package.json b/package.json index 9ed46369..9d0943b9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,6 @@ { "name": "openwa", "version": "0.2.10", - "engines": { - "node": ">=20.19.0" - }, "description": "Open Source WhatsApp API Gateway - Free, Self-Hosted HTTP API for WhatsApp", "author": "Yudhi Armyndharis & OpenWA Contributors", "private": true, @@ -112,7 +109,7 @@ "rootDir": "src", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)s$": ["ts-jest", { "tsconfig": { "module": "CommonJS", "moduleResolution": "node", "resolvePackageJsonExports": false } }] }, "moduleNameMapper": { "^@whiskeysockets/baileys$": "/../test/__mocks__/@whiskeysockets/baileys.ts", diff --git a/src/engine/adapters/baileys-message-store.service.ts b/src/engine/adapters/baileys-message-store.service.ts index 54b44858..8a76488e 100644 --- a/src/engine/adapters/baileys-message-store.service.ts +++ b/src/engine/adapters/baileys-message-store.service.ts @@ -1,7 +1,6 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { BufferJSON } from '@whiskeysockets/baileys'; import type { WAMessage } from '@whiskeysockets/baileys'; import { BaileysStoredMessage } from './baileys-stored-message.entity'; import { BaileysMessageStore } from '../types/baileys.types'; @@ -13,6 +12,13 @@ function positiveIntFromEnv(name: string, fallback: number): number { @Injectable() export class BaileysMessageStoreService implements BaileysMessageStore { + /** Lazily loaded @whiskeysockets/baileys module (ESM-only; loaded on first use, not at boot). */ + private baileysLib: any; + + private async loadLib(): Promise { + return (this.baileysLib ??= await import('@whiskeysockets/baileys')); + } + constructor( @InjectRepository(BaileysStoredMessage, 'data') private readonly repo: Repository, @@ -23,6 +29,9 @@ export class BaileysMessageStoreService implements BaileysMessageStore { if (!waMessageId) { return; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { BufferJSON } = await this.loadLib(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const serializedMessage = JSON.stringify(msg, BufferJSON.replacer); // Idempotent: the same message arrives from the send return AND the messages.upsert echo. await this.repo.upsert({ sessionId, waMessageId, serializedMessage }, ['sessionId', 'waMessageId']); @@ -34,6 +43,9 @@ export class BaileysMessageStoreService implements BaileysMessageStore { if (!row) { return null; } + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { BufferJSON } = await this.loadLib(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access return JSON.parse(row.serializedMessage, BufferJSON.reviver) as WAMessage; } diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index fc8edc73..1d10a73c 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -1,10 +1,4 @@ import * as path from 'path'; -import makeWASocket, { - DisconnectReason, - fetchLatestBaileysVersion, - getContentType, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; import type { AnyMessageContent, MiscMessageGenerationOptions, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { buildIncomingMessageFromBaileys, mapBaileysStatus } from './baileys-message-mapper'; import { mapBaileysGroup, mapBaileysGroupInfo } from './baileys-group-mapper'; @@ -71,6 +65,12 @@ export class BaileysAdapter implements IWhatsAppEngine { private pushName: string | null = null; private callbacks: EngineEventCallbacks = {}; private intentionalClose = false; + /** Lazily loaded @whiskeysockets/baileys module (ESM-only; loaded on first connect, not at boot). */ + private lib: any; + + private async loadLib(): Promise { + return (this.lib ??= await import('@whiskeysockets/baileys')); + } constructor(private readonly config: BaileysAdapterConfig) { // Isolate each session's auth state under its own subdirectory of the shared auth dir. @@ -94,11 +94,18 @@ export class BaileysAdapter implements IWhatsAppEngine { private async connect(): Promise { this.setStatus(EngineStatus.INITIALIZING); - const { state, saveCreds } = await useMultiFileAuthState(this.authPath); - const { version } = await fetchLatestBaileysVersion(); - - const sock = makeWASocket({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const b = await this.loadLib(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const { state, saveCreds } = await b.useMultiFileAuthState(this.authPath); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const { version } = await b.fetchLatestBaileysVersion(); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const sock = b.default({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment auth: state, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment version, browser: BAILEYS_BROWSER, printQRInTerminal: false, @@ -106,10 +113,10 @@ export class BaileysAdapter implements IWhatsAppEngine { // the type through a deep import path that TypeScript does not auto-unify here. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion logger: createSilentLogger() as unknown as ILogger, - }); + }) as WASocket; this.sock = sock; - // eslint-disable-next-line @typescript-eslint/no-misused-promises + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument sock.ev.on('creds.update', saveCreds); sock.ev.on('connection.update', update => this.handleConnectionUpdate(update)); sock.ev.on('messages.upsert', event => this.handleMessagesUpsert(event)); @@ -162,7 +169,8 @@ export class BaileysAdapter implements IWhatsAppEngine { return; } - if (statusCode === DisconnectReason.loggedOut) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (statusCode === this.lib?.DisconnectReason.loggedOut) { // Credentials invalidated — terminal. Re-linking requires a fresh QR/pairing. this.setStatus(EngineStatus.DISCONNECTED); this.callbacks.onDisconnected?.('logged out'); @@ -609,7 +617,8 @@ export class BaileysAdapter implements IWhatsAppEngine { private mapMessage(msg: WAMessage): IncomingMessage { const content = msg.message ?? {}; - const contentType = getContentType(msg.message ?? undefined); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const contentType = this.lib?.getContentType(msg.message ?? undefined); const body = content.conversation ?? content.extendedTextMessage?.text ?? ''; return buildIncomingMessageFromBaileys({ id: msg.key.id ?? '', @@ -617,6 +626,7 @@ export class BaileysAdapter implements IWhatsAppEngine { fromMe: msg.key.fromMe === true, participant: msg.key.participant ?? undefined, body, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment contentType, isPtt: content.audioMessage?.ptt === true, timestamp: this.toUnixSeconds(msg.messageTimestamp), From 6a014f677f76fd1fe4910308b3bfe1dc9dafeda6 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 14:11:49 +0700 Subject: [PATCH 2/7] refactor(engine): type the lazy Baileys module (drop any + unsafe suppressions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `private lib: any` / `private baileysLib: any` with `typeof BaileysLib` (namespace import type — fully erased at compile time, zero dist require). Loader return types updated to `Promise`. Removes all `// eslint-disable-next-line @typescript-eslint/no-unsafe-*` suppressions that the `any` fields required. Wraps `saveCreds` in `() => void` to satisfy the ev.on void-return constraint now that the call site is typed. --- .../adapters/baileys-message-store.service.ts | 9 +++------ src/engine/adapters/baileys.adapter.ts | 19 +++++-------------- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/src/engine/adapters/baileys-message-store.service.ts b/src/engine/adapters/baileys-message-store.service.ts index 8a76488e..f82095f4 100644 --- a/src/engine/adapters/baileys-message-store.service.ts +++ b/src/engine/adapters/baileys-message-store.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import type * as BaileysLib from '@whiskeysockets/baileys'; import type { WAMessage } from '@whiskeysockets/baileys'; import { BaileysStoredMessage } from './baileys-stored-message.entity'; import { BaileysMessageStore } from '../types/baileys.types'; @@ -13,9 +14,9 @@ function positiveIntFromEnv(name: string, fallback: number): number { @Injectable() export class BaileysMessageStoreService implements BaileysMessageStore { /** Lazily loaded @whiskeysockets/baileys module (ESM-only; loaded on first use, not at boot). */ - private baileysLib: any; + private baileysLib?: typeof BaileysLib; - private async loadLib(): Promise { + private async loadLib(): Promise { return (this.baileysLib ??= await import('@whiskeysockets/baileys')); } @@ -29,9 +30,7 @@ export class BaileysMessageStoreService implements BaileysMessageStore { if (!waMessageId) { return; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { BufferJSON } = await this.loadLib(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access const serializedMessage = JSON.stringify(msg, BufferJSON.replacer); // Idempotent: the same message arrives from the send return AND the messages.upsert echo. await this.repo.upsert({ sessionId, waMessageId, serializedMessage }, ['sessionId', 'waMessageId']); @@ -43,9 +42,7 @@ export class BaileysMessageStoreService implements BaileysMessageStore { if (!row) { return null; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { BufferJSON } = await this.loadLib(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access return JSON.parse(row.serializedMessage, BufferJSON.reviver) as WAMessage; } diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 1d10a73c..57c24e6f 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -1,4 +1,5 @@ import * as path from 'path'; +import type * as BaileysLib from '@whiskeysockets/baileys'; import type { AnyMessageContent, MiscMessageGenerationOptions, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { buildIncomingMessageFromBaileys, mapBaileysStatus } from './baileys-message-mapper'; import { mapBaileysGroup, mapBaileysGroupInfo } from './baileys-group-mapper'; @@ -66,9 +67,9 @@ export class BaileysAdapter implements IWhatsAppEngine { private callbacks: EngineEventCallbacks = {}; private intentionalClose = false; /** Lazily loaded @whiskeysockets/baileys module (ESM-only; loaded on first connect, not at boot). */ - private lib: any; + private lib?: typeof BaileysLib; - private async loadLib(): Promise { + private async loadLib(): Promise { return (this.lib ??= await import('@whiskeysockets/baileys')); } @@ -94,18 +95,12 @@ export class BaileysAdapter implements IWhatsAppEngine { private async connect(): Promise { this.setStatus(EngineStatus.INITIALIZING); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const b = await this.loadLib(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const { state, saveCreds } = await b.useMultiFileAuthState(this.authPath); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const { version } = await b.fetchLatestBaileysVersion(); - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const sock = b.default({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment auth: state, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment version, browser: BAILEYS_BROWSER, printQRInTerminal: false, @@ -113,11 +108,10 @@ export class BaileysAdapter implements IWhatsAppEngine { // the type through a deep import path that TypeScript does not auto-unify here. // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion logger: createSilentLogger() as unknown as ILogger, - }) as WASocket; + }); this.sock = sock; - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - sock.ev.on('creds.update', saveCreds); + sock.ev.on('creds.update', () => void saveCreds()); sock.ev.on('connection.update', update => this.handleConnectionUpdate(update)); sock.ev.on('messages.upsert', event => this.handleMessagesUpsert(event)); sock.ev.on('messages.update', updates => this.handleMessagesUpdate(updates)); @@ -169,7 +163,6 @@ export class BaileysAdapter implements IWhatsAppEngine { return; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (statusCode === this.lib?.DisconnectReason.loggedOut) { // Credentials invalidated — terminal. Re-linking requires a fresh QR/pairing. this.setStatus(EngineStatus.DISCONNECTED); @@ -617,7 +610,6 @@ export class BaileysAdapter implements IWhatsAppEngine { private mapMessage(msg: WAMessage): IncomingMessage { const content = msg.message ?? {}; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call const contentType = this.lib?.getContentType(msg.message ?? undefined); const body = content.conversation ?? content.extendedTextMessage?.text ?? ''; return buildIncomingMessageFromBaileys({ @@ -626,7 +618,6 @@ export class BaileysAdapter implements IWhatsAppEngine { fromMe: msg.key.fromMe === true, participant: msg.key.participant ?? undefined, body, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment contentType, isPtt: content.audioMessage?.ptt === true, timestamp: this.toUnixSeconds(msg.messageTimestamp), From 1821f93b6771c8930fef1170a03e9d9e16509903 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 14:21:17 +0700 Subject: [PATCH 3/7] feat(engine): baileys inbound media/location/quoted + revoke + reaction + status filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleMessagesUpsert fans out to async processInboundMessage per message - protocolMessage REVOKE → onMessageRevoked (no onMessage, no spurious DB row) - reactionMessage → onMessageReaction (no onMessage) - mapMessage (now async): caption falls back to body; image/video/audio/document/sticker downloads via downloadMediaMessage (guarded try/catch — failure logs + omits media, never throws into event loop); locationMessage populates location coords + name/address; contextInfo.quotedMessage populates quotedMessage {id, body} - BaileysIncomingFields gains media/location/quotedMessage; buildIncomingMessageFromBaileys passes them through to the neutral IncomingMessage - session.service.ts onMessage: guard isStatusBroadcast at the top (mirrors onMessageCreate) so incoming status@broadcast posts are never persisted or webhooked - Tests: all 634 unit + 6 e2e pass; lint clean; build clean --- src/engine/adapters/baileys-message-mapper.ts | 18 ++ src/engine/adapters/baileys.adapter.spec.ts | 238 ++++++++++++++++++ src/engine/adapters/baileys.adapter.ts | 166 ++++++++++-- src/modules/session/session.service.spec.ts | 18 ++ src/modules/session/session.service.ts | 5 + 5 files changed, 431 insertions(+), 14 deletions(-) diff --git a/src/engine/adapters/baileys-message-mapper.ts b/src/engine/adapters/baileys-message-mapper.ts index 3fab3473..c954532b 100644 --- a/src/engine/adapters/baileys-message-mapper.ts +++ b/src/engine/adapters/baileys-message-mapper.ts @@ -77,6 +77,12 @@ export interface BaileysIncomingFields { pushName?: string; /** The account's own normalized JID, for from/to on outgoing messages. */ selfJid?: string; + /** Pre-extracted media: mimetype + base64 data (+ optional filename). Populated by the adapter. */ + media?: IncomingMessage['media']; + /** Pre-extracted location. Populated by the adapter for `locationMessage`. */ + location?: IncomingMessage['location']; + /** Pre-extracted quoted message context. Populated by the adapter when `contextInfo` is present. */ + quotedMessage?: IncomingMessage['quotedMessage']; } /** @@ -117,5 +123,17 @@ export function buildIncomingMessageFromBaileys(fields: BaileysIncomingFields): incoming.contact = { pushName: fields.pushName }; } + if (fields.media) { + incoming.media = fields.media; + } + + if (fields.location) { + incoming.location = fields.location; + } + + if (fields.quotedMessage) { + incoming.quotedMessage = fields.quotedMessage; + } + return incoming; } diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index cc397000..514eb90f 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -49,7 +49,15 @@ jest.mock('@whiskeysockets/baileys', () => ({ useMultiFileAuthState: jest.fn().mockResolvedValue({ state: { creds: {}, keys: {} }, saveCreds }), fetchLatestBaileysVersion: jest.fn().mockResolvedValue({ version: [2, 3000, 0] }), getContentType: jest.fn(() => 'conversation'), + downloadMediaMessage: jest.fn().mockResolvedValue(Buffer.from('IMGDATA')), DisconnectReason: { loggedOut: 401, restartRequired: 515 }, + proto: { + Message: { + ProtocolMessage: { + Type: { REVOKE: 0 }, + }, + }, + }, })); import { BaileysAdapter } from './baileys.adapter'; @@ -298,6 +306,7 @@ describe('BaileysAdapter inbound fan-out', () => { }, ], }); + await new Promise(r => setImmediate(r)); expect(onMessage).toHaveBeenCalledTimes(1); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const msg = onMessage.mock.calls[0][0] as { id: string; body: string; type: string; fromMe: boolean }; @@ -319,6 +328,7 @@ describe('BaileysAdapter inbound fan-out', () => { }, ], }); + await new Promise(r => setImmediate(r)); expect(onMessageCreate).toHaveBeenCalledTimes(1); expect(onMessage).not.toHaveBeenCalled(); }); @@ -343,6 +353,231 @@ describe('BaileysAdapter inbound fan-out', () => { fakeSock.fire('messages.update', [{ key: { id: 'OUT1' }, update: { status: 3 } }]); expect(onMessageAck).toHaveBeenCalledWith('OUT1', 'delivered'); }); + + it('inbound image: downloads media and exposes base64 + caption as body', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { + getContentType: jest.Mock; + downloadMediaMessage: jest.Mock; + }; + baileys.getContentType.mockReturnValue('imageMessage'); + const imgBuf = Buffer.from('PNGBYTES'); + baileys.downloadMediaMessage.mockResolvedValue(imgBuf); + + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'IMG1' }, + message: { imageMessage: { mimetype: 'image/png', caption: 'look at this' } }, + messageTimestamp: 1700000020, + }, + ], + }); + await new Promise(r => setImmediate(r)); + expect(onMessage).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { + id: string; + body: string; + type: string; + media: { mimetype: string; data: string }; + }; + expect(msg.type).toBe('image'); + expect(msg.body).toBe('look at this'); + expect(msg.media).toEqual({ mimetype: 'image/png', data: imgBuf.toString('base64') }); + }); + + it('inbound location: populates the location field with coordinates', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { getContentType: jest.Mock }; + baileys.getContentType.mockReturnValue('locationMessage'); + + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'LOC1' }, + message: { + locationMessage: { + degreesLatitude: 1.23, + degreesLongitude: 4.56, + name: 'Office', + address: '1 Main St', + }, + }, + messageTimestamp: 1700000021, + }, + ], + }); + await new Promise(r => setImmediate(r)); + expect(onMessage).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { + type: string; + location: { latitude: number; longitude: number; description?: string; address?: string }; + }; + expect(msg.type).toBe('location'); + expect(msg.location).toEqual({ latitude: 1.23, longitude: 4.56, description: 'Office', address: '1 Main St' }); + }); + + it('inbound quoted reply: populates quotedMessage', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { getContentType: jest.Mock }; + baileys.getContentType.mockReturnValue('extendedTextMessage'); + + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'REPLY1' }, + message: { + extendedTextMessage: { + text: 'reply text', + contextInfo: { + stanzaId: 'QUOTED_ID', + quotedMessage: { conversation: 'original message' }, + }, + }, + }, + messageTimestamp: 1700000022, + }, + ], + }); + await new Promise(r => setImmediate(r)); + expect(onMessage).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { + body: string; + quotedMessage: { id: string; body: string }; + }; + expect(msg.body).toBe('reply text'); + expect(msg.quotedMessage).toEqual({ id: 'QUOTED_ID', body: 'original message' }); + }); + + it('REVOKE protocolMessage: fires onMessageRevoked and NOT onMessage', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { getContentType: jest.Mock }; + baileys.getContentType.mockReturnValue('protocolMessage'); + + const onMessage = jest.fn(); + const onMessageRevoked = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage, onMessageRevoked }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'PROTO1' }, + message: { + protocolMessage: { + key: { id: 'ORIGINAL_ID' }, + type: 0, // REVOKE + }, + }, + messageTimestamp: 1700000023, + }, + ], + }); + await new Promise(r => setImmediate(r)); + expect(onMessage).not.toHaveBeenCalled(); + expect(onMessageRevoked).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const revoked = onMessageRevoked.mock.calls[0][0] as { + id: string; + chatId: string; + type: string; + body: string; + }; + expect(revoked.id).toBe('ORIGINAL_ID'); + expect(revoked.chatId).toBe('628111@s.whatsapp.net'); + expect(revoked.type).toBe('revoked'); + expect(revoked.body).toBe(''); + }); + + it('reactionMessage: fires onMessageReaction and NOT onMessage', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { getContentType: jest.Mock }; + baileys.getContentType.mockReturnValue('reactionMessage'); + + const onMessage = jest.fn(); + const onMessageReaction = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage, onMessageReaction }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { + remoteJid: '628111@s.whatsapp.net', + fromMe: false, + id: 'REACT1', + participant: '628111@s.whatsapp.net', + }, + message: { + reactionMessage: { + key: { id: 'TARGET_MSG_ID' }, + text: '👍', + }, + }, + messageTimestamp: 1700000024, + }, + ], + }); + await new Promise(r => setImmediate(r)); + expect(onMessage).not.toHaveBeenCalled(); + expect(onMessageReaction).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const event = onMessageReaction.mock.calls[0][0] as { + messageId: string; + chatId: string; + reaction: string; + senderId: string; + }; + expect(event.messageId).toBe('TARGET_MSG_ID'); + expect(event.chatId).toBe('628111@s.whatsapp.net'); + expect(event.reaction).toBe('👍'); + expect(event.senderId).toBe('628111@s.whatsapp.net'); + }); + + it('media download failure: logs the error and emits the message without media (no throw)', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { + getContentType: jest.Mock; + downloadMediaMessage: jest.Mock; + }; + baileys.getContentType.mockReturnValue('imageMessage'); + baileys.downloadMediaMessage.mockRejectedValue(new Error('download failed')); + + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'IMGFAIL' }, + message: { imageMessage: { mimetype: 'image/jpeg', caption: 'broken' } }, + messageTimestamp: 1700000025, + }, + ], + }); + await new Promise(r => setImmediate(r)); + // message is still emitted, just without media + expect(onMessage).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { media?: unknown }; + expect(msg.media).toBeUndefined(); + }); }); describe('BaileysAdapter media sends', () => { @@ -520,6 +755,7 @@ describe('BaileysAdapter store-backed ops', () => { { key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'IN9' }, message: { conversation: 'hi' } }, ], }); + await new Promise(r => setImmediate(r)); // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const inboundMatcher = expect.objectContaining({ key: expect.objectContaining({ id: 'IN9' }) }); expect(fakeStore.put).toHaveBeenCalledWith('sess-1', inboundMatcher); @@ -692,6 +928,7 @@ describe('BaileysAdapter contact + chat reads', () => { }, ], }); + await new Promise(r => setImmediate(r)); const chats = await adapter.getChats(); expect(chats[0]).toEqual({ id: '628111@s.whatsapp.net', @@ -744,6 +981,7 @@ describe('BaileysAdapter sendSeen + deleteChat', () => { }, ], }); + await new Promise(r => setImmediate(r)); // let async processInboundMessage complete return adapter; }; diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 57c24e6f..81f946de 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -25,6 +25,8 @@ import { PaginatedProducts, Product, ProductQueryOptions, + ReactionEvent, + RevokedMessage, Status, StatusResult, ChatSummary, @@ -582,19 +584,63 @@ export class BaileysAdapter implements IWhatsAppEngine { if (!msg.message || !msg.key?.remoteJid) { continue; // protocol/empty messages carry no neutral content } - const incoming = this.mapMessage(msg); - if (msg.key.fromMe === true) { - this.callbacks.onMessageCreate?.(incoming); - } else { - this.callbacks.onMessage?.(incoming); + void this.processInboundMessage(msg); + } + } + + private async processInboundMessage(msg: WAMessage): Promise { + const b = await this.loadLib(); + const remoteJid = msg.key.remoteJid!; + const contentType = b.getContentType(msg.message ?? undefined); + + // --- protocolMessage REVOKE: don't emit onMessage --- + if (contentType === 'protocolMessage') { + const pm = msg.message?.protocolMessage; + if (pm?.type === b.proto.Message.ProtocolMessage.Type.REVOKE) { + const from = msg.key.fromMe === true ? this.normalizedSelfJid() : remoteJid; + const to = msg.key.fromMe === true ? remoteJid : this.normalizedSelfJid(); + const revoked: RevokedMessage = { + id: pm.key?.id ?? '', + chatId: remoteJid, + from, + to, + type: 'revoked', + body: '', + timestamp: this.toUnixSeconds(msg.messageTimestamp), + }; + this.callbacks.onMessageRevoked?.(revoked); + return; } - void this.config.messageStore?.put(this.config.sessionId, msg).catch(err => - this.logger.warn('Failed to persist message to store', { - error: err instanceof Error ? err.message : String(err), - }), - ); - this.sessionStore.recordMessage(msg); + // Other protocol messages (ephemeral, history sync, etc.) — skip silently. + return; + } + + // --- reactionMessage: don't emit onMessage --- + if (contentType === 'reactionMessage') { + const rm = msg.message?.reactionMessage; + const event: ReactionEvent = { + messageId: rm?.key?.id ?? '', + chatId: remoteJid, + reaction: rm?.text ?? '', + senderId: msg.key.participant ?? remoteJid, + }; + this.callbacks.onMessageReaction?.(event); + return; + } + + // --- Normal message: enrich + emit --- + const incoming = await this.mapMessage(msg, contentType); + if (msg.key.fromMe === true) { + this.callbacks.onMessageCreate?.(incoming); + } else { + this.callbacks.onMessage?.(incoming); } + void this.config.messageStore?.put(this.config.sessionId, msg).catch(err => + this.logger.warn('Failed to persist message to store', { + error: err instanceof Error ? err.message : String(err), + }), + ); + this.sessionStore.recordMessage(msg); } private handleMessagesUpdate( @@ -608,10 +654,99 @@ export class BaileysAdapter implements IWhatsAppEngine { } } - private mapMessage(msg: WAMessage): IncomingMessage { + private async mapMessage(msg: WAMessage, contentType: string | undefined): Promise { + const b = await this.loadLib(); const content = msg.message ?? {}; - const contentType = this.lib?.getContentType(msg.message ?? undefined); - const body = content.conversation ?? content.extendedTextMessage?.text ?? ''; + + // Body: text first, then media caption as fallback. + const body = + content.conversation ?? + content.extendedTextMessage?.text ?? + content.imageMessage?.caption ?? + content.videoMessage?.caption ?? + content.documentMessage?.caption ?? + ''; + + // --- location --- + // ILocationMessage has name/address; ILiveLocationMessage does not — use the static variant only. + let location: IncomingMessage['location']; + if (contentType === 'locationMessage' || contentType === 'liveLocationMessage') { + const lm = content.locationMessage ?? content.liveLocationMessage; + if (lm) { + const staticLm = content.locationMessage; // only ILocationMessage has name/address + location = { + latitude: lm.degreesLatitude ?? 0, + longitude: lm.degreesLongitude ?? 0, + description: staticLm?.name ?? undefined, + address: staticLm?.address ?? undefined, + }; + } + } + + // --- media (image / video / audio / document / sticker) --- + let media: IncomingMessage['media']; + const isMediaType = + contentType === 'imageMessage' || + contentType === 'videoMessage' || + contentType === 'audioMessage' || + contentType === 'documentMessage' || + contentType === 'documentWithCaptionMessage' || + contentType === 'stickerMessage'; + if (isMediaType) { + try { + const buf = await b.downloadMediaMessage( + msg, + 'buffer', + {}, + { + logger: createSilentLogger(), + reuploadRequest: this.sock!.updateMediaMessage, + }, + ); + const subMessage = + content.imageMessage ?? + content.videoMessage ?? + content.audioMessage ?? + content.documentMessage ?? + content.stickerMessage; + const mimetype = subMessage?.mimetype ?? ''; + const filename = content.documentMessage?.fileName ?? undefined; + media = { mimetype, data: buf.toString('base64'), filename }; + } catch (err) { + this.logger.debug('Failed to download inbound media; emitting message without media', { + error: err instanceof Error ? err.message : String(err), + msgId: msg.key.id, + }); + } + } + + // --- quoted message --- + let quotedMessage: IncomingMessage['quotedMessage']; + const subForContext = + content.extendedTextMessage ?? + content.imageMessage ?? + content.videoMessage ?? + content.audioMessage ?? + content.documentMessage ?? + content.stickerMessage ?? + content.locationMessage; + const contextInfo = ( + subForContext as + | { contextInfo?: { stanzaId?: string | null; quotedMessage?: Record | null } } + | undefined + )?.contextInfo; + if (contextInfo?.quotedMessage && contextInfo.stanzaId) { + const qm = contextInfo.quotedMessage as Record; + const qBody = + (qm.conversation as unknown as string) ?? + qm.extendedTextMessage?.text ?? + qm.imageMessage?.caption ?? + qm.videoMessage?.caption ?? + qm.documentMessage?.caption ?? + ''; + quotedMessage = { id: contextInfo.stanzaId, body: qBody }; + } + return buildIncomingMessageFromBaileys({ id: msg.key.id ?? '', remoteJid: msg.key.remoteJid!, @@ -623,6 +758,9 @@ export class BaileysAdapter implements IWhatsAppEngine { timestamp: this.toUnixSeconds(msg.messageTimestamp), pushName: msg.pushName ?? undefined, selfJid: this.normalizedSelfJid(), + media, + location, + quotedMessage, }); } diff --git a/src/modules/session/session.service.spec.ts b/src/modules/session/session.service.spec.ts index 921bb999..3201ca07 100644 --- a/src/modules/session/session.service.spec.ts +++ b/src/modules/session/session.service.spec.ts @@ -557,6 +557,24 @@ describe('SessionService', () => { expect(dispatchedEvents('message.sent')).toHaveLength(0); }); + it('does not dispatch message.received for a status/story broadcast via onMessage (isStatusBroadcast)', async () => { + const callbacks = await startAndCaptureCallbacks(); + + // Engine delivers a status@broadcast inbound — engine-neutral guard must drop it. + callbacks.onMessage!( + makeMessage({ + from: 'status@broadcast', + to: 'me@c.us', + chatId: 'status@broadcast', + fromMe: false, + isStatusBroadcast: true, + }), + ); + await flush(); + + expect(dispatchedEvents('message.received')).toHaveLength(0); + }); + // The default hookManager mock returns an empty `data: {}`; echo the message through so the // engine-set fields (isLidSender) survive the hook and reach the inline-resolution branch. const echoHook = () => diff --git a/src/modules/session/session.service.ts b/src/modules/session/session.service.ts index a93f98be..476d0a14 100644 --- a/src/modules/session/session.service.ts +++ b/src/modules/session/session.service.ts @@ -393,6 +393,11 @@ export class SessionService implements OnModuleDestroy, OnModuleInit, OnApplicat }); }, onMessage: (message): void => { + // Status/Story posts arrive via the inbound path for some engines; don't persist or webhook them. + // Mirrors the isStatusBroadcast guard in onMessageCreate below. + if (message.isStatusBroadcast) { + return; + } this.logger.debug(`Message received from ${message.from}`, { sessionId: id, messageId: message.id, From 3c410ae0578fdc8a69d0496f933fa4b084e2d69f Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 14:32:58 +0700 Subject: [PATCH 4/7] fix(engine): guard inbound processing + unwrap documentWithCaption media Wrap the entire processInboundMessage body in try/catch so a throw from any path logs-and-drops instead of becoming an unhandled rejection. Use normalizeMessageContent (available in @whiskeysockets/baileys@6.7.23) to unwrap documentWithCaptionMessage/viewOnceMessage/ephemeralMessage before extracting subMessage, so documentWithCaptionMessage now yields a non-empty mimetype and fileName. Replace the (qm.conversation as unknown as string) double-cast in the quoted-message body extraction with a properly typed intermediate. Tests: add fakeStore.put not.toHaveBeenCalled assertions to REVOKE and reaction tests; add normalizeMessageContent identity mock; add fixture test proving documentWithCaptionMessage extracts non-empty mimetype. --- src/engine/adapters/baileys.adapter.spec.ts | 51 ++++++++ src/engine/adapters/baileys.adapter.ts | 124 +++++++++++--------- 2 files changed, 121 insertions(+), 54 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 514eb90f..7096cab7 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -50,6 +50,8 @@ jest.mock('@whiskeysockets/baileys', () => ({ fetchLatestBaileysVersion: jest.fn().mockResolvedValue({ version: [2, 3000, 0] }), getContentType: jest.fn(() => 'conversation'), downloadMediaMessage: jest.fn().mockResolvedValue(Buffer.from('IMGDATA')), + // Identity passthrough by default; individual tests may override to simulate unwrapping. + normalizeMessageContent: jest.fn((c: unknown) => c), DisconnectReason: { loggedOut: 401, restartRequired: 515 }, proto: { Message: { @@ -391,6 +393,53 @@ describe('BaileysAdapter inbound fan-out', () => { expect(msg.media).toEqual({ mimetype: 'image/png', data: imgBuf.toString('base64') }); }); + it('inbound documentWithCaption: normalizeMessageContent unwraps wrapper, yields non-empty mimetype', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { + getContentType: jest.Mock; + downloadMediaMessage: jest.Mock; + normalizeMessageContent: jest.Mock; + }; + baileys.getContentType.mockReturnValue('documentWithCaptionMessage'); + const docBuf = Buffer.from('PDFBYTES'); + baileys.downloadMediaMessage.mockResolvedValue(docBuf); + // Simulate normalizeMessageContent unwrapping: returns the inner documentMessage. + baileys.normalizeMessageContent.mockReturnValue({ + documentMessage: { mimetype: 'application/pdf', fileName: 'report.pdf', caption: 'Q1 report' }, + }); + + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'DOC1' }, + message: { + documentWithCaptionMessage: { + message: { + documentMessage: { mimetype: 'application/pdf', fileName: 'report.pdf', caption: 'Q1 report' }, + }, + }, + }, + messageTimestamp: 1700000030, + }, + ], + }); + await new Promise(r => setImmediate(r)); + expect(onMessage).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { + type: string; + media: { mimetype: string; filename?: string; data: string }; + }; + expect(msg.type).toBe('document'); + expect(msg.media.mimetype).toBe('application/pdf'); + expect(msg.media.filename).toBe('report.pdf'); + expect(msg.media.data).toBe(docBuf.toString('base64')); + }); + it('inbound location: populates the location field with coordinates', async () => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const baileys = jest.requireMock('@whiskeysockets/baileys') as { getContentType: jest.Mock }; @@ -491,6 +540,7 @@ describe('BaileysAdapter inbound fan-out', () => { await new Promise(r => setImmediate(r)); expect(onMessage).not.toHaveBeenCalled(); expect(onMessageRevoked).toHaveBeenCalledTimes(1); + expect(fakeStore.put).not.toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const revoked = onMessageRevoked.mock.calls[0][0] as { id: string; @@ -536,6 +586,7 @@ describe('BaileysAdapter inbound fan-out', () => { await new Promise(r => setImmediate(r)); expect(onMessage).not.toHaveBeenCalled(); expect(onMessageReaction).toHaveBeenCalledTimes(1); + expect(fakeStore.put).not.toHaveBeenCalled(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const event = onMessageReaction.mock.calls[0][0] as { messageId: string; diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 81f946de..a38c25cb 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -589,58 +589,65 @@ export class BaileysAdapter implements IWhatsAppEngine { } private async processInboundMessage(msg: WAMessage): Promise { - const b = await this.loadLib(); - const remoteJid = msg.key.remoteJid!; - const contentType = b.getContentType(msg.message ?? undefined); - - // --- protocolMessage REVOKE: don't emit onMessage --- - if (contentType === 'protocolMessage') { - const pm = msg.message?.protocolMessage; - if (pm?.type === b.proto.Message.ProtocolMessage.Type.REVOKE) { - const from = msg.key.fromMe === true ? this.normalizedSelfJid() : remoteJid; - const to = msg.key.fromMe === true ? remoteJid : this.normalizedSelfJid(); - const revoked: RevokedMessage = { - id: pm.key?.id ?? '', + try { + const b = await this.loadLib(); + const remoteJid = msg.key.remoteJid!; + const contentType = b.getContentType(msg.message ?? undefined); + + // --- protocolMessage REVOKE: don't emit onMessage --- + if (contentType === 'protocolMessage') { + const pm = msg.message?.protocolMessage; + if (pm?.type === b.proto.Message.ProtocolMessage.Type.REVOKE) { + const from = msg.key.fromMe === true ? this.normalizedSelfJid() : remoteJid; + const to = msg.key.fromMe === true ? remoteJid : this.normalizedSelfJid(); + const revoked: RevokedMessage = { + id: pm.key?.id ?? '', + chatId: remoteJid, + from, + to, + type: 'revoked', + body: '', + timestamp: this.toUnixSeconds(msg.messageTimestamp), + }; + this.callbacks.onMessageRevoked?.(revoked); + return; + } + // Other protocol messages (ephemeral, history sync, etc.) — skip silently. + return; + } + + // --- reactionMessage: don't emit onMessage --- + if (contentType === 'reactionMessage') { + const rm = msg.message?.reactionMessage; + const event: ReactionEvent = { + messageId: rm?.key?.id ?? '', chatId: remoteJid, - from, - to, - type: 'revoked', - body: '', - timestamp: this.toUnixSeconds(msg.messageTimestamp), + reaction: rm?.text ?? '', + senderId: msg.key.participant ?? remoteJid, }; - this.callbacks.onMessageRevoked?.(revoked); + this.callbacks.onMessageReaction?.(event); return; } - // Other protocol messages (ephemeral, history sync, etc.) — skip silently. - return; - } - - // --- reactionMessage: don't emit onMessage --- - if (contentType === 'reactionMessage') { - const rm = msg.message?.reactionMessage; - const event: ReactionEvent = { - messageId: rm?.key?.id ?? '', - chatId: remoteJid, - reaction: rm?.text ?? '', - senderId: msg.key.participant ?? remoteJid, - }; - this.callbacks.onMessageReaction?.(event); - return; - } - // --- Normal message: enrich + emit --- - const incoming = await this.mapMessage(msg, contentType); - if (msg.key.fromMe === true) { - this.callbacks.onMessageCreate?.(incoming); - } else { - this.callbacks.onMessage?.(incoming); + // --- Normal message: enrich + emit --- + const incoming = await this.mapMessage(msg, contentType); + if (msg.key.fromMe === true) { + this.callbacks.onMessageCreate?.(incoming); + } else { + this.callbacks.onMessage?.(incoming); + } + void this.config.messageStore?.put(this.config.sessionId, msg).catch(err => + this.logger.warn('Failed to persist message to store', { + error: err instanceof Error ? err.message : String(err), + }), + ); + this.sessionStore.recordMessage(msg); + } catch (err) { + this.logger.error( + `Unhandled error processing inbound message (id=${msg.key?.id ?? 'unknown'}); dropping`, + err instanceof Error ? err.message : String(err), + ); } - void this.config.messageStore?.put(this.config.sessionId, msg).catch(err => - this.logger.warn('Failed to persist message to store', { - error: err instanceof Error ? err.message : String(err), - }), - ); - this.sessionStore.recordMessage(msg); } private handleMessagesUpdate( @@ -703,14 +710,17 @@ export class BaileysAdapter implements IWhatsAppEngine { reuploadRequest: this.sock!.updateMediaMessage, }, ); + // normalizeMessageContent unwraps documentWithCaptionMessage / viewOnceMessage / + // ephemeralMessage wrappers so we always reach the inner media sub-message. + const normalizedContent = b.normalizeMessageContent(content) ?? content; const subMessage = - content.imageMessage ?? - content.videoMessage ?? - content.audioMessage ?? - content.documentMessage ?? - content.stickerMessage; + normalizedContent.imageMessage ?? + normalizedContent.videoMessage ?? + normalizedContent.audioMessage ?? + normalizedContent.documentMessage ?? + normalizedContent.stickerMessage; const mimetype = subMessage?.mimetype ?? ''; - const filename = content.documentMessage?.fileName ?? undefined; + const filename = normalizedContent.documentMessage?.fileName ?? undefined; media = { mimetype, data: buf.toString('base64'), filename }; } catch (err) { this.logger.debug('Failed to download inbound media; emitting message without media', { @@ -736,9 +746,15 @@ export class BaileysAdapter implements IWhatsAppEngine { | undefined )?.contextInfo; if (contextInfo?.quotedMessage && contextInfo.stanzaId) { - const qm = contextInfo.quotedMessage as Record; + const qm = contextInfo.quotedMessage as { + conversation?: string | null; + extendedTextMessage?: { text?: string | null } | null; + imageMessage?: { caption?: string | null } | null; + videoMessage?: { caption?: string | null } | null; + documentMessage?: { caption?: string | null } | null; + }; const qBody = - (qm.conversation as unknown as string) ?? + qm.conversation ?? qm.extendedTextMessage?.text ?? qm.imageMessage?.caption ?? qm.videoMessage?.caption ?? From fb93554752f854063420d90a1b7f19a5febe6448 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 14:42:10 +0700 Subject: [PATCH 5/7] =?UTF-8?q?fix(engine):=20baileys=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20stop-race=20guard,=20capped=20backoff=20reconnect,?= =?UTF-8?q?=20first-connect=20error=20surfacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/engine/adapters/baileys.adapter.spec.ts | 184 +++++++++++++++++++- src/engine/adapters/baileys.adapter.ts | 73 +++++++- 2 files changed, 245 insertions(+), 12 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 7096cab7..92a7595e 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -134,13 +134,21 @@ describe('BaileysAdapter lifecycle & status', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access const makeWASocket = jest.requireMock('@whiskeysockets/baileys').default as jest.Mock; makeWASocket.mockClear(); - fakeSock.fire('connection.update', { - connection: 'close', - lastDisconnect: { error: { output: { statusCode: 515 } } }, - }); - await new Promise(r => setImmediate(r)); // let the async connect() run - expect(makeWASocket).toHaveBeenCalledTimes(1); - expect(onDisconnected).not.toHaveBeenCalled(); + + // Reconnect is now backoff-delayed (1 s on first attempt): use fake timers to advance. + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + try { + fakeSock.fire('connection.update', { + connection: 'close', + lastDisconnect: { error: { output: { statusCode: 515 } } }, + }); + jest.advanceTimersByTime(1_000); + await new Promise(r => setImmediate(r)); // let the async connect() body reach makeWASocket + expect(makeWASocket).toHaveBeenCalledTimes(1); + expect(onDisconnected).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } }); it('disconnect() ends the socket and does not reconnect', async () => { @@ -169,6 +177,168 @@ describe('BaileysAdapter lifecycle & status', () => { fakeSock.fire('creds.update', {}); expect(saveCreds).toHaveBeenCalled(); }); + + // C2 — resurrect-after-stop race + it('C2: disconnect() during in-flight connect does NOT assign a socket or reach READY', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { + fetchLatestBaileysVersion: jest.Mock; + default: jest.Mock; + }; + + // Make fetchLatestBaileysVersion block until we manually resolve it. + let resolveVersion!: (v: { version: number[] }) => void; + const versionPromise = new Promise<{ version: number[] }>(res => { + resolveVersion = res; + }); + baileys.fetchLatestBaileysVersion.mockReturnValueOnce(versionPromise); + baileys.default.mockClear(); + + const adapter = newAdapter(); + const initPromise = adapter.initialize(noopCallbacks({})); + + // While connect() is blocked waiting for fetchLatestBaileysVersion, call disconnect(). + await adapter.disconnect(); + + // Now resolve the version fetch. + resolveVersion({ version: [2, 3000, 0] }); + await initPromise.catch(() => undefined); // initialize() resolves regardless + + // The connect() body should have bailed out: no socket created, not READY. + expect(baileys.default).not.toHaveBeenCalled(); + expect(adapter.getStatus()).toBe(EngineStatus.DISCONNECTED); + }); + + // I5 — first-connect error surfacing + it('I5: first connect failure → initialize() rejects, status FAILED, onError fired', async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = jest.requireMock('@whiskeysockets/baileys') as { + fetchLatestBaileysVersion: jest.Mock; + }; + baileys.fetchLatestBaileysVersion.mockRejectedValueOnce(new Error('network error')); + + const onError = jest.fn(); + const adapter = newAdapter(); + await expect(adapter.initialize(noopCallbacks({ onError }))).rejects.toThrow('network error'); + expect(adapter.getStatus()).toBe(EngineStatus.FAILED); + expect(onError).toHaveBeenCalledWith('network error'); + }); +}); + +describe('BaileysAdapter lifecycle hardening — I4 reconnect backoff', () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + const baileys = () => jest.requireMock('@whiskeysockets/baileys') as { default: jest.Mock }; + + const fireRecoverableClose = (): void => { + fakeSock.fire('connection.update', { + connection: 'close', + lastDisconnect: { error: { output: { statusCode: 515 } } }, + }); + }; + + // Helper: initialize the adapter with REAL timers (loadLib uses dynamic import), + // then hand the test an adapter ready for fake-timer-driven reconnect testing. + const initWithRealTimers = async (over: Partial = {}): Promise => { + fakeSock.user = undefined; + fakeSock.resetEmitter(); + jest.clearAllMocks(); + const adapter = newAdapter(); + await adapter.initialize(noopCallbacks(over)); + return adapter; + }; + + afterEach(() => { + // Ensure fake timers are always cleaned up even if a test fails mid-way. + jest.useRealTimers(); + }); + + it('I4: after MAX_RECONNECT_ATTEMPTS recoverable closes → FAILED + onError, no more reconnects', async () => { + const onError = jest.fn(); + const adapter = await initWithRealTimers({ onError }); + + // Switch to fake timers AFTER initialize() has resolved. + jest.useFakeTimers(); + + // Each close increments reconnectAttempts and schedules a timer. + // After the timer fires, connect() runs and re-attaches listeners to fakeSock. + // We do NOT reset the emitter — each reconnect adds new listeners, and the close + // event fires them all (they all point at the same adapter, so the increment happens once per fire). + // Strategy: fire close → run timers (reconnect executes) → fire close again → repeat. + for (let i = 0; i < 5 /* MAX_RECONNECT_ATTEMPTS */; i++) { + fireRecoverableClose(); + await jest.runAllTimersAsync(); + } + + // The (MAX+1)th close — reconnectAttempts is now MAX → exhausted path + fireRecoverableClose(); + await jest.runAllTimersAsync(); + + expect(adapter.getStatus()).toBe(EngineStatus.FAILED); + expect(onError).toHaveBeenCalledWith(expect.stringContaining('exhausted')); + }); + + it('I4: successful connection resets the reconnect counter (next drop can reconnect again)', async () => { + const onError = jest.fn(); + const adapter = await initWithRealTimers({ onError }); + + jest.useFakeTimers(); + + // Fire one recoverable drop and reconnect — increments counter to 1 + fireRecoverableClose(); + await jest.runAllTimersAsync(); + + // Simulate a successful open — should reset the reconnect counter to 0 + fakeSock.fire('connection.update', { connection: 'open' }); + expect(adapter.getStatus()).toBe(EngineStatus.READY); + + // Now exhaust MAX_RECONNECT_ATTEMPTS again — should work because counter was reset + for (let i = 0; i < 5 /* MAX_RECONNECT_ATTEMPTS */; i++) { + fireRecoverableClose(); + await jest.runAllTimersAsync(); + } + + // (MAX+1)th drop after reset → FAILED again + fireRecoverableClose(); + await jest.runAllTimersAsync(); + + expect(adapter.getStatus()).toBe(EngineStatus.FAILED); + expect(onError).toHaveBeenCalledWith(expect.stringContaining('exhausted')); + }); + + it('I4: a recoverable close after disconnect() (intentionalClose) does NOT schedule a reconnect', async () => { + const adapter = await initWithRealTimers({}); + baileys().default.mockClear(); + + jest.useFakeTimers(); + + await adapter.disconnect(); + // Fire a close event after intentional disconnect — must be ignored entirely + fireRecoverableClose(); + await jest.runAllTimersAsync(); + + expect(baileys().default).not.toHaveBeenCalled(); + expect(adapter.getStatus()).toBe(EngineStatus.DISCONNECTED); + }); + + it('I4: backoff timers are used — first reconnect is delayed ~1 s (not immediate)', async () => { + await initWithRealTimers({}); + baileys().default.mockClear(); + + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + + // First drop: should schedule at delay = 1000 ms (2^0 * 1000) + fireRecoverableClose(); + + // Advance only 500 ms — connect should NOT have been called yet + jest.advanceTimersByTime(500); + await new Promise(r => setImmediate(r)); + expect(baileys().default).not.toHaveBeenCalled(); + + // Advance remaining 500 ms → timer fires → connect() is invoked + jest.advanceTimersByTime(500); + await new Promise(r => setImmediate(r)); + expect(baileys().default).toHaveBeenCalledTimes(1); + }); }); describe('BaileysAdapter capability gating', () => { diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index a38c25cb..a32206fb 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -58,6 +58,8 @@ function createSilentLogger(): BaileysLogger { } export class BaileysAdapter implements IWhatsAppEngine { + private static readonly MAX_RECONNECT_ATTEMPTS = 5; + private readonly logger = createLogger('BaileysAdapter'); private readonly authPath: string; private readonly sessionStore = new BaileysSessionStore(); @@ -68,6 +70,9 @@ export class BaileysAdapter implements IWhatsAppEngine { private pushName: string | null = null; private callbacks: EngineEventCallbacks = {}; private intentionalClose = false; + private connecting = false; + private reconnectAttempts = 0; + private reconnectTimer?: ReturnType; /** Lazily loaded @whiskeysockets/baileys module (ESM-only; loaded on first connect, not at boot). */ private lib?: typeof BaileysLib; @@ -92,15 +97,40 @@ export class BaileysAdapter implements IWhatsAppEngine { async initialize(callbacks: EngineEventCallbacks): Promise { this.callbacks = callbacks; this.intentionalClose = false; - await this.connect(); + try { + await this.connect(); + } catch (err) { + this.setStatus(EngineStatus.FAILED); + this.callbacks.onError?.(err instanceof Error ? err.message : String(err)); + throw err; + } } private async connect(): Promise { + // I4: in-flight guard — skip if a connect() is already in progress. + if (this.connecting) { + return; + } + this.connecting = true; + try { + await this.connectInner(); + } finally { + this.connecting = false; + } + } + + private async connectInner(): Promise { this.setStatus(EngineStatus.INITIALIZING); const b = await this.loadLib(); const { state, saveCreds } = await b.useMultiFileAuthState(this.authPath); const { version } = await b.fetchLatestBaileysVersion(); + // C2: resurrect-after-stop guard — if disconnect/logout/destroy ran during the awaits above, + // bail now so we don't create a live socket for a session that was intentionally stopped. + if (this.intentionalClose) { + return; + } + const sock = b.default({ auth: state, version, @@ -152,6 +182,8 @@ export class BaileysAdapter implements IWhatsAppEngine { this.qrCode = null; this.phoneNumber = this.extractPhone(this.sock?.user?.id); this.pushName = this.sock?.user?.name ?? null; + // I4: reset the reconnect counter on a successful connection. + this.reconnectAttempts = 0; this.setStatus(EngineStatus.READY); this.callbacks.onReady?.(this.phoneNumber ?? '', this.pushName ?? ''); } @@ -172,19 +204,42 @@ export class BaileysAdapter implements IWhatsAppEngine { return; } - // Recoverable (e.g. restartRequired right after pairing, transient drop) — reconnect. + // Recoverable (e.g. restartRequired right after pairing, transient drop) — reconnect with backoff. // Do NOT fire onDisconnected here; this is a transient drop, not a terminal disconnect. // connect() calls setStatus(INITIALIZING) which fires onStateChanged — that is the correct signal. this.logger.log('Baileys connection dropped; reconnecting', { statusCode }); - this.connect().catch(err => { + + // I4: capped exponential backoff with in-flight timer guard. + if (this.reconnectAttempts >= BaileysAdapter.MAX_RECONNECT_ATTEMPTS) { this.setStatus(EngineStatus.FAILED); - this.callbacks.onError?.(err instanceof Error ? err.message : String(err)); - }); + this.callbacks.onError?.(`reconnect attempts exhausted (${this.reconnectAttempts})`); + return; + } + this.reconnectAttempts += 1; + const delay = Math.min(30_000, 1_000 * 2 ** (this.reconnectAttempts - 1)); + // Guard: if a timer is already pending, don't stack another one. + if (this.reconnectTimer) { + return; + } + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined; + if (this.intentionalClose) { + return; // stopped while waiting — abort + } + void this.connect().catch(err => { + this.setStatus(EngineStatus.FAILED); + this.callbacks.onError?.(err instanceof Error ? err.message : String(err)); + }); + }, delay); } } disconnect(): Promise { this.intentionalClose = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } this.sock?.end(undefined); this.sock = null; this.setStatus(EngineStatus.DISCONNECTED); @@ -193,6 +248,10 @@ export class BaileysAdapter implements IWhatsAppEngine { async logout(): Promise { this.intentionalClose = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } try { await this.sock?.logout(); } catch (err) { @@ -210,6 +269,10 @@ export class BaileysAdapter implements IWhatsAppEngine { destroy(): Promise { this.intentionalClose = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } this.sock?.end(undefined); this.sock = null; this.setStatus(EngineStatus.DISCONNECTED); From 136ee8fe8685bcf9c1b1ad5d493914a5d933887f Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 14:54:18 +0700 Subject: [PATCH 6/7] test(engine): isolate FakeSock emitter per connect so reconnect-cap tests prove cap=5 --- src/engine/adapters/baileys.adapter.spec.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 92a7595e..2132702c 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -45,7 +45,10 @@ const saveCreds = jest.fn().mockResolvedValue(undefined); jest.mock('@whiskeysockets/baileys', () => ({ __esModule: true, - default: jest.fn(() => fakeSock), + default: jest.fn(() => { + fakeSock.resetEmitter(); + return fakeSock; + }), useMultiFileAuthState: jest.fn().mockResolvedValue({ state: { creds: {}, keys: {} }, saveCreds }), fetchLatestBaileysVersion: jest.fn().mockResolvedValue({ version: [2, 3000, 0] }), getContentType: jest.fn(() => 'conversation'), @@ -260,20 +263,21 @@ describe('BaileysAdapter lifecycle hardening — I4 reconnect backoff', () => { jest.useFakeTimers(); // Each close increments reconnectAttempts and schedules a timer. - // After the timer fires, connect() runs and re-attaches listeners to fakeSock. - // We do NOT reset the emitter — each reconnect adds new listeners, and the close - // event fires them all (they all point at the same adapter, so the increment happens once per fire). - // Strategy: fire close → run timers (reconnect executes) → fire close again → repeat. + // After the timer fires, connect() calls makeWASocket() which resets the emitter, + // so each reconnect cycle has exactly one listener — no accumulation across attempts. + // Strategy: fire close → run timers (reconnect executes, emitter reset) → fire close again → repeat. for (let i = 0; i < 5 /* MAX_RECONNECT_ATTEMPTS */; i++) { fireRecoverableClose(); await jest.runAllTimersAsync(); } - // The (MAX+1)th close — reconnectAttempts is now MAX → exhausted path + // The (MAX+1)th close — reconnectAttempts is now MAX (5) → exhausted path: + // no reconnect scheduled, status → FAILED, onError fired exactly once. fireRecoverableClose(); await jest.runAllTimersAsync(); expect(adapter.getStatus()).toBe(EngineStatus.FAILED); + expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith(expect.stringContaining('exhausted')); }); @@ -297,11 +301,12 @@ describe('BaileysAdapter lifecycle hardening — I4 reconnect backoff', () => { await jest.runAllTimersAsync(); } - // (MAX+1)th drop after reset → FAILED again + // (MAX+1)th drop after reset → FAILED again, onError fired exactly once fireRecoverableClose(); await jest.runAllTimersAsync(); expect(adapter.getStatus()).toBe(EngineStatus.FAILED); + expect(onError).toHaveBeenCalledTimes(1); expect(onError).toHaveBeenCalledWith(expect.stringContaining('exhausted')); }); From 739c6e32d5cf88e934fa093b7d81b39663f9c460 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 15:02:39 +0700 Subject: [PATCH 7/7] =?UTF-8?q?fix(engine):=20baileys=20store=20=E2=80=94?= =?UTF-8?q?=20ms-precise=20eviction=20(no=20SQLite=20wipe)=20+=20FK=20CASC?= =?UTF-8?q?ADE=20on=20session=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: set createdAt: new Date() explicitly in put() upsert so the stored value carries millisecond precision matching the :createdAt bound param in enforceLimit(). Without this, SQLite's datetime('now') stores second-precision (e.g. '…:11') while the JS Date bound serializes as '…:11.000' — SQLite string-compares '…:11' < '…:11.000' = TRUE, so every same-second row matches the eviction predicate, wiping the store to ~0. Reply/forward/react/delete then fail with "message not found". Add a real put()-driven eviction regression test (6 msgs, cap=3, same wall-clock second) that must fail on old code (count=0) and pass with the fix (count=3, C4/C5/C6 kept). I6: add @ManyToOne(() => Session, { onDelete: 'CASCADE' }) to BaileysStoredMessage so the synchronize:true SQLite path emits the CASCADE FK (the migration path already had it). Deleting a session now cascade-deletes its stored messages on both paths. Session entity added to the unit-test DataSource; session rows seeded before put() calls. SQLite FK enforcement in the unit test: TypeORM sqlite driver does not run PRAGMA foreign_keys=ON by default, so FKs are declared but not enforced — existing put() tests pass without a parent sessions row pre-existing. Session rows are seeded anyway for correctness (and to be safe if FK enforcement is ever enabled). --- .../baileys-message-store.service.spec.ts | 73 ++++++++++++++++++- .../adapters/baileys-message-store.service.ts | 10 ++- .../adapters/baileys-stored-message.entity.ts | 10 ++- 3 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/engine/adapters/baileys-message-store.service.spec.ts b/src/engine/adapters/baileys-message-store.service.spec.ts index 07e95508..9c894dc6 100644 --- a/src/engine/adapters/baileys-message-store.service.spec.ts +++ b/src/engine/adapters/baileys-message-store.service.spec.ts @@ -1,14 +1,40 @@ import { DataSource, Repository } from 'typeorm'; import { BaileysStoredMessage } from './baileys-stored-message.entity'; import { BaileysMessageStoreService } from './baileys-message-store.service'; +import { Session, SessionStatus } from '../../modules/session/entities/session.entity'; describe('BaileysMessageStoreService', () => { let ds: DataSource; let repo: Repository; let service: BaileysMessageStoreService; + // Seed a sessions row so FK constraints (if SQLite enables them) resolve correctly. + const seedSession = async (id: string): Promise => { + await ds.getRepository(Session).save( + ds.getRepository(Session).create({ + id, + name: `session-${id}`, + status: SessionStatus.READY, + phone: null, + pushName: null, + config: {}, + proxyUrl: null, + proxyType: null, + connectedAt: null, + lastActiveAt: null, + }), + ); + }; + beforeEach(async () => { - ds = new DataSource({ type: 'sqlite', database: ':memory:', entities: [BaileysStoredMessage], synchronize: true }); + ds = new DataSource({ + type: 'sqlite', + database: ':memory:', + // Session must be present so the @ManyToOne relation metadata resolves and synchronize + // can emit the CASCADE FK on the baileys_stored_messages table (I6). + entities: [BaileysStoredMessage, Session], + synchronize: true, + }); await ds.initialize(); repo = ds.getRepository(BaileysStoredMessage); service = new BaileysMessageStoreService(repo); @@ -27,6 +53,7 @@ describe('BaileysMessageStoreService', () => { }) as unknown as Parameters[1]; it('round-trips a WAMessage through BufferJSON', async () => { + await seedSession('s1'); await service.put('s1', msg('M1')); const got = await service.getMessage('s1', 'M1'); expect(got?.key?.id).toBe('M1'); @@ -34,19 +61,58 @@ describe('BaileysMessageStoreService', () => { }); it('returns null for an unknown id and is session-scoped', async () => { + await seedSession('s1'); await service.put('s1', msg('M1')); expect(await service.getMessage('s1', 'NOPE')).toBeNull(); expect(await service.getMessage('s2', 'M1')).toBeNull(); }); it('is idempotent on (sessionId, waMessageId)', async () => { + await seedSession('s1'); await service.put('s1', msg('M1')); await service.put('s1', msg('M1')); expect(await repo.count({ where: { sessionId: 's1' } })).toBe(1); }); - it('evicts oldest beyond the per-session cap', async () => { + /** + * C1 regression test — drives eviction through the REAL put() path so the stored + * createdAt value comes from the upsert payload (millisecond precision), not from + * SQLite's datetime('now') (second precision). Without the `createdAt: new Date()` + * fix in put(), the string comparison '…:XX' < '…:XX.000' evaluates TRUE for every + * same-second row, and enforceLimit() deletes ALL rows instead of keeping the cap. + * + * This test MUST FAIL against the old code (no explicit createdAt in upsert) and + * PASS with the fix. + */ + it('eviction via put() keeps exactly the cap — never wipes the store (C1)', async () => { + process.env.BAILEYS_MESSAGE_STORE_LIMIT = '3'; + await seedSession('s_c1'); + const s = new BaileysMessageStoreService(repo); + + // Insert 6 messages via put() — each call sets createdAt: new Date(), so even within the + // same wall-clock second the stored values carry millisecond precision. + for (let i = 1; i <= 6; i++) { + await s.put('s_c1', msg(`C${i}`)); + } + + const count = await repo.count({ where: { sessionId: 's_c1' } }); + // With the bug: count is 0 (all evicted). With the fix: count is exactly 3. + expect(count).toBe(3); + + // The newest messages (C4, C5, C6) must survive — they have the latest createdAt. + expect(await s.getMessage('s_c1', 'C4')).not.toBeNull(); + expect(await s.getMessage('s_c1', 'C5')).not.toBeNull(); + expect(await s.getMessage('s_c1', 'C6')).not.toBeNull(); + + // The oldest messages must be evicted. + expect(await s.getMessage('s_c1', 'C1')).toBeNull(); + expect(await s.getMessage('s_c1', 'C2')).toBeNull(); + expect(await s.getMessage('s_c1', 'C3')).toBeNull(); + }); + + it('evicts oldest beyond the per-session cap (pre-seeded rows, distinct timestamps)', async () => { process.env.BAILEYS_MESSAGE_STORE_LIMIT = '2'; + await seedSession('s1'); const s = new BaileysMessageStoreService(repo); // Use distinct createdAt values so ordering is deterministic regardless of UUID tiebreaker. const t0 = new Date('2024-01-01T00:00:00.000Z'); @@ -69,6 +135,7 @@ describe('BaileysMessageStoreService', () => { it('keeps exactly limit rows when multiple share the same createdAt (tiebreaker via id)', async () => { process.env.BAILEYS_MESSAGE_STORE_LIMIT = '2'; + await seedSession('s2'); const s = new BaileysMessageStoreService(repo); // Insert 3 rows with identical createdAt to stress the (createdAt, id) tiebreaker. // With UUID primary keys, id ordering is lexicographic — we can only assert count, not which survive. @@ -85,6 +152,8 @@ describe('BaileysMessageStoreService', () => { }); it('clearSession removes only that session', async () => { + await seedSession('s1'); + await seedSession('s2'); await service.put('s1', msg('M1')); await service.put('s2', msg('M2')); await service.clearSession('s1'); diff --git a/src/engine/adapters/baileys-message-store.service.ts b/src/engine/adapters/baileys-message-store.service.ts index f82095f4..0f6caf77 100644 --- a/src/engine/adapters/baileys-message-store.service.ts +++ b/src/engine/adapters/baileys-message-store.service.ts @@ -33,7 +33,15 @@ export class BaileysMessageStoreService implements BaileysMessageStore { const { BufferJSON } = await this.loadLib(); const serializedMessage = JSON.stringify(msg, BufferJSON.replacer); // Idempotent: the same message arrives from the send return AND the messages.upsert echo. - await this.repo.upsert({ sessionId, waMessageId, serializedMessage }, ['sessionId', 'waMessageId']); + // createdAt is set explicitly so the stored value carries millisecond precision — matching the + // :createdAt bound param used in enforceLimit(). Without this, SQLite's datetime('now') stores + // second-precision (e.g. '…:11') while the JS Date bound serializes as '…:11.000', and SQLite + // string-compares '…:11' < '…:11.000' = TRUE, causing every same-second row to be over-evicted + // and the store to be wiped to ~0 (C1). + await this.repo.upsert({ sessionId, waMessageId, serializedMessage, createdAt: new Date() }, [ + 'sessionId', + 'waMessageId', + ]); await this.enforceLimit(sessionId); } diff --git a/src/engine/adapters/baileys-stored-message.entity.ts b/src/engine/adapters/baileys-stored-message.entity.ts index 55884631..acb32d37 100644 --- a/src/engine/adapters/baileys-stored-message.entity.ts +++ b/src/engine/adapters/baileys-stored-message.entity.ts @@ -1,9 +1,13 @@ -import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; +import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Session } from '../../modules/session/entities/session.entity'; /** * Persisted Baileys message store (the lib ships none). Holds the serialized WAMessage proto * (via BufferJSON) so reply/forward/react/delete can resolve the original message/key by id across * restarts. Engine-specific — lives in the engine layer, not the neutral `messages` table. + * + * The `session` relation declares the CASCADE FK so both the `synchronize:true` SQLite path and + * the migration path clean up stored messages when the parent session row is deleted (I6). */ @Entity('baileys_stored_messages') @Index(['sessionId', 'waMessageId'], { unique: true }) // lookup + dedup (send-return vs upsert echo) @@ -15,6 +19,10 @@ export class BaileysStoredMessage { @Column() sessionId: string; + @ManyToOne(() => Session, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'sessionId' }) + session?: Session; + @Column() waMessageId: string;