From aae8e4ed6d8c8012ee7bcd70ec1ec858029e816c Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:27:20 +0700 Subject: [PATCH 1/7] feat(engine): add in-memory BaileysSessionStore (contacts/chats/last-message) --- .../adapters/baileys-session-store.spec.ts | 86 +++++++++++ src/engine/adapters/baileys-session-store.ts | 146 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 src/engine/adapters/baileys-session-store.spec.ts create mode 100644 src/engine/adapters/baileys-session-store.ts diff --git a/src/engine/adapters/baileys-session-store.spec.ts b/src/engine/adapters/baileys-session-store.spec.ts new file mode 100644 index 00000000..706d7c47 --- /dev/null +++ b/src/engine/adapters/baileys-session-store.spec.ts @@ -0,0 +1,86 @@ +import { BaileysSessionStore } from './baileys-session-store'; + +describe('BaileysSessionStore', () => { + let store: BaileysSessionStore; + beforeEach(() => { + store = new BaileysSessionStore(); + }); + + it('upserts contacts (full then partial merge) and maps to neutral', () => { + store.upsertContacts([{ id: '628111@s.whatsapp.net', notify: 'Al', imgUrl: 'http://p/x.jpg' }]); + store.upsertContacts([{ id: '628111@s.whatsapp.net', name: 'Alice' }]); // partial: name added, notify kept + const c = store.findContact('628111@s.whatsapp.net'); + expect(c).toEqual({ + id: '628111@s.whatsapp.net', + name: 'Alice', + pushName: 'Al', + number: '628111', + isMyContact: true, + isBlocked: false, + profilePicUrl: 'http://p/x.jpg', + }); + expect(store.findContact('nope@s.whatsapp.net')).toBeNull(); + expect(store.listContacts()).toHaveLength(1); + }); + + it('records the newest message per chat and surfaces it in getChats', () => { + store.upsertChats([{ id: '628111@s.whatsapp.net', name: 'Alice', unreadCount: 2 }]); + store.recordMessage({ + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'OLD' }, + message: { conversation: 'old' }, + messageTimestamp: 100, + }); + store.recordMessage({ + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'NEW' }, + message: { conversation: 'newest' }, + messageTimestamp: 200, + }); + const chats = store.listChats(); + expect(chats).toEqual([ + { + id: '628111@s.whatsapp.net', + name: 'Alice', + isGroup: false, + unreadCount: 2, + timestamp: 200, + lastMessage: 'newest', + }, + ]); + expect(store.lastMessage('628111@s.whatsapp.net')).toEqual({ + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'NEW' }, + timestamp: 200, + }); + }); + + it('does not overwrite a newer last-message with an older one', () => { + store.recordMessage({ + key: { remoteJid: 'c@s.whatsapp.net', id: 'NEW' }, + message: {}, + messageTimestamp: 200, + }); + store.recordMessage({ + key: { remoteJid: 'c@s.whatsapp.net', id: 'OLD' }, + message: {}, + messageTimestamp: 100, + }); + expect(store.lastMessage('c@s.whatsapp.net')?.key.id).toBe('NEW'); + }); + + it('flags a group chat by jid', () => { + store.upsertChats([{ id: '123-456@g.us', name: 'Grp' }]); + expect(store.listChats()[0].isGroup).toBe(true); + }); + + it('lastMessage returns null for an unknown chat', () => { + expect(store.lastMessage('unknown@s.whatsapp.net')).toBeNull(); + }); + + it('resolves a phone jid to its user-part, a lid via lidPnMappings, and a contact phoneNumber', () => { + expect(store.resolvePhone('628111@s.whatsapp.net')).toBe('628111'); + store.addLidMappings([{ lid: '111@lid', pn: '628999@s.whatsapp.net' }]); + expect(store.resolvePhone('111@lid')).toBe('628999'); + store.upsertContacts([{ id: '222@lid', phoneNumber: '628222@s.whatsapp.net' }]); + expect(store.resolvePhone('222@lid')).toBe('628222'); + expect(store.resolvePhone('333@lid')).toBeNull(); + }); +}); diff --git a/src/engine/adapters/baileys-session-store.ts b/src/engine/adapters/baileys-session-store.ts new file mode 100644 index 00000000..9ba3703f --- /dev/null +++ b/src/engine/adapters/baileys-session-store.ts @@ -0,0 +1,146 @@ +import type { Chat, Contact as BaileysContact, WAMessage, WAMessageKey } from '@whiskeysockets/baileys'; +import { ChatSummary, Contact } from '../interfaces/whatsapp-engine.interface'; + +/** + * Baileys `Contact` does not include a `phoneNumber` field, but WhatsApp Business events may supply + * the resolved phone JID alongside the lid-based id. We extend the input type locally so callers can + * pass `phoneNumber` when available (e.g. from `contacts.upsert` payloads that carry lid+pn pairs). + */ +type BaileysContactWithPhone = BaileysContact & { phoneNumber?: string }; + +interface LastMessage { + key: WAMessageKey; + timestamp: number; + text: string; +} + +/** + * Per-session, in-memory snapshot of Baileys contacts + chats, fed from `sock.ev` events. Baileys has + * no fetch-all; this data arrives via `contacts.*`/`chats.*`/`messaging-history.set` (a full re-sync on + * each connect) and is mapped to the neutral `Contact`/`ChatSummary` on read. Holds no socket — pure data. + */ +export class BaileysSessionStore { + private readonly contacts = new Map(); + private readonly chats = new Map(); + private readonly lastMessages = new Map(); + private readonly lidToPn = new Map(); + + upsertContacts(records: Partial[] = []): void { + for (const r of records) { + if (!r.id) { + continue; + } + const existing = this.contacts.get(r.id) ?? { id: r.id }; + const merged: BaileysContactWithPhone = { ...existing, ...r }; + this.contacts.set(r.id, merged); + if (r.lid && r.phoneNumber) { + this.lidToPn.set(r.lid, r.phoneNumber); + } + } + } + + upsertChats(records: Partial[] = []): void { + for (const r of records) { + if (!r.id) { + continue; + } + const existing = this.chats.get(r.id) ?? { id: r.id }; + this.chats.set(r.id, { ...existing, ...r }); + } + } + + addLidMappings(mappings: { lid?: string; pn?: string }[] = []): void { + for (const m of mappings) { + if (m.lid && m.pn) { + this.lidToPn.set(m.lid, m.pn); + } + } + } + + recordMessage(msg: WAMessage): void { + const chatId = msg.key?.remoteJid; + if (!chatId || !msg.key) { + return; + } + const timestamp = this.toUnixSeconds(msg.messageTimestamp); + const existing = this.lastMessages.get(chatId); + if (existing && existing.timestamp >= timestamp) { + return; // keep the newest + } + const text = msg.message?.conversation ?? msg.message?.extendedTextMessage?.text ?? ''; + this.lastMessages.set(chatId, { key: msg.key, timestamp, text }); + } + + listContacts(): Contact[] { + return [...this.contacts.values()].map(c => this.toNeutralContact(c)); + } + + findContact(id: string): Contact | null { + const c = this.contacts.get(id); + return c ? this.toNeutralContact(c) : null; + } + + listChats(): ChatSummary[] { + return [...this.chats.values()].map(c => this.toNeutralChat(c)); + } + + lastMessage(chatId: string): { key: WAMessageKey; timestamp: number } | null { + const m = this.lastMessages.get(chatId); + return m ? { key: m.key, timestamp: m.timestamp } : null; + } + + resolvePhone(id: string): string | null { + if (id.endsWith('@s.whatsapp.net')) { + return this.userPart(id); + } + if (id.endsWith('@lid')) { + const pn = this.lidToPn.get(id); + if (pn) { + return this.userPart(pn); + } + const contactPhone = this.contacts.get(id)?.phoneNumber; + return contactPhone ? this.userPart(contactPhone) : null; + } + return null; + } + + private toNeutralContact(c: BaileysContactWithPhone): Contact { + const number = c.phoneNumber + ? this.userPart(c.phoneNumber) + : c.id.endsWith('@s.whatsapp.net') + ? this.userPart(c.id) + : ''; + return { + id: c.id, + name: c.name ?? c.verifiedName, + pushName: c.notify, + number, + isMyContact: true, // best-effort: present in the synced address book / chat list + isBlocked: false, // best-effort: blocklist state is not tracked in this slice + profilePicUrl: c.imgUrl ?? undefined, + }; + } + + private toNeutralChat(c: Chat): ChatSummary { + const last = this.lastMessages.get(c.id); + return { + id: c.id, + name: c.name ?? this.userPart(c.id), + isGroup: c.id.endsWith('@g.us'), + unreadCount: c.unreadCount ?? 0, + timestamp: last?.timestamp ?? this.toUnixSeconds(c.conversationTimestamp), + lastMessage: last?.text, + }; + } + + private userPart(jid: string): string { + return jid.split('@')[0].split(':')[0]; + } + + private toUnixSeconds(ts: number | { toNumber(): number } | null | undefined): number { + if (ts == null) { + return 0; + } + return typeof ts === 'number' ? ts : ts.toNumber(); + } +} From 98780958bb7661058a6b8fb22b5cba1df3a60966 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:33:40 +0700 Subject: [PATCH 2/7] feat(engine): baileys contact/chat reads via in-memory session store --- src/engine/adapters/baileys.adapter.spec.ts | 73 ++++++++++++++++++++- src/engine/adapters/baileys.adapter.ts | 65 ++++++++++++++---- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 75e4fbb2..47d1bdc2 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -30,6 +30,8 @@ class FakeSock extends EventEmitter { public groupRevokeInvite = jest.fn(); public profilePictureUrl = jest.fn(); public updateBlockStatus = jest.fn().mockResolvedValue(undefined); + public readMessages = jest.fn().mockResolvedValue(undefined); + public chatModify = jest.fn().mockResolvedValue(undefined); fire(event: string, arg: unknown): void { this.emitter.emit(event, arg); } @@ -160,9 +162,9 @@ describe('BaileysAdapter lifecycle & status', () => { }); describe('BaileysAdapter capability gating', () => { - it('throws EngineNotSupportedError for store-backed methods (e.g. getChats)', async () => { + it('throws EngineNotSupportedError for still-gated methods (e.g. sendSeen)', async () => { const adapter = newAdapter(); - await expect(adapter.getChats()).rejects.toBeInstanceOf(EngineNotSupportedError); + await expect(adapter.sendSeen('628111@s.whatsapp.net')).rejects.toBeInstanceOf(EngineNotSupportedError); }); }); @@ -645,3 +647,70 @@ describe('BaileysAdapter profile + block', () => { expect(fakeSock.updateBlockStatus).toHaveBeenCalledWith('628111@s.whatsapp.net', 'unblock'); }); }); + +describe('BaileysAdapter contact + chat reads', () => { + beforeEach(() => { + fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + jest.clearAllMocks(); + }); + + const ready = async (): Promise => { + const adapter = newAdapter(); + await adapter.initialize({}); + fakeSock.fire('connection.update', { connection: 'open' }); + return adapter; + }; + + it('populates contacts from contacts.upsert and reads them', async () => { + const adapter = await ready(); + fakeSock.fire('contacts.upsert', [{ id: '628111@s.whatsapp.net', notify: 'Al' }]); + const contacts = await adapter.getContacts(); + expect(contacts).toHaveLength(1); + expect(contacts[0]).toMatchObject({ id: '628111@s.whatsapp.net', pushName: 'Al', number: '628111' }); + expect((await adapter.getContactById('628111@s.whatsapp.net'))?.number).toBe('628111'); + expect(await adapter.getContactById('x@s.whatsapp.net')).toBeNull(); + }); + + it('populates chats + last message and reads getChats', async () => { + const adapter = await ready(); + fakeSock.fire('chats.upsert', [{ id: '628111@s.whatsapp.net', name: 'Alice', unreadCount: 1 }]); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'M1' }, + message: { conversation: 'hi' }, + messageTimestamp: 1700000010, + }, + ], + }); + const chats = await adapter.getChats(); + expect(chats[0]).toEqual({ + id: '628111@s.whatsapp.net', + name: 'Alice', + isGroup: false, + unreadCount: 1, + timestamp: 1700000010, + lastMessage: 'hi', + }); + }); + + it('populates from messaging-history.set incl. lid mappings', async () => { + const adapter = await ready(); + fakeSock.fire('messaging-history.set', { + contacts: [{ id: '628222@s.whatsapp.net', name: 'Bob' }], + chats: [{ id: '628222@s.whatsapp.net', name: 'Bob' }], + messages: [], + lidPnMappings: [{ lid: '111@lid', pn: '628999@s.whatsapp.net' }], + }); + expect(await adapter.getContacts()).toHaveLength(1); + expect(await adapter.resolveContactPhone('111@lid')).toBe('628999'); + expect(await adapter.resolveContactPhone('628222@s.whatsapp.net')).toBe('628222'); + }); + + it('contact/chat reads reject with EngineNotReadyError before connect', async () => { + const adapter = newAdapter(); + await adapter.initialize({}); + await expect(adapter.getContacts()).rejects.toBeInstanceOf(EngineNotReadyError); + }); +}); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 856ee766..30669ec0 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -40,6 +40,7 @@ import { EngineNotReadyError } from '../../common/errors/engine-not-ready.error' import { EngineNotSupportedError } from '../../common/errors/engine-not-supported.error'; import { createLogger } from '../../common/services/logger.service'; import { BaileysAdapterConfig, BaileysLogger } from '../types/baileys.types'; +import { BaileysSessionStore } from './baileys-session-store'; /** Linked-device identity shown in WhatsApp (Settings → Linked Devices). */ const BAILEYS_BROWSER: [string, string, string] = ['OpenWA', 'Chrome', '120.0.0']; @@ -62,6 +63,7 @@ function createSilentLogger(): BaileysLogger { export class BaileysAdapter implements IWhatsAppEngine { private readonly logger = createLogger('BaileysAdapter'); private readonly authPath: string; + private readonly sessionStore = new BaileysSessionStore(); private sock: WASocket | null = null; private status: EngineStatus = EngineStatus.DISCONNECTED; private qrCode: string | null = null; @@ -112,6 +114,18 @@ export class BaileysAdapter implements IWhatsAppEngine { 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)); + sock.ev.on('contacts.upsert', contacts => this.sessionStore.upsertContacts(contacts)); + sock.ev.on('contacts.update', updates => this.sessionStore.upsertContacts(updates)); + sock.ev.on('chats.upsert', chats => this.sessionStore.upsertChats(chats)); + sock.ev.on('chats.update', updates => this.sessionStore.upsertChats(updates)); + sock.ev.on('messaging-history.set', history => { + this.sessionStore.upsertContacts(history.contacts); + this.sessionStore.upsertChats(history.chats); + // lidPnMappings is not in the installed @whiskeysockets/baileys@6.7.23 type definition but + // is present at runtime in later protocol versions; cast to access it safely. + const lidPnMappings = (history as unknown as { lidPnMappings?: { lid: string; pn: string }[] }).lidPnMappings; + this.sessionStore.addLidMappings(lidPnMappings ?? []); + }); } private handleConnectionUpdate(update: { @@ -433,21 +447,50 @@ export class BaileysAdapter implements IWhatsAppEngine { await this.sock!.updateBlockStatus(contactId, 'unblock'); } + // ----- Contacts & chats ----- + + getContacts(): Promise { + try { + this.ensureReady(); + return Promise.resolve(this.sessionStore.listContacts()); + } catch (e) { + return Promise.reject(e as Error); + } + } + + getContactById(contactId: string): Promise { + try { + this.ensureReady(); + return Promise.resolve(this.sessionStore.findContact(contactId)); + } catch (e) { + return Promise.reject(e as Error); + } + } + + resolveContactPhone(contactId: string): Promise { + try { + this.ensureReady(); + return Promise.resolve(this.sessionStore.resolvePhone(contactId)); + } catch (e) { + return Promise.reject(e as Error); + } + } + + getChats(): Promise { + try { + this.ensureReady(); + return Promise.resolve(this.sessionStore.listChats()); + } catch (e) { + return Promise.reject(e as Error); + } + } + // ----- Gated: not supported by this minimal slice (no store) ----- /* eslint-disable @typescript-eslint/no-unused-vars */ getMessageReactions(_chatId: string, _messageId: string): Promise { return this.unsupported('getMessageReactions'); } - getContacts(): Promise { - return this.unsupported('getContacts'); - } - getContactById(_contactId: string): Promise { - return this.unsupported('getContactById'); - } - resolveContactPhone(_contactId: string): Promise { - return this.unsupported('resolveContactPhone'); - } getChatHistory(_chatId: string, _limit?: number, _includeMedia?: boolean): Promise { return this.unsupported('getChatHistory'); } @@ -514,9 +557,6 @@ export class BaileysAdapter implements IWhatsAppEngine { sendCatalog(_chatId: string, _body?: string): Promise { return this.unsupported('sendCatalog'); } - getChats(): Promise { - return this.unsupported('getChats'); - } sendSeen(_chatId: string): Promise { return this.unsupported('sendSeen'); } @@ -547,6 +587,7 @@ export class BaileysAdapter implements IWhatsAppEngine { error: err instanceof Error ? err.message : String(err), }), ); + this.sessionStore.recordMessage(msg); } } From 80f9f1f54910cf1eaee2e877d0495adeb77018c7 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:39:06 +0700 Subject: [PATCH 3/7] test(engine): reset FakeSock emitter per describe to stop listener leak; sound catch errors --- src/engine/adapters/baileys.adapter.spec.ts | 8 ++++++++ src/engine/adapters/baileys.adapter.ts | 8 ++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 47d1bdc2..1cf8d985 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -171,6 +171,7 @@ describe('BaileysAdapter capability gating', () => { describe('BaileysAdapter location + contact sends', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); fakeSock.sendMessage.mockResolvedValue({ key: { id: 'M2' }, messageTimestamp: 1700000006 }); }); @@ -222,6 +223,7 @@ describe('BaileysAdapter location + contact sends', () => { describe('BaileysAdapter messaging', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); }); @@ -276,6 +278,7 @@ describe('BaileysAdapter inbound fan-out', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); baileys.getContentType.mockReturnValue('conversation'); }); @@ -345,6 +348,7 @@ describe('BaileysAdapter inbound fan-out', () => { describe('BaileysAdapter media sends', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); fakeSock.sendMessage.mockResolvedValue({ key: { id: 'M1' }, messageTimestamp: 1700000005 }); }); @@ -441,6 +445,7 @@ describe('BaileysAdapter media sends', () => { describe('BaileysAdapter store-backed ops', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); fakeSock.sendMessage.mockResolvedValue({ key: { id: 'OUT', remoteJid: '628111@s.whatsapp.net', fromMe: true }, @@ -544,6 +549,7 @@ describe('BaileysAdapter group management', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); }); @@ -620,6 +626,7 @@ describe('BaileysAdapter group management', () => { describe('BaileysAdapter profile + block', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); }); @@ -651,6 +658,7 @@ describe('BaileysAdapter profile + block', () => { describe('BaileysAdapter contact + chat reads', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); }); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 30669ec0..27dd4f30 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -454,7 +454,7 @@ export class BaileysAdapter implements IWhatsAppEngine { this.ensureReady(); return Promise.resolve(this.sessionStore.listContacts()); } catch (e) { - return Promise.reject(e as Error); + return Promise.reject(e instanceof Error ? e : new Error(String(e))); } } @@ -463,7 +463,7 @@ export class BaileysAdapter implements IWhatsAppEngine { this.ensureReady(); return Promise.resolve(this.sessionStore.findContact(contactId)); } catch (e) { - return Promise.reject(e as Error); + return Promise.reject(e instanceof Error ? e : new Error(String(e))); } } @@ -472,7 +472,7 @@ export class BaileysAdapter implements IWhatsAppEngine { this.ensureReady(); return Promise.resolve(this.sessionStore.resolvePhone(contactId)); } catch (e) { - return Promise.reject(e as Error); + return Promise.reject(e instanceof Error ? e : new Error(String(e))); } } @@ -481,7 +481,7 @@ export class BaileysAdapter implements IWhatsAppEngine { this.ensureReady(); return Promise.resolve(this.sessionStore.listChats()); } catch (e) { - return Promise.reject(e as Error); + return Promise.reject(e instanceof Error ? e : new Error(String(e))); } } From 606c370f404c31fee0001cf631cb72fc41beb5a4 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:43:20 +0700 Subject: [PATCH 4/7] feat(engine): baileys sendSeen + deleteChat; advertise read-receipts --- CHANGELOG.md | 1 + src/engine/adapters/baileys.adapter.spec.ts | 68 ++++++++++++++++++++- src/engine/adapters/baileys.adapter.ts | 29 +++++++-- src/plugins/engines/baileys/index.spec.ts | 3 +- src/plugins/engines/baileys/index.ts | 1 + test/baileys-engine.e2e-spec.ts | 1 + 6 files changed, 94 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bb0f06..4e4efd55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ plugins instead of in core (#265). - **Baileys engine — media/location/contact sends.** The Baileys engine (`ENGINE_TYPE=baileys`) can now send image/video/audio/document/sticker, location, and contact messages (slice 2a). URL media is fetched through the same SSRF-guarded path as the whatsapp-web.js engine (host guard + byte cap + timeout, no redirects). Reply/forward/react/delete remain unsupported (HTTP 501) until a later slice adds a message store. (#307) - **Baileys engine — reply/forward/react/delete.** The Baileys engine can now reply to, forward, react to, and delete (revoke-for-everyone) messages, backed by a persisted per-session message store (`baileys_stored_messages`, bounded by `BAILEYS_MESSAGE_STORE_LIMIT`, default 5000). Delete-for-me and reading reactions remain unsupported (HTTP 501). Note: this engine persists recent message protos to the data database (so these operations survive restarts) — more data-at-rest than the whatsapp-web.js engine; the store is bounded per session, cleared on logout, and CASCADE-deleted with its session, and operators with retention requirements can tune `BAILEYS_MESSAGE_STORE_LIMIT`. (#308) - **Baileys engine — group management + profile picture + block/unblock.** The Baileys engine can now list/inspect/create groups, manage participants (add/remove/promote/demote), leave a group, set its subject/description, and get/revoke its invite code, plus fetch a contact's profile picture and block/unblock contacts (slice 3a). Contacts/chats lists, read receipts, chat history, and `deleteChat` remain unsupported (HTTP 501) until a later slice adds a contact/chat store; labels/channels/status/catalog stay unsupported (parity with the whatsapp-web.js engine). (#309) +- **Baileys engine — contacts, chats, read receipts.** The Baileys engine can now list contacts (`getContacts`/`getContactById`), resolve a contact's phone (`resolveContactPhone`, best-effort), list chats (`getChats`), mark a chat read (`sendSeen`), and delete a chat (`deleteChat`) — backed by a per-session in-memory store fed from Baileys' contact/chat sync events (rebuilt on each connect). `getChatHistory` remains unsupported (HTTP 501): Baileys has no on-demand history API. Labels/channels/status/catalog/message-reactions stay unsupported (parity with the whatsapp-web.js engine). With this, the Baileys engine implements every neutral capability it can meaningfully support. (#PR) ### Changed diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 1cf8d985..830abc94 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -162,9 +162,9 @@ describe('BaileysAdapter lifecycle & status', () => { }); describe('BaileysAdapter capability gating', () => { - it('throws EngineNotSupportedError for still-gated methods (e.g. sendSeen)', async () => { + it('throws EngineNotSupportedError for still-gated methods (e.g. getChatHistory)', async () => { const adapter = newAdapter(); - await expect(adapter.sendSeen('628111@s.whatsapp.net')).rejects.toBeInstanceOf(EngineNotSupportedError); + await expect(adapter.getChatHistory('628111@s.whatsapp.net')).rejects.toBeInstanceOf(EngineNotSupportedError); }); }); @@ -722,3 +722,67 @@ describe('BaileysAdapter contact + chat reads', () => { await expect(adapter.getContacts()).rejects.toBeInstanceOf(EngineNotReadyError); }); }); + +describe('BaileysAdapter sendSeen + deleteChat', () => { + beforeEach(() => { + fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + jest.clearAllMocks(); + }); + + const readyWithMessage = async (): Promise => { + const adapter = newAdapter(); + await adapter.initialize({}); + fakeSock.fire('connection.update', { connection: 'open' }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'M1' }, + message: { conversation: 'hi' }, + messageTimestamp: 1700000020, + }, + ], + }); + return adapter; + }; + + it('sendSeen marks the last message read and returns true', async () => { + const adapter = await readyWithMessage(); + const ok = await adapter.sendSeen('628111@s.whatsapp.net'); + expect(ok).toBe(true); + expect(fakeSock.readMessages).toHaveBeenCalledWith([ + { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'M1' }, + ]); + }); + + it('sendSeen returns false when no last message is known', async () => { + const adapter = newAdapter(); + await adapter.initialize({}); + fakeSock.fire('connection.update', { connection: 'open' }); + expect(await adapter.sendSeen('628999@s.whatsapp.net')).toBe(false); + expect(fakeSock.readMessages).not.toHaveBeenCalled(); + }); + + it('deleteChat revokes the chat via chatModify with the last message', async () => { + const adapter = await readyWithMessage(); + const ok = await adapter.deleteChat('628111@s.whatsapp.net'); + expect(ok).toBe(true); + expect(fakeSock.chatModify).toHaveBeenCalledWith( + { + delete: true, + lastMessages: [ + { key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'M1' }, messageTimestamp: 1700000020 }, + ], + }, + '628111@s.whatsapp.net', + ); + }); + + it('deleteChat returns false when no last message is known', async () => { + const adapter = newAdapter(); + await adapter.initialize({}); + fakeSock.fire('connection.update', { connection: 'open' }); + expect(await adapter.deleteChat('628999@s.whatsapp.net')).toBe(false); + expect(fakeSock.chatModify).not.toHaveBeenCalled(); + }); +}); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 27dd4f30..256be154 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -485,6 +485,29 @@ export class BaileysAdapter implements IWhatsAppEngine { } } + async sendSeen(chatId: string): Promise { + this.ensureReady(); + const last = this.sessionStore.lastMessage(chatId); + if (!last) { + return false; // nothing known to mark read + } + await this.sock!.readMessages([last.key]); + return true; + } + + async deleteChat(chatId: string): Promise { + this.ensureReady(); + const last = this.sessionStore.lastMessage(chatId); + if (!last) { + return false; // Baileys' delete needs the last message; can't synthesize it + } + await this.sock!.chatModify( + { delete: true, lastMessages: [{ key: last.key, messageTimestamp: last.timestamp }] }, + chatId, + ); + return true; + } + // ----- Gated: not supported by this minimal slice (no store) ----- /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -557,12 +580,6 @@ export class BaileysAdapter implements IWhatsAppEngine { sendCatalog(_chatId: string, _body?: string): Promise { return this.unsupported('sendCatalog'); } - sendSeen(_chatId: string): Promise { - return this.unsupported('sendSeen'); - } - deleteChat(_chatId: string): Promise { - return this.unsupported('deleteChat'); - } /* eslint-enable @typescript-eslint/no-unused-vars */ // ----- Helpers ----- diff --git a/src/plugins/engines/baileys/index.spec.ts b/src/plugins/engines/baileys/index.spec.ts index fc1cd39a..38d55f2a 100644 --- a/src/plugins/engines/baileys/index.spec.ts +++ b/src/plugins/engines/baileys/index.spec.ts @@ -37,7 +37,7 @@ describe('BaileysPlugin.createEngine (opaque config)', () => { ); }); - it('advertises the slice-3a supported feature set', () => { + it('advertises the slice-3b supported feature set', () => { expect(new BaileysPlugin().getFeatures()).toEqual([ 'text-messages', 'typing-indicator', @@ -49,6 +49,7 @@ describe('BaileysPlugin.createEngine (opaque config)', () => { 'message-reactions', 'message-deletion', 'group-management', + 'read-receipts', ]); }); diff --git a/src/plugins/engines/baileys/index.ts b/src/plugins/engines/baileys/index.ts index fd269264..21e7859c 100644 --- a/src/plugins/engines/baileys/index.ts +++ b/src/plugins/engines/baileys/index.ts @@ -62,6 +62,7 @@ export class BaileysPlugin implements IEnginePlugin { 'message-reactions', 'message-deletion', 'group-management', + 'read-receipts', ]; } diff --git a/test/baileys-engine.e2e-spec.ts b/test/baileys-engine.e2e-spec.ts index 15dfb2c8..66a57d1a 100644 --- a/test/baileys-engine.e2e-spec.ts +++ b/test/baileys-engine.e2e-spec.ts @@ -65,6 +65,7 @@ describe('Baileys engine boot (e2e)', () => { 'message-reactions', 'message-deletion', 'group-management', + 'read-receipts', ]); }); }); From 2d3eef9f1e00393d2723d3197fdbc16a12a3d5ba Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:46:11 +0700 Subject: [PATCH 5/7] test(engine): resetEmitter in the sendSeen/deleteChat describe (listener-leak uniformity) --- src/engine/adapters/baileys.adapter.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 830abc94..cc397000 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -726,6 +726,7 @@ describe('BaileysAdapter contact + chat reads', () => { describe('BaileysAdapter sendSeen + deleteChat', () => { beforeEach(() => { fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' }; + fakeSock.resetEmitter(); jest.clearAllMocks(); }); From dd9d917b7d69853dd8036f83b50bc88b873de1e1 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:51:31 +0700 Subject: [PATCH 6/7] refactor(engine): make baileys contact/chat reads async (file-consistent) --- src/engine/adapters/baileys.adapter.ts | 44 ++++++++++---------------- 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 256be154..fc8edc73 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -449,40 +449,28 @@ export class BaileysAdapter implements IWhatsAppEngine { // ----- Contacts & chats ----- - getContacts(): Promise { - try { - this.ensureReady(); - return Promise.resolve(this.sessionStore.listContacts()); - } catch (e) { - return Promise.reject(e instanceof Error ? e : new Error(String(e))); - } + // eslint-disable-next-line @typescript-eslint/require-await + async getContacts(): Promise { + this.ensureReady(); + return this.sessionStore.listContacts(); } - getContactById(contactId: string): Promise { - try { - this.ensureReady(); - return Promise.resolve(this.sessionStore.findContact(contactId)); - } catch (e) { - return Promise.reject(e instanceof Error ? e : new Error(String(e))); - } + // eslint-disable-next-line @typescript-eslint/require-await + async getContactById(contactId: string): Promise { + this.ensureReady(); + return this.sessionStore.findContact(contactId); } - resolveContactPhone(contactId: string): Promise { - try { - this.ensureReady(); - return Promise.resolve(this.sessionStore.resolvePhone(contactId)); - } catch (e) { - return Promise.reject(e instanceof Error ? e : new Error(String(e))); - } + // eslint-disable-next-line @typescript-eslint/require-await + async resolveContactPhone(contactId: string): Promise { + this.ensureReady(); + return this.sessionStore.resolvePhone(contactId); } - getChats(): Promise { - try { - this.ensureReady(); - return Promise.resolve(this.sessionStore.listChats()); - } catch (e) { - return Promise.reject(e instanceof Error ? e : new Error(String(e))); - } + // eslint-disable-next-line @typescript-eslint/require-await + async getChats(): Promise { + this.ensureReady(); + return this.sessionStore.listChats(); } async sendSeen(chatId: string): Promise { From 52de417e04970d620f1684723ea66de5517b8e87 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 12:53:04 +0700 Subject: [PATCH 7/7] docs(changelog): reference PR #310 for baileys contacts/chats --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e4efd55..b4034b5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ plugins instead of in core (#265). - **Baileys engine — media/location/contact sends.** The Baileys engine (`ENGINE_TYPE=baileys`) can now send image/video/audio/document/sticker, location, and contact messages (slice 2a). URL media is fetched through the same SSRF-guarded path as the whatsapp-web.js engine (host guard + byte cap + timeout, no redirects). Reply/forward/react/delete remain unsupported (HTTP 501) until a later slice adds a message store. (#307) - **Baileys engine — reply/forward/react/delete.** The Baileys engine can now reply to, forward, react to, and delete (revoke-for-everyone) messages, backed by a persisted per-session message store (`baileys_stored_messages`, bounded by `BAILEYS_MESSAGE_STORE_LIMIT`, default 5000). Delete-for-me and reading reactions remain unsupported (HTTP 501). Note: this engine persists recent message protos to the data database (so these operations survive restarts) — more data-at-rest than the whatsapp-web.js engine; the store is bounded per session, cleared on logout, and CASCADE-deleted with its session, and operators with retention requirements can tune `BAILEYS_MESSAGE_STORE_LIMIT`. (#308) - **Baileys engine — group management + profile picture + block/unblock.** The Baileys engine can now list/inspect/create groups, manage participants (add/remove/promote/demote), leave a group, set its subject/description, and get/revoke its invite code, plus fetch a contact's profile picture and block/unblock contacts (slice 3a). Contacts/chats lists, read receipts, chat history, and `deleteChat` remain unsupported (HTTP 501) until a later slice adds a contact/chat store; labels/channels/status/catalog stay unsupported (parity with the whatsapp-web.js engine). (#309) -- **Baileys engine — contacts, chats, read receipts.** The Baileys engine can now list contacts (`getContacts`/`getContactById`), resolve a contact's phone (`resolveContactPhone`, best-effort), list chats (`getChats`), mark a chat read (`sendSeen`), and delete a chat (`deleteChat`) — backed by a per-session in-memory store fed from Baileys' contact/chat sync events (rebuilt on each connect). `getChatHistory` remains unsupported (HTTP 501): Baileys has no on-demand history API. Labels/channels/status/catalog/message-reactions stay unsupported (parity with the whatsapp-web.js engine). With this, the Baileys engine implements every neutral capability it can meaningfully support. (#PR) +- **Baileys engine — contacts, chats, read receipts.** The Baileys engine can now list contacts (`getContacts`/`getContactById`), resolve a contact's phone (`resolveContactPhone`, best-effort), list chats (`getChats`), mark a chat read (`sendSeen`), and delete a chat (`deleteChat`) — backed by a per-session in-memory store fed from Baileys' contact/chat sync events (rebuilt on each connect). `getChatHistory` remains unsupported (HTTP 501): Baileys has no on-demand history API. Labels/channels/status/catalog/message-reactions stay unsupported (parity with the whatsapp-web.js engine). With this, the Baileys engine implements every neutral capability it can meaningfully support. (#310) ### Changed