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-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-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 54b44858..0f6caf77 100644 --- a/src/engine/adapters/baileys-message-store.service.ts +++ b/src/engine/adapters/baileys-message-store.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { BufferJSON } from '@whiskeysockets/baileys'; +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,6 +13,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?: typeof BaileysLib; + + private async loadLib(): Promise { + return (this.baileysLib ??= await import('@whiskeysockets/baileys')); + } + constructor( @InjectRepository(BaileysStoredMessage, 'data') private readonly repo: Repository, @@ -23,9 +30,18 @@ export class BaileysMessageStoreService implements BaileysMessageStore { if (!waMessageId) { return; } + 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); } @@ -34,6 +50,7 @@ export class BaileysMessageStoreService implements BaileysMessageStore { if (!row) { return null; } + const { BufferJSON } = await this.loadLib(); return JSON.parse(row.serializedMessage, BufferJSON.reviver) as WAMessage; } 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; diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index cc397000..2132702c 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -45,11 +45,24 @@ 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'), + 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: { + ProtocolMessage: { + Type: { REVOKE: 0 }, + }, + }, + }, })); import { BaileysAdapter } from './baileys.adapter'; @@ -124,13 +137,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 () => { @@ -159,6 +180,170 @@ 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() 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 (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')); + }); + + 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, onError fired exactly once + fireRecoverableClose(); + await jest.runAllTimersAsync(); + + expect(adapter.getStatus()).toBe(EngineStatus.FAILED); + expect(onError).toHaveBeenCalledTimes(1); + 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', () => { @@ -298,6 +483,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 +505,7 @@ describe('BaileysAdapter inbound fan-out', () => { }, ], }); + await new Promise(r => setImmediate(r)); expect(onMessageCreate).toHaveBeenCalledTimes(1); expect(onMessage).not.toHaveBeenCalled(); }); @@ -343,6 +530,280 @@ 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 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 }; + 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); + 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; + 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); + 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; + 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 +981,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 +1154,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 +1207,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 fc8edc73..a32206fb 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -1,10 +1,5 @@ import * as path from 'path'; -import makeWASocket, { - DisconnectReason, - fetchLatestBaileysVersion, - getContentType, - useMultiFileAuthState, -} from '@whiskeysockets/baileys'; +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'; @@ -30,6 +25,8 @@ import { PaginatedProducts, Product, ProductQueryOptions, + ReactionEvent, + RevokedMessage, Status, StatusResult, ChatSummary, @@ -61,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(); @@ -71,6 +70,15 @@ 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; + + 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. @@ -89,15 +97,41 @@ 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 { state, saveCreds } = await useMultiFileAuthState(this.authPath); - const { version } = await fetchLatestBaileysVersion(); + const b = await this.loadLib(); + const { state, saveCreds } = await b.useMultiFileAuthState(this.authPath); + const { version } = await b.fetchLatestBaileysVersion(); - const sock = makeWASocket({ + // 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, browser: BAILEYS_BROWSER, @@ -109,8 +143,7 @@ export class BaileysAdapter implements IWhatsAppEngine { }); this.sock = sock; - // eslint-disable-next-line @typescript-eslint/no-misused-promises - 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)); @@ -149,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 ?? ''); } @@ -162,26 +197,49 @@ export class BaileysAdapter implements IWhatsAppEngine { return; } - if (statusCode === DisconnectReason.loggedOut) { + 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'); 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); @@ -190,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) { @@ -207,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); @@ -581,7 +647,53 @@ export class BaileysAdapter implements IWhatsAppEngine { if (!msg.message || !msg.key?.remoteJid) { continue; // protocol/empty messages carry no neutral content } - const incoming = this.mapMessage(msg); + void this.processInboundMessage(msg); + } + } + + private async processInboundMessage(msg: WAMessage): Promise { + 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, + 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 { @@ -593,6 +705,11 @@ export class BaileysAdapter implements IWhatsAppEngine { }), ); 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), + ); } } @@ -607,10 +724,108 @@ 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 = 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, + }, + ); + // normalizeMessageContent unwraps documentWithCaptionMessage / viewOnceMessage / + // ephemeralMessage wrappers so we always reach the inner media sub-message. + const normalizedContent = b.normalizeMessageContent(content) ?? content; + const subMessage = + normalizedContent.imageMessage ?? + normalizedContent.videoMessage ?? + normalizedContent.audioMessage ?? + normalizedContent.documentMessage ?? + normalizedContent.stickerMessage; + const mimetype = subMessage?.mimetype ?? ''; + 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', { + 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 { + 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 ?? + 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!, @@ -622,6 +837,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,