diff --git a/src/Bridge/__tests__/adapt.test.ts b/src/Bridge/__tests__/adapt.test.ts index c080413..cf2bb16 100644 --- a/src/Bridge/__tests__/adapt.test.ts +++ b/src/Bridge/__tests__/adapt.test.ts @@ -385,6 +385,91 @@ describe('adaptBridgeEvent — anti-corruption layer', () => { expect(result).toEqual({ type: 'archiveUpdate', jid: '5511@s.whatsapp.net', archived: false }) }) + it('label_edit_update maps to canonical labelEdit (predefinedId stringified)', () => { + const result = adaptBridgeEvent({ + type: 'label_edit_update', + data: { + label_id: '7', + action: { name: 'Clientes', color: 3, deleted: false, predefinedId: 2 } + } + } as never) + expect(result).toEqual({ + type: 'labelEdit', + labelId: '7', + name: 'Clientes', + color: 3, + deleted: false, + predefinedId: '2' + }) + }) + + it('label_edit_update tolerates a sparse action (typed defaults, no undefined leaks)', () => { + const result = adaptBridgeEvent({ + type: 'label_edit_update', + data: { label_id: '7', action: { deleted: true } } + } as never) + expect(result).toEqual({ + type: 'labelEdit', + labelId: '7', + name: '', + color: 0, + deleted: true, + predefinedId: undefined + }) + }) + + it('label_association_update maps add/remove from action.labeled', () => { + const add = adaptBridgeEvent({ + type: 'label_association_update', + data: { + label_id: '7', + chat_jid: { user: '5511', server: 's.whatsapp.net' }, + action: { labeled: true } + } + } as never) + expect(add).toEqual({ + type: 'labelAssociation', + labelId: '7', + chatJid: '5511@s.whatsapp.net', + labeled: true + }) + + const remove = adaptBridgeEvent({ + type: 'label_association_update', + data: { + label_id: '7', + chat_jid: { user: '5511', server: 's.whatsapp.net' }, + action: { labeled: false } + } + } as never) + expect(remove).toEqual({ + type: 'labelAssociation', + labelId: '7', + chatJid: '5511@s.whatsapp.net', + labeled: false + }) + }) + + it('clear_chat_update maps to chatClear (messages.delete all) and noops without a jid', () => { + expect( + adaptBridgeEvent({ + type: 'clear_chat_update', + data: { jid: { user: '5511', server: 's.whatsapp.net' } } + } as never) + ).toEqual({ type: 'chatClear', jid: '5511@s.whatsapp.net' }) + expect(adaptBridgeEvent({ type: 'clear_chat_update', data: {} } as never)).toEqual({ + type: 'noop', + bridgeType: 'clear_chat_update' + }) + }) + + it('user_status_mute_update is a documented noop (no Baileys counterpart)', () => { + expect(adaptBridgeEvent({ type: 'user_status_mute_update', data: {} } as never)).toEqual({ + type: 'noop', + bridgeType: 'user_status_mute_update' + }) + }) + it('star_update reads action.starred even when wrapped', () => { const result = adaptBridgeEvent({ type: 'star_update', diff --git a/src/Bridge/schema.ts b/src/Bridge/schema.ts index c568a0b..2f6b4c0 100644 --- a/src/Bridge/schema.ts +++ b/src/Bridge/schema.ts @@ -291,6 +291,29 @@ const ADAPTERS = { if (!jid) return null return { type: 'markChatAsReadUpdate', jid, read: asBoolOr(extractAction(data)?.read, true) } }, + label_edit_update: data => { + const labelId = asString(data.label_id) + if (!labelId) return { type: 'noop', bridgeType: 'label_edit_update' } + const action = extractAction(data) + // `predefinedId` is proto `predefined_id` (a number); upstream `Label` + // wants it as a string. Dual-read the spelling, then stringify. + const predefined = asNumber(action?.predefinedId) ?? asNumber(action?.predefined_id) + return { + type: 'labelEdit', + labelId, + name: asString(action?.name) ?? '', + color: asNumber(action?.color) ?? 0, + deleted: asBoolOr(action?.deleted, false), + predefinedId: predefined != null ? String(predefined) : undefined + } + }, + label_association_update: data => { + const labelId = asString(data.label_id) + const chatJid = asJidString(data.chat_jid) + if (!labelId || !chatJid) return { type: 'noop', bridgeType: 'label_association_update' } + // `action.labeled === true` → label added to the chat, else removed. + return { type: 'labelAssociation', labelId, chatJid, labeled: asBoolOr(extractAction(data)?.labeled, true) } + }, // ── Calls ── incoming_call: (data, logger) => adaptIncomingCall(data, logger), @@ -398,6 +421,16 @@ const ADAPTERS = { const jid = asJidString(data.jid) return jid ? { type: 'chatDelete', jid } : { type: 'noop', bridgeType: 'delete_chat_update' } }, + clear_chat_update: data => { + // Clear = drop all messages but keep the chat. Maps to upstream + // `messages.delete` `{ jid, all: true }` (the chat-clear surface noted in + // the messageDelete dispatcher), distinct from chatDelete (whole chat gone). + const jid = asJidString(data.jid) + return jid ? { type: 'chatClear', jid } : { type: 'noop', bridgeType: 'clear_chat_update' } + }, + // Muting a contact's status (stories) updates. Forwarded for surface completeness, + // but noop'd: upstream Baileys has no status-mute event/chatModify to map it onto. + user_status_mute_update: () => ({ type: 'noop', bridgeType: 'user_status_mute_update' }), delete_message_for_me_update: data => { const chatJid = asJidString(data.chat_jid) const messageId = asString(data.message_id) @@ -579,6 +612,7 @@ const adaptContactUpdate = (data: unknown): CanonicalEvent | null => { */ const RECEIPT_TYPE_MAP: Record> = { Delivered: 'delivered', + Sent: 'sent', Sender: 'sender', Retry: 'retry', EncRekeyRetry: 'enc-rekey-retry', @@ -591,6 +625,7 @@ const RECEIPT_TYPE_MAP: Record ({ * Compatibility wrapper for original Baileys chatModify API. * Routes to the appropriate bridge method based on the modification type. * - * Fully supported: archive, pin, mute, star, markRead, delete, deleteForMe, pushNameSetting - * Not yet in bridge (app-state patches): clear, contact, disableLinkPreviews, labels, quickReply + * Fully supported: archive, pin, mute, star, markRead, delete, deleteForMe, pushNameSetting, contact, clear + * Not yet in bridge (app-state patches): disableLinkPreviews, labels, quickReply */ chatModify: async (mod: ChatModification, jid: string) => { const client = await ctx.getClient() @@ -45,9 +45,32 @@ export const makeChatActionMethods = (ctx: SocketContext) => ({ await client.deleteMessageForMe(jid, mod.deleteForMe.key.id!, !!mod.deleteForMe.key.fromMe) } else if ('pushNameSetting' in mod) { await client.setPushName(mod.pushNameSetting) + } else if ('contact' in mod) { + // Save/rename a contact (syncs the name to linked devices). `jid` is the + // contact's bare PN jid. + if (mod.contact) { + await client.saveContact( + jid, + mod.contact.fullName ?? undefined, + mod.contact.firstName ?? undefined, + mod.contact.saveOnPrimaryAddressbook ?? true + ) + } else { + // `contact: null` = remove-contact in upstream Baileys; no + // bridge/core path yet — warn instead of silently dropping. + ctx.logger.warn({ jid }, 'chatModify: contact removal (contact: null) not yet supported by bridge') + } + } else if ('clear' in mod) { + // Clear a chat's messages while keeping the chat. `lastMessages` (the + // message range) is ignored, same as the `delete` branch — the bridge + // clears the whole chat. deleteStarred/deleteMedia aren't part of the + // Baileys `clear` shape, so default both to false (keep starred + media). + if (mod.clear) { + await client.clearChat(jid, false, false) + } } else { // App-state-patch variants not yet exposed by bridge: - // clear, contact, disableLinkPreviews, addLabel, addChatLabel, + // disableLinkPreviews, addLabel, addChatLabel, // removeChatLabel, addMessageLabel, removeMessageLabel, quickReply const variant = Object.keys(mod)[0] ctx.logger.warn( diff --git a/src/Socket/contacts.ts b/src/Socket/contacts.ts index 9d57174..3e72598 100644 --- a/src/Socket/contacts.ts +++ b/src/Socket/contacts.ts @@ -7,6 +7,8 @@ export type OnWhatsAppResult = { /** PN counterpart, present when the server returned a LID as primary JID. */ pnJid?: string isBusiness?: boolean + /** Verified business name (from usync ``), present for verified businesses. */ + verifiedName?: string } export const makeContactMethods = (ctx: SocketContext) => ({ @@ -19,6 +21,7 @@ export const makeContactMethods = (ctx: SocketContext) => ({ const out: OnWhatsAppResult = { exists: r.isRegistered, jid: r.jid, isBusiness: r.isBusiness } if (r.lid) out.lid = r.lid if (r.pnJid) out.pnJid = r.pnJid + if (r.verifiedName) out.verifiedName = r.verifiedName return out }) }, diff --git a/src/Socket/events.ts b/src/Socket/events.ts index 993b3aa..f55e16c 100644 --- a/src/Socket/events.ts +++ b/src/Socket/events.ts @@ -20,6 +20,7 @@ import type { WAPresence } from '../Types/index.ts' import { DisconnectReason, WAProto } from '../Types/index.ts' +import { LabelAssociationType } from '../Types/LabelAssociation.ts' import { Boom } from '../Utils/boom.ts' import { toNumber } from '../Utils/generics.ts' import { isJidGroup } from '../WABinary/jid-utils.ts' @@ -501,6 +502,23 @@ const DISPATCHERS: DispatcherMap = { // Mirrors upstream `chat-utils.ts:852`: read=true → unreadCount=0, // read=false (mark as unread) → -1 sentinel. ctx.ev.emit('chats.update', [{ id: evt.jid, unreadCount: evt.read ? 0 : -1 }]), + labelEdit: (evt, { ctx }) => + // Upstream `labels.edit` carries a `Label`. A delete arrives with + // `deleted: true` (consumers remove it from their store). + ctx.ev.emit('labels.edit', { + id: evt.labelId, + name: evt.name, + color: evt.color, + deleted: evt.deleted, + predefinedId: evt.predefinedId + }), + labelAssociation: (evt, { ctx }) => + // Inbound sync only ever carries chat associations (the bridge event + // has a `chat_jid`); message-label associations are a separate path. + ctx.ev.emit('labels.association', { + association: { type: LabelAssociationType.Chat, chatId: evt.chatJid, labelId: evt.labelId }, + type: evt.labeled ? 'add' : 'remove' + }), // ── Calls ── incomingCall: (evt, { ctx }) => { @@ -571,6 +589,7 @@ const DISPATCHERS: DispatcherMap = { } }, chatDelete: (evt, { ctx }) => ctx.ev.emit('chats.delete', [evt.jid]), + chatClear: (evt, { ctx }) => ctx.ev.emit('messages.delete', { jid: evt.jid, all: true }), messageDelete: (evt, { ctx }) => // Upstream `chat-utils.ts:857` shape: `{ keys: WAMessageKey[] }` for // per-message delete (not the `{ jid, all: true }` chat-clear case diff --git a/src/Socket/index.ts b/src/Socket/index.ts index f2955e6..9e48c69 100644 --- a/src/Socket/index.ts +++ b/src/Socket/index.ts @@ -389,7 +389,8 @@ const makeWASocket = (config: UserFacingSocketConfig) => { handleEvent, bridgeStore, fullConfig.cache ?? null, - fullConfig.version + fullConfig.version, + fullConfig.wantedPreKeyCount ?? null ) // Make this client the fallback for standalone helpers like // downloadContentFromMessage that have no socket reference. diff --git a/src/Socket/messages.ts b/src/Socket/messages.ts index 8e27f25..a3fad40 100644 --- a/src/Socket/messages.ts +++ b/src/Socket/messages.ts @@ -211,9 +211,9 @@ export const makeMessageMethods = (ctx: SocketContext) => ({ * Send a receipt for messages. The bridge handles most receipt types automatically * (delivered, sender). Use `readMessages` for the common case of sending read receipts. * - * Supported types via bridge: 'read', 'read-self' + * Supported types via bridge: 'read', 'read-self', 'played' * Auto-handled by bridge: 'sender', 'inactive', undefined (delivered) - * Not supported: 'played', 'hist_sync', 'peer_msg' (logged as warning) + * Not supported: 'hist_sync', 'peer_msg' (logged as warning) */ sendReceipt: async (jid: string, participant: string | undefined, messageIds: string[], type: MessageReceiptType) => { if (!messageIds.length) return @@ -225,9 +225,20 @@ export const makeMessageMethods = (ctx: SocketContext) => ({ ...(participant ? { participant } : {}) })) await (await ctx.getClient()).readMessages(keys) + } else if (type === 'played') { + // Voice/video-note played receipts. The bridge (and core) pick the wire + // type (`played` vs `played-self` for newsletters) and the `participant` + // attr from the chat jid, so we just hand over the keys — same shape as + // readMessages. + const keys = messageIds.map(id => ({ + remoteJid: jid, + id, + ...(participant ? { participant } : {}) + })) + await (await ctx.getClient()).markPlayed(keys) } else { // delivered/sender/inactive receipts are sent automatically by the Rust bridge - // played/hist_sync/peer_msg require bridge-side support + // hist_sync/peer_msg require bridge-side support ctx.logger.debug( { type, jid, count: messageIds.length }, 'sendReceipt: type handled automatically by bridge or not yet supported' @@ -248,6 +259,13 @@ export const makeMessageMethods = (ctx: SocketContext) => ({ if (readKeys.length) { await client.readMessages(readKeys) } + } else if (type === 'played') { + const playedKeys = keys + .filter(k => !k.fromMe && k.remoteJid && k.id) + .map(k => ({ remoteJid: k.remoteJid!, id: k.id!, ...(k.participant ? { participant: k.participant } : {}) })) + if (playedKeys.length) { + await client.markPlayed(playedKeys) + } } else { ctx.logger.debug( { type, count: keys.length }, diff --git a/src/Socket/newsletter.ts b/src/Socket/newsletter.ts index 70148b7..77b3438 100644 --- a/src/Socket/newsletter.ts +++ b/src/Socket/newsletter.ts @@ -19,5 +19,13 @@ export const makeNewsletterMethods = (ctx: SocketContext) => ({ newsletterReactMessage: async (jid: string, serverId: string, reaction?: string) => { await (await ctx.getClient()).newsletterReactMessage(jid, serverId, reaction ?? null) + }, + + /** + * Mute or unmute a newsletter (channel) — silences its follower-activity + * notifications, the mute a subscriber toggles. `mute = true` silences. + */ + newsletterMute: async (jid: string, mute: boolean) => { + await (await ctx.getClient()).newsletterMute(jid, mute) } }) diff --git a/src/Types/Socket.ts b/src/Types/Socket.ts index a01c4a7..7de377d 100644 --- a/src/Types/Socket.ts +++ b/src/Types/Socket.ts @@ -74,6 +74,14 @@ export type SocketConfig = { */ deviceProps?: DevicePropsInput + /** + * Number of one-time pre-keys generated and uploaded per batch. Defaults to + * WhatsApp Web's 812; clamped to the protocol-safe range at upload time. + * Lower it to cut memory/CPU of the first pre-key upload (the whole batch is + * generated and encoded in one shot). Must be set before connecting. + */ + wantedPreKeyCount?: number + // ───────────────────────────────────────────────────────────────────── // Upstream-Baileys options accepted for type-level compatibility. //