Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<phone>@c.us` but also those whose sender was an unresolved `<lid>@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 `<phone>@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
Expand Down Expand Up @@ -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 `<phone>@s.whatsapp.net` / `<lid>@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
Expand Down
32 changes: 32 additions & 0 deletions src/database/migrations/1781200000000-AddLidMappings.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await queryRunner.query(`DROP INDEX "IDX_lid_mappings_phone"`);
await queryRunner.query(`DROP TABLE "lid_mappings"`);
}
}
80 changes: 78 additions & 2 deletions src/engine/adapters/baileys-session-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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<string, string | null>();
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();
});
});
});
55 changes: 47 additions & 8 deletions src/engine/adapters/baileys-session-store.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,6 +27,16 @@ export class BaileysSessionStore {
private readonly lastMessages = new Map<string, LastMessage>();
private readonly lidToPn = new Map<string, string>();

/**
* @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<BaileysContactWithPhone>[] = []): void {
for (const r of records) {
if (!r.id) {
Expand All @@ -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);
}
}
}
Expand All @@ -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);
}
}
}
Expand All @@ -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<WAMessageKey, 'senderLid' | 'senderPn' | 'participantLid' | 'participantPn'>): void {
this.addLidMappings([
Expand All @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}
Expand All @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions src/engine/adapters/baileys.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand All @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions src/engine/adapters/baileys.adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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', {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 3 additions & 1 deletion src/engine/engine.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string>('engine.type') ?? 'whatsapp-web.js';
}
Expand Down Expand Up @@ -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,
);

Expand Down
Loading
Loading