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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ plugins instead of in core (#265).
- **Baileys engine (minimal slice)** — `ENGINE_TYPE=baileys` now selects a second, browser-free WhatsApp engine built on `@whiskeysockets/baileys` (WebSocket/Noise protocol, no Chromium). This first slice supports linking (QR + pairing code), sending and receiving **text**, recipient resolution, and typing presence; all other operations return HTTP 501 until later slices add a message store. Config: `BAILEYS_AUTH_DIR` (default `./data/baileys`). Proxy is not yet supported on this engine. (#299)
- **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)

### Changed

Expand Down
62 changes: 62 additions & 0 deletions src/engine/adapters/baileys-group-mapper.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { GroupMetadata } from '@whiskeysockets/baileys';
import { mapBaileysGroup, mapBaileysGroupInfo } from './baileys-group-mapper';

const meta = (over: Partial<GroupMetadata> = {}): GroupMetadata =>
({
id: '123-456@g.us',
subject: 'My Group',
owner: '628999@s.whatsapp.net',
desc: 'a description',
creation: 1700000000,
announce: false,
participants: [
{ id: '628999@s.whatsapp.net', admin: 'superadmin' },
{ id: '628111@s.whatsapp.net', admin: null },
{ id: '628222@s.whatsapp.net', admin: 'admin' },
],
...over,
}) as GroupMetadata;

describe('mapBaileysGroup', () => {
it('maps the summary shape and flags self-admin', () => {
const g = mapBaileysGroup(meta(), '628999:3@s.whatsapp.net');
expect(g).toEqual({
id: '123-456@g.us',
name: 'My Group',
participantsCount: 3,
isAdmin: true, // self is superadmin
linkedParentJID: null,
});
});

it('isAdmin is false when self is a non-admin member', () => {
expect(mapBaileysGroup(meta(), '628111@s.whatsapp.net').isAdmin).toBe(false);
});

it('isAdmin is true when self is a plain admin', () => {
const m = meta({ participants: [{ id: '628222@s.whatsapp.net', admin: 'admin' }] });
expect(mapBaileysGroup(m, '628222@s.whatsapp.net').isAdmin).toBe(true);
});

it('carries the linked community parent when present', () => {
expect(mapBaileysGroup(meta({ linkedParent: '999@g.us' }), 'x@s.whatsapp.net').linkedParentJID).toBe('999@g.us');
});
});

describe('mapBaileysGroupInfo', () => {
it('maps full info incl. participants admin/superadmin', () => {
const info = mapBaileysGroupInfo(meta({ announce: true }));
expect(info.id).toBe('123-456@g.us');
expect(info.name).toBe('My Group');
expect(info.description).toBe('a description');
expect(info.owner).toBe('628999@s.whatsapp.net');
expect(info.createdAt).toBe(1700000000);
expect(info.isAnnounce).toBe(true);
expect(info.isReadOnly).toBe(true);
expect(info.participants).toEqual([
{ id: '628999@s.whatsapp.net', number: '628999', name: undefined, isAdmin: true, isSuperAdmin: true },
{ id: '628111@s.whatsapp.net', number: '628111', name: undefined, isAdmin: false, isSuperAdmin: false },
{ id: '628222@s.whatsapp.net', number: '628222', name: undefined, isAdmin: true, isSuperAdmin: false },
]);
});
});
46 changes: 46 additions & 0 deletions src/engine/adapters/baileys-group-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { GroupMetadata } from '@whiskeysockets/baileys';
import { Group, GroupInfo, GroupParticipant } from '../interfaces/whatsapp-engine.interface';

/** `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];
}

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'));
}

/** 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 {
return {
id: metadata.id,
name: metadata.subject,
participantsCount: metadata.participants.length,
isAdmin: isSelfAdmin(metadata, selfJid),
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',
}));
return {
id: metadata.id,
name: metadata.subject,
description: metadata.desc,
owner: metadata.owner,
createdAt: metadata.creation,
participants,
// WhatsApp "announce" = only admins can post; surface as both isAnnounce and (members') isReadOnly (best-effort).
isAnnounce: metadata.announce,
isReadOnly: metadata.announce,
linkedParentJID: metadata.linkedParent ?? null,
};
}
127 changes: 125 additions & 2 deletions src/engine/adapters/baileys.adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ class FakeSock extends EventEmitter {
public sendMessage = jest.fn();
public onWhatsApp = jest.fn();
public sendPresenceUpdate = jest.fn().mockResolvedValue(undefined);
public groupFetchAllParticipating = jest.fn();
public groupMetadata = jest.fn();
public groupCreate = jest.fn();
public groupParticipantsUpdate = jest.fn().mockResolvedValue(undefined);
public groupLeave = jest.fn().mockResolvedValue(undefined);
public groupUpdateSubject = jest.fn().mockResolvedValue(undefined);
public groupUpdateDescription = jest.fn().mockResolvedValue(undefined);
public groupInviteCode = jest.fn();
public groupRevokeInvite = jest.fn();
public profilePictureUrl = jest.fn();
public updateBlockStatus = jest.fn().mockResolvedValue(undefined);
fire(event: string, arg: unknown): void {
this.emitter.emit(event, arg);
}
Expand Down Expand Up @@ -149,9 +160,8 @@ describe('BaileysAdapter lifecycle & status', () => {
});

describe('BaileysAdapter capability gating', () => {
it('throws EngineNotSupportedError for store-backed methods (e.g. getGroups, getChats)', async () => {
it('throws EngineNotSupportedError for store-backed methods (e.g. getChats)', async () => {
const adapter = newAdapter();
await expect(adapter.getGroups()).rejects.toBeInstanceOf(EngineNotSupportedError);
await expect(adapter.getChats()).rejects.toBeInstanceOf(EngineNotSupportedError);
});
});
Expand Down Expand Up @@ -522,3 +532,116 @@ describe('BaileysAdapter store-backed ops', () => {
expect(fakeStore.clearSession).toHaveBeenCalledWith('sess-1');
});
});

describe('BaileysAdapter group management', () => {
const META = {
id: '123-456@g.us',
subject: 'G',
participants: [{ id: '628999@s.whatsapp.net', admin: 'superadmin' }],
};

beforeEach(() => {
fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' };
jest.clearAllMocks();
});

const ready = async (): Promise<BaileysAdapter> => {
const adapter = newAdapter();
await adapter.initialize({});
fakeSock.fire('connection.update', { connection: 'open' });
return adapter;
};

it('getGroups maps groupFetchAllParticipating', async () => {
fakeSock.groupFetchAllParticipating.mockResolvedValue({ '123-456@g.us': META });
const adapter = await ready();
const groups = await adapter.getGroups();
expect(groups).toEqual([
{ id: '123-456@g.us', name: 'G', participantsCount: 1, isAdmin: true, linkedParentJID: null },
]);
});

it('getGroupInfo maps groupMetadata, and returns null when it rejects', async () => {
fakeSock.groupMetadata.mockResolvedValueOnce(META);
const adapter = await ready();
expect((await adapter.getGroupInfo('123-456@g.us'))?.id).toBe('123-456@g.us');
fakeSock.groupMetadata.mockRejectedValueOnce(new Error('not a group'));
expect(await adapter.getGroupInfo('x@g.us')).toBeNull();
});

it('createGroup returns the mapped new group', async () => {
fakeSock.groupCreate.mockResolvedValue(META);
const adapter = await ready();
const g = await adapter.createGroup('G', ['628111@s.whatsapp.net']);
expect(fakeSock.groupCreate).toHaveBeenCalledWith('G', ['628111@s.whatsapp.net']);
expect(g.id).toBe('123-456@g.us');
});

it.each([
['addParticipants', 'add'],
['removeParticipants', 'remove'],
['promoteParticipants', 'promote'],
['demoteParticipants', 'demote'],
])('%s calls groupParticipantsUpdate with %s', async (method, action) => {
const adapter = await ready();
await (adapter as unknown as Record<string, (g: string, p: string[]) => Promise<void>>)[method]('123-456@g.us', [
'628111@s.whatsapp.net',
]);
expect(fakeSock.groupParticipantsUpdate).toHaveBeenCalledWith('123-456@g.us', ['628111@s.whatsapp.net'], action);
});

it('leaveGroup / setGroupSubject / setGroupDescription delegate to the socket', async () => {
const adapter = await ready();
await adapter.leaveGroup('123-456@g.us');
expect(fakeSock.groupLeave).toHaveBeenCalledWith('123-456@g.us');
await adapter.setGroupSubject('123-456@g.us', 'New');
expect(fakeSock.groupUpdateSubject).toHaveBeenCalledWith('123-456@g.us', 'New');
await adapter.setGroupDescription('123-456@g.us', 'Desc');
expect(fakeSock.groupUpdateDescription).toHaveBeenCalledWith('123-456@g.us', 'Desc');
});

it('getGroupInviteCode / revokeGroupInviteCode return the code', async () => {
fakeSock.groupInviteCode.mockResolvedValue('ABC123');
fakeSock.groupRevokeInvite.mockResolvedValue('NEW456');
const adapter = await ready();
expect(await adapter.getGroupInviteCode('123-456@g.us')).toBe('ABC123');
expect(await adapter.revokeGroupInviteCode('123-456@g.us')).toBe('NEW456');
});

it('group ops reject with EngineNotReadyError before connect', async () => {
const adapter = newAdapter();
await adapter.initialize({});
await expect(adapter.getGroups()).rejects.toBeInstanceOf(EngineNotReadyError);
});
});

describe('BaileysAdapter profile + block', () => {
beforeEach(() => {
fakeSock.user = { id: '628999:1@s.whatsapp.net', name: 'Me' };
jest.clearAllMocks();
});

const ready = async (): Promise<BaileysAdapter> => {
const adapter = newAdapter();
await adapter.initialize({});
fakeSock.fire('connection.update', { connection: 'open' });
return adapter;
};

it('getProfilePicture returns the url, or null when none', async () => {
fakeSock.profilePictureUrl.mockResolvedValueOnce('https://pps/x.jpg');
const adapter = await ready();
expect(await adapter.getProfilePicture('628111@s.whatsapp.net')).toBe('https://pps/x.jpg');
expect(fakeSock.profilePictureUrl).toHaveBeenCalledWith('628111@s.whatsapp.net', 'image');
fakeSock.profilePictureUrl.mockRejectedValueOnce(new Error('no picture'));
expect(await adapter.getProfilePicture('628222@s.whatsapp.net')).toBeNull();
});

it('blockContact / unblockContact call updateBlockStatus', async () => {
const adapter = await ready();
await adapter.blockContact('628111@s.whatsapp.net');
expect(fakeSock.updateBlockStatus).toHaveBeenCalledWith('628111@s.whatsapp.net', 'block');
await adapter.unblockContact('628111@s.whatsapp.net');
expect(fakeSock.updateBlockStatus).toHaveBeenCalledWith('628111@s.whatsapp.net', 'unblock');
});
});
Loading