Skip to content
Merged
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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
system use the `<phone>@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
Expand Down
27 changes: 27 additions & 0 deletions docs/03-system-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<phone>@c.us`, while Baileys speaks the raw protocol forms `<phone>@s.whatsapp.net`
and `<lid>@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 |
| --- | --- |
| `<phone>@c.us` | a user, by phone (the raw `@s.whatsapp.net` form folds into this) |
| `<id>@g.us` | a group |
| `<lid>@lid` | a user known **only** by privacy id - phone genuinely unknown (a first-class state) |
| `status@broadcast`, `<id>@newsletter`, `<id>@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:
Expand Down
5 changes: 4 additions & 1 deletion docs/21-glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<phone>@c.us` (whatsapp-web.js, and OpenWA's neutral form), `<phone>@s.whatsapp.net` (Baileys' raw form for the same user), `<id>@g.us` (a group), or `<lid>@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** (`<number>@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.

Expand Down
26 changes: 26 additions & 0 deletions src/engine/adapters/baileys-group-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
49 changes: 31 additions & 18 deletions src/engine/adapters/baileys-group-mapper.ts
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
19 changes: 19 additions & 0 deletions src/engine/adapters/baileys-message-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 16 additions & 8 deletions src/engine/adapters/baileys-message-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
35 changes: 35 additions & 0 deletions src/engine/adapters/baileys-session-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,39 @@ describe('BaileysSessionStore', () => {
expect(store.resolvePhone('222@lid')).toBe('628222');
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 <phone>@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');
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 <phone>@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');
});
});
});
40 changes: 23 additions & 17 deletions src/engine/adapters/baileys-session-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
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';

/**
* Baileys `Contact` does not include a `phoneNumber` field, but WhatsApp Business events may supply
Expand Down Expand Up @@ -90,26 +91,35 @@ export class BaileysSessionStore {
}

resolvePhone(id: string): string | null {
if (id.endsWith('@s.whatsapp.net')) {
return this.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 <phone>@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 this.userPart(pn);
return userPart(pn);
}
const contactPhone = this.contacts.get(id)?.phoneNumber;
return contactPhone ? this.userPart(contactPhone) : null;
const contactPhone = (this.contacts.get(lidJid) ?? this.contacts.get(id))?.phoneNumber;
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,
Expand All @@ -125,18 +135,14 @@ 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),
lastMessage: last?.text,
};
}

private userPart(jid: string): string {
return jid.split('@')[0].split(':')[0];
}

private toUnixSeconds(ts: number | { toNumber(): number } | null | undefined): number {
if (ts == null) {
return 0;
Expand Down
Loading
Loading