From bd1838b145e4248c61907f220fe2a25bde3ad381 Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Sat, 20 Jun 2026 09:57:01 +0800 Subject: [PATCH 1/2] feat(engine): typed WaId + persistent lid->phone resolution table + message from-filter Implements #349 (the typed-WaId + resolution-table follow-up to #342). - WaId value object (src/engine/identity/wa-id.value.ts): in-memory only, serializes byte-identically to today's neutral JID; three-valued refersToSamePerson. - lid_mappings table on the data connection (global, last-write-wins, nullable phone for a negative cache) + portable pg/sqlite migration; LidMappingStore loads on boot and writes through, keeping resolvePhone synchronous. - Back resolvePhone with the table; populate it at runtime from the lid<->phone pairs the Baileys engine actually carries: inbound senderPn/participantPn, chats.phoneNumberShare, contacts (jid), and history sync. - from-filter on GET messages that resolves a phone to its lids, so a lid-resolved match becomes a hit (closes the silent-miss gap); covered by a test. - On-demand POST :chatId/history/sync (Baileys fetchMessageHistory) to backfill mappings on an already-authed session. - Canonicalize Baileys contact/chat listing ids to @c.us (read-back folds the neutral id back to the engine dialect so send/mark-read round-trip). RESOLVE_LID_TO_PHONE resolves internally into the table and gates only what is exposed (privacy flag, not a correctness toggle), per maintainer guidance on #349. --- CHANGELOG.md | 26 ++++- .../1781200000000-AddLidMappings.ts | 32 +++++ .../adapters/baileys-session-store.spec.ts | 80 ++++++++++++- src/engine/adapters/baileys-session-store.ts | 55 +++++++-- src/engine/adapters/baileys.adapter.spec.ts | 5 +- src/engine/adapters/baileys.adapter.ts | 15 ++- src/engine/engine.factory.ts | 4 +- src/engine/engine.module.ts | 8 +- .../identity/lid-mapping-store.service.ts | 89 ++++++++++++++ src/engine/identity/lid-mapping-store.spec.ts | 93 +++++++++++++++ src/engine/identity/lid-mapping.entity.ts | 31 +++++ src/engine/identity/wa-id.value.spec.ts | 109 ++++++++++++++++++ src/engine/identity/wa-id.value.ts | 103 +++++++++++++++++ src/engine/types/baileys.types.ts | 3 + src/modules/message/message.controller.ts | 7 ++ src/modules/message/message.service.spec.ts | 55 +++++++++ src/modules/message/message.service.ts | 28 ++++- src/plugins/engines/baileys/index.ts | 3 + 18 files changed, 726 insertions(+), 20 deletions(-) create mode 100644 src/database/migrations/1781200000000-AddLidMappings.ts create mode 100644 src/engine/identity/lid-mapping-store.service.ts create mode 100644 src/engine/identity/lid-mapping-store.spec.ts create mode 100644 src/engine/identity/lid-mapping.entity.ts create mode 100644 src/engine/identity/wa-id.value.spec.ts create mode 100644 src/engine/identity/wa-id.value.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb1abed..c7d18632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Persistent, cross-session `lid -> phone` resolution + a `from` filter on message history.** A new + `lid_mappings` table (on the `data` connection) records the `lid -> phone` mappings WhatsApp pushes us + (history sync, contacts) so resolution is shared across sessions and survives restarts, instead of + living only in one Baileys session's in-memory map. `GET /api/sessions/:sessionId/messages` now accepts + a `from` query param that resolves through this table: filtering by a phone returns not just messages + stored as `@c.us` but also those whose sender was an unresolved `@lid` that has since + resolved to that phone - closing a gap where a phone-based filter silently missed the same person's + lid-addressed (e.g. group) messages. The table is populated at runtime from the lid<->phone pairs the + Baileys engine observes (inbound message `senderPn`/`participantPn`, the `chats.phoneNumberShare` + event, contacts, and history sync), so it fills continuously without re-auth. Internally these ids are + now carried by a typed `WaId` value object; it is in-memory only and serializes to the exact same + neutral string, so **no webhook / WebSocket / REST response shape changes**. + +### Fixed + +- **Baileys engine: contact and chat *listing* ids are now engine-neutral (`@c.us`).** `getContacts` / + `getChats` / `getContactById` previously returned the raw `@s.whatsapp.net` id (visible in the + dashboard, and mismatched against the `@c.us` chatId stored on messages). They now emit the neutral + `@c.us` dialect like the message payloads; the read-back paths (`sendSeen` / `deleteChat` / contact + lookup) accept the neutral id and fold it back internally, so sending and marking-read still round-trip. + **Consumer-visible:** Baileys contact/chat-list ids flip `@s.whatsapp.net` -> `@c.us` (whatsapp-web.js + already used `@c.us`). + ## [0.4.5] - 2026-06-20 A Baileys engine quality-and-correctness release, plus a chat-history enhancement. **Identity:** inbound @@ -45,7 +70,6 @@ consumer that stored or compared the old ids will see the new value. `participantPn`), so the sender of an incoming message resolves to its number and later contact lookups succeed. Still best-effort by design — a number is only revealed once WhatsApp delivers the mapping (e.g. an inbound message from that contact). (#362) - - **Baileys engine: inbound message ids are now engine-neutral (`@c.us`).** The Baileys adapter emitted its native `@s.whatsapp.net` / `@lid` ids in message payloads (`from` / `to` / `chatId` / `author`, plus revoked and reaction events), while the whatsapp-web.js engine and the rest of the diff --git a/src/database/migrations/1781200000000-AddLidMappings.ts b/src/database/migrations/1781200000000-AddLidMappings.ts new file mode 100644 index 00000000..8671caa9 --- /dev/null +++ b/src/database/migrations/1781200000000-AddLidMappings.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Creates `lid_mappings` - the persisted, cross-session `lid -> phone` resolution table on the `data` + * connection. Hand-authored because `synchronize` is off for `data` on Postgres (and optional on + * SQLite); the `hasTable` guard keeps it idempotent on a DB where synchronize already created it. + */ +export class AddLidMappings1781200000000 implements MigrationInterface { + name = 'AddLidMappings1781200000000'; + + public async up(queryRunner: QueryRunner): Promise { + if (await queryRunner.hasTable('lid_mappings')) return; + const isPostgres = queryRunner.connection.options.type === 'postgres'; + + if (isPostgres) { + await queryRunner.query( + `CREATE TABLE "lid_mappings" ("lid" varchar PRIMARY KEY NOT NULL, "phone" varchar, "sessionId" varchar, "updatedAt" timestamp NOT NULL DEFAULT NOW())`, + ); + } else { + await queryRunner.query( + `CREATE TABLE "lid_mappings" ("lid" varchar PRIMARY KEY NOT NULL, "phone" varchar, "sessionId" varchar, "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`, + ); + } + + await queryRunner.query(`CREATE INDEX "IDX_lid_mappings_phone" ON "lid_mappings" ("phone")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_lid_mappings_phone"`); + await queryRunner.query(`DROP TABLE "lid_mappings"`); + } +} diff --git a/src/engine/adapters/baileys-session-store.spec.ts b/src/engine/adapters/baileys-session-store.spec.ts index fb78208b..3207f821 100644 --- a/src/engine/adapters/baileys-session-store.spec.ts +++ b/src/engine/adapters/baileys-session-store.spec.ts @@ -11,7 +11,8 @@ describe('BaileysSessionStore', () => { 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', + id: '628111@c.us', // listing ids are emitted in the neutral dialect + name: 'Alice', pushName: 'Al', number: '628111', @@ -38,7 +39,7 @@ describe('BaileysSessionStore', () => { const chats = store.listChats(); expect(chats).toEqual([ { - id: '628111@s.whatsapp.net', + id: '628111@c.us', // listing ids are emitted in the neutral dialect name: 'Alice', isGroup: false, unreadCount: 2, @@ -174,4 +175,79 @@ describe('BaileysSessionStore', () => { expect(store.toNeutralJid('628111@c.us')).toBe('628111@c.us'); }); }); + + describe('neutral contact/chat ids (round-trip)', () => { + it('emits @c.us listing ids and accepts a neutral id back on lookup', () => { + store.upsertContacts([{ id: '628111@s.whatsapp.net', name: 'Alice' }]); + store.upsertChats([{ id: '628111@s.whatsapp.net', name: 'Alice' }]); + store.recordMessage({ + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'M1' }, + message: { conversation: 'hi' }, + messageTimestamp: 100, + }); + // listing emits the neutral dialect + expect(store.listContacts()[0].id).toBe('628111@c.us'); + expect(store.listChats()[0].id).toBe('628111@c.us'); + // and the read-back paths accept that same neutral id (folded to the engine dialect internally) + expect(store.findContact('628111@c.us')?.id).toBe('628111@c.us'); + expect(store.lastMessage('628111@c.us')?.key.id).toBe('M1'); + }); + + it('keeps group ids unchanged', () => { + store.upsertChats([{ id: '120363-9@g.us', name: 'Team' }]); + expect(store.listChats()[0].id).toBe('120363-9@g.us'); + }); + }); + + describe('persistent lid->phone table', () => { + const makeFakeLidStore = () => { + const map = new Map(); + return { + map, + getCached: jest.fn((lid: string) => map.get(lid)), + lidsForPhone: jest.fn(() => [] as string[]), + remember: jest.fn((lid: string, phone: string | null) => { + map.set(lid, phone); + return Promise.resolve(); + }), + }; + }; + + it('writes learned mappings through to the table (bare digits + session provenance)', () => { + const lidStore = makeFakeLidStore(); + const s = new BaileysSessionStore(lidStore, 'sess-1'); + s.addLidMappings([{ lid: '111@lid', pn: '628999@s.whatsapp.net' }]); + // Baileys 6.7.23 carries the phone in `jid`; the WhatsApp Business shape uses `phoneNumber`. + s.upsertContacts([{ id: '222@lid', lid: '222@lid', jid: '628222@s.whatsapp.net' }]); + s.upsertContacts([{ id: '333@lid', lid: '333@lid', phoneNumber: '628333@s.whatsapp.net' }]); + expect(lidStore.remember).toHaveBeenCalledWith('111', '628999', 'sess-1'); + expect(lidStore.remember).toHaveBeenCalledWith('222', '628222', 'sess-1'); + expect(lidStore.remember).toHaveBeenCalledWith('333', '628333', 'sess-1'); + }); + + it('pairs a lid and phone that arrive in separate contact updates', () => { + const lidStore = makeFakeLidStore(); + const s = new BaileysSessionStore(lidStore, 'sess-1'); + s.upsertContacts([{ id: 'c1', lid: '444@lid' }]); // lid first, no phone yet + expect(lidStore.remember).not.toHaveBeenCalled(); + s.upsertContacts([{ id: 'c1', jid: '628444@s.whatsapp.net' }]); // phone arrives later + expect(lidStore.remember).toHaveBeenCalledWith('444', '628444', 'sess-1'); + }); + + it('resolves a lid via the persistent cache when the in-session map misses', () => { + const lidStore = makeFakeLidStore(); + lidStore.map.set('444', '628777'); // known only to the cross-session table + const s = new BaileysSessionStore(lidStore, 'sess-1'); + expect(s.resolvePhone('444@lid')).toBe('628777'); + expect(s.toNeutralJid('444@lid')).toBe('628777@c.us'); + }); + + it('returns null for a cached-negative or unseen lid', () => { + const lidStore = makeFakeLidStore(); + lidStore.map.set('555', null); // known-but-unresolved + const s = new BaileysSessionStore(lidStore, 'sess-1'); + expect(s.resolvePhone('555@lid')).toBeNull(); + expect(s.resolvePhone('666@lid')).toBeNull(); + }); + }); }); diff --git a/src/engine/adapters/baileys-session-store.ts b/src/engine/adapters/baileys-session-store.ts index 8d4db9c4..adf046ff 100644 --- a/src/engine/adapters/baileys-session-store.ts +++ b/src/engine/adapters/baileys-session-store.ts @@ -1,6 +1,7 @@ import type { Chat, Contact as BaileysContact, WAMessage, WAMessageKey } from '@whiskeysockets/baileys'; import { ChatSummary, Contact } from '../interfaces/whatsapp-engine.interface'; import { parseWaId, toNeutralJid as canonicalizeWaId, userPart } from '../identity/wa-id'; +import type { LidMappingStore } from '../identity/lid-mapping-store.service'; /** * Baileys `Contact` does not include a `phoneNumber` field, but WhatsApp Business events may supply @@ -26,6 +27,16 @@ export class BaileysSessionStore { private readonly lastMessages = new Map(); private readonly lidToPn = new Map(); + /** + * @param lidStore optional persisted, cross-session lid->phone table that backs resolution beyond + * this session's in-memory map (survives restarts, shared across sessions). + * @param sessionId provenance recorded on rows this session writes to the table. + */ + constructor( + private readonly lidStore?: LidMappingStore, + private readonly sessionId?: string, + ) {} + upsertContacts(records: Partial[] = []): void { for (const r of records) { if (!r.id) { @@ -34,8 +45,13 @@ export class BaileysSessionStore { 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); + // Capture a lid->phone pair from the merged record (lid + phone can arrive in separate updates). + // The phone is `jid` on a Baileys Contact (`@s.whatsapp.net`); `phoneNumber` only appears on the + // WhatsApp Business event shape we extend in locally. + const phone = merged.phoneNumber ?? merged.jid; + if (merged.lid && phone) { + this.lidToPn.set(merged.lid, phone); + this.persistLidMapping(merged.lid, phone); } } } @@ -54,6 +70,7 @@ export class BaileysSessionStore { for (const m of mappings) { if (m.lid && m.pn) { this.lidToPn.set(m.lid, m.pn); + this.persistLidMapping(m.lid, m.pn); } } } @@ -63,7 +80,8 @@ export class BaileysSessionStore { * (`senderPn` / `participantPn`) next to its privacy id (`senderLid` / `participantLid`) on the message * key — the only place a fresh `@lid` sender's number is revealed in @whiskeysockets/baileys@6.7.23 * (there is no `getPNForLID` lookup and `contacts.*` / `messaging-history.set` don't fire for it). This - * lets `resolvePhone` (senderPhone, `GET /contacts/:id/phone`) and lid canonicalization succeed. + * lets `resolvePhone` (senderPhone, `GET /contacts/:id/phone`) and lid canonicalization succeed. The + * pairs flow through addLidMappings, so they also write through to the persistent table. */ recordKeyLidMappings(key: Pick): void { this.addLidMappings([ @@ -72,6 +90,11 @@ export class BaileysSessionStore { ]); } + /** Write a learned lid->phone pair through to the persistent table (bare digits, fire-and-forget). */ + private persistLidMapping(lidJid: string, pnJid: string): void { + void this.lidStore?.remember(userPart(lidJid), userPart(pnJid), this.sessionId); + } + recordMessage(msg: WAMessage): void { const chatId = msg.key?.remoteJid; if (!chatId || !msg.key) { @@ -91,7 +114,7 @@ export class BaileysSessionStore { } findContact(id: string): Contact | null { - const c = this.contacts.get(id); + const c = this.contacts.get(id) ?? this.contacts.get(this.toEngineJid(id)); return c ? this.toNeutralContact(c) : null; } @@ -100,7 +123,7 @@ export class BaileysSessionStore { } lastMessage(chatId: string): { key: WAMessageKey; timestamp: number } | null { - const m = this.lastMessages.get(chatId); + const m = this.lastMessages.get(chatId) ?? this.lastMessages.get(this.toEngineJid(chatId)); return m ? { key: m.key, timestamp: m.timestamp } : null; } @@ -119,7 +142,12 @@ export class BaileysSessionStore { return userPart(pn); } const contactPhone = (this.contacts.get(lidJid) ?? this.contacts.get(id))?.phoneNumber; - return contactPhone ? userPart(contactPhone) : null; + if (contactPhone) { + return userPart(contactPhone); + } + // Fall back to the persistent, cross-session table (in-memory cache, keyed by bare lid digits). + // `null` means a cached negative (known-unresolved); `undefined` means never seen - both -> null. + return this.lidStore?.getCached(parsed.userPart) ?? null; } return null; } @@ -132,10 +160,21 @@ export class BaileysSessionStore { return canonicalizeWaId(jid, id => this.resolvePhone(id)); } + /** + * Fold an app-facing neutral id back to the engine's raw dialect for map lookups. The contacts / + * chats / lastMessages maps are keyed by Baileys' raw `@s.whatsapp.net`, but the app now hands us the + * neutral `@c.us` (contact/chat ids are emitted neutral). Groups/lids/others share the dialect, so + * pass them through unchanged. + */ + private toEngineJid(jid: string): string { + const parsed = parseWaId(jid); + return parsed.kind === 'user' ? `${parsed.userPart}@s.whatsapp.net` : jid; + } + private toNeutralContact(c: BaileysContactWithPhone): Contact { const number = c.phoneNumber ? userPart(c.phoneNumber) : c.id.endsWith('@s.whatsapp.net') ? userPart(c.id) : ''; return { - id: c.id, + id: this.toNeutralJid(c.id), name: c.name ?? c.verifiedName, pushName: c.notify, number, @@ -148,7 +187,7 @@ export class BaileysSessionStore { private toNeutralChat(c: Chat): ChatSummary { const last = this.lastMessages.get(c.id); return { - id: c.id, + id: this.toNeutralJid(c.id), name: c.name ?? this.resolveContactName(c.id), isGroup: c.id.endsWith('@g.us'), unreadCount: c.unreadCount ?? 0, diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 0f0cb940..6814a468 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -1329,8 +1329,9 @@ describe('BaileysAdapter contact + chat reads', () => { 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(contacts[0]).toMatchObject({ id: '628111@c.us', pushName: 'Al', number: '628111' }); expect((await adapter.getContactById('628111@s.whatsapp.net'))?.number).toBe('628111'); + expect((await adapter.getContactById('628111@c.us'))?.id).toBe('628111@c.us'); // neutral id round-trips expect(await adapter.getContactById('x@s.whatsapp.net')).toBeNull(); }); @@ -1350,7 +1351,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', + id: '628111@c.us', name: 'Alice', isGroup: false, unreadCount: 1, diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index bce4afd3..ccdac4e1 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -65,7 +65,7 @@ export class BaileysAdapter implements IWhatsAppEngine { private readonly logger = createLogger('BaileysAdapter'); private readonly authPath: string; - private readonly sessionStore = new BaileysSessionStore(); + private readonly sessionStore: BaileysSessionStore; private sock: WASocket | null = null; private status: EngineStatus = EngineStatus.DISCONNECTED; private qrCode: string | null = null; @@ -86,6 +86,7 @@ export class BaileysAdapter implements IWhatsAppEngine { constructor(private readonly config: BaileysAdapterConfig) { // Isolate each session's auth state under its own subdirectory of the shared auth dir. this.authPath = path.join(config.authDir, config.sessionId); + this.sessionStore = new BaileysSessionStore(config.lidMappingStore, config.sessionId); if (config.proxyUrl) { // Proxy support is gated for this slice — Baileys proxying needs an http/socks agent (a new dep). this.logger.warn('Proxy configured but not supported by the baileys engine in this slice; ignoring it', { @@ -185,7 +186,16 @@ export class BaileysAdapter implements IWhatsAppEngine { // 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 ?? []); + this.logger.debug('History sync received', { + action: 'baileys_history_set', + sessionId: this.config.sessionId, + contacts: history.contacts?.length ?? 0, + lidContacts: history.contacts?.filter(c => c.lid).length ?? 0, + lidPnMappings: lidPnMappings?.length ?? 0, + }); }); + // WhatsApp pushes this when a lid contact shares its phone number - a direct lid->phone pair. + sock.ev.on('chats.phoneNumberShare', ({ lid, jid }) => this.sessionStore.addLidMappings([{ lid, pn: jid }])); } private handleConnectionUpdate(update: { @@ -707,7 +717,8 @@ export class BaileysAdapter implements IWhatsAppEngine { const b = await this.loadLib(); const remoteJid = msg.key.remoteJid!; // Learn any lid->pn pair the key carries BEFORE canonicalizing ids below, so a fresh @lid - // sender resolves to its phone in this message and for later contact lookups (#362). + // sender resolves to its phone in this message and for later contact lookups (#362). The pairs + // also write through to the persistent lid->phone table via addLidMappings. this.sessionStore.recordKeyLidMappings(msg.key); const contentType = b.getContentType(msg.message ?? undefined); diff --git a/src/engine/engine.factory.ts b/src/engine/engine.factory.ts index 59199d5f..8709e009 100644 --- a/src/engine/engine.factory.ts +++ b/src/engine/engine.factory.ts @@ -7,6 +7,7 @@ import { WhatsAppWebJsPlugin } from '../plugins/engines/whatsapp-web-js'; import { BaileysPlugin } from '../plugins/engines/baileys'; import { createLogger } from '../common/services/logger.service'; import { BaileysMessageStoreService } from './adapters/baileys-message-store.service'; +import { LidMappingStoreService } from './identity/lid-mapping-store.service'; export interface EngineCreateOptions { sessionId: string; @@ -23,6 +24,7 @@ export class EngineFactory implements OnModuleInit { private readonly configService: ConfigService, private readonly pluginLoader: PluginLoaderService, private readonly baileysMessageStore: BaileysMessageStoreService, + private readonly lidMappingStore: LidMappingStoreService, ) { this.engineType = this.configService.get('engine.type') ?? 'whatsapp-web.js'; } @@ -66,7 +68,7 @@ export class EngineFactory implements OnModuleInit { }; this.pluginLoader.registerBuiltInPlugin( baileysManifest, - new BaileysPlugin(this.baileysMessageStore, engineConfig), + new BaileysPlugin(this.baileysMessageStore, engineConfig, this.lidMappingStore), engineConfig, ); diff --git a/src/engine/engine.module.ts b/src/engine/engine.module.ts index 8ddf47d5..a20aaedb 100644 --- a/src/engine/engine.module.ts +++ b/src/engine/engine.module.ts @@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { EngineFactory } from './engine.factory'; import { BaileysStoredMessage } from './adapters/baileys-stored-message.entity'; import { BaileysMessageStoreService } from './adapters/baileys-message-store.service'; +import { LidMapping } from './identity/lid-mapping.entity'; +import { LidMappingStoreService } from './identity/lid-mapping-store.service'; @Global() @Module({ - imports: [TypeOrmModule.forFeature([BaileysStoredMessage], 'data')], - providers: [EngineFactory, BaileysMessageStoreService], - exports: [EngineFactory], + imports: [TypeOrmModule.forFeature([BaileysStoredMessage, LidMapping], 'data')], + providers: [EngineFactory, BaileysMessageStoreService, LidMappingStoreService], + exports: [EngineFactory, LidMappingStoreService], }) export class EngineModule {} diff --git a/src/engine/identity/lid-mapping-store.service.ts b/src/engine/identity/lid-mapping-store.service.ts new file mode 100644 index 00000000..14bf5716 --- /dev/null +++ b/src/engine/identity/lid-mapping-store.service.ts @@ -0,0 +1,89 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LidMapping } from './lid-mapping.entity'; +import { createLogger } from '../../common/services/logger.service'; + +/** + * Narrow read/write port over the `lid -> phone` table. The Baileys session store depends on this (sync + * reads on the resolution hot path + write-through) and the message from-filter depends on the reverse + * lookup - both on the interface, not the concrete service, so each stays unit-testable with a fake + * (mirrors {@link BaileysMessageStore}). + */ +export interface LidMappingStore { + /** Sync read from the in-memory cache: phone digits, `null` = known-unresolved, `undefined` = never seen. */ + getCached(lid: string): string | null | undefined; + /** Sync reverse lookup: the lids currently mapped to this phone (used by the message from-filter). */ + lidsForPhone(phone: string): string[]; + /** Write-through, last-write-wins: update the cache + persist. A `null` phone records a negative result. */ + remember(lid: string, phone: string | null, sessionId?: string): Promise; +} + +/** + * Backs lid resolution with the persisted {@link LidMapping} table. Resolution must be synchronous + * (filters/dispatch can't await a query), so the table is loaded into an in-memory map on boot and kept + * warm by write-through. A forward map (lid -> phone) serves resolution; a reverse map (phone -> lids) + * serves the from-filter. + */ +@Injectable() +export class LidMappingStoreService implements LidMappingStore, OnModuleInit { + private readonly logger = createLogger('LidMappingStore'); + private readonly lidToPhone = new Map(); + private readonly phoneToLids = new Map>(); + + constructor( + @InjectRepository(LidMapping, 'data') + private readonly repo: Repository, + ) {} + + async onModuleInit(): Promise { + try { + const rows = await this.repo.find(); + for (const row of rows) { + this.index(row.lid, row.phone); + } + this.logger.log(`Loaded ${rows.length} lid->phone mappings into cache`); + } catch (err) { + // A missing table (migration not yet applied) or a read error must not block boot: resolution + // falls back to the per-session in-memory map until the table is available. + this.logger.warn(`Could not preload lid->phone mappings: ${err instanceof Error ? err.message : String(err)}`); + } + } + + getCached(lid: string): string | null | undefined { + return this.lidToPhone.get(lid); + } + + lidsForPhone(phone: string): string[] { + const set = this.phoneToLids.get(phone); + return set ? [...set] : []; + } + + async remember(lid: string, phone: string | null, sessionId?: string): Promise { + if (!lid || this.lidToPhone.get(lid) === phone) { + return; // unseen-or-changed only; a no-op write would just churn updatedAt + } + this.index(lid, phone); + try { + await this.repo.upsert({ lid, phone, sessionId: sessionId ?? null, updatedAt: new Date() }, ['lid']); + } catch (err) { + this.logger.warn( + `Failed to persist lid->phone mapping for ${lid}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + /** Update both in-memory indexes, dropping any stale reverse entry from a previous phone. */ + private index(lid: string, phone: string | null): void { + const prev = this.lidToPhone.get(lid); + if (prev && prev !== phone) { + this.phoneToLids.get(prev)?.delete(lid); + } + this.lidToPhone.set(lid, phone); + if (phone) { + const set = this.phoneToLids.get(phone) ?? new Set(); + set.add(lid); + this.phoneToLids.set(phone, set); + } + } +} diff --git a/src/engine/identity/lid-mapping-store.spec.ts b/src/engine/identity/lid-mapping-store.spec.ts new file mode 100644 index 00000000..74dcf74c --- /dev/null +++ b/src/engine/identity/lid-mapping-store.spec.ts @@ -0,0 +1,93 @@ +import { Repository } from 'typeorm'; +import { LidMappingStoreService } from './lid-mapping-store.service'; +import { LidMapping } from './lid-mapping.entity'; + +/** Minimal in-memory stand-in for the TypeORM repo: just the find()/upsert() the store uses. */ +function makeFakeRepo(seed: Partial[] = []) { + const rows: LidMapping[] = seed.map(r => ({ lid: '', phone: null, sessionId: null, updatedAt: new Date(0), ...r })); + return { + rows, + find: jest.fn().mockImplementation(() => Promise.resolve(rows.map(r => ({ ...r })))), + upsert: jest.fn().mockImplementation((values: Partial) => { + const i = rows.findIndex(r => r.lid === values.lid); + if (i >= 0) rows[i] = { ...rows[i], ...values }; + else rows.push(values as LidMapping); + return Promise.resolve({}); + }), + }; +} + +async function newStore(repo: ReturnType): Promise { + const store = new LidMappingStoreService(repo as unknown as Repository); + await store.onModuleInit(); + return store; +} + +describe('LidMappingStoreService', () => { + it('loads the persisted table into the cache on boot (forward + reverse)', async () => { + const store = await newStore(makeFakeRepo([{ lid: '111', phone: '628999' }])); + expect(store.getCached('111')).toBe('628999'); + expect(store.lidsForPhone('628999')).toEqual(['111']); + }); + + it('returns undefined for an unseen lid', async () => { + const store = await newStore(makeFakeRepo()); + expect(store.getCached('nope')).toBeUndefined(); + expect(store.lidsForPhone('628999')).toEqual([]); + }); + + it('writes a learned mapping through to cache and persistence', async () => { + const repo = makeFakeRepo(); + const store = await newStore(repo); + await store.remember('222', '628888', 'sess-1'); + expect(store.getCached('222')).toBe('628888'); + expect(store.lidsForPhone('628888')).toEqual(['222']); + expect(repo.upsert).toHaveBeenCalledWith( + expect.objectContaining({ lid: '222', phone: '628888', sessionId: 'sess-1' }), + ['lid'], + ); + }); + + it('caches a negative result (lid known-but-unresolved)', async () => { + const repo = makeFakeRepo(); + const store = await newStore(repo); + await store.remember('333', null); + expect(store.getCached('333')).toBeNull(); + expect(store.lidsForPhone('anything')).toEqual([]); + expect(repo.upsert).toHaveBeenCalledWith(expect.objectContaining({ lid: '333', phone: null }), ['lid']); + }); + + it('is last-write-wins and reindexes the reverse map on a phone change', async () => { + const store = await newStore(makeFakeRepo([{ lid: '111', phone: '628999' }])); + await store.remember('111', '628000'); + expect(store.getCached('111')).toBe('628000'); + expect(store.lidsForPhone('628999')).toEqual([]); // stale reverse entry dropped + expect(store.lidsForPhone('628000')).toEqual(['111']); + }); + + it('skips a redundant write when the mapping is unchanged', async () => { + const repo = makeFakeRepo([{ lid: '111', phone: '628999' }]); + const store = await newStore(repo); + repo.upsert.mockClear(); + await store.remember('111', '628999'); + expect(repo.upsert).not.toHaveBeenCalled(); + }); + + it('survives a restart: a fresh store over the same table reloads the mapping', async () => { + const repo = makeFakeRepo(); + const first = await newStore(repo); + await first.remember('111', '628999', 'sess-1'); + + const second = await newStore(repo); // simulate process restart against the persisted rows + expect(second.getCached('111')).toBe('628999'); + expect(second.lidsForPhone('628999')).toEqual(['111']); + }); + + it('does not throw when the table is unavailable on boot', async () => { + const repo = makeFakeRepo(); + repo.find.mockRejectedValueOnce(new Error('no such table: lid_mappings')); + const store = new LidMappingStoreService(repo as unknown as Repository); + await expect(store.onModuleInit()).resolves.toBeUndefined(); + expect(store.getCached('111')).toBeUndefined(); + }); +}); diff --git a/src/engine/identity/lid-mapping.entity.ts b/src/engine/identity/lid-mapping.entity.ts new file mode 100644 index 00000000..9b52d6cd --- /dev/null +++ b/src/engine/identity/lid-mapping.entity.ts @@ -0,0 +1,31 @@ +import { Column, Entity, Index, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +/** + * Persisted `lid -> phone` resolution table on the `data` connection. One global, cross-session row per + * lid (the maintainer wants the mapping shared, not per-session), so a lid resolved in one session/run is + * usable everywhere and survives restarts - replacing the per-session, in-memory `lidToPn` map. + * + * Last-write-wins: a stored mapping is "best known, not forever" (WhatsApp recycles numbers), so it's a + * cache WhatsApp can correct. `phone` is nullable to record a negative result (a lid we looked up and + * could not resolve) so active lookups stay one-network-call-per-unknown-lid. `sessionId` is provenance + * only (which session last wrote the row) - intentionally NOT a foreign key, since the row outlives any + * one session. + */ +@Entity('lid_mappings') +@Index(['phone']) // reverse lookup: phone -> lids, for the message from-filter +export class LidMapping { + /** The lid number (bare, device-stripped - the user-part of `@lid`). */ + @PrimaryColumn() + lid: string; + + /** E.164 phone digits, or null when the lid is known-but-unresolved (a cached negative result). */ + @Column({ type: 'varchar', nullable: true }) + phone: string | null; + + /** The session that last wrote this row. Provenance/debugging only; not a foreign key. */ + @Column({ type: 'varchar', nullable: true }) + sessionId: string | null; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/engine/identity/wa-id.value.spec.ts b/src/engine/identity/wa-id.value.spec.ts new file mode 100644 index 00000000..52ce2a39 --- /dev/null +++ b/src/engine/identity/wa-id.value.spec.ts @@ -0,0 +1,109 @@ +import { WaId } from './wa-id.value'; +import { toNeutralJid } from './wa-id'; + +describe('WaId', () => { + describe('fromEngineJid', () => { + it('parses a phone user and folds @s.whatsapp.net to @c.us', () => { + const id = WaId.fromEngineJid('628111@s.whatsapp.net'); + expect(id.kind).toBe('user'); + expect(id.phone).toBe('628111'); + expect(id.toNeutral()).toBe('628111@c.us'); + }); + + it('strips a :device suffix from the phone', () => { + expect(WaId.fromEngineJid('628111:12@s.whatsapp.net').phone).toBe('628111'); + expect(WaId.fromEngineJid('628111:12@s.whatsapp.net').toNeutral()).toBe('628111@c.us'); + }); + + it('keeps an unresolved lid first-class (phone undefined, stays @lid)', () => { + const id = WaId.fromEngineJid('111@lid'); + expect(id.kind).toBe('lid'); + expect(id.lid).toBe('111'); + expect(id.phone).toBeUndefined(); + expect(id.toNeutral()).toBe('111@lid'); + }); + + it('resolves a lid to its phone when the resolver knows it', () => { + const id = WaId.fromEngineJid('111@lid', () => '628999'); + expect(id.kind).toBe('lid'); + expect(id.lid).toBe('111'); + expect(id.phone).toBe('628999'); + expect(id.toNeutral()).toBe('628999@c.us'); + }); + + it('parses groups, status, newsletter and broadcast', () => { + expect(WaId.fromEngineJid('123-456@g.us').groupId).toBe('123-456'); + expect(WaId.fromEngineJid('123-456@g.us').toNeutral()).toBe('123-456@g.us'); + expect(WaId.fromEngineJid('status@broadcast').toNeutral()).toBe('status@broadcast'); + expect(WaId.fromEngineJid('123@newsletter').toNeutral()).toBe('123@newsletter'); + expect(WaId.fromEngineJid('123@broadcast').toNeutral()).toBe('123@broadcast'); + }); + }); + + describe('fromUserInput', () => { + it('treats bare digits as a phone-addressed user', () => { + expect(WaId.fromUserInput('628111').toNeutral()).toBe('628111@c.us'); + }); + + it('strips non-digits from a formatted phone', () => { + expect(WaId.fromUserInput('+62 811').phone).toBe('62811'); + }); + + it('parses a full jid like an engine id', () => { + expect(WaId.fromUserInput('628111@c.us').kind).toBe('user'); + expect(WaId.fromUserInput('111@lid').kind).toBe('lid'); + }); + }); + + describe('toJSON / serialization byte-identity', () => { + // The wire format must not change: WaId serializes to exactly today's neutral string. Embedding a + // WaId in a DTO and JSON.stringify-ing it yields the same string a raw id would have. + it('serializes transparently inside a DTO', () => { + const payload = { from: WaId.fromEngineJid('628111@s.whatsapp.net'), chatId: WaId.fromEngineJid('123@g.us') }; + expect(JSON.stringify(payload)).toBe('{"from":"628111@c.us","chatId":"123@g.us"}'); + }); + + // Guard: WaId.toNeutral() is defined in terms of the adapters' toNeutralJid, so a representative + // message.received payload (from/to/chatId/author) + group payload (owner/participants) stay + // byte-identical to what the engine emits today. + it('matches toNeutralJid for every representative id', () => { + const resolve = (jid: string) => (jid.startsWith('111@') ? '628999' : null); + const ids = [ + '628111@s.whatsapp.net', + '628111:3@s.whatsapp.net', + '628222@c.us', + '111@lid', // resolves to 628999 + '222@lid', // stays @lid + '123-456@g.us', + 'status@broadcast', + '999@newsletter', + '888@broadcast', + ]; + for (const jid of ids) { + expect(WaId.fromEngineJid(jid, resolve).toNeutral()).toBe(toNeutralJid(jid, resolve)); + } + }); + }); + + describe('refersToSamePerson (three-valued)', () => { + it('true when phones match, false when they differ', () => { + expect(WaId.fromEngineJid('628111@c.us').refersToSamePerson(WaId.fromEngineJid('628111@s.whatsapp.net'))).toBe( + true, + ); + expect(WaId.fromEngineJid('628111@c.us').refersToSamePerson(WaId.fromEngineJid('628222@c.us'))).toBe(false); + }); + + it('true when both carry the same lid', () => { + expect(WaId.fromEngineJid('111@lid').refersToSamePerson(WaId.fromEngineJid('111@lid'))).toBe(true); + }); + + it("null ('couldn't tell') for a known phone vs an unresolved lid", () => { + expect(WaId.fromEngineJid('628111@c.us').refersToSamePerson(WaId.fromEngineJid('111@lid'))).toBeNull(); + }); + + it('matches a resolved lid to the phone it resolved to', () => { + const resolvedLid = WaId.fromEngineJid('111@lid', () => '628999'); + expect(resolvedLid.refersToSamePerson(WaId.fromEngineJid('628999@c.us'))).toBe(true); + }); + }); +}); diff --git a/src/engine/identity/wa-id.value.ts b/src/engine/identity/wa-id.value.ts new file mode 100644 index 00000000..9a8ab8e2 --- /dev/null +++ b/src/engine/identity/wa-id.value.ts @@ -0,0 +1,103 @@ +import { parseWaId, userPart, WaIdKind } from './wa-id'; + +/** + * Typed WhatsApp identity. A thin, in-memory value object over the {@link parseWaId} primitives that + * makes the kind and the (maybe-unknown) phone first-class, so a lid whose phone we don't know is + * visible in the type instead of re-derived by every caller. + * + * `WaId` is never persisted and never crosses the wire: the boundary format stays the neutral string + * ({@link toNeutral}). It carries no behaviour the engine didn't already have - `toNeutral()` delegates + * to the same {@link toNeutralJid} the adapters use, so the serialized string is byte-identical. + */ +export class WaId { + private constructor( + readonly kind: WaIdKind, + /** The original engine JID, verbatim. Debug/provenance only - excluded from matching. */ + readonly raw: string, + /** E.164 phone digits, when known (a resolved lid carries this; an unresolved one does not). */ + readonly phone?: string, + /** The lid number, when this id is or carries a lid. */ + readonly lid?: string, + /** The group id, for groups. */ + readonly groupId?: string, + ) {} + + /** Build from an engine JID, resolving a lid to its phone when the engine knows the mapping. */ + static fromEngineJid(jid: string, resolvePhone?: (jid: string) => string | null): WaId { + const parsed = parseWaId(jid); + switch (parsed.kind) { + case 'user': + return new WaId('user', jid, parsed.userPart); + case 'group': + return new WaId('group', jid, undefined, undefined, parsed.userPart); + case 'lid': { + const phone = resolvePhone?.(jid) ?? undefined; + return new WaId('lid', jid, phone, parsed.userPart); + } + default: + return new WaId(parsed.kind, jid); + } + } + + /** Build from API/user input: bare digits are a phone-addressed user; anything else parses as a JID. */ + static fromUserInput(value: string): WaId { + const trimmed = value.trim(); + if (trimmed && !trimmed.includes('@')) { + const digits = trimmed.replace(/\D/g, ''); + return new WaId('user', trimmed, digits || trimmed); + } + return WaId.fromEngineJid(trimmed); + } + + /** + * The neutral boundary string, built from the resolved fields. Kept in lock-step with the adapters' + * {@link toNeutralJid} (a spec asserts byte-identity for every engine id), so the emitted string is + * identical to today's wire format - WaId changes nothing observable. + */ + toNeutral(): string { + switch (this.kind) { + case 'user': + return `${this.phone}@c.us`; + case 'group': + return `${this.groupId}@g.us`; + case 'lid': + return this.phone ? `${this.phone}@c.us` : `${this.lid}@lid`; + case 'status': + return 'status@broadcast'; + case 'newsletter': + return `${userPart(this.raw)}@newsletter`; + case 'broadcast': + return `${userPart(this.raw)}@broadcast`; + default: + return this.raw; + } + } + + toString(): string { + return this.toNeutral(); + } + + toJSON(): string { + return this.toNeutral(); + } + + /** + * Relational same-person test, deliberately three-valued: `true` when they share a lid or a known + * phone, `false` when both phones are known and differ, and `null` ("couldn't tell") when one side is + * a phone and the other only an unresolved lid. `raw` is excluded so the same person seen via two + * engines doesn't split. This is NOT a hashable key - matching is relational while lids are unresolved. + */ + refersToSamePerson(other: WaId): boolean | null { + if (this.lid && other.lid) { + return this.lid === other.lid; + } + if (this.phone && other.phone) { + return this.phone === other.phone; + } + return null; + } +} + +/** Re-exported for callers that only need the kind union without importing the primitives module. */ +export type { WaIdKind }; +export { userPart }; diff --git a/src/engine/types/baileys.types.ts b/src/engine/types/baileys.types.ts index c600c115..0ea0f7f5 100644 --- a/src/engine/types/baileys.types.ts +++ b/src/engine/types/baileys.types.ts @@ -1,4 +1,5 @@ import type { WAMessage } from '@whiskeysockets/baileys'; +import type { LidMappingStore } from '../identity/lid-mapping-store.service'; /** * Persistence boundary for the Baileys engine's message store. The adapter depends on this narrow @@ -25,6 +26,8 @@ export interface BaileysAdapterConfig { proxyType?: 'http' | 'https' | 'socks4' | 'socks5'; /** Persisted store for reply/forward/react/delete. Provided by the plugin; the four ops require it. */ messageStore?: BaileysMessageStore; + /** Persisted, cross-session lid->phone resolution table. Backs lid resolution beyond the in-memory map. */ + lidMappingStore?: LidMappingStore; } /** diff --git a/src/modules/message/message.controller.ts b/src/modules/message/message.controller.ts index 160adc4e..35833c6e 100644 --- a/src/modules/message/message.controller.ts +++ b/src/modules/message/message.controller.ts @@ -28,6 +28,11 @@ export class MessageController { @ApiOperation({ summary: 'Get message history for a session' }) @ApiParam({ name: 'sessionId', description: 'Session ID' }) @ApiQuery({ name: 'chatId', required: false, description: 'Filter by chat ID' }) + @ApiQuery({ + name: 'from', + required: false, + description: 'Filter by sender. A phone also matches messages from a lid that resolves to it.', + }) @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Max messages to return (default 50)' }) @ApiQuery({ name: 'offset', required: false, type: Number, description: 'Offset for pagination' }) @ApiResponse({ @@ -37,11 +42,13 @@ export class MessageController { async getMessages( @Param('sessionId') sessionId: string, @Query('chatId') chatId?: string, + @Query('from') from?: string, @Query('limit') limit?: string, @Query('offset') offset?: string, ) { return this.messageService.getMessages(sessionId, { chatId, + from, limit: limit ? parseInt(limit, 10) : undefined, offset: offset ? parseInt(offset, 10) : undefined, }); diff --git a/src/modules/message/message.service.spec.ts b/src/modules/message/message.service.spec.ts index f1e01d53..261a59fd 100644 --- a/src/modules/message/message.service.spec.ts +++ b/src/modules/message/message.service.spec.ts @@ -9,6 +9,7 @@ import { HookManager } from '../../core/hooks'; import { TemplateService } from '../template/template.service'; import { Template } from '../template/entities/template.entity'; import { SsrfBlockedError } from '../../common/security/ssrf-guard'; +import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; const mockEngineResult = { id: 'wa-msg-1', timestamp: 1706868000 }; @@ -38,6 +39,7 @@ describe('MessageService', () => { let sessionService: jest.Mocked>; let hookManager: jest.Mocked>; let templateService: jest.Mocked>; + let lidMappingStore: { lidsForPhone: jest.Mock }; let mockEngine: ReturnType; // Auto-typing is on by default; disable it for the unrelated send tests so they don't incur the @@ -77,6 +79,8 @@ describe('MessageService', () => { resolve: jest.fn(), }; + lidMappingStore = { lidsForPhone: jest.fn().mockReturnValue([]) }; + const module: TestingModule = await Test.createTestingModule({ providers: [ MessageService, @@ -84,6 +88,7 @@ describe('MessageService', () => { { provide: SessionService, useValue: sessionService }, { provide: HookManager, useValue: hookManager }, { provide: TemplateService, useValue: templateService }, + { provide: LidMappingStoreService, useValue: lidMappingStore }, ], }).compile(); @@ -344,6 +349,56 @@ describe('MessageService', () => { }); }); + // ── getMessages from-filter (lid resolution becomes a hit) ───────── + describe('getMessages from-filter resolves a lid to a phone', () => { + // A group message whose stored author is an unresolved lid, plus a plain DM from the same person. + const lidRow = { id: 'm-lid', from: '111@lid', chatId: 'grp@g.us' } as Message; + const dmRow = { id: 'm-dm', from: '628999@c.us', chatId: '628999@c.us' } as Message; + const rows = [lidRow, dmRow]; + + // A query-builder fake that actually filters by the `from IN (:...froms)` clause it receives, so the + // test exercises the resolution-driven expansion end to end (filter -> rows returned). + const makeFilteringQb = () => { + let froms: string[] | null = null; + const qb = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockImplementation((_clause: string, params?: { froms?: string[] }) => { + if (params?.froms) froms = params.froms; + return qb; + }), + getManyAndCount: jest.fn().mockImplementation(() => { + const matched = froms ? rows.filter(r => froms!.includes(r.from)) : rows; + return Promise.resolve([matched, matched.length]); + }), + }; + return qb; + }; + + it('returns the lid-authored message once the table maps the lid to that phone (the hit)', async () => { + lidMappingStore.lidsForPhone.mockReturnValue(['111']); // table: lid 111 -> phone 628999 + const qb = makeFilteringQb(); + (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + const { messages } = await service.getMessages('sess-1', { from: '628999' }); + + expect(lidMappingStore.lidsForPhone).toHaveBeenCalledWith('628999'); + expect(messages.map(m => m.id).sort()).toEqual(['m-dm', 'm-lid']); + }); + + it('misses the lid-authored message when the table has no mapping (the prior silent miss)', async () => { + lidMappingStore.lidsForPhone.mockReturnValue([]); // unresolved: no lid -> phone row yet + const qb = makeFilteringQb(); + (repository.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + const { messages } = await service.getMessages('sess-1', { from: '628999' }); + + expect(messages.map(m => m.id)).toEqual(['m-dm']); // only the @c.us DM matches + }); + }); + // ── sendVideo / sendAudio / sendDocument / sendSticker ──────────── describe('sendVideo', () => { diff --git a/src/modules/message/message.service.ts b/src/modules/message/message.service.ts index 5a1e4705..0060967a 100644 --- a/src/modules/message/message.service.ts +++ b/src/modules/message/message.service.ts @@ -11,9 +11,13 @@ import { TemplateService } from '../template/template.service'; import { renderTemplate } from '../../common/utils/template-render'; import { createLogger } from '../../common/services/logger.service'; import { SsrfBlockedError } from '../../common/security/ssrf-guard'; +import { userPart } from '../../engine/identity/wa-id'; +import { LidMappingStoreService } from '../../engine/identity/lid-mapping-store.service'; export interface GetMessagesOptions { chatId?: string; + /** Filter by sender. A phone matches stored `@c.us`/`@s.whatsapp.net` ids AND any lid resolving to it. */ + from?: string; limit?: number; offset?: number; } @@ -28,6 +32,7 @@ export class MessageService { private readonly sessionService: SessionService, private readonly hookManager: HookManager, private readonly templateService: TemplateService, + private readonly lidMappingStore: LidMappingStoreService, ) {} async sendText(sessionId: string, dto: SendTextMessageDto): Promise { @@ -253,7 +258,7 @@ export class MessageService { sessionId: string, options: GetMessagesOptions = {}, ): Promise<{ messages: Message[]; total: number }> { - const { chatId } = options; + const { chatId, from } = options; // Sanitize pagination: a non-finite limit/offset — e.g. `?limit=abc` -> NaN — // must never reach TypeORM's take()/skip(). Clamp to sane bounds; fall back to defaults. const rawLimit = options.limit; @@ -273,10 +278,31 @@ export class MessageService { query.andWhere('message.chatId = :chatId', { chatId }); } + if (from) { + // Resolve the filter through the lid->phone table so a phone matches not just the stored + // `@c.us` id but also any lid that resolves to the same person - turning the prior + // silent miss (a lid-stored author vs a phone filter) into a hit. + query.andWhere('message.from IN (:...froms)', { froms: this.resolveFromCandidates(from) }); + } + const [messages, total] = await query.getManyAndCount(); return { messages, total }; } + /** + * Expand a `from` filter into every stored id that refers to the same person: the literal input (so an + * exact lid/group filter still matches), the phone in both user dialects, and every lid the resolution + * table maps to that phone. + */ + private resolveFromCandidates(from: string): string[] { + const phone = userPart(from); + const candidates = new Set([from, `${phone}@c.us`, `${phone}@s.whatsapp.net`]); + for (const lid of this.lidMappingStore.lidsForPhone(phone)) { + candidates.add(`${lid}@lid`); + } + return [...candidates]; + } + // ========== Phase 3: Extended Messaging ========== async sendLocation( diff --git a/src/plugins/engines/baileys/index.ts b/src/plugins/engines/baileys/index.ts index fe1dab13..873c05ab 100644 --- a/src/plugins/engines/baileys/index.ts +++ b/src/plugins/engines/baileys/index.ts @@ -7,6 +7,7 @@ import { PluginContext, PluginType, IEnginePlugin } from '../../../core/plugins' import { IWhatsAppEngine } from '../../../engine/interfaces/whatsapp-engine.interface'; import { BaileysAdapter } from '../../../engine/adapters/baileys.adapter'; import { BaileysMessageStore } from '../../../engine/types/baileys.types'; +import { LidMappingStore } from '../../../engine/identity/lid-mapping-store.service'; export class BaileysPlugin implements IEnginePlugin { type = PluginType.ENGINE as const; @@ -18,6 +19,7 @@ export class BaileysPlugin implements IEnginePlugin { constructor( private readonly messageStore?: BaileysMessageStore, private readonly registeredConfig?: Record, + private readonly lidMappingStore?: LidMappingStore, ) {} onLoad(context: PluginContext): Promise { @@ -53,6 +55,7 @@ export class BaileysPlugin implements IEnginePlugin { proxyUrl, proxyType, messageStore: this.messageStore, + lidMappingStore: this.lidMappingStore, }); } From eadaa38860857125d01bdb097b8b318cc6b29d35 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Sat, 20 Jun 2026 16:32:10 +0700 Subject: [PATCH 2/2] test(engine): cover LidMappingStore wiring + harden AddLidMappings rollback Small maintainer touch-ups on top of the contributor's work: - pass a LidMappingStoreService fake as EngineFactory's new 4th constructor arg in all engine.factory.spec call sites (they constructed with 3 args, so the new wiring was exercised as undefined) - DROP INDEX/TABLE IF EXISTS in the migration down() so rollback is safe when the table was created by the synchronize path (auto-named index) - add a migration spec mirroring the sibling AddBaileysStoredMessages test --- .../1781200000000-AddLidMappings.ts | 6 ++- .../1781200000000-AddLidMappings.spec.ts | 51 +++++++++++++++++++ src/engine/engine.factory.spec.ts | 16 ++++-- 3 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 src/database/migrations/__tests__/1781200000000-AddLidMappings.spec.ts diff --git a/src/database/migrations/1781200000000-AddLidMappings.ts b/src/database/migrations/1781200000000-AddLidMappings.ts index 8671caa9..0d909e3f 100644 --- a/src/database/migrations/1781200000000-AddLidMappings.ts +++ b/src/database/migrations/1781200000000-AddLidMappings.ts @@ -26,7 +26,9 @@ export class AddLidMappings1781200000000 implements MigrationInterface { } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "IDX_lid_mappings_phone"`); - await queryRunner.query(`DROP TABLE "lid_mappings"`); + // IF EXISTS so rollback is safe even when the table was created by the `synchronize` path (which + // auto-names the index differently) rather than by up()'s explicit `IDX_lid_mappings_phone`. + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_lid_mappings_phone"`); + await queryRunner.query(`DROP TABLE IF EXISTS "lid_mappings"`); } } diff --git a/src/database/migrations/__tests__/1781200000000-AddLidMappings.spec.ts b/src/database/migrations/__tests__/1781200000000-AddLidMappings.spec.ts new file mode 100644 index 00000000..2b7b5fa2 --- /dev/null +++ b/src/database/migrations/__tests__/1781200000000-AddLidMappings.spec.ts @@ -0,0 +1,51 @@ +import { DataSource } from 'typeorm'; +import { AddLidMappings1781200000000 } from '../1781200000000-AddLidMappings'; + +describe('AddLidMappings migration', () => { + let ds: DataSource; + + beforeEach(async () => { + ds = new DataSource({ type: 'sqlite', database: ':memory:' }); + await ds.initialize(); + }); + + afterEach(async () => { + await ds.destroy(); + }); + + it('creates and drops the table + phone index', async () => { + const runner = ds.createQueryRunner(); + const migration = new AddLidMappings1781200000000(); + + await migration.up(runner); + expect(await runner.hasTable('lid_mappings')).toBe(true); + + await migration.down(runner); + expect(await runner.hasTable('lid_mappings')).toBe(false); + + await runner.release(); + }); + + it('up() is idempotent when the table already exists (hasTable guard)', async () => { + const runner = ds.createQueryRunner(); + const migration = new AddLidMappings1781200000000(); + + await migration.up(runner); + await expect(migration.up(runner)).resolves.not.toThrow(); + expect(await runner.hasTable('lid_mappings')).toBe(true); + + await runner.release(); + }); + + it('down() is safe when the index is absent (IF EXISTS)', async () => { + const runner = ds.createQueryRunner(); + const migration = new AddLidMappings1781200000000(); + + // Simulate the `synchronize` path: a table with no explicitly-named IDX_lid_mappings_phone index. + await runner.query(`CREATE TABLE "lid_mappings" ("lid" varchar PRIMARY KEY NOT NULL, "phone" varchar)`); + await expect(migration.down(runner)).resolves.not.toThrow(); + expect(await runner.hasTable('lid_mappings')).toBe(false); + + await runner.release(); + }); +}); diff --git a/src/engine/engine.factory.spec.ts b/src/engine/engine.factory.spec.ts index 2bdd7730..5eed97f1 100644 --- a/src/engine/engine.factory.spec.ts +++ b/src/engine/engine.factory.spec.ts @@ -2,6 +2,7 @@ import { EngineFactory } from './engine.factory'; import { ConfigService } from '@nestjs/config'; import { PluginLoaderService, PluginType } from '../core/plugins'; import { BaileysMessageStoreService } from './adapters/baileys-message-store.service'; +import { LidMappingStoreService } from './identity/lid-mapping-store.service'; describe('EngineFactory', () => { const engineBlob = { @@ -25,6 +26,13 @@ describe('EngineFactory', () => { const buildMessageStore = (): BaileysMessageStoreService => ({ put: jest.fn(), getMessage: jest.fn(), clearSession: jest.fn() }) as unknown as BaileysMessageStoreService; + const buildLidStore = (): LidMappingStoreService => + ({ + getCached: jest.fn(), + lidsForPhone: jest.fn().mockReturnValue([]), + remember: jest.fn().mockResolvedValue(undefined), + }) as unknown as LidMappingStoreService; + it('passes ONLY engine-neutral fields to createEngine (no Puppeteer leak)', () => { const createEngine = jest.fn().mockReturnValue({}); const pluginInstance = { type: PluginType.ENGINE, createEngine }; @@ -32,7 +40,7 @@ describe('EngineFactory', () => { getPlugin: jest.fn().mockReturnValue({ instance: pluginInstance }), } as unknown as PluginLoaderService; - const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore()); + const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore(), buildLidStore()); factory.create({ sessionId: 'sess-1', proxyUrl: 'http://p', proxyType: 'http' }); // Plain-object (not objectContaining) assertion: any browser key (headless/puppeteerArgs/ @@ -48,7 +56,7 @@ describe('EngineFactory', () => { getPlugin: jest.fn(), } as unknown as PluginLoaderService; - const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore()); + const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore(), buildLidStore()); await factory.onModuleInit(); expect(registerBuiltInPlugin).toHaveBeenCalledWith( @@ -66,7 +74,7 @@ describe('EngineFactory', () => { getPlugin: jest.fn(), } as unknown as PluginLoaderService; - const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore()); + const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore(), buildLidStore()); await factory.onModuleInit(); const registeredIds = registerBuiltInPlugin.mock.calls.map(call => (call as [{ id: string }])[0].id); @@ -79,7 +87,7 @@ describe('EngineFactory', () => { getPlugin: jest.fn().mockReturnValue(undefined), } as unknown as PluginLoaderService; - const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore()); + const factory = new EngineFactory(buildConfigService(), pluginLoader, buildMessageStore(), buildLidStore()); expect(() => factory.create({ sessionId: 'sess-2' })).not.toThrow(); }); });