Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ plugins instead of in core (#265).
- **Baileys engine — media/location/contact sends.** The Baileys engine (`ENGINE_TYPE=baileys`) can now send image/video/audio/document/sticker, location, and contact messages (slice 2a). URL media is fetched through the same SSRF-guarded path as the whatsapp-web.js engine (host guard + byte cap + timeout, no redirects). Reply/forward/react/delete remain unsupported (HTTP 501) until a later slice adds a message store. (#307)
- **Baileys engine — reply/forward/react/delete.** The Baileys engine can now reply to, forward, react to, and delete (revoke-for-everyone) messages, backed by a persisted per-session message store (`baileys_stored_messages`, bounded by `BAILEYS_MESSAGE_STORE_LIMIT`, default 5000). Delete-for-me and reading reactions remain unsupported (HTTP 501). Note: this engine persists recent message protos to the data database (so these operations survive restarts) — more data-at-rest than the whatsapp-web.js engine; the store is bounded per session, cleared on logout, and CASCADE-deleted with its session, and operators with retention requirements can tune `BAILEYS_MESSAGE_STORE_LIMIT`. (#308)
- **Baileys engine — group management + profile picture + block/unblock.** The Baileys engine can now list/inspect/create groups, manage participants (add/remove/promote/demote), leave a group, set its subject/description, and get/revoke its invite code, plus fetch a contact's profile picture and block/unblock contacts (slice 3a). Contacts/chats lists, read receipts, chat history, and `deleteChat` remain unsupported (HTTP 501) until a later slice adds a contact/chat store; labels/channels/status/catalog stay unsupported (parity with the whatsapp-web.js engine). (#309)
- **Baileys engine — contacts, chats, read receipts.** The Baileys engine can now list contacts (`getContacts`/`getContactById`), resolve a contact's phone (`resolveContactPhone`, best-effort), list chats (`getChats`), mark a chat read (`sendSeen`), and delete a chat (`deleteChat`) — backed by a per-session in-memory store fed from Baileys' contact/chat sync events (rebuilt on each connect). `getChatHistory` remains unsupported (HTTP 501): Baileys has no on-demand history API. Labels/channels/status/catalog/message-reactions stay unsupported (parity with the whatsapp-web.js engine). With this, the Baileys engine implements every neutral capability it can meaningfully support. (#310)

### Changed

Expand Down
86 changes: 86 additions & 0 deletions src/engine/adapters/baileys-session-store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { BaileysSessionStore } from './baileys-session-store';

describe('BaileysSessionStore', () => {
let store: BaileysSessionStore;
beforeEach(() => {
store = new BaileysSessionStore();
});

it('upserts contacts (full then partial merge) and maps to neutral', () => {
store.upsertContacts([{ id: '628111@s.whatsapp.net', notify: 'Al', imgUrl: 'http://p/x.jpg' }]);
store.upsertContacts([{ id: '628111@s.whatsapp.net', name: 'Alice' }]); // partial: name added, notify kept
const c = store.findContact('628111@s.whatsapp.net');
expect(c).toEqual({
id: '628111@s.whatsapp.net',
name: 'Alice',
pushName: 'Al',
number: '628111',
isMyContact: true,
isBlocked: false,
profilePicUrl: 'http://p/x.jpg',
});
expect(store.findContact('nope@s.whatsapp.net')).toBeNull();
expect(store.listContacts()).toHaveLength(1);
});

it('records the newest message per chat and surfaces it in getChats', () => {
store.upsertChats([{ id: '628111@s.whatsapp.net', name: 'Alice', unreadCount: 2 }]);
store.recordMessage({
key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'OLD' },
message: { conversation: 'old' },
messageTimestamp: 100,
});
store.recordMessage({
key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'NEW' },
message: { conversation: 'newest' },
messageTimestamp: 200,
});
const chats = store.listChats();
expect(chats).toEqual([
{
id: '628111@s.whatsapp.net',
name: 'Alice',
isGroup: false,
unreadCount: 2,
timestamp: 200,
lastMessage: 'newest',
},
]);
expect(store.lastMessage('628111@s.whatsapp.net')).toEqual({
key: { remoteJid: '628111@s.whatsapp.net', fromMe: false, id: 'NEW' },
timestamp: 200,
});
});

it('does not overwrite a newer last-message with an older one', () => {
store.recordMessage({
key: { remoteJid: 'c@s.whatsapp.net', id: 'NEW' },
message: {},
messageTimestamp: 200,
});
store.recordMessage({
key: { remoteJid: 'c@s.whatsapp.net', id: 'OLD' },
message: {},
messageTimestamp: 100,
});
expect(store.lastMessage('c@s.whatsapp.net')?.key.id).toBe('NEW');
});

it('flags a group chat by jid', () => {
store.upsertChats([{ id: '123-456@g.us', name: 'Grp' }]);
expect(store.listChats()[0].isGroup).toBe(true);
});

it('lastMessage returns null for an unknown chat', () => {
expect(store.lastMessage('unknown@s.whatsapp.net')).toBeNull();
});

it('resolves a phone jid to its user-part, a lid via lidPnMappings, and a contact phoneNumber', () => {
expect(store.resolvePhone('628111@s.whatsapp.net')).toBe('628111');
store.addLidMappings([{ lid: '111@lid', pn: '628999@s.whatsapp.net' }]);
expect(store.resolvePhone('111@lid')).toBe('628999');
store.upsertContacts([{ id: '222@lid', phoneNumber: '628222@s.whatsapp.net' }]);
expect(store.resolvePhone('222@lid')).toBe('628222');
expect(store.resolvePhone('333@lid')).toBeNull();
});
});
146 changes: 146 additions & 0 deletions src/engine/adapters/baileys-session-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import type { Chat, Contact as BaileysContact, WAMessage, WAMessageKey } from '@whiskeysockets/baileys';
import { ChatSummary, Contact } from '../interfaces/whatsapp-engine.interface';

/**
* Baileys `Contact` does not include a `phoneNumber` field, but WhatsApp Business events may supply
* the resolved phone JID alongside the lid-based id. We extend the input type locally so callers can
* pass `phoneNumber` when available (e.g. from `contacts.upsert` payloads that carry lid+pn pairs).
*/
type BaileysContactWithPhone = BaileysContact & { phoneNumber?: string };

interface LastMessage {
key: WAMessageKey;
timestamp: number;
text: string;
}

/**
* Per-session, in-memory snapshot of Baileys contacts + chats, fed from `sock.ev` events. Baileys has
* no fetch-all; this data arrives via `contacts.*`/`chats.*`/`messaging-history.set` (a full re-sync on
* each connect) and is mapped to the neutral `Contact`/`ChatSummary` on read. Holds no socket — pure data.
*/
export class BaileysSessionStore {
private readonly contacts = new Map<string, BaileysContactWithPhone>();
private readonly chats = new Map<string, Chat>();
private readonly lastMessages = new Map<string, LastMessage>();
private readonly lidToPn = new Map<string, string>();

upsertContacts(records: Partial<BaileysContactWithPhone>[] = []): void {
for (const r of records) {
if (!r.id) {
continue;
}
const existing = this.contacts.get(r.id) ?? { id: r.id };
const merged: BaileysContactWithPhone = { ...existing, ...r };
this.contacts.set(r.id, merged);
if (r.lid && r.phoneNumber) {
this.lidToPn.set(r.lid, r.phoneNumber);
}
}
}

upsertChats(records: Partial<Chat>[] = []): void {
for (const r of records) {
if (!r.id) {
continue;
}
const existing = this.chats.get(r.id) ?? { id: r.id };
this.chats.set(r.id, { ...existing, ...r });
}
}

addLidMappings(mappings: { lid?: string; pn?: string }[] = []): void {
for (const m of mappings) {
if (m.lid && m.pn) {
this.lidToPn.set(m.lid, m.pn);
}
}
}

recordMessage(msg: WAMessage): void {
const chatId = msg.key?.remoteJid;
if (!chatId || !msg.key) {
return;
}
const timestamp = this.toUnixSeconds(msg.messageTimestamp);
const existing = this.lastMessages.get(chatId);
if (existing && existing.timestamp >= timestamp) {
return; // keep the newest
}
const text = msg.message?.conversation ?? msg.message?.extendedTextMessage?.text ?? '';
this.lastMessages.set(chatId, { key: msg.key, timestamp, text });
}

listContacts(): Contact[] {
return [...this.contacts.values()].map(c => this.toNeutralContact(c));
}

findContact(id: string): Contact | null {
const c = this.contacts.get(id);
return c ? this.toNeutralContact(c) : null;
}

listChats(): ChatSummary[] {
return [...this.chats.values()].map(c => this.toNeutralChat(c));
}

lastMessage(chatId: string): { key: WAMessageKey; timestamp: number } | null {
const m = this.lastMessages.get(chatId);
return m ? { key: m.key, timestamp: m.timestamp } : null;
}

resolvePhone(id: string): string | null {
if (id.endsWith('@s.whatsapp.net')) {
return this.userPart(id);
}
if (id.endsWith('@lid')) {
const pn = this.lidToPn.get(id);
if (pn) {
return this.userPart(pn);
}
const contactPhone = this.contacts.get(id)?.phoneNumber;
return contactPhone ? this.userPart(contactPhone) : null;
}
return null;
}

private toNeutralContact(c: BaileysContactWithPhone): Contact {
const number = c.phoneNumber
? this.userPart(c.phoneNumber)
: c.id.endsWith('@s.whatsapp.net')
? this.userPart(c.id)
: '';
return {
id: c.id,
name: c.name ?? c.verifiedName,
pushName: c.notify,
number,
isMyContact: true, // best-effort: present in the synced address book / chat list
isBlocked: false, // best-effort: blocklist state is not tracked in this slice
profilePicUrl: c.imgUrl ?? undefined,
};
}

private toNeutralChat(c: Chat): ChatSummary {
const last = this.lastMessages.get(c.id);
return {
id: c.id,
name: c.name ?? this.userPart(c.id),
isGroup: c.id.endsWith('@g.us'),
unreadCount: c.unreadCount ?? 0,
timestamp: last?.timestamp ?? this.toUnixSeconds(c.conversationTimestamp),
lastMessage: last?.text,
};
}

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

private toUnixSeconds(ts: number | { toNumber(): number } | null | undefined): number {
if (ts == null) {
return 0;
}
return typeof ts === 'number' ? ts : ts.toNumber();
}
}
Loading