From a715b03cf7faf0722b76dcb42b412f4894ee84fa Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 11:34:05 +0700 Subject: [PATCH 1/6] feat(engine): add baileys-group-mapper (GroupMetadata -> neutral) --- .../adapters/baileys-group-mapper.spec.ts | 57 +++++++++++++++++++ src/engine/adapters/baileys-group-mapper.ts | 47 +++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/engine/adapters/baileys-group-mapper.spec.ts create mode 100644 src/engine/adapters/baileys-group-mapper.ts diff --git a/src/engine/adapters/baileys-group-mapper.spec.ts b/src/engine/adapters/baileys-group-mapper.spec.ts new file mode 100644 index 00000000..be3cbe73 --- /dev/null +++ b/src/engine/adapters/baileys-group-mapper.spec.ts @@ -0,0 +1,57 @@ +import type { GroupMetadata } from '@whiskeysockets/baileys'; +import { mapBaileysGroup, mapBaileysGroupInfo } from './baileys-group-mapper'; + +const meta = (over: Partial = {}): 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('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 }), '628999@s.whatsapp.net'); + 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 }, + ]); + }); +}); diff --git a/src/engine/adapters/baileys-group-mapper.ts b/src/engine/adapters/baileys-group-mapper.ts new file mode 100644 index 00000000..53227661 --- /dev/null +++ b/src/engine/adapters/baileys-group-mapper.ts @@ -0,0 +1,47 @@ +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). */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function mapBaileysGroupInfo(metadata: GroupMetadata, selfJid: string): GroupInfo { + const participants: GroupParticipant[] = metadata.participants.map(p => ({ + id: p.id, + number: userPart(p.id), + name: p.name ?? undefined, + 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, + }; +} From fb4b249358e8cf123f4f6a2a1d99022b09f68bb9 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 11:37:30 +0700 Subject: [PATCH 2/6] refactor(engine): drop unused selfJid from mapBaileysGroupInfo; tidy mapper --- src/engine/adapters/baileys-group-mapper.spec.ts | 7 ++++++- src/engine/adapters/baileys-group-mapper.ts | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/engine/adapters/baileys-group-mapper.spec.ts b/src/engine/adapters/baileys-group-mapper.spec.ts index be3cbe73..12a1bc2b 100644 --- a/src/engine/adapters/baileys-group-mapper.spec.ts +++ b/src/engine/adapters/baileys-group-mapper.spec.ts @@ -33,6 +33,11 @@ describe('mapBaileysGroup', () => { 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'); }); @@ -40,7 +45,7 @@ describe('mapBaileysGroup', () => { describe('mapBaileysGroupInfo', () => { it('maps full info incl. participants admin/superadmin', () => { - const info = mapBaileysGroupInfo(meta({ announce: true }), '628999@s.whatsapp.net'); + 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'); diff --git a/src/engine/adapters/baileys-group-mapper.ts b/src/engine/adapters/baileys-group-mapper.ts index 53227661..138b32eb 100644 --- a/src/engine/adapters/baileys-group-mapper.ts +++ b/src/engine/adapters/baileys-group-mapper.ts @@ -23,12 +23,11 @@ export function mapBaileysGroup(metadata: GroupMetadata, selfJid: string): Group } /** Map a Baileys GroupMetadata to the neutral {@link GroupInfo} (full participant list). */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -export function mapBaileysGroupInfo(metadata: GroupMetadata, selfJid: string): GroupInfo { +export function mapBaileysGroupInfo(metadata: GroupMetadata): GroupInfo { const participants: GroupParticipant[] = metadata.participants.map(p => ({ id: p.id, number: userPart(p.id), - name: p.name ?? undefined, + name: p.name, isAdmin: p.admin === 'admin' || p.admin === 'superadmin', isSuperAdmin: p.admin === 'superadmin', })); From 6df74c3f1fa351194793590841288646be46c943 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 11:40:35 +0700 Subject: [PATCH 3/6] feat(engine): baileys group management (list/info/create/participants/leave/subject/description/invite) --- src/engine/adapters/baileys.adapter.spec.ts | 96 +++++++++++++++++- src/engine/adapters/baileys.adapter.ts | 103 +++++++++++++------- 2 files changed, 163 insertions(+), 36 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 59253739..64c38b20 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -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); } @@ -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); }); }); @@ -522,3 +532,85 @@ 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 => { + 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 Promise>)[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); + }); +}); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index f90d01d0..86b639b9 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -7,6 +7,7 @@ import makeWASocket, { } from '@whiskeysockets/baileys'; import type { AnyMessageContent, MiscMessageGenerationOptions, WAMessage, WASocket } from '@whiskeysockets/baileys'; import { buildIncomingMessageFromBaileys, mapBaileysStatus } from './baileys-message-mapper'; +import { mapBaileysGroup, mapBaileysGroupInfo } from './baileys-group-mapper'; import type { ILogger } from '@whiskeysockets/baileys/lib/Utils/logger.js'; import { ChatState, @@ -335,56 +336,90 @@ export class BaileysAdapter implements IWhatsAppEngine { await this.sock!.sendMessage(chatId, { delete: target.key }); } - // ----- Gated: not supported by this minimal slice (no store) ----- - /* eslint-disable @typescript-eslint/no-unused-vars */ + // ----- Groups ----- - getMessageReactions(_chatId: string, _messageId: string): Promise { - return this.unsupported('getMessageReactions'); + async getGroups(): Promise { + this.ensureReady(); + const all = await this.sock!.groupFetchAllParticipating(); + const self = this.normalizedSelfJid(); + return Object.values(all).map(metadata => mapBaileysGroup(metadata, self)); } - getContacts(): Promise { - return this.unsupported('getContacts'); + + async getGroupInfo(groupId: string): Promise { + this.ensureReady(); + try { + const metadata = await this.sock!.groupMetadata(groupId); + return mapBaileysGroupInfo(metadata); + } catch { + return null; // not a group / not found + } } - getContactById(_contactId: string): Promise { - return this.unsupported('getContactById'); + + async createGroup(name: string, participants: string[]): Promise { + this.ensureReady(); + const metadata = await this.sock!.groupCreate(name, participants); + return mapBaileysGroup(metadata, this.normalizedSelfJid()); } - resolveContactPhone(_contactId: string): Promise { - return this.unsupported('resolveContactPhone'); + + async addParticipants(groupId: string, participants: string[]): Promise { + this.ensureReady(); + await this.sock!.groupParticipantsUpdate(groupId, participants, 'add'); } - getGroups(): Promise { - return this.unsupported('getGroups'); + + async removeParticipants(groupId: string, participants: string[]): Promise { + this.ensureReady(); + await this.sock!.groupParticipantsUpdate(groupId, participants, 'remove'); } - getGroupInfo(_groupId: string): Promise { - return this.unsupported('getGroupInfo'); + + async promoteParticipants(groupId: string, participants: string[]): Promise { + this.ensureReady(); + await this.sock!.groupParticipantsUpdate(groupId, participants, 'promote'); } - createGroup(_name: string, _participants: string[]): Promise { - return this.unsupported('createGroup'); + + async demoteParticipants(groupId: string, participants: string[]): Promise { + this.ensureReady(); + await this.sock!.groupParticipantsUpdate(groupId, participants, 'demote'); } - addParticipants(_groupId: string, _participants: string[]): Promise { - return this.unsupported('addParticipants'); + + async leaveGroup(groupId: string): Promise { + this.ensureReady(); + await this.sock!.groupLeave(groupId); } - removeParticipants(_groupId: string, _participants: string[]): Promise { - return this.unsupported('removeParticipants'); + + async setGroupSubject(groupId: string, subject: string): Promise { + this.ensureReady(); + await this.sock!.groupUpdateSubject(groupId, subject); } - promoteParticipants(_groupId: string, _participants: string[]): Promise { - return this.unsupported('promoteParticipants'); + + async setGroupDescription(groupId: string, description: string): Promise { + this.ensureReady(); + await this.sock!.groupUpdateDescription(groupId, description); } - demoteParticipants(_groupId: string, _participants: string[]): Promise { - return this.unsupported('demoteParticipants'); + + async getGroupInviteCode(groupId: string): Promise { + this.ensureReady(); + return (await this.sock!.groupInviteCode(groupId)) ?? ''; } - leaveGroup(_groupId: string): Promise { - return this.unsupported('leaveGroup'); + + async revokeGroupInviteCode(groupId: string): Promise { + this.ensureReady(); + return (await this.sock!.groupRevokeInvite(groupId)) ?? ''; } - setGroupSubject(_groupId: string, _subject: string): Promise { - return this.unsupported('setGroupSubject'); + + // ----- Gated: not supported by this minimal slice (no store) ----- + /* eslint-disable @typescript-eslint/no-unused-vars */ + + getMessageReactions(_chatId: string, _messageId: string): Promise { + return this.unsupported('getMessageReactions'); } - setGroupDescription(_groupId: string, _description: string): Promise { - return this.unsupported('setGroupDescription'); + getContacts(): Promise { + return this.unsupported('getContacts'); } - getGroupInviteCode(_groupId: string): Promise { - return this.unsupported('getGroupInviteCode'); + getContactById(_contactId: string): Promise { + return this.unsupported('getContactById'); } - revokeGroupInviteCode(_groupId: string): Promise { - return this.unsupported('revokeGroupInviteCode'); + resolveContactPhone(_contactId: string): Promise { + return this.unsupported('resolveContactPhone'); } getChatHistory(_chatId: string, _limit?: number, _includeMedia?: boolean): Promise { return this.unsupported('getChatHistory'); From 82c68b1614ae8ccfa9a0b3dddbb055d2e39d1d21 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 11:46:16 +0700 Subject: [PATCH 4/6] feat(engine): baileys profile picture + block/unblock; advertise group-management --- CHANGELOG.md | 1 + src/engine/adapters/baileys.adapter.spec.ts | 31 +++++++++++++++++++++ src/engine/adapters/baileys.adapter.ts | 28 +++++++++++++------ src/plugins/engines/baileys/index.spec.ts | 3 +- src/plugins/engines/baileys/index.ts | 1 + test/baileys-engine.e2e-spec.ts | 1 + 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be05baaf..bf6d6a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). (#PR) ### Changed diff --git a/src/engine/adapters/baileys.adapter.spec.ts b/src/engine/adapters/baileys.adapter.spec.ts index 64c38b20..75e4fbb2 100644 --- a/src/engine/adapters/baileys.adapter.spec.ts +++ b/src/engine/adapters/baileys.adapter.spec.ts @@ -614,3 +614,34 @@ describe('BaileysAdapter group management', () => { 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 => { + 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'); + }); +}); diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 86b639b9..62f12b5b 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -406,6 +406,25 @@ export class BaileysAdapter implements IWhatsAppEngine { return (await this.sock!.groupRevokeInvite(groupId)) ?? ''; } + async getProfilePicture(contactId: string): Promise { + this.ensureReady(); + try { + return (await this.sock!.profilePictureUrl(contactId, 'image')) ?? null; + } catch { + return null; // no picture set, or hidden by privacy + } + } + + async blockContact(contactId: string): Promise { + this.ensureReady(); + await this.sock!.updateBlockStatus(contactId, 'block'); + } + + async unblockContact(contactId: string): Promise { + this.ensureReady(); + await this.sock!.updateBlockStatus(contactId, 'unblock'); + } + // ----- Gated: not supported by this minimal slice (no store) ----- /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -424,15 +443,6 @@ export class BaileysAdapter implements IWhatsAppEngine { getChatHistory(_chatId: string, _limit?: number, _includeMedia?: boolean): Promise { return this.unsupported('getChatHistory'); } - getProfilePicture(_contactId: string): Promise { - return this.unsupported('getProfilePicture'); - } - blockContact(_contactId: string): Promise { - return this.unsupported('blockContact'); - } - unblockContact(_contactId: string): Promise { - return this.unsupported('unblockContact'); - } getLabels(): Promise { return this.unsupported('getLabels'); } diff --git a/src/plugins/engines/baileys/index.spec.ts b/src/plugins/engines/baileys/index.spec.ts index e73a6beb..fc1cd39a 100644 --- a/src/plugins/engines/baileys/index.spec.ts +++ b/src/plugins/engines/baileys/index.spec.ts @@ -37,7 +37,7 @@ describe('BaileysPlugin.createEngine (opaque config)', () => { ); }); - it('advertises the slice-2b supported feature set', () => { + it('advertises the slice-3a supported feature set', () => { expect(new BaileysPlugin().getFeatures()).toEqual([ 'text-messages', 'typing-indicator', @@ -48,6 +48,7 @@ describe('BaileysPlugin.createEngine (opaque config)', () => { 'message-forwarding', 'message-reactions', 'message-deletion', + 'group-management', ]); }); diff --git a/src/plugins/engines/baileys/index.ts b/src/plugins/engines/baileys/index.ts index 90ed0346..fd269264 100644 --- a/src/plugins/engines/baileys/index.ts +++ b/src/plugins/engines/baileys/index.ts @@ -61,6 +61,7 @@ export class BaileysPlugin implements IEnginePlugin { 'message-forwarding', 'message-reactions', 'message-deletion', + 'group-management', ]; } diff --git a/test/baileys-engine.e2e-spec.ts b/test/baileys-engine.e2e-spec.ts index bf396e85..15dfb2c8 100644 --- a/test/baileys-engine.e2e-spec.ts +++ b/test/baileys-engine.e2e-spec.ts @@ -64,6 +64,7 @@ describe('Baileys engine boot (e2e)', () => { 'message-forwarding', 'message-reactions', 'message-deletion', + 'group-management', ]); }); }); From 4492180ca5b5599cf69c01c6db79f50fb83a8846 Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 11:50:52 +0700 Subject: [PATCH 5/6] chore(engine): debug-log swallowed baileys getGroupInfo/getProfilePicture errors --- src/engine/adapters/baileys.adapter.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/engine/adapters/baileys.adapter.ts b/src/engine/adapters/baileys.adapter.ts index 62f12b5b..856ee766 100644 --- a/src/engine/adapters/baileys.adapter.ts +++ b/src/engine/adapters/baileys.adapter.ts @@ -350,7 +350,11 @@ export class BaileysAdapter implements IWhatsAppEngine { try { const metadata = await this.sock!.groupMetadata(groupId); return mapBaileysGroupInfo(metadata); - } catch { + } catch (err) { + this.logger.debug('groupMetadata failed; treating as not-found', { + groupId, + error: err instanceof Error ? err.message : String(err), + }); return null; // not a group / not found } } @@ -410,7 +414,11 @@ export class BaileysAdapter implements IWhatsAppEngine { this.ensureReady(); try { return (await this.sock!.profilePictureUrl(contactId, 'image')) ?? null; - } catch { + } catch (err) { + this.logger.debug('profilePictureUrl failed; no picture or hidden', { + contactId, + error: err instanceof Error ? err.message : String(err), + }); return null; // no picture set, or hidden by privacy } } From 0fda51f34b0df9218fca6fc38f8b7aea7ab8f26f Mon Sep 17 00:00:00 2001 From: rmyndharis Date: Thu, 18 Jun 2026 11:52:32 +0700 Subject: [PATCH 6/6] docs(changelog): reference PR #309 for baileys group management --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf6d6a81..03bb0f06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +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). (#PR) +- **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