diff --git a/src/core/engines/webjs/WebjsClientCore.ts b/src/core/engines/webjs/WebjsClientCore.ts index bd23d119..2a978498 100644 --- a/src/core/engines/webjs/WebjsClientCore.ts +++ b/src/core/engines/webjs/WebjsClientCore.ts @@ -1,4 +1,5 @@ import { WebJSPresence } from '@waha/core/engines/webjs/types'; +import { GetSerialized } from '@waha/core/utils/serialized'; import { GetChatMessagesFilter } from '@waha/structures/chats.dto'; import { Label } from '@waha/structures/labels.dto'; import { LidToPhoneNumber } from '@waha/structures/lids.dto'; @@ -75,7 +76,7 @@ function extractReactionsByMessageKey( ): Map { const reactions = new Map(); for (const reaction of newsletterReactions) { - const key = reaction.parentMsgKey._serialized; + const key = GetSerialized(reaction.parentMsgKey); const emojiCountMap = reaction.emojiCountMap; const reactionList: ChannelMessageReaction[] = []; for (const emoji in emojiCountMap) { @@ -266,7 +267,8 @@ export class WebjsClientCore extends Client { // Filter chats by IDs if filter is provided if (filter && filter.ids && filter.ids.length > 0) { chats = chats.filter((chat) => - filter.ids.includes(chat.id._serialized), + // @ts-ignore + filter.ids.includes(window.WWebJS.GetSerialized(chat.id)), ); } @@ -382,7 +384,10 @@ export class WebjsClientCore extends Client { // Construct the initial anchor the same way wa-js does: // serialize to string then reconstruct via MsgKey.fromString so the // object has the exact shape msgFindByDirection expects. - const lastReceivedSerialized = chat.lastReceivedKey?._serialized; + // @ts-ignore + const lastReceivedSerialized = window.WWebJS.GetSerialized( + chat.lastReceivedKey, + ); if (!lastReceivedSerialized) return []; let currentAnchorKey = window .require('WAWebMsgKey') @@ -406,7 +411,8 @@ export class WebjsClientCore extends Client { // @ts-ignore const toModel = (m) => { if (m && typeof m.serialize === 'function') return m; - const serializedId = m?.id?._serialized; + // @ts-ignore + const serializedId = window.WWebJS.GetSerialized(m?.id); const Msg = window.require('WAWebCollections').Msg; if (serializedId) { const stored = Msg.get(serializedId); @@ -437,7 +443,8 @@ export class WebjsClientCore extends Client { // appear in multiple batches when anchors overlap const seenIds = new Set(); msgs = msgs.filter((m) => { - const sid = m?.id?._serialized; + // @ts-ignore + const sid = window.WWebJS.GetSerialized(m?.id); if (!sid || seenIds.has(sid)) return false; seenIds.add(sid); return true; @@ -463,7 +470,10 @@ export class WebjsClientCore extends Client { // the oldest message in this batch — use it as the next anchor to // walk further back in history without overlap const oldestInBatch = batchModels[batchModels.length - 1]; - const oldestSerialized = oldestInBatch?.id?._serialized; + // @ts-ignore + const oldestSerialized = window.WWebJS.GetSerialized( + oldestInBatch?.id, + ); if (!oldestSerialized) break; currentAnchorKey = window .require('WAWebMsgKey') @@ -532,9 +542,9 @@ export class WebjsClientCore extends Client { const result = values.map((map) => { return { // @ts-ignore - lid: map.lid._serialized, + lid: window.WWebJS.GetSerialized(map.lid), // @ts-ignore - pn: map.phoneNumber._serialized, + pn: window.WWebJS.GetSerialized(map.phoneNumber), }; }); // @ts-ignore @@ -565,7 +575,8 @@ export class WebjsClientCore extends Client { const wid = WAWebWidFactory.createWid(lid); const result = WAWebApiContact.getPhoneNumber(wid); - return result ? result._serialized : null; + // @ts-ignore + return window.WWebJS.GetSerialized(result); }, lid); return pn; } @@ -577,7 +588,8 @@ export class WebjsClientCore extends Client { const wid = WAWebWidFactory.createWid(pn); const result = WAWebApiContact.getCurrentLid(wid); - return result ? result._serialized : null; + // @ts-ignore + return window.WWebJS.GetSerialized(result); }, phoneNumber)) as any; return lid; } @@ -617,7 +629,8 @@ export class WebjsClientCore extends Client { } return chatstates.map((chatstate) => { return { - participant: chatstate.id._serialized, + // @ts-ignore + participant: window.WWebJS.GetSerialized(chatstate.id), lastSeen: chatstate.t, state: chatstate.type, }; @@ -681,7 +694,7 @@ export class WebjsClientCore extends Client { const messages: WebjsChannelMessage[] = messageInstances.map((msg) => { return { message: msg, - reactions: reactions.get(msg.id._serialized) || [], + reactions: reactions.get(GetSerialized(msg.id)) || [], viewCount: msg.rawData.viewCount, }; }); diff --git a/src/core/engines/webjs/groups.webjs.ts b/src/core/engines/webjs/groups.webjs.ts index 23e0d0f9..06298561 100644 --- a/src/core/engines/webjs/groups.webjs.ts +++ b/src/core/engines/webjs/groups.webjs.ts @@ -19,6 +19,7 @@ import { GroupParticipant as WEBJSGroupParticipant, } from 'whatsapp-web.js'; import { isPnUser } from '@waha/core/utils/jids'; +import { GetSerialized } from '@waha/core/utils/serialized'; function ToGroupInfo( group: GroupChat, @@ -29,7 +30,7 @@ function ToGroupInfo( const groupMetadata = group.groupMetadata; const info: GroupInfo = { // @ts-ignore - id: group.id._serialized, + id: GetSerialized(group.id), subject: group.name, description: group.description, invite: invite, @@ -72,11 +73,10 @@ export function getParticipants( role = GroupParticipantRole.ADMIN; } + const participantId = GetSerialized(participant.id); return { - id: participant.id._serialized, - pn: isPnUser(participant.id._serialized) - ? participant.id._serialized - : null, + id: participantId, + pn: isPnUser(participantId) ? participantId : null, role: role, }; }); diff --git a/src/core/engines/webjs/session.webjs.core.ts b/src/core/engines/webjs/session.webjs.core.ts index fd859376..6997a136 100644 --- a/src/core/engines/webjs/session.webjs.core.ts +++ b/src/core/engines/webjs/session.webjs.core.ts @@ -182,6 +182,7 @@ import { Message as MessageInstance, Call as CallInstance, } from 'whatsapp-web.js/src/structures'; +import { GetSerialized } from '@waha/core/utils/serialized'; import { WAJSPresenceChatStateType, WebJSPresence } from './types'; import { WebJSAuthFactory } from './WebJSAuthFactory'; @@ -531,7 +532,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { } const wid = clientInfo.wid; return { - id: wid?._serialized, + id: GetSerialized(wid), pushName: clientInfo?.pushname, }; } @@ -738,7 +739,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { } return { numberExists: true, - chatId: result._serialized, + chatId: GetSerialized(result), }; } @@ -1135,14 +1136,14 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { protected async fetchChatSummary(chat: Chat): Promise { const picture = await this.getContactProfilePicture( - chat.id._serialized, + GetSerialized(chat.id), false, ); const lastMessage = chat.lastMessage ? this.toWAMessage(chat.lastMessage) : null; return { - id: chat.id._serialized, + id: GetSerialized(chat.id), name: chat.name || null, picture: picture, lastMessage: lastMessage, @@ -1248,7 +1249,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { // @ts-ignore message.rawData.receipts = await message.getInfo().catch((error) => { this.logger.error( - { error: error, msg: message.id._serialized }, + { error: error, msg: GetSerialized(message.id) }, 'Failed to get receipts', ); return null; @@ -1713,7 +1714,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { role = ChannelRole.GUEST; } return { - id: chat.id._serialized, + id: GetSerialized(chat.id), name: chat.name, description: chat.description, invite: getChannelInviteLink(metadata.inviteCode), @@ -2212,10 +2213,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { }, ); const chatsArchived$ = chatArchived$.pipe( - filter((event: any) => this.jids.include(event?.chat?.id?._serialized)), + filter((event: any) => this.jids.include(GetSerialized(event?.chat?.id))), map((event) => { return { - id: event.chat.id._serialized, + id: GetSerialized(event.chat.id), archived: event.archived, timestamp: event.chat.timestamp, }; @@ -2302,7 +2303,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { const source = this.getMessageSource(reaction.id.id); return { - id: reaction.id._serialized, + id: GetSerialized(reaction.id), from: normalizeJid(reaction.senderId), fromMe: reaction.id.fromMe, source: source, @@ -2311,13 +2312,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { timestamp: reaction.timestamp, reaction: { text: reaction.reaction, - messageId: reaction.msgId._serialized, + messageId: GetSerialized(reaction.msgId), }, }; } private toPollVotePayload(vote: WebjsPollVote): PollVotePayload | null { - const pollMessageId = vote?.parentMessage?.id?._serialized; + const pollMessageId = GetSerialized(vote?.parentMessage?.id); if (!pollMessageId) { return null; } @@ -2397,10 +2398,10 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { protected toWAMessage(message: Message): WAMessage { const replyTo = this.extractReplyTo(message); const source = this.getMessageSource(message.id.id); - const key = parseMessageIdSerialized(message.id._serialized); + const key = parseMessageIdSerialized(GetSerialized(message.id)); // @ts-ignore return { - id: message.id._serialized, + id: GetSerialized(message.id), timestamp: message.timestamp, from: message.from, fromMe: message.fromMe, @@ -2474,7 +2475,7 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { protected toWAContact(contact: Contact) { // @ts-ignore - contact.id = contact.id._serialized; + contact.id = GetSerialized(contact.id); return contact; } @@ -2552,7 +2553,7 @@ export class WEBJSEngineMediaProcessor } getMessageId(message: Message): string { - return message.id._serialized; + return GetSerialized(message.id); } getMimetype(message: Message): string { diff --git a/src/core/utils/serialized.test.ts b/src/core/utils/serialized.test.ts new file mode 100644 index 00000000..3e48b970 --- /dev/null +++ b/src/core/utils/serialized.test.ts @@ -0,0 +1,131 @@ +import { + GetSerialized, + GetSerializedMsgKey, + GetSerializedWid, +} from './serialized'; + +describe('serialized helpers', () => { + describe('GetSerializedWid', () => { + it('returns strings and nullish values as-is', () => { + expect(GetSerializedWid('123@c.us')).toBe('123@c.us'); + expect(GetSerializedWid(null)).toBeNull(); + expect(GetSerializedWid(undefined)).toBeUndefined(); + }); + + it('uses existing _serialized', () => { + expect(GetSerializedWid({ _serialized: '123@c.us' })).toBe('123@c.us'); + }); + + it('ignores the minified $1 property and reconstructs from keys', () => { + const wid: any = { $1: 'ignored', user: '123', server: 'c.us' }; + expect(GetSerializedWid(wid)).toBe('123@c.us'); + expect(wid._serialized).toBe('123@c.us'); + }); + + it('reconstructs from user/server and caches', () => { + const wid: any = { user: '123', server: 'c.us' }; + expect(GetSerializedWid(wid)).toBe('123@c.us'); + expect(wid._serialized).toBe('123@c.us'); + }); + + it('reconstructs a group wid', () => { + expect(GetSerializedWid({ user: '123-456', server: 'g.us' })).toBe( + '123-456@g.us', + ); + }); + + it('appends the device when present and truthy', () => { + expect(GetSerializedWid({ user: '123', server: 'c.us', device: 7 })).toBe( + '123:7@c.us', + ); + expect(GetSerializedWid({ user: '123', server: 'c.us', device: 0 })).toBe( + '123@c.us', + ); + }); + + it('handles the special "call" wid', () => { + expect(GetSerializedWid({ user: 'call', server: 'call' })).toBe('call'); + }); + + it('returns null when it cannot reconstruct (no keys, only $1)', () => { + expect(GetSerializedWid({ foo: 'bar' })).toBeNull(); + expect(GetSerializedWid({ $1: '123@c.us' })).toBeNull(); + }); + }); + + describe('GetSerializedMsgKey', () => { + it('reconstructs fromMe_remote_id and caches', () => { + const key: any = { + fromMe: true, + remote: { _serialized: '123@c.us' }, + id: 'AAA', + }; + expect(GetSerializedMsgKey(key)).toBe('true_123@c.us_AAA'); + expect(key._serialized).toBe('true_123@c.us_AAA'); + }); + + it('reconstructs a remote wid that only has component keys', () => { + const key: any = { + fromMe: false, + remote: { user: '123', server: 'c.us' }, + id: 'AAA', + }; + expect(GetSerializedMsgKey(key)).toBe('false_123@c.us_AAA'); + }); + + it('appends the participant', () => { + const key: any = { + fromMe: false, + remote: { _serialized: '123-456@g.us' }, + id: 'AAA', + participant: { _serialized: '789@c.us' }, + }; + expect(GetSerializedMsgKey(key)).toBe('false_123-456@g.us_AAA_789@c.us'); + }); + + it('appends self before participant', () => { + const key: any = { + fromMe: true, + remote: { _serialized: '123@c.us' }, + id: 'AAA', + self: 'out', + participant: { _serialized: '789@c.us' }, + }; + expect(GetSerializedMsgKey(key)).toBe('true_123@c.us_AAA_out_789@c.us'); + }); + + it('ignores $1 and reconstructs from keys', () => { + const key: any = { + $1: 'ignored', + fromMe: true, + remote: { _serialized: '999@c.us' }, + id: 'BBB', + }; + expect(GetSerializedMsgKey(key)).toBe('true_999@c.us_BBB'); + }); + }); + + describe('GetSerialized (dispatcher)', () => { + it('dispatches wid shapes', () => { + expect(GetSerialized({ user: '123', server: 'c.us' })).toBe('123@c.us'); + }); + + it('dispatches msgkey shapes', () => { + expect( + GetSerialized({ + fromMe: true, + remote: { user: '123', server: 'c.us' }, + id: 'AAA', + }), + ).toBe('true_123@c.us_AAA'); + }); + + it('honours existing _serialized before dispatching', () => { + expect(GetSerialized({ _serialized: 'x@c.us' })).toBe('x@c.us'); + }); + + it('returns null for unknown shapes', () => { + expect(GetSerialized({ foo: 'bar' })).toBeNull(); + }); + }); +}); diff --git a/src/core/utils/serialized.ts b/src/core/utils/serialized.ts new file mode 100644 index 00000000..0db816ad --- /dev/null +++ b/src/core/utils/serialized.ts @@ -0,0 +1,103 @@ +/** + * Serialized-ID helpers for the WEBJS engine. + * + * COPIED from whatsapp-web.js `src/util/Serialized.js` (fork: ../whatsapp-web.js). + * Keep this file in sync with that upstream version whenever it changes. + * + * WhatsApp Web renamed the `_serialized` property on its ID objects (Wid / + * MsgKey) to a minified name (`$1`) in the 2026-07 update. Rather than depend on + * that unstable minified name, each helper reconstructs the serialized string + * deterministically from the object's own component keys - existing + * `_serialized` -> reconstruct from keys - and caches it back onto the object as + * `_serialized` so later reads keep working. + */ + +interface CacheHit { + done: boolean; + value: string | null; +} + +function cached(id: any): CacheHit { + if (id == null) { + return { done: true, value: id }; + } + if (typeof id === 'string') { + return { done: true, value: id }; + } + if (typeof id._serialized === 'string' && id._serialized !== '') { + return { done: true, value: id._serialized }; + } + return { done: false, value: null }; +} + +/** + * Serializes a Wid (WhatsApp ID): `user[:device]@server`. + */ +export function GetSerializedWid(id: any): string | null { + const hit = cached(id); + if (hit.done) { + return hit.value; + } + + let value: string | null; + if (id.user != null && id.server != null) { + value = + id.user === 'call' + ? 'call' + : `${id.user}${id.device ? `:${id.device}` : ''}@${id.server}`; + } else { + value = null; + } + + if (value != null) { + id._serialized = value; + } + return value; +} + +/** + * Serializes a MsgKey (message ID): `fromMe_remote_id[_self][_participant]`. + */ +export function GetSerializedMsgKey(id: any): string | null { + const hit = cached(id); + if (hit.done) { + return hit.value; + } + + let value: string | null; + if (id.remote != null && id.id != null) { + const remote = GetSerializedWid(id.remote); + const participant = + id.participant != null ? GetSerializedWid(id.participant) : null; + value = + `${id.fromMe ? 'true' : 'false'}_${remote}_${id.id}` + + (id.self ? `_${id.self}` : '') + + (participant ? `_${participant}` : ''); + } else { + value = null; + } + + if (value != null) { + id._serialized = value; + } + return value; +} + +/** + * Generic entry point: resolves `_serialized`/`$1` then dispatches to the Wid + * or MsgKey helper based on the object's shape. Use the specific helpers when + * the id type is known. + */ +export function GetSerialized(id: any): string | null { + const hit = cached(id); + if (hit.done) { + return hit.value; + } + if (id.remote != null && id.id != null) { + return GetSerializedMsgKey(id); + } + if (id.user != null && id.server != null) { + return GetSerializedWid(id); + } + return null; +}