From e993169e593bfac37652f8038bd5724e73760185 Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Fri, 19 Jun 2026 11:15:18 +0800 Subject: [PATCH 1/2] feat(engine): engine-neutral WhatsApp identities (Baileys inbound conformance) WhatsApp addresses the same entity through several dialects (@c.us, @s.whatsapp.net, @lid, plus group/status/channel and :device variants). The application layer and the whatsapp-web.js engine assume the @c.us dialect, but the Baileys adapter leaked its native @s.whatsapp.net / @lid into neutral payloads - so under Baileys the same contact surfaces under a different id than under whatsapp-web.js, @lid contacts can't be resolved to a phone for display, and any consumer that compares a message's sender against a known contact id sees a mismatch. Establish a single neutral identity contract at the engine boundary and make the Baileys adapter conform on the inbound read path: - engine/identity/wa-id.ts: the shared, documented home of the contract. parseWaId() classifies any JID; toNeutralJid() reduces it to the neutral dialect (@c.us / @g.us / @lid + special channels), resolving a lid to its phone when the mapping is known and keeping an unresolved lid as a first-class @lid (never pretending it's a phone). - IWhatsAppEngine documents the contract every adapter must emit. - The Baileys adapter applies it to inbound message from/to/chatId/author + revoked + reaction; BaileysSessionStore.toNeutralJid delegates to the shared module with its lid->pn resolver. Scope: inbound read path only. Contact/chat list ids, the outbound send path (which needs neutral -> engine de-normalization), and a whatsapp-web.js conformance audit are follow-ups in the same arc. Pairs with the session-store persistence branch so the lid->pn map survives restarts. --- CHANGELOG.md | 15 +++ docs/03-system-architecture.md | 27 +++++ docs/21-glossary.md | 5 +- .../adapters/baileys-message-mapper.spec.ts | 19 ++++ src/engine/adapters/baileys-message-mapper.ts | 24 +++-- .../adapters/baileys-session-store.spec.ts | 23 ++++ src/engine/adapters/baileys-session-store.ts | 27 ++--- src/engine/adapters/baileys.adapter.spec.ts | 51 ++++++++- src/engine/adapters/baileys.adapter.ts | 43 ++++---- src/engine/identity/wa-id.spec.ts | 49 +++++++++ src/engine/identity/wa-id.ts | 101 ++++++++++++++++++ .../interfaces/whatsapp-engine.interface.ts | 14 +++ 12 files changed, 353 insertions(+), 45 deletions(-) create mode 100644 src/engine/identity/wa-id.spec.ts create mode 100644 src/engine/identity/wa-id.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b775446..9f44ebac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **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 + system use the `@c.us` convention - so the same contact was addressed under a different id + depending on the engine, and `@lid` (privacy-id) contacts could not be resolved to a phone. Baileys + now canonicalizes these to the neutral dialect (resolving a `@lid` to its phone when the mapping is + known, keeping it as `@lid` otherwise), matching whatsapp-web.js. Group participant and owner ids are + canonicalized through the same path, so admin/controller recognition (e.g. the translation plugin) + keeps working. **Consumer-visible:** `message.received` / `revoked` / `reaction` webhook and WebSocket + payloads from a Baileys session now carry `@c.us` ids where they previously carried + `@s.whatsapp.net` (or a resolved `@lid`); a consumer that stored or compared the old ids will see the + new value. Outbound sending and contact/chat list ids are unchanged for now. + ## [0.4.4] - 2026-06-20 A reliability and correctness patch. Engine: Baileys reconnect no longer leaks its socket, and a session diff --git a/docs/03-system-architecture.md b/docs/03-system-architecture.md index 0bc23377..fd5df9e7 100644 --- a/docs/03-system-architecture.md +++ b/docs/03-system-architecture.md @@ -157,6 +157,33 @@ flowchart LR IC -.-> Cache ``` +### WhatsApp Identity Contract (engine-neutral ids) + +WhatsApp addresses the same entity through several id dialects, and each engine speaks a different one: +whatsapp-web.js uses `@c.us`, while Baileys speaks the raw protocol forms `@s.whatsapp.net` +and `@lid` (a privacy id whose number is **not** a phone number). To keep application code, the +REST/webhook payloads, and plugins free of that, the **engine boundary is an anti-corruption layer**: +every WhatsApp id an engine emits in a neutral field (`from` / `to` / `chatId` / `author`, contact and +chat `id`) is reduced to one small **neutral dialect**: + +| Neutral form | Meaning | +| --- | --- | +| `@c.us` | a user, by phone (the raw `@s.whatsapp.net` form folds into this) | +| `@g.us` | a group | +| `@lid` | a user known **only** by privacy id - phone genuinely unknown (a first-class state) | +| `status@broadcast`, `@newsletter`, `@broadcast` | special channels | + +Never `@s.whatsapp.net`, never a `:device` suffix. **Resolution rule:** prefer `@c.us` (resolve a lid +to its phone when the mapping is known), and fall back to `@lid` only when it can't be resolved - an +unresolved lid is never faked into a phone number. + +The shared implementation lives in `src/engine/identity/wa-id.ts` (`parseWaId` / `toNeutralJid`); the +contract is documented on the `IWhatsAppEngine` interface. + +> **Rollout status:** the contract is applied per-engine. It currently covers the **Baileys inbound +> read path** (message / revoked / reaction payloads). Outbound id de-normalization (neutral -> engine +> dialect on send) and contact/chat list ids are tracked follow-ups. + ### Adapter Lifecycle State Machine Each adapter follows a consistent lifecycle: diff --git a/docs/21-glossary.md b/docs/21-glossary.md index d0f1f299..8da4a687 100644 --- a/docs/21-glossary.md +++ b/docs/21-glossary.md @@ -107,13 +107,16 @@ Data stored in RAM. Fast but non-persistent. Used for cache in minimal deploymen ## J ### JID (Jabber ID) -Identifier format in the XMPP protocol used by WhatsApp. Same as Chat ID. +WhatsApp's id format, inherited from XMPP; the user-facing "Chat ID" is a JID. The same entity can be addressed in more than one dialect: `@c.us` (whatsapp-web.js, and OpenWA's neutral form), `@s.whatsapp.net` (Baileys' raw form for the same user), `@g.us` (a group), or `@lid` (a LID, a privacy id). OpenWA normalizes engine ids to a single neutral dialect at the engine boundary - see *System Architecture > WhatsApp Identity Contract*. ### Job Queue Queueing system for asynchronous task processing. Used for webhook delivery and message scheduling. ## L +### LID (Linked ID) +A WhatsApp **privacy identifier** (`@lid`) that addresses a user without exposing their phone number - increasingly used in groups and communities. Its number is **not** a phone number; a separate `lid -> phone` mapping (supplied by WhatsApp via history sync / contacts) resolves it when known. OpenWA keeps an unresolved LID as-is rather than guessing a phone. See *System Architecture > WhatsApp Identity Contract*. + ### Linked Device WhatsApp feature that allows up to 4 additional devices to be linked to one account without requiring an active phone connection. diff --git a/src/engine/adapters/baileys-message-mapper.spec.ts b/src/engine/adapters/baileys-message-mapper.spec.ts index 9eabe112..7c007f4a 100644 --- a/src/engine/adapters/baileys-message-mapper.spec.ts +++ b/src/engine/adapters/baileys-message-mapper.spec.ts @@ -71,6 +71,25 @@ describe('buildIncomingMessageFromBaileys', () => { expect(r.to).toBe('628111@s.whatsapp.net'); // chat }); + it('applies the supplied normalizer to from/to/chatId on a 1:1 message', () => { + const normalize = (jid: string) => jid.replace('@s.whatsapp.net', '@c.us'); + const r = buildIncomingMessageFromBaileys(base, normalize); + expect(r.from).toBe('628111@c.us'); + expect(r.to).toBe('628999@c.us'); + expect(r.chatId).toBe('628111@c.us'); + }); + + it('normalizes the group author and self while leaving the group JID intact', () => { + const normalize = (jid: string) => jid.replace('@s.whatsapp.net', '@c.us'); + const r = buildIncomingMessageFromBaileys( + { ...base, remoteJid: '123-456@g.us', participant: '628222@s.whatsapp.net' }, + normalize, + ); + expect(r.from).toBe('123-456@g.us'); // group jid untouched by this normalizer + expect(r.to).toBe('628999@c.us'); // self normalized + expect(r.author).toBe('628222@c.us'); // participant normalized + }); + it('sets author to the participant for a group message and flags isGroup', () => { const r = buildIncomingMessageFromBaileys({ ...base, diff --git a/src/engine/adapters/baileys-message-mapper.ts b/src/engine/adapters/baileys-message-mapper.ts index c954532b..6468c886 100644 --- a/src/engine/adapters/baileys-message-mapper.ts +++ b/src/engine/adapters/baileys-message-mapper.ts @@ -91,10 +91,18 @@ export interface BaileysIncomingFields { * sender lives in `participant` (exposed as `author`), matching the wwjs convention where `from` * is the group JID. */ -export function buildIncomingMessageFromBaileys(fields: BaileysIncomingFields): IncomingMessage { - const chatId = fields.remoteJid; - const isGroup = chatId.endsWith('@g.us'); - const self = fields.selfJid ?? ''; +export function buildIncomingMessageFromBaileys( + fields: BaileysIncomingFields, + // Canonicalizes the emitted JIDs (from/to/chatId/author) to the neutral @c.us convention. Defaults + // to identity so the pure-shape behaviour (and its tests) is unchanged; the adapter supplies the + // session-store-backed normalizer that resolves @lid / @s.whatsapp.net. + normalizeJid: (jid: string) => string = jid => jid, +): IncomingMessage { + const rawChatId = fields.remoteJid; + const isGroup = rawChatId.endsWith('@g.us'); + const isStatusBroadcast = rawChatId === 'status@broadcast'; + const chatId = normalizeJid(rawChatId); + const self = normalizeJid(fields.selfJid ?? ''); const incoming: IncomingMessage = { id: fields.id, @@ -106,15 +114,15 @@ export function buildIncomingMessageFromBaileys(fields: BaileysIncomingFields): timestamp: fields.timestamp, fromMe: fields.fromMe, isGroup, - isStatusBroadcast: chatId === 'status@broadcast', + isStatusBroadcast, }; if (isGroup && fields.participant) { - incoming.author = fields.participant; + incoming.author = normalizeJid(fields.participant); } - // The real sender for an @lid check is the participant in a group, else the chat JID itself. - const senderJid = fields.participant ?? chatId; + // The lid check uses the RAW sender (participant in a group, else the chat JID) before normalization. + const senderJid = fields.participant ?? rawChatId; if (senderJid.endsWith('@lid')) { incoming.isLidSender = true; } diff --git a/src/engine/adapters/baileys-session-store.spec.ts b/src/engine/adapters/baileys-session-store.spec.ts index 706d7c47..6bde6c9d 100644 --- a/src/engine/adapters/baileys-session-store.spec.ts +++ b/src/engine/adapters/baileys-session-store.spec.ts @@ -83,4 +83,27 @@ describe('BaileysSessionStore', () => { expect(store.resolvePhone('222@lid')).toBe('628222'); expect(store.resolvePhone('333@lid')).toBeNull(); }); + + describe('toNeutralJid', () => { + it('maps @s.whatsapp.net to @c.us and strips the device suffix', () => { + expect(store.toNeutralJid('628111@s.whatsapp.net')).toBe('628111@c.us'); + expect(store.toNeutralJid('628111:12@s.whatsapp.net')).toBe('628111@c.us'); + }); + + it('keeps groups as @g.us and passes status@broadcast / empty through', () => { + expect(store.toNeutralJid('120363-456@g.us')).toBe('120363-456@g.us'); + expect(store.toNeutralJid('status@broadcast')).toBe('status@broadcast'); + expect(store.toNeutralJid('')).toBe(''); + }); + + it('resolves a @lid to @c.us when known, else keeps the raw lid', () => { + expect(store.toNeutralJid('111@lid')).toBe('111@lid'); // no mapping yet + store.addLidMappings([{ lid: '111@lid', pn: '628999@s.whatsapp.net' }]); + expect(store.toNeutralJid('111@lid')).toBe('628999@c.us'); + }); + + it('is idempotent on an already-neutral @c.us id', () => { + expect(store.toNeutralJid('628111@c.us')).toBe('628111@c.us'); + }); + }); }); diff --git a/src/engine/adapters/baileys-session-store.ts b/src/engine/adapters/baileys-session-store.ts index 9ba3703f..a5d210bd 100644 --- a/src/engine/adapters/baileys-session-store.ts +++ b/src/engine/adapters/baileys-session-store.ts @@ -1,5 +1,6 @@ import type { Chat, Contact as BaileysContact, WAMessage, WAMessageKey } from '@whiskeysockets/baileys'; import { ChatSummary, Contact } from '../interfaces/whatsapp-engine.interface'; +import { toNeutralJid as canonicalizeWaId, userPart } from '../identity/wa-id'; /** * Baileys `Contact` does not include a `phoneNumber` field, but WhatsApp Business events may supply @@ -91,25 +92,29 @@ export class BaileysSessionStore { resolvePhone(id: string): string | null { if (id.endsWith('@s.whatsapp.net')) { - return this.userPart(id); + return userPart(id); } if (id.endsWith('@lid')) { const pn = this.lidToPn.get(id); if (pn) { - return this.userPart(pn); + return userPart(pn); } const contactPhone = this.contacts.get(id)?.phoneNumber; - return contactPhone ? this.userPart(contactPhone) : null; + return contactPhone ? userPart(contactPhone) : null; } return null; } + /** + * Canonicalize a Baileys JID to the neutral dialect (see {@link canonicalizeWaId} / wa-id.ts), + * resolving a lid to its phone via this session's lid->pn map when the mapping is known. + */ + toNeutralJid(jid: string): string { + return canonicalizeWaId(jid, id => this.resolvePhone(id)); + } + private toNeutralContact(c: BaileysContactWithPhone): Contact { - const number = c.phoneNumber - ? this.userPart(c.phoneNumber) - : c.id.endsWith('@s.whatsapp.net') - ? this.userPart(c.id) - : ''; + const number = c.phoneNumber ? userPart(c.phoneNumber) : c.id.endsWith('@s.whatsapp.net') ? userPart(c.id) : ''; return { id: c.id, name: c.name ?? c.verifiedName, @@ -125,7 +130,7 @@ export class BaileysSessionStore { const last = this.lastMessages.get(c.id); return { id: c.id, - name: c.name ?? this.userPart(c.id), + name: c.name ?? userPart(c.id), isGroup: c.id.endsWith('@g.us'), unreadCount: c.unreadCount ?? 0, timestamp: last?.timestamp ?? this.toUnixSeconds(c.conversationTimestamp), @@ -133,10 +138,6 @@ export class BaileysSessionStore { }; } - 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; diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 207cec85..9471db4f 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -565,6 +565,51 @@ describe('BaileysAdapter inbound fan-out', () => { expect(msg).toMatchObject({ id: 'IN1', body: 'hi there', type: 'text', fromMe: false }); }); + it('canonicalizes an inbound message JID from @s.whatsapp.net to @c.us', async () => { + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'IN_C' }, + message: { conversation: 'hi' }, + messageTimestamp: 1700000002, + }, + ], + }); + await new Promise(r => setImmediate(r)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { from: string; to: string; chatId: string }; + expect(msg.from).toBe('628111@c.us'); + expect(msg.to).toBe('628999@c.us'); // self (fakeSock.user is 628999) + expect(msg.chatId).toBe('628111@c.us'); + }); + + it('resolves an @lid sender to @c.us using a history-sync lid->pn mapping', async () => { + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + // History sync supplies the lid -> phone mapping the resolver needs. + fakeSock.fire('messaging-history.set', { lidPnMappings: [{ lid: '111@lid', pn: '628111@s.whatsapp.net' }] }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '111@lid', fromMe: false, id: 'IN_LID' }, + message: { conversation: 'hi from lid' }, + messageTimestamp: 1700000005, + }, + ], + }); + await new Promise(r => setImmediate(r)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { from: string; isLidSender?: boolean }; + expect(msg.from).toBe('628111@c.us'); // lid resolved to phone, neutral dialect + expect(msg.isLidSender).toBe(true); // still flagged: the raw sender was a lid + }); + it('routes a fromMe message to onMessageCreate (outgoing), not onMessage', async () => { const onMessage = jest.fn(); const onMessageCreate = jest.fn(); @@ -799,7 +844,7 @@ describe('BaileysAdapter inbound fan-out', () => { body: string; }; expect(revoked.id).toBe('ORIGINAL_ID'); - expect(revoked.chatId).toBe('628111@s.whatsapp.net'); + expect(revoked.chatId).toBe('628111@c.us'); // canonicalized to the neutral dialect expect(revoked.type).toBe('revoked'); expect(revoked.body).toBe(''); }); @@ -845,9 +890,9 @@ describe('BaileysAdapter inbound fan-out', () => { senderId: string; }; expect(event.messageId).toBe('TARGET_MSG_ID'); - expect(event.chatId).toBe('628111@s.whatsapp.net'); + expect(event.chatId).toBe('628111@c.us'); // canonicalized to the neutral dialect expect(event.reaction).toBe('👍'); - expect(event.senderId).toBe('628111@s.whatsapp.net'); + expect(event.senderId).toBe('628111@c.us'); // canonicalized to the neutral dialect }); it('media download failure: logs the error and emits the message without media (no throw)', async () => { diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index c9f39193..45312f7d 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -709,9 +709,9 @@ export class BaileysAdapter implements IWhatsAppEngine { const to = msg.key.fromMe === true ? remoteJid : this.normalizedSelfJid(); const revoked: RevokedMessage = { id: pm.key?.id ?? '', - chatId: remoteJid, - from, - to, + chatId: this.sessionStore.toNeutralJid(remoteJid), + from: this.sessionStore.toNeutralJid(from), + to: this.sessionStore.toNeutralJid(to), type: 'revoked', body: '', timestamp: this.toUnixSeconds(msg.messageTimestamp), @@ -728,9 +728,9 @@ export class BaileysAdapter implements IWhatsAppEngine { const rm = msg.message?.reactionMessage; const event: ReactionEvent = { messageId: rm?.key?.id ?? '', - chatId: remoteJid, + chatId: this.sessionStore.toNeutralJid(remoteJid), reaction: rm?.text ?? '', - senderId: msg.key.participant ?? remoteJid, + senderId: this.sessionStore.toNeutralJid(msg.key.participant ?? remoteJid), }; this.callbacks.onMessageReaction?.(event); return; @@ -883,21 +883,24 @@ export class BaileysAdapter implements IWhatsAppEngine { quotedMessage = { id: contextInfo.stanzaId, body: qBody }; } - return buildIncomingMessageFromBaileys({ - id: msg.key.id ?? '', - remoteJid: msg.key.remoteJid!, - fromMe: msg.key.fromMe === true, - participant: msg.key.participant ?? undefined, - body, - contentType, - isPtt: content.audioMessage?.ptt === true, - timestamp: this.toUnixSeconds(msg.messageTimestamp), - pushName: msg.pushName ?? undefined, - selfJid: this.normalizedSelfJid(), - media, - location, - quotedMessage, - }); + return buildIncomingMessageFromBaileys( + { + id: msg.key.id ?? '', + remoteJid: msg.key.remoteJid!, + fromMe: msg.key.fromMe === true, + participant: msg.key.participant ?? undefined, + body, + contentType, + isPtt: content.audioMessage?.ptt === true, + timestamp: this.toUnixSeconds(msg.messageTimestamp), + pushName: msg.pushName ?? undefined, + selfJid: this.normalizedSelfJid(), + media, + location, + quotedMessage, + }, + jid => this.sessionStore.toNeutralJid(jid), + ); } private normalizedSelfJid(): string { diff --git a/src/engine/identity/wa-id.spec.ts b/src/engine/identity/wa-id.spec.ts new file mode 100644 index 00000000..1804ecc6 --- /dev/null +++ b/src/engine/identity/wa-id.spec.ts @@ -0,0 +1,49 @@ +import { parseWaId, toNeutralJid, userPart } from './wa-id'; + +describe('wa-id', () => { + describe('userPart', () => { + it('strips the domain and the device suffix', () => { + expect(userPart('628111@c.us')).toBe('628111'); + expect(userPart('628111:12@s.whatsapp.net')).toBe('628111'); + expect(userPart('120363-456@g.us')).toBe('120363-456'); + }); + }); + + describe('parseWaId', () => { + it('classifies each dialect, folding @s.whatsapp.net and @c.us into one user kind', () => { + expect(parseWaId('628111@c.us')).toMatchObject({ kind: 'user', userPart: '628111' }); + expect(parseWaId('628111@s.whatsapp.net')).toMatchObject({ kind: 'user', userPart: '628111' }); + expect(parseWaId('628111:3@s.whatsapp.net')).toMatchObject({ kind: 'user', userPart: '628111', device: '3' }); + expect(parseWaId('120-456@g.us')).toMatchObject({ kind: 'group' }); + expect(parseWaId('111@lid')).toMatchObject({ kind: 'lid', userPart: '111' }); + expect(parseWaId('status@broadcast')).toMatchObject({ kind: 'status' }); + expect(parseWaId('abc@newsletter')).toMatchObject({ kind: 'newsletter' }); + expect(parseWaId('not-a-jid')).toMatchObject({ kind: 'unknown' }); + }); + }); + + describe('toNeutralJid', () => { + it('maps @s.whatsapp.net (and device suffixes) to @c.us, idempotent on @c.us', () => { + expect(toNeutralJid('628111@s.whatsapp.net')).toBe('628111@c.us'); + expect(toNeutralJid('628111:12@s.whatsapp.net')).toBe('628111@c.us'); + expect(toNeutralJid('628111@c.us')).toBe('628111@c.us'); + }); + + it('keeps groups as @g.us and passes status / empty through', () => { + expect(toNeutralJid('120-456@g.us')).toBe('120-456@g.us'); + expect(toNeutralJid('status@broadcast')).toBe('status@broadcast'); + expect(toNeutralJid('')).toBe(''); + }); + + it('resolves a lid to @c.us when the resolver knows it, else keeps the raw lid', () => { + const resolve = (jid: string) => (jid === '111@lid' ? '628999' : null); + expect(toNeutralJid('111@lid', resolve)).toBe('628999@c.us'); + expect(toNeutralJid('222@lid', resolve)).toBe('222@lid'); // unresolved: kept as a privacy id + expect(toNeutralJid('111@lid')).toBe('111@lid'); // no resolver supplied + }); + + it('passes an unrecognized format through unchanged', () => { + expect(toNeutralJid('weird-thing')).toBe('weird-thing'); + }); + }); +}); diff --git a/src/engine/identity/wa-id.ts b/src/engine/identity/wa-id.ts new file mode 100644 index 00000000..3ed4b35e --- /dev/null +++ b/src/engine/identity/wa-id.ts @@ -0,0 +1,101 @@ +/** + * Engine-neutral WhatsApp identity handling. + * + * WhatsApp addresses the same entity through several dialects: + * - `@c.us` a user, addressed by phone (whatsapp-web.js dialect) + * - `@s.whatsapp.net` the SAME user, in the raw protocol dialect (Baileys) + * - `@lid` a user addressed by a privacy id (LID); the number is NOT a phone + * - `@g.us` a group + * - `status@broadcast` the status/stories pseudo-JID + * - `@newsletter` a channel; `@broadcast` a broadcast list + * - any of the above may carry a `:` multi-device suffix + * + * The engine boundary is an anti-corruption layer: adapters reduce all of that to the NEUTRAL dialect + * the application layer sees, so app code never has to know which engine produced an id. The neutral + * dialect is intentionally small: + * - `@c.us` a user known by phone (the common case; @s.whatsapp.net folds into this) + * - `@g.us` a group + * - `@lid` a user known ONLY by privacy id - phone genuinely unknown (a first-class state) + * - `status@broadcast` / `@newsletter` / `@broadcast` special channels + * - never `@s.whatsapp.net`, never a `:device` suffix + * + * Resolution rule: prefer `@c.us` (resolve a lid to its phone when the mapping is known); fall back to + * `@lid` only when it can't be resolved. An unresolved lid is NOT pretended to be a phone. + */ + +export type WaIdKind = 'user' | 'group' | 'lid' | 'status' | 'newsletter' | 'broadcast' | 'unknown'; + +/** Domains that denote a phone-addressed user (the two are the same entity, different dialects). */ +const USER_DOMAINS = new Set(['c.us', 's.whatsapp.net']); + +export interface ParsedWaId { + kind: WaIdKind; + /** The local part with the device suffix and domain stripped (phone digits, lid number, or group id). */ + userPart: string; + /** The multi-device suffix (`:N`), when present. */ + device?: string; + /** The original JID, verbatim. */ + raw: string; +} + +/** The local part of a JID: domain and `:device` suffix stripped (`628:12@s.whatsapp.net` -> `628`). */ +export function userPart(jid: string): string { + return jid.split('@')[0].split(':')[0]; +} + +/** Classify any WhatsApp JID into its neutral kind + parts, without resolving anything. */ +export function parseWaId(jid: string): ParsedWaId { + const raw = jid; + const lower = jid.trim().toLowerCase(); + if (lower === 'status@broadcast') { + return { kind: 'status', userPart: 'status', raw }; + } + const at = lower.lastIndexOf('@'); + if (at === -1) { + return { kind: 'unknown', userPart: lower, raw }; + } + const domain = lower.slice(at + 1); + const [local, device] = lower.slice(0, at).split(':'); + const kind: WaIdKind = USER_DOMAINS.has(domain) + ? 'user' + : domain === 'g.us' + ? 'group' + : domain === 'lid' + ? 'lid' + : domain === 'newsletter' + ? 'newsletter' + : domain === 'broadcast' + ? 'broadcast' + : 'unknown'; + return { kind, userPart: local, device, raw }; +} + +/** + * Reduce any WhatsApp JID to the neutral dialect (see the module contract above). `resolvePhone` maps a + * lid to its phone user-part when the engine knows the mapping; an unresolvable lid is kept as + * `@lid`. Idempotent on an already-neutral id. An unrecognized format is passed through unchanged. + */ +export function toNeutralJid(jid: string, resolvePhone?: (jid: string) => string | null): string { + if (!jid) { + return jid; + } + const parsed = parseWaId(jid); + switch (parsed.kind) { + case 'user': + return `${parsed.userPart}@c.us`; + case 'group': + return `${parsed.userPart}@g.us`; + case 'lid': { + const phone = resolvePhone?.(jid); + return phone ? `${phone}@c.us` : `${parsed.userPart}@lid`; + } + case 'status': + return 'status@broadcast'; + case 'newsletter': + return `${parsed.userPart}@newsletter`; + case 'broadcast': + return `${parsed.userPart}@broadcast`; + default: + return jid; + } +} diff --git a/src/engine/interfaces/whatsapp-engine.interface.ts b/src/engine/interfaces/whatsapp-engine.interface.ts index 9c539f57..395524e9 100644 --- a/src/engine/interfaces/whatsapp-engine.interface.ts +++ b/src/engine/interfaces/whatsapp-engine.interface.ts @@ -1,4 +1,18 @@ // WhatsApp Engine Interface - Abstract layer for WA engines +// +// Identity contract (the engine boundary is an anti-corruption layer for WhatsApp's id dialects): +// every JID an engine EMITS in a neutral field (`from` / `to` / `chatId` / `author` / contact + chat +// `id`, etc.) is in the NEUTRAL dialect, so application code never has to know which engine produced +// it. The neutral dialect is small: +// - `@c.us` a user known by phone (the raw `@s.whatsapp.net` form folds into this) +// - `@g.us` a group +// - `@lid` a user known ONLY by privacy id - phone genuinely unknown (a first-class state) +// - `status@broadcast` / `@newsletter` / `@broadcast` special channels +// - never `@s.whatsapp.net`, never a `:device` suffix +// Resolution rule: prefer `@c.us` (resolve a lid to its phone when the mapping is known), fall back to +// `@lid` only when it can't be resolved. See `engine/identity/wa-id.ts` for the shared implementation. +// (Ids the engine ACCEPTS - e.g. `sendTextMessage(chatId)` - may be neutral; the adapter de-normalizes +// to its own dialect. Full inbound + outbound conformance is being rolled out per-engine.) export enum EngineStatus { DISCONNECTED = 'disconnected', From 47a42694d38f4b295c952746972ea2407231a97a Mon Sep 17 00:00:00 2001 From: Tobias Strebitzer Date: Sat, 20 Jun 2026 07:22:19 +0800 Subject: [PATCH 2/2] fix(engine): canonicalize Baileys group ids + resolve @c.us phones (PR #342 review) Addresses two one-sided-canonicalization regressions surfaced in review of the engine-neutral identity work: the inbound author was canonicalized while the other side of each comparison stayed in the old dialect. 1. Group admin/owner recognition. mapBaileysGroup/mapBaileysGroupInfo now take a normalizer and the adapter passes its session-store canonicalizer, so group participant + owner ids share the neutral dialect of message authors. Fixes widEquals(adminId, author) silently denying a legit admin/owner (e.g. the /tr translation commands) in a lid-addressed group. 2. senderPhone for resolved-lid senders. BaileysSessionStore.resolvePhone now returns the user-part for an already-neutral @c.us id (a resolved lid arrives as @c.us once authors are canonicalized) and strips a :device suffix before the lid lookup. Fixes senderPhone regressing to null under RESOLVE_LID_TO_PHONE for exactly the case the field exists to surface. Also folds the group-mapper's ad-hoc user-part helper into wa-id, and adds coverage: coordinator admin recognition across the dialect split, the resolved- lid senderPhone path, @c.us/device-suffixed resolvePhone, an unresolved lid kept end-to-end, and the broadcast/newsletter + lowercasing branches in wa-id. --- .../adapters/baileys-group-mapper.spec.ts | 26 ++++++++++ src/engine/adapters/baileys-group-mapper.ts | 49 ++++++++++++------- .../adapters/baileys-session-store.spec.ts | 12 +++++ src/engine/adapters/baileys-session-store.ts | 17 ++++--- src/engine/adapters/baileys.adapter.spec.ts | 42 ++++++++++++++++ src/engine/adapters/baileys.adapter.ts | 8 +-- src/engine/identity/wa-id.spec.ts | 11 +++++ src/modules/session/session.service.spec.ts | 28 +++++++++++ .../core/translation.coordinator.spec.ts | 24 +++++++++ 9 files changed, 190 insertions(+), 27 deletions(-) diff --git a/src/engine/adapters/baileys-group-mapper.spec.ts b/src/engine/adapters/baileys-group-mapper.spec.ts index 12a1bc2b..74a6c97e 100644 --- a/src/engine/adapters/baileys-group-mapper.spec.ts +++ b/src/engine/adapters/baileys-group-mapper.spec.ts @@ -59,4 +59,30 @@ describe('mapBaileysGroupInfo', () => { { id: '628222@s.whatsapp.net', number: '628222', name: undefined, isAdmin: true, isSuperAdmin: false }, ]); }); + + it('canonicalizes participant ids and owner through the supplied normalizer (lid -> resolved phone)', () => { + // A lid-addressed group: participants/owner arrive as @lid. The normalizer (the adapter's + // session-store) resolves the known lid to its phone so both sides of the admin check share a dialect. + const m = meta({ + owner: '111@lid', + participants: [ + { id: '111@lid', admin: 'superadmin' }, + { id: '222@lid', admin: null }, + ], + }); + const normalize = (jid: string) => (jid === '111@lid' ? '628111@c.us' : jid); + const info = mapBaileysGroupInfo(m, normalize); + expect(info.owner).toBe('628111@c.us'); + expect(info.participants).toEqual([ + { id: '628111@c.us', number: '628111', name: undefined, isAdmin: true, isSuperAdmin: true }, + { id: '222@lid', number: '222', name: undefined, isAdmin: false, isSuperAdmin: false }, // unresolved: kept raw + ]); + }); + + it('mapBaileysGroup flags self-admin across the dialect split via the normalizer', () => { + const m = meta({ participants: [{ id: '111@lid', admin: 'admin' }] }); + // Self is reported in the raw protocol dialect; the normalizer folds both onto @c.us so they match. + const normalize = (jid: string) => (jid === '111@lid' || jid === '628111@s.whatsapp.net' ? '628111@c.us' : jid); + expect(mapBaileysGroup(m, '628111@s.whatsapp.net', normalize).isAdmin).toBe(true); + }); }); diff --git a/src/engine/adapters/baileys-group-mapper.ts b/src/engine/adapters/baileys-group-mapper.ts index 138b32eb..0052a619 100644 --- a/src/engine/adapters/baileys-group-mapper.ts +++ b/src/engine/adapters/baileys-group-mapper.ts @@ -1,41 +1,54 @@ import type { GroupMetadata } from '@whiskeysockets/baileys'; import { Group, GroupInfo, GroupParticipant } from '../interfaces/whatsapp-engine.interface'; +import { userPart } from '../identity/wa-id'; -/** `628xxx:3@s.whatsapp.net` / `628xxx@lid` -> `628xxx` (the user part, device + scheme stripped). */ -function userPart(jid: string): string { - return jid.split('@')[0].split(':')[0]; -} +/** + * Canonicalizes participant/owner JIDs to the neutral dialect (see wa-id.ts). Defaults to identity so + * the pure-shape behaviour is unchanged; the adapter supplies the session-store-backed normalizer so + * group ids share the dialect of inbound message authors (admin/controller recognition relies on this). + */ +type NormalizeJid = (jid: string) => string; +const identity: NormalizeJid = jid => jid; -function isSelfAdmin(metadata: GroupMetadata, selfJid: string): boolean { - const self = userPart(selfJid); - return metadata.participants.some(p => userPart(p.id) === self && (p.admin === 'admin' || p.admin === 'superadmin')); +function isSelfAdmin(metadata: GroupMetadata, selfJid: string, normalizeJid: NormalizeJid): boolean { + const self = userPart(normalizeJid(selfJid)); + return metadata.participants.some( + p => userPart(normalizeJid(p.id)) === self && (p.admin === 'admin' || p.admin === 'superadmin'), + ); } /** Map a Baileys GroupMetadata to the neutral summary {@link Group}. `selfJid` flags whether WE are an admin. */ -export function mapBaileysGroup(metadata: GroupMetadata, selfJid: string): Group { +export function mapBaileysGroup( + metadata: GroupMetadata, + selfJid: string, + normalizeJid: NormalizeJid = identity, +): Group { return { id: metadata.id, name: metadata.subject, participantsCount: metadata.participants.length, - isAdmin: isSelfAdmin(metadata, selfJid), + isAdmin: isSelfAdmin(metadata, selfJid, normalizeJid), linkedParentJID: metadata.linkedParent ?? null, }; } /** Map a Baileys GroupMetadata to the neutral {@link GroupInfo} (full participant list). */ -export function mapBaileysGroupInfo(metadata: GroupMetadata): GroupInfo { - const participants: GroupParticipant[] = metadata.participants.map(p => ({ - id: p.id, - number: userPart(p.id), - name: p.name, - isAdmin: p.admin === 'admin' || p.admin === 'superadmin', - isSuperAdmin: p.admin === 'superadmin', - })); +export function mapBaileysGroupInfo(metadata: GroupMetadata, normalizeJid: NormalizeJid = identity): GroupInfo { + const participants: GroupParticipant[] = metadata.participants.map(p => { + const id = normalizeJid(p.id); + return { + id, + number: userPart(id), + name: p.name, + isAdmin: p.admin === 'admin' || p.admin === 'superadmin', + isSuperAdmin: p.admin === 'superadmin', + }; + }); return { id: metadata.id, name: metadata.subject, description: metadata.desc, - owner: metadata.owner, + owner: metadata.owner ? normalizeJid(metadata.owner) : metadata.owner, createdAt: metadata.creation, participants, // WhatsApp "announce" = only admins can post; surface as both isAnnounce and (members') isReadOnly (best-effort). diff --git a/src/engine/adapters/baileys-session-store.spec.ts b/src/engine/adapters/baileys-session-store.spec.ts index 6bde6c9d..a0a58c04 100644 --- a/src/engine/adapters/baileys-session-store.spec.ts +++ b/src/engine/adapters/baileys-session-store.spec.ts @@ -84,6 +84,18 @@ describe('BaileysSessionStore', () => { expect(store.resolvePhone('333@lid')).toBeNull(); }); + it('returns the user-part of an already-neutral @c.us id (a resolved-lid sender arrives as @c.us)', () => { + // Once inbound ids are canonicalized, a resolved lid reaches resolvePhone as @c.us. Without + // this branch senderPhone regresses to null for exactly the case the feature exists to surface. + expect(store.resolvePhone('628111@c.us')).toBe('628111'); + expect(store.resolvePhone('628111:5@c.us')).toBe('628111'); + }); + + it('resolves a :device-suffixed lid via the device-stripped mapping', () => { + store.addLidMappings([{ lid: '111@lid', pn: '628999@s.whatsapp.net' }]); + expect(store.resolvePhone('111:7@lid')).toBe('628999'); + }); + describe('toNeutralJid', () => { it('maps @s.whatsapp.net to @c.us and strips the device suffix', () => { expect(store.toNeutralJid('628111@s.whatsapp.net')).toBe('628111@c.us'); diff --git a/src/engine/adapters/baileys-session-store.ts b/src/engine/adapters/baileys-session-store.ts index a5d210bd..9f80e621 100644 --- a/src/engine/adapters/baileys-session-store.ts +++ b/src/engine/adapters/baileys-session-store.ts @@ -1,6 +1,6 @@ import type { Chat, Contact as BaileysContact, WAMessage, WAMessageKey } from '@whiskeysockets/baileys'; import { ChatSummary, Contact } from '../interfaces/whatsapp-engine.interface'; -import { toNeutralJid as canonicalizeWaId, userPart } from '../identity/wa-id'; +import { parseWaId, toNeutralJid as canonicalizeWaId, userPart } from '../identity/wa-id'; /** * Baileys `Contact` does not include a `phoneNumber` field, but WhatsApp Business events may supply @@ -91,15 +91,20 @@ export class BaileysSessionStore { } resolvePhone(id: string): string | null { - if (id.endsWith('@s.whatsapp.net')) { - return userPart(id); + const parsed = parseWaId(id); + // A user id (@c.us / @s.whatsapp.net) already carries the phone as its user-part. The @c.us case + // matters once inbound ids are canonicalized: a resolved-lid sender arrives as @c.us. + if (parsed.kind === 'user') { + return parsed.userPart; } - if (id.endsWith('@lid')) { - const pn = this.lidToPn.get(id); + if (parsed.kind === 'lid') { + // Look up by the device-stripped lid; mappings/contacts are keyed without a :device suffix. + const lidJid = `${parsed.userPart}@lid`; + const pn = this.lidToPn.get(lidJid) ?? this.lidToPn.get(id); if (pn) { return userPart(pn); } - const contactPhone = this.contacts.get(id)?.phoneNumber; + const contactPhone = (this.contacts.get(lidJid) ?? this.contacts.get(id))?.phoneNumber; return contactPhone ? userPart(contactPhone) : null; } return null; diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 9471db4f..828ebec1 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -610,6 +610,28 @@ describe('BaileysAdapter inbound fan-out', () => { expect(msg.isLidSender).toBe(true); // still flagged: the raw sender was a lid }); + it('keeps an unresolved @lid sender as @lid end-to-end (no mapping known)', async () => { + const onMessage = jest.fn(); + const adapter = newAdapter(); + await adapter.initialize({ onMessage }); + fakeSock.fire('messages.upsert', { + type: 'notify', + messages: [ + { + key: { remoteJid: '111@lid', fromMe: false, id: 'IN_LID_RAW' }, + message: { conversation: 'hi from unknown lid' }, + messageTimestamp: 1700000005, + }, + ], + }); + await new Promise(r => setImmediate(r)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const msg = onMessage.mock.calls[0][0] as { from: string; chatId: string; isLidSender?: boolean }; + expect(msg.from).toBe('111@lid'); // unresolved: kept as a privacy id, not faked into a phone + expect(msg.chatId).toBe('111@lid'); + expect(msg.isLidSender).toBe(true); + }); + it('routes a fromMe message to onMessageCreate (outgoing), not onMessage', async () => { const onMessage = jest.fn(); const onMessageCreate = jest.fn(); @@ -1159,6 +1181,26 @@ describe('BaileysAdapter group management', () => { expect(await adapter.getGroupInfo('x@g.us')).toBeNull(); }); + it('getGroupInfo canonicalizes participant + owner ids through the session store (lid -> phone)', async () => { + const adapter = await ready(); + // History sync supplies the lid -> phone mapping; the adapter passes the store's canonicalizer in. + fakeSock.fire('messaging-history.set', { lidPnMappings: [{ lid: '111@lid', pn: '628111@s.whatsapp.net' }] }); + fakeSock.groupMetadata.mockResolvedValueOnce({ + id: '123-456@g.us', + subject: 'G', + owner: '111@lid', + participants: [ + { id: '111@lid', admin: 'superadmin' }, + { id: '222@lid', admin: null }, + ], + }); + const info = await adapter.getGroupInfo('123-456@g.us'); + // Owner + the known admin fold to @c.us, so they share the dialect of canonicalized authors. + expect(info?.owner).toBe('628111@c.us'); + expect(info?.participants[0]).toMatchObject({ id: '628111@c.us', number: '628111', isSuperAdmin: true }); + expect(info?.participants[1]).toMatchObject({ id: '222@lid', number: '222' }); // unresolved kept raw + }); + it('createGroup returns the mapped new group', async () => { fakeSock.groupCreate.mockResolvedValue(META); const adapter = await ready(); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 45312f7d..4040c3fb 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -466,14 +466,16 @@ export class BaileysAdapter implements IWhatsAppEngine { this.ensureReady(); const all = await this.sock!.groupFetchAllParticipating(); const self = this.normalizedSelfJid(); - return Object.values(all).map(metadata => mapBaileysGroup(metadata, self)); + return Object.values(all).map(metadata => + mapBaileysGroup(metadata, self, jid => this.sessionStore.toNeutralJid(jid)), + ); } async getGroupInfo(groupId: string): Promise { this.ensureReady(); try { const metadata = await this.sock!.groupMetadata(groupId); - return mapBaileysGroupInfo(metadata); + return mapBaileysGroupInfo(metadata, jid => this.sessionStore.toNeutralJid(jid)); } catch (err) { this.logger.debug('groupMetadata failed; treating as not-found', { groupId, @@ -486,7 +488,7 @@ export class BaileysAdapter implements IWhatsAppEngine { async createGroup(name: string, participants: string[]): Promise { this.ensureReady(); const metadata = await this.sock!.groupCreate(name, participants); - return mapBaileysGroup(metadata, this.normalizedSelfJid()); + return mapBaileysGroup(metadata, this.normalizedSelfJid(), jid => this.sessionStore.toNeutralJid(jid)); } async addParticipants(groupId: string, participants: string[]): Promise { diff --git a/src/engine/identity/wa-id.spec.ts b/src/engine/identity/wa-id.spec.ts index 1804ecc6..f4a7a6b9 100644 --- a/src/engine/identity/wa-id.spec.ts +++ b/src/engine/identity/wa-id.spec.ts @@ -20,6 +20,12 @@ describe('wa-id', () => { expect(parseWaId('abc@newsletter')).toMatchObject({ kind: 'newsletter' }); expect(parseWaId('not-a-jid')).toMatchObject({ kind: 'unknown' }); }); + + it('classifies broadcast and lowercases the parsed parts', () => { + expect(parseWaId('123@broadcast')).toMatchObject({ kind: 'broadcast', userPart: '123' }); + expect(parseWaId('ABC@NEWSLETTER')).toMatchObject({ kind: 'newsletter', userPart: 'abc' }); + expect(parseWaId('AbCd@LID')).toMatchObject({ kind: 'lid', userPart: 'abcd' }); + }); }); describe('toNeutralJid', () => { @@ -42,6 +48,11 @@ describe('wa-id', () => { expect(toNeutralJid('111@lid')).toBe('111@lid'); // no resolver supplied }); + it('keeps newsletter and broadcast channels in their own dialect', () => { + expect(toNeutralJid('120363-abc@newsletter')).toBe('120363-abc@newsletter'); + expect(toNeutralJid('120363-def@broadcast')).toBe('120363-def@broadcast'); + }); + it('passes an unrecognized format through unchanged', () => { expect(toNeutralJid('weird-thing')).toBe('weird-thing'); }); diff --git a/src/modules/session/session.service.spec.ts b/src/modules/session/session.service.spec.ts index f1119849..20e6a089 100644 --- a/src/modules/session/session.service.spec.ts +++ b/src/modules/session/session.service.spec.ts @@ -10,6 +10,7 @@ import { EventsGateway } from '../events/events.gateway'; import { WebhookService } from '../webhook/webhook.service'; import { HookManager } from '../../core/hooks'; import { IncomingMessage, EngineEventCallbacks, EngineStatus } from '../../engine/interfaces/whatsapp-engine.interface'; +import { BaileysSessionStore } from '../../engine/adapters/baileys-session-store'; function createMockSession(overrides: Partial = {}): Session { return { @@ -863,6 +864,33 @@ describe('SessionService', () => { } }); + it('resolves senderPhone from a canonicalized @c.us author for a resolved-lid sender (#263)', async () => { + // After JID canonicalization a resolved lid reaches the service as @c.us while isLidSender + // stays true. Wire resolveContactPhone to the real store so the @c.us branch is genuinely exercised: + // if resolvePhone regressed to null for @c.us, senderPhone would be null here. + process.env.RESOLVE_LID_TO_PHONE = 'true'; + try { + echoHook(); + const store = new BaileysSessionStore(); + store.addLidMappings([{ lid: '111@lid', pn: '628111222333@s.whatsapp.net' }]); + mockEngine.resolveContactPhone.mockImplementation((id: string) => Promise.resolve(store.resolvePhone(id))); + const callbacks = await startAndCaptureCallbacks(); + + // Group lid author resolved to @c.us by the engine boundary. + callbacks.onMessage!( + makeMessage({ from: 'g@g.us', chatId: 'g@g.us', author: '628111222333@c.us', isLidSender: true }), + ); + await flush(); + + const received = dispatchedEvents('message.received'); + expect(received).toHaveLength(1); + expect((received[0][2] as IncomingMessage).senderPhone).toBe('628111222333'); + expect(mockEngine.resolveContactPhone).toHaveBeenCalledWith('628111222333@c.us'); + } finally { + delete process.env.RESOLVE_LID_TO_PHONE; + } + }); + it('does not resolve senderPhone when RESOLVE_LID_TO_PHONE is unset (default off)', async () => { delete process.env.RESOLVE_LID_TO_PHONE; echoHook(); diff --git a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts index 15b5adb2..3f0802a7 100644 --- a/src/plugins/extensions/translation/core/translation.coordinator.spec.ts +++ b/src/plugins/extensions/translation/core/translation.coordinator.spec.ts @@ -102,6 +102,30 @@ describe('TranslationCoordinator', () => { expect(saved.at(-1)?.active).toBe(true); }); + it('recognizes a resolved-lid admin once group ids share the @c.us dialect', async () => { + // In a lid-addressed group the author is canonicalized to @c.us; the admin list must be + // canonicalized through the same path so widEquals (which does NOT bridge lid<->phone) can match. + const state = freshState({ announced: true }); + const { store, gateway, translator, saved, mocks } = makeDeps(state); + mocks.getGroupAdmins.mockResolvedValue(['628111@c.us']); // resolved-lid admin, neutral dialect + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg({ author: '628111@c.us', body: '/tr on' })); + expect(res).toEqual({ swallow: true }); + expect(saved.at(-1)?.active).toBe(true); + }); + + it('regression guard: a raw @lid admin list does NOT match a resolved @c.us author', async () => { + // The pre-fix one-sided state: author canonicalized but the admin list left raw. widEquals compares + // user-parts (111 vs 628111) and deliberately won't bridge the namespaces, so the admin is denied. + const state = freshState({ announced: true }); + const { store, gateway, translator, saved, mocks } = makeDeps(state); + mocks.getGroupAdmins.mockResolvedValue(['111@lid']); // un-canonicalized + const c = new TranslationCoordinator(translator, store, gateway, OPTS); + const res = await c.handleMessage('s', msg({ author: '628111@c.us', body: '/tr on' })); + expect(res).toEqual({ swallow: true }); + expect(saved.at(-1)?.active ?? false).toBe(false); + }); + it('rejects activation from a non-admin (silent by default)', async () => { const state = freshState({ announced: true }); const { store, gateway, translator, saved, mocks } = makeDeps(state);