Compare commits

..

12 Commits

Author SHA1 Message Date
Renato Alcara 5bb18da6bf fix: address PR #305 review comments — Origin header, lowServerCount threshold, keepalive guard
- history.ts: add Origin: DEFAULT_ORIGIN to CDN DELETE request headers —
  the download path injects it internally but options does not carry it
- socket.ts: fix lowServerCount comparison — was preKeyCount <= topUpAmount
  which triggered uploads even when above MIN_PREKEY_COUNT (e.g. 250 prekeys
  → topUp=550 → 250<=550=true → unnecessary upload). Now uses correct
  threshold: preKeyCount < MIN_PREKEY_COUNT
- socket.ts: guard scheduleNextKeepAlive() with !closed check to prevent
  orphan timers when end() was called concurrently on a different path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:41 -03:00
Renato Alcara 52d8ddae32 fix: prevent double prekey upload when concurrent uploads race on count=0
uploadPreKeys() waited for a concurrent upload but then proceeded to upload
again. With top-up logic and count=0, both handleEncryptNotification and
uploadPreKeysToServerIfRequired fire simultaneously, both see count=0, first
wins and uploads 800, second waits then uploads another 800 → 1600 prekeys.

Fix: return immediately after awaiting the concurrent upload — it already
replenished the pool.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:41 -03:00
Renato Alcara 8c8c5a312e fix: address PR review comments — CDN DELETE timing, keepalive leak, spread order
- history.ts: move CDN DELETE from downloadHistory() to
  downloadAndProcessHistorySyncNotification(), after processHistoryMessage()
  succeeds — prevents permanent history loss if processing throws post-download
- history.ts: fix fetch spread order: { ...options, method: 'DELETE' } so
  options.method cannot shadow the intended DELETE verb
- socket.ts: add early return after void end() in onKeepAliveTick to prevent
  scheduleNextKeepAlive() from leaking a timer while connection is closing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:28 -03:00
Renato Alcara 52756fb50d fix: histsync LID improvements — raw_id mapping, prekeys, keepalive jitter, CDN DELETE
1. USyncQuery: extract raw_id attr from node attrs into rawId field; implement
   side_list parsing (was TODO/commented) reusing shared parseNodeList helper

2. getUSyncDevices: add 4th LID→PN source via raw_id pairing from device-list
   WA Business sends zero phoneNumberToLidMappings in HistorySync — raw_id is
   the only way to resolve LID↔PN for those accounts during message send

3. MIN_PREKEY_COUNT: 5 → 25 (WA Business maintains ~812; 5 was too low a buffer
   before triggering replenishment upload)

4. startKeepAliveRequest: replace fixed setInterval with recursive setTimeout
   + ±15% jitter, matching WA Desktop's ~25-30s variable heartbeat pattern;
   clearInterval → clearTimeout for the handle

5. downloadHistory: fire-and-forget DELETE to CDN after successful download,
   mirroring WA Desktop behaviour (server-side one-time file cleanup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 18:23:27 -03:00
Renato Alcara bd8465d9b8 fix: histsync LID improvements — raw_id mapping, prekeys, keepalive j… (#304)
* fix: histsync LID improvements — raw_id mapping, prekeys, keepalive jitter, CDN DELETE

* fix: prekey pool strategy — 800 initial, top-up to 800 when below 200

- INITIAL_PREKEY_COUNT: 812 → 800 (rounded, matches WA Business ~812 from CDP capture)
- MIN_PREKEY_COUNT: 25 → 200 (replenishment trigger threshold)
- uploadPreKeysToServerIfRequired: top-up to INITIAL_PREKEY_COUNT instead of
  uploading a flat MIN_PREKEY_COUNT — restores full 800-key pool on each replenish
- handleEncryptNotification: same top-up logic (INITIAL_PREKEY_COUNT - count)
  so server notification path also restores to 800, not just adds 200

uploadPreKeys(5) in error recovery path intentionally left unchanged.
2026-03-19 17:58:30 -03:00
Renato Alcara 6c52b01ea7 fix: add pastParticipants to HistorySync with LID normalization and deduplication (#303)
Two bugs fixed compared to prior implementation:

1. history.ts — normalize userJid LID→PN
   pastParticipants[].userJid may arrive as a LID identifier (e.g. "46802258641027@lid").
   The consumer expects a phone number. After building the lidPnMap from conversations
   and phoneNumberToLidMappings, each userJid is resolved to its PN equivalent.
   If the mapping is not available the original value is preserved (no data loss).

2. event-buffer.ts — deduplicate by groupJid + userJid across chunks
   Multiple HistorySync chunks can contain the same group. The previous approach
   concatenated blindly, producing duplicate entries. Now a keyed map
   { [groupJid]: IPastParticipant[] } is used so each group appears once and
   each participant within a group appears at most once.

Types updated (Events.ts):
   - messaging-history.set event includes pastParticipants?: IPastParticipants[]
   - BufferedEventData.historySets.pastParticipants typed as keyed map
2026-03-19 16:44:14 -03:00
Renato Alcara a9e926b907 fix: correct w:mex QueryIds and response fields for newsletter operations (#302)
Reverse-engineered from WA Web JS bundle and live CDP interception.
All QueryIds and XWAPaths were wrong; mute/unmute also had wrong variables structure.

- FOLLOW QueryId: 7871414976211147 → 24404358912487870
- UNFOLLOW QueryId: 7238632346214362 → 9767147403369991
- MUTE QueryId: 29766401636284406 → 31938993655691868
- UNMUTE QueryId: 9864994326891137 → 31938993655691868 (same mutation as MUTE)
- xwa2_newsletter_follow: 'xwa2_newsletter_follow' → 'xwa2_newsletter_join_v2'
- xwa2_newsletter_unfollow: 'xwa2_newsletter_unfollow' → 'xwa2_newsletter_leave_v2'
- xwa2_newsletter_mute_v2: 'xwa2_newsletter_mute_v2' → 'xwa2_newsletter_update_user_setting'
- xwa2_newsletter_unmute_v2: 'xwa2_newsletter_unmute_v2' → 'xwa2_newsletter_update_user_setting'
- newsletterMute/Unmute variables: flat {newsletter_id} → {input: {newsletter_id, type, value}}

Fixes #2346
2026-03-19 11:49:42 -03:00
Renato Alcara 71fe69fd21 chore: update WhatsApp Web version to v2.3000.1035504971 (#301)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-19 06:33:42 -03:00
github-actions[bot] eecf6df2a2 chore: update proto/version to v2.3000.1035484955 (#300)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-19 01:48:35 -03:00
Renato Alcara d04a3e1af8 fix: restore working carousel send/render path (#297)
fix: restore working carousel send/render path (#297)
2026-03-18 20:30:18 -03:00
Renato Alcara 21462c26d5 chore: update WhatsApp Web version to v2.3000.1035406580 (#299)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-18 06:42:44 -03:00
github-actions[bot] fc2122b11a chore: update proto/version to v2.3000.1035370989 (#298)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-18 01:49:58 -03:00
22 changed files with 658 additions and 394 deletions
+14 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1035302375
/// WhatsApp Version: 2.3000.1035484955
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -417,6 +417,7 @@ message BotCapabilityMetadata {
JSON_PATCH_STREAMING = 58;
AI_TAB_FORCE_CLIPPY = 59;
UNIFIED_RESPONSE_EMBEDDED_SCREENS = 60;
AI_SUBSCRIPTION_ENABLED = 61;
}
}
@@ -1933,8 +1934,20 @@ message HandshakeMessage {
optional bytes paddedBytes = 6;
optional bool sendServerHelloPaddedBytes = 7;
optional bool simulateXxkemFs = 8;
optional HandshakeMessage.HandshakePqMode pqMode = 9;
}
enum HandshakePqMode {
HANDSHAKE_PQ_MODE_UNKNOWN = 0;
XXKEM = 1;
XXKEM_FS = 2;
WA_CLASSICAL = 3;
WA_PQ = 4;
IKKEM = 5;
IKKEM_FS = 6;
XXKEM_2 = 7;
IKKEM_2 = 8;
}
message ServerHello {
optional bytes ephemeral = 1;
optional bytes static = 2;
+16 -1
View File
@@ -1092,7 +1092,8 @@ export namespace proto {
RICH_RESPONSE_UR_IMAGINE_VIDEO = 57,
JSON_PATCH_STREAMING = 58,
AI_TAB_FORCE_CLIPPY = 59,
UNIFIED_RESPONSE_EMBEDDED_SCREENS = 60
UNIFIED_RESPONSE_EMBEDDED_SCREENS = 60,
AI_SUBSCRIPTION_ENABLED = 61
}
}
@@ -4759,6 +4760,7 @@ export namespace proto {
paddedBytes?: (Uint8Array|null);
sendServerHelloPaddedBytes?: (boolean|null);
simulateXxkemFs?: (boolean|null);
pqMode?: (proto.HandshakeMessage.HandshakePqMode|null);
}
class ClientHello implements IClientHello {
@@ -4771,6 +4773,7 @@ export namespace proto {
public paddedBytes?: (Uint8Array|null);
public sendServerHelloPaddedBytes?: (boolean|null);
public simulateXxkemFs?: (boolean|null);
public pqMode?: (proto.HandshakeMessage.HandshakePqMode|null);
public static create(properties?: proto.HandshakeMessage.IClientHello): proto.HandshakeMessage.ClientHello;
public static encode(m: proto.HandshakeMessage.IClientHello, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.HandshakeMessage.ClientHello;
@@ -4780,6 +4783,18 @@ export namespace proto {
public static getTypeUrl(typeUrlPrefix?: string): string;
}
enum HandshakePqMode {
HANDSHAKE_PQ_MODE_UNKNOWN = 0,
XXKEM = 1,
XXKEM_FS = 2,
WA_CLASSICAL = 3,
WA_PQ = 4,
IKKEM = 5,
IKKEM_FS = 6,
XXKEM_2 = 7,
IKKEM_2 = 8
}
interface IServerHello {
ephemeral?: (Uint8Array|null);
"static"?: (Uint8Array|null);
+81
View File
@@ -6897,6 +6897,10 @@ export const proto = $root.proto = (() => {
case 60:
m.capabilities[i] = 60;
break;
case "AI_SUBSCRIPTION_ENABLED":
case 61:
m.capabilities[i] = 61;
break;
}
}
}
@@ -6993,6 +6997,7 @@ export const proto = $root.proto = (() => {
values[valuesById[58] = "JSON_PATCH_STREAMING"] = 58;
values[valuesById[59] = "AI_TAB_FORCE_CLIPPY"] = 59;
values[valuesById[60] = "UNIFIED_RESPONSE_EMBEDDED_SCREENS"] = 60;
values[valuesById[61] = "AI_SUBSCRIPTION_ENABLED"] = 61;
return values;
})();
@@ -32585,6 +32590,7 @@ export const proto = $root.proto = (() => {
ClientHello.prototype.paddedBytes = null;
ClientHello.prototype.sendServerHelloPaddedBytes = null;
ClientHello.prototype.simulateXxkemFs = null;
ClientHello.prototype.pqMode = null;
let $oneOfFields;
@@ -32636,6 +32642,12 @@ export const proto = $root.proto = (() => {
set: $util.oneOfSetter($oneOfFields)
});
// Virtual OneOf for proto3 optional field
Object.defineProperty(ClientHello.prototype, "_pqMode", {
get: $util.oneOfGetter($oneOfFields = ["pqMode"]),
set: $util.oneOfSetter($oneOfFields)
});
ClientHello.create = function create(properties) {
return new ClientHello(properties);
};
@@ -32659,6 +32671,8 @@ export const proto = $root.proto = (() => {
w.uint32(56).bool(m.sendServerHelloPaddedBytes);
if (m.simulateXxkemFs != null && Object.hasOwnProperty.call(m, "simulateXxkemFs"))
w.uint32(64).bool(m.simulateXxkemFs);
if (m.pqMode != null && Object.hasOwnProperty.call(m, "pqMode"))
w.uint32(72).int32(m.pqMode);
return w;
};
@@ -32703,6 +32717,10 @@ export const proto = $root.proto = (() => {
m.simulateXxkemFs = r.bool();
break;
}
case 9: {
m.pqMode = r.int32();
break;
}
default:
r.skipType(t & 7);
break;
@@ -32754,6 +32772,50 @@ export const proto = $root.proto = (() => {
if (d.simulateXxkemFs != null) {
m.simulateXxkemFs = Boolean(d.simulateXxkemFs);
}
switch (d.pqMode) {
default:
if (typeof d.pqMode === "number") {
m.pqMode = d.pqMode;
break;
}
break;
case "HANDSHAKE_PQ_MODE_UNKNOWN":
case 0:
m.pqMode = 0;
break;
case "XXKEM":
case 1:
m.pqMode = 1;
break;
case "XXKEM_FS":
case 2:
m.pqMode = 2;
break;
case "WA_CLASSICAL":
case 3:
m.pqMode = 3;
break;
case "WA_PQ":
case 4:
m.pqMode = 4;
break;
case "IKKEM":
case 5:
m.pqMode = 5;
break;
case "IKKEM_FS":
case 6:
m.pqMode = 6;
break;
case "XXKEM_2":
case 7:
m.pqMode = 7;
break;
case "IKKEM_2":
case 8:
m.pqMode = 8;
break;
}
return m;
};
@@ -32801,6 +32863,11 @@ export const proto = $root.proto = (() => {
if (o.oneofs)
d._simulateXxkemFs = "simulateXxkemFs";
}
if (m.pqMode != null && m.hasOwnProperty("pqMode")) {
d.pqMode = o.enums === String ? $root.proto.HandshakeMessage.HandshakePqMode[m.pqMode] === undefined ? m.pqMode : $root.proto.HandshakeMessage.HandshakePqMode[m.pqMode] : m.pqMode;
if (o.oneofs)
d._pqMode = "pqMode";
}
return d;
};
@@ -32818,6 +32885,20 @@ export const proto = $root.proto = (() => {
return ClientHello;
})();
HandshakeMessage.HandshakePqMode = (function() {
const valuesById = {}, values = Object.create(valuesById);
values[valuesById[0] = "HANDSHAKE_PQ_MODE_UNKNOWN"] = 0;
values[valuesById[1] = "XXKEM"] = 1;
values[valuesById[2] = "XXKEM_FS"] = 2;
values[valuesById[3] = "WA_CLASSICAL"] = 3;
values[valuesById[4] = "WA_PQ"] = 4;
values[valuesById[5] = "IKKEM"] = 5;
values[valuesById[6] = "IKKEM_FS"] = 6;
values[valuesById[7] = "XXKEM_2"] = 7;
values[valuesById[8] = "IKKEM_2"] = 8;
return values;
})();
HandshakeMessage.ServerHello = (function() {
function ServerHello(p) {
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1035317910]}
{"version":[2,3000,1035504971]}
+5 -3
View File
@@ -188,10 +188,12 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
/** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */
export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000
export const MIN_PREKEY_COUNT = 5
// Replenishment threshold: when server count drops below this, top-up back to INITIAL_PREKEY_COUNT
export const MIN_PREKEY_COUNT = 200
// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
export const INITIAL_PREKEY_COUNT = 200
// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration)
// Rounded to 800 for cleanliness; replenishment always tops up to this value
export const INITIAL_PREKEY_COUNT = 800
export const UPLOAD_TIMEOUT = 30000 // 30 seconds
// Moderate upload interval to balance rate limiting and responsiveness (was 5000)
+1 -1
View File
@@ -306,7 +306,7 @@ export function makeLibSignalRepository(
// This prevents PN/LID race conditions where concurrent operations for the
// same logical contact acquire different mutex locks because one uses PN
// and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.)
const resolveCanonicalJid = async (jid: string): Promise<string> => {
const resolveCanonicalJid = async(jid: string): Promise<string> => {
if (isAnyLidUser(jid)) {
return jid
}
+18 -22
View File
@@ -25,30 +25,28 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const lidMapping = signalRepository.lidMapping
// Resolve all participant LIDs in parallel for better performance on large groups
await Promise.all(
metadata.participants.map(async p => {
if (isLidUser(p.id)) {
if (p.phoneNumber) {
await Promise.all(metadata.participants.map(async (p) => {
if (isLidUser(p.id)) {
if (p.phoneNumber) {
p.lid = p.id
p.id = p.phoneNumber
} else {
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
if (resolved && resolved !== p.id) {
p.lid = p.id
p.id = p.phoneNumber
} else {
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
if (resolved && resolved !== p.id) {
p.lid = p.id
p.id = resolved
}
p.id = resolved
}
}
})
)
}
}))
// Normalize owner/subjectOwner if LID (parallel)
const [resolvedOwner, resolvedSubjectOwner] = await Promise.all([
metadata.owner && isLidUser(metadata.owner)
? metadata.ownerPn || resolveLidToPn(metadata.owner, lidMapping, logger)
? (metadata.ownerPn || resolveLidToPn(metadata.owner, lidMapping, logger))
: null,
metadata.subjectOwner && isLidUser(metadata.subjectOwner)
? metadata.subjectOwnerPn || resolveLidToPn(metadata.subjectOwner, lidMapping, logger)
? (metadata.subjectOwnerPn || resolveLidToPn(metadata.subjectOwner, lidMapping, logger))
: null
])
if (resolvedOwner) metadata.owner = resolvedOwner
@@ -97,13 +95,11 @@ export const makeGroupsSocket = (config: SocketConfig) => {
if (groupsChild) {
const groups = getBinaryNodeChildren(groupsChild, 'group')
for (const groupNode of groups) {
const meta = await normalizeGroupMetadata(
extractGroupMetadata({
tag: 'result',
attrs: {},
content: [groupNode]
})
)
const meta = await normalizeGroupMetadata(extractGroupMetadata({
tag: 'result',
attrs: {},
content: [groupNode]
}))
data[meta.id] = meta
}
}
+236 -238
View File
@@ -7,6 +7,7 @@ import { proto } from '../../WAProto/index.js'
import {
DEFAULT_CACHE_TTLS,
DEFAULT_SESSION_CLEANUP_CONFIG,
INITIAL_PREKEY_COUNT,
KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS,
@@ -376,7 +377,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
for (const update of updates) {
if (update.jid && update.user) {
const [resolvedAuthor, resolvedUser] = await Promise.all([
resolveLidToPn(node.attrs.from, signalRepository.lidMapping, logger),
resolveLidToPn(node.attrs.from!, signalRepository.lidMapping, logger),
resolveLidToPn(update.user, signalRepository.lidMapping, logger)
])
ev.emit('newsletter-participants.update', {
@@ -403,7 +404,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const child = getAllBinaryNodeChildren(node)[0]!
const rawAuthor = node.attrs.participant!
// Resolve author LID→PN (participant is a user JID that could be LID)
const author = (await resolveLidToPn(rawAuthor, signalRepository.lidMapping, logger)) || rawAuthor
const author = await resolveLidToPn(rawAuthor, signalRepository.lidMapping, logger) || rawAuthor
logger.info({ from, child }, 'got newsletter notification')
@@ -430,8 +431,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
break
case 'participant':
const resolvedParticipantUser =
(await resolveLidToPn(child.attrs.jid, signalRepository.lidMapping, logger)) || child.attrs.jid!
const resolvedParticipantUser = await resolveLidToPn(child.attrs.jid!, signalRepository.lidMapping, logger) || child.attrs.jid!
const participantUpdate = {
id: from,
author,
@@ -574,7 +574,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const offerContent: BinaryNode[] = [
{ tag: 'privacy', attrs: {}, content: undefined },
{ tag: 'audio', attrs: { rate: '8000', enc: 'opus' }, content: undefined },
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined }
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
]
if (isVideo) {
@@ -595,31 +595,31 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
{ tag: 'net', attrs: { medium: '3' }, content: undefined },
{ tag: 'capability', attrs: { ver: '1' }, content: undefined },
{ tag: 'enc', attrs: { v: '2', type: isVideo ? 'msg' : 'pkmsg' }, content: undefined },
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined }
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
)
// Voice calls include device-identity (verified via Frida capture)
if (!isVideo) {
offerContent.push({ tag: 'device-identity', attrs: {}, content: undefined })
offerContent.push(
{ tag: 'device-identity', attrs: {}, content: undefined },
)
}
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: jid,
id: stanzaId
id: stanzaId,
},
content: [
{
tag: 'offer',
attrs: {
'call-creator': meId,
'call-id': callId,
device_class: '2013'
},
content: offerContent
}
]
content: [{
tag: 'offer',
attrs: {
'call-creator': meId,
'call-id': callId,
'device_class': '2013',
},
content: offerContent
}]
}
await query(stanza)
@@ -648,7 +648,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const terminateAttrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator || meId
'call-creator': callCreator || meId,
}
if (reason) {
@@ -664,15 +664,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
tag: 'call',
attrs: {
to: callTo,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'terminate',
attrs: terminateAttrs,
content: undefined
}
]
content: [{
tag: 'terminate',
attrs: terminateAttrs,
content: undefined
}]
}
await query(stanza)
@@ -686,18 +684,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
* @param callFrom - JID of the caller (call-creator)
* @param isVideo - true for video call
*/
const acceptCall = async (callId: string, callFrom: string, isVideo?: boolean) => {
const acceptCall = async (
callId: string,
callFrom: string,
isVideo?: boolean,
) => {
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const acceptContent: BinaryNode[] = [{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined }]
const acceptContent: BinaryNode[] = [
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
]
if (isVideo) {
acceptContent.push({
tag: 'video',
attrs: {
dec: 'H264,AV1',
device_orientation: '1'
device_orientation: '1',
},
content: undefined
})
@@ -705,7 +709,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
acceptContent.push(
{ tag: 'net', attrs: { medium: '2' }, content: undefined },
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined }
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
)
const stanza: BinaryNode = {
@@ -713,18 +717,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
attrs: {
from: meId,
to: callFrom,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'accept',
attrs: {
'call-id': callId,
'call-creator': callFrom
},
content: acceptContent
}
]
content: [{
tag: 'accept',
attrs: {
'call-id': callId,
'call-creator': callFrom,
},
content: acceptContent
}]
}
await query(stanza)
@@ -739,8 +741,14 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
* @param callCreator - JID of the caller
* @param isVideo - true for video call
*/
const preacceptCall = async (callId: string, callCreator: string, isVideo?: boolean) => {
const preacceptContent: BinaryNode[] = [{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined }]
const preacceptCall = async (
callId: string,
callCreator: string,
isVideo?: boolean,
) => {
const preacceptContent: BinaryNode[] = [
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
]
if (isVideo) {
preacceptContent.push({
@@ -749,7 +757,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
screen_width: '1080',
screen_height: '2400',
dec: 'H264,H265,AV1',
device_orientation: '0'
device_orientation: '0',
},
content: undefined
})
@@ -757,25 +765,23 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
preacceptContent.push(
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
{ tag: 'capability', attrs: { ver: '1' }, content: undefined }
{ tag: 'capability', attrs: { ver: '1' }, content: undefined },
)
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: callCreator,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'preaccept',
attrs: {
'call-id': callId,
'call-creator': callCreator
},
content: preacceptContent
}
]
content: [{
tag: 'preaccept',
attrs: {
'call-id': callId,
'call-creator': callCreator,
},
content: preacceptContent
}]
}
await query(stanza)
@@ -801,11 +807,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
dlBw?: number
ulBw?: number
}>,
transactionId?: string
transactionId?: string,
) => {
const relayLatencyAttrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator
'call-creator': callCreator,
}
if (transactionId) {
@@ -838,15 +844,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
tag: 'call',
attrs: {
to: callCreator,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'relaylatency',
attrs: relayLatencyAttrs,
content: teChildren
}
]
content: [{
tag: 'relaylatency',
attrs: relayLatencyAttrs,
content: teChildren
}]
}
await sendNode(stanza)
@@ -867,12 +871,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
callCreator: string,
to: string,
candidates: Array<{ priority: string; data?: Uint8Array }>,
round?: number
round?: number,
) => {
const transportAttrs: Record<string, string> = {
'call-id': callId,
'call-creator': callCreator,
'transport-message-type': '1'
'transport-message-type': '1',
}
if (round !== undefined) {
@@ -882,22 +886,20 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const teChildren: BinaryNode[] = candidates.map(c => ({
tag: 'te',
attrs: { priority: c.priority },
content: c.data
content: c.data,
}))
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'transport',
attrs: transportAttrs,
content: teChildren
}
]
content: [{
tag: 'transport',
attrs: transportAttrs,
content: teChildren
}]
}
await sendNode(stanza)
@@ -918,27 +920,25 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
callCreator: string,
peer: string,
audioDuration: number,
callType = '1x1'
callType: string = '1x1',
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'duration',
attrs: {
'call-id': callId,
'call-creator': callCreator,
peer,
audio_duration: String(audioDuration),
type: callType
},
content: undefined
}
]
content: [{
tag: 'duration',
attrs: {
'call-id': callId,
'call-creator': callCreator,
peer,
audio_duration: String(audioDuration),
type: callType,
},
content: undefined
}]
}
await sendNode(stanza)
@@ -953,24 +953,27 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
* @param to - destination JID
* @param muted - true to mute, false to unmute
*/
const muteCall = async (callId: string, callCreator: string, to: string, muted: boolean) => {
const muteCall = async (
callId: string,
callCreator: string,
to: string,
muted: boolean,
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'mute_v2',
attrs: {
'mute-state': muted ? '1' : '0',
'call-id': callId,
'call-creator': callCreator
},
content: undefined
}
]
content: [{
tag: 'mute_v2',
attrs: {
'mute-state': muted ? '1' : '0',
'call-id': callId,
'call-creator': callCreator,
},
content: undefined
}]
}
await sendNode(stanza)
@@ -983,23 +986,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
* @param callId - the call-id (also used as JID with @call)
* @param callCreator - JID of the call creator
*/
const sendHeartbeat = async (callId: string, callCreator: string) => {
const sendHeartbeat = async (
callId: string,
callCreator: string,
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to: `${callId}@call`,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'heartbeat',
attrs: {
'call-id': callId,
'call-creator': callCreator
},
content: undefined
}
]
content: [{
tag: 'heartbeat',
attrs: {
'call-id': callId,
'call-creator': callCreator,
},
content: undefined
}]
}
await sendNode(stanza)
@@ -1014,27 +1018,30 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
* @param to - destination JID
* @param transactionId - transaction ID for the rekey
*/
const sendEncRekey = async (callId: string, callCreator: string, to: string, transactionId: string) => {
const sendEncRekey = async (
callId: string,
callCreator: string,
to: string,
transactionId: string,
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'enc_rekey',
attrs: {
'transaction-id': transactionId,
'call-id': callId,
'call-creator': callCreator
},
content: [
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
{ tag: 'enc', attrs: { v: '2', type: 'msg' }, content: undefined }
]
}
]
content: [{
tag: 'enc_rekey',
attrs: {
'transaction-id': transactionId,
'call-id': callId,
'call-creator': callCreator,
},
content: [
{ tag: 'encopt', attrs: { keygen: '2' }, content: undefined },
{ tag: 'enc', attrs: { v: '2', type: 'msg' }, content: undefined },
]
}]
}
await sendNode(stanza)
@@ -1055,26 +1062,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
callCreator: string,
to: string,
enabled: boolean,
orientation = '1'
orientation: string = '1',
) => {
const stanza: BinaryNode = {
tag: 'call',
attrs: {
to,
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'video',
attrs: {
'call-id': callId,
'call-creator': callCreator,
state: enabled ? '1' : '0',
device_orientation: orientation
},
content: undefined
}
]
content: [{
tag: 'video',
attrs: {
'call-id': callId,
'call-creator': callCreator,
state: enabled ? '1' : '0',
device_orientation: orientation,
},
content: undefined
}]
}
await sendNode(stanza)
@@ -1096,17 +1101,15 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'link_create',
attrs: { media },
content: event
? [{ tag: 'event', attrs: { start_time: String(event.startTime) }, content: undefined }]
: undefined
}
]
content: [{
tag: 'link_create',
attrs: { media },
content: event
? [{ tag: 'event', attrs: { start_time: String(event.startTime) }, content: undefined }]
: undefined
}]
}
const response = await query(stanza, timeoutMs)
@@ -1134,7 +1137,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}
// URL format verified via Frida capture: https://call.whatsapp.com/<token>
const url = token ? `https://call.whatsapp.com/${token}` : undefined
const url = token
? `https://call.whatsapp.com/${token}`
: undefined
return { token, url, response }
}
@@ -1152,15 +1157,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'link_query',
attrs: { media, token },
content: undefined
}
]
content: [{
tag: 'link_query',
attrs: { media, token },
content: undefined
}]
}
return await query(stanza)
@@ -1178,7 +1181,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const joinContent: BinaryNode[] = [
{ tag: 'audio', attrs: { rate: '16000', enc: 'opus' }, content: undefined },
{ tag: 'net', attrs: { medium: '2' }, content: undefined },
{ tag: 'capability', attrs: { ver: '1' }, content: undefined }
{ tag: 'capability', attrs: { ver: '1' }, content: undefined },
]
if (media === 'video') {
@@ -1188,7 +1191,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
screen_width: '1080',
screen_height: '2400',
dec: 'H264,H265,AV1',
device_orientation: '0'
device_orientation: '0',
},
content: undefined
})
@@ -1198,15 +1201,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
tag: 'call',
attrs: {
to: 'call',
id: randomBytes(16).toString('hex').toUpperCase()
id: randomBytes(16).toString('hex').toUpperCase(),
},
content: [
{
tag: 'link_join',
attrs: { media, token },
content: joinContent
}
]
content: [{
tag: 'link_join',
attrs: { media, token },
content: joinContent
}]
}
return await query(stanza)
@@ -1223,7 +1224,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// For group messages, scope by participant (each participant has its own Signal session).
const retryDedupeJid = msgKey.participant
? jidNormalizedUser(msgKey.participant)
: jidNormalizedUser(node.attrs.from)
: jidNormalizedUser(node.attrs.from!)
if (retryRequestActiveJids.has(retryDedupeJid)) {
logger.debug(
{ fromJid: retryDedupeJid, msgId },
@@ -1246,9 +1247,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// this cleanup only runs as a last resort.
// For group messages, use participant JID (Signal sessions are per-participant, not per-group).
if (autoCleanCorrupted) {
const senderJid = msgKey.participant
? jidNormalizedUser(msgKey.participant)
: jidNormalizedUser(node.attrs.from)
const senderJid = msgKey.participant ? jidNormalizedUser(msgKey.participant) : jidNormalizedUser(node.attrs.from!)
try {
const decryptionJid = await getDecryptionJid(senderJid, signalRepository)
const deletedCount = await cleanupCorruptedSession(decryptionJid, signalRepository, logger)
@@ -1284,9 +1283,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Safety net cleanup (same as new system above)
if (autoCleanCorrupted) {
const senderJid = msgKey.participant
? jidNormalizedUser(msgKey.participant)
: jidNormalizedUser(node.attrs.from)
const senderJid = msgKey.participant ? jidNormalizedUser(msgKey.participant) : jidNormalizedUser(node.attrs.from!)
try {
const decryptionJid = await getDecryptionJid(senderJid, signalRepository)
await cleanupCorruptedSession(decryptionJid, signalRepository, logger)
@@ -1444,7 +1441,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count')
if (shouldUploadMorePreKeys) {
await uploadPreKeys()
// Top-up back to INITIAL_PREKEY_COUNT so the pool is always restored to full size
await uploadPreKeys(Math.max(1, INITIAL_PREKEY_COUNT - count))
}
} else {
const result = await handleIdentityChange(node, {
@@ -1468,13 +1466,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const senderTs = unixTimestampSeconds()
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
getPrivacyTokens([normalizedJid], senderTs)
.then(async iqResult => {
.then(async (iqResult) => {
await storeTcTokensFromIqResult({
result: iqResult,
fallbackJid: normalizedJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: storedJid => {
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
@@ -1520,20 +1518,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn!
// Resolve acting participant to PN — prefer inline PN, fall back to LID→PN resolution
const actingParticipant = actingParticipantPn || (await resolveLidToPn(actingParticipantLid, lidMapping, logger))
const actingParticipant = actingParticipantPn
|| await resolveLidToPn(actingParticipantLid, lidMapping, logger)
// Resolve affected participant to PN
const affectedParticipant =
affectedParticipantPn || (await resolveLidToPn(affectedParticipantLid, lidMapping, logger))
const affectedParticipant = affectedParticipantPn
|| await resolveLidToPn(affectedParticipantLid, lidMapping, logger)
// Store LID↔PN mappings from notification attributes
const mappingsToStore: Array<{ lid: string; pn: string }> = []
if (
actingParticipantLid &&
actingParticipantPn &&
isLidUser(actingParticipantLid) &&
isPnUser(actingParticipantPn)
) {
if (actingParticipantLid && actingParticipantPn && isLidUser(actingParticipantLid) && isPnUser(actingParticipantPn)) {
mappingsToStore.push({ lid: actingParticipantLid, pn: actingParticipantPn })
}
@@ -1562,7 +1556,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Resolve metadata owner to PN
if (metadata.owner && isLidUser(metadata.owner)) {
const resolvedOwner = metadata.ownerPn || (await resolveLidToPn(metadata.owner, lidMapping, logger))
const resolvedOwner = metadata.ownerPn || await resolveLidToPn(metadata.owner, lidMapping, logger)
if (resolvedOwner) metadata.owner = resolvedOwner
}
@@ -1596,7 +1590,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
case 'modify':
const oldNumbers = await Promise.all(
getBinaryNodeChildren(child, 'participant').map(async p => {
const resolved = await resolveLidToPn(p.attrs.jid, lidMapping, logger)
const resolved = await resolveLidToPn(p.attrs.jid!, lidMapping, logger)
return resolved || p.attrs.jid!
})
)
@@ -1778,7 +1772,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
{
const rawPictureJid = jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || ''
const pictureJid = (await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger)) || rawPictureJid
const pictureJid = await resolveLidToPn(rawPictureJid, signalRepository.lidMapping, logger) || rawPictureJid
ev.emit('contacts.update', [
{
id: pictureJid,
@@ -1823,8 +1817,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const blocklists = getBinaryNodeChildren(child, 'item')
for (const { attrs } of blocklists) {
const resolvedBlockJid =
(await resolveLidToPn(attrs.jid, signalRepository.lidMapping, logger)) || attrs.jid!
const resolvedBlockJid = await resolveLidToPn(attrs.jid!, signalRepository.lidMapping, logger) || attrs.jid!
const blocklist = [resolvedBlockJid]
const type = attrs.action === 'block' ? 'add' : 'remove'
ev.emit('blocklist.update', { blocklist, type })
@@ -2155,8 +2148,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (attrs.participant) {
const updateKey: keyof MessageUserReceipt =
status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
const resolvedReceiptUserJid =
(await resolveLidToPn(attrs.participant, lidMapping, logger)) || jidNormalizedUser(attrs.participant)
const resolvedReceiptUserJid = await resolveLidToPn(attrs.participant, lidMapping, logger) || jidNormalizedUser(attrs.participant)
ev.emit(
'message-receipt.update',
ids.map(id => ({
@@ -2269,7 +2261,13 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
category,
author,
decrypt
} = decryptMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '', signalRepository, logger)
} = decryptMessageNode(
node,
authState.creds.me!.id,
authState.creds.me!.lid || '',
signalRepository,
logger,
)
const alt = msg.key.participantAlt || msg.key.remoteJidAlt
// Handle LID/PN mappings with hybrid approach:
@@ -2684,7 +2682,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
jid: u.attrs.jid,
state: u.attrs.state,
userPn: u.attrs.user_pn,
type: u.attrs.type
type: u.attrs.type,
}))
}
@@ -2772,7 +2770,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const callSummary = getBinaryNodeChild(infoChild, 'call_summary')
if (callSummary) {
call.media = callSummary.attrs.media
call.duration = callSummary.attrs.call_duration ? Number(callSummary.attrs.call_duration) : undefined
call.duration = callSummary.attrs.call_duration
? Number(callSummary.attrs.call_duration)
: undefined
call.participants = extractParticipants(callSummary)
}
}
@@ -2816,14 +2816,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (resolvedLinkCreator) call.linkCreator = resolvedLinkCreator
// Resolve participant JIDs in parallel
if (call.participants) {
await Promise.all(
call.participants.map(async p => {
if (p.jid) {
const resolved = p.userPn || (await resolveLidToPn(p.jid, callLidMapping, logger))
if (resolved) p.jid = resolved
}
})
)
await Promise.all(call.participants.map(async (p) => {
if (p.jid) {
const resolved = p.userPn || await resolveLidToPn(p.jid, callLidMapping, logger)
if (resolved) p.jid = resolved
}
}))
}
ev.emit('call', [call])
@@ -2861,22 +2859,20 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// WABA Android: error 463 triggers getPrivacyTokens() fire-and-forget
// to ensure token is available for the retry below
getPrivacyTokens([jid])
.then(async result => {
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: storedJid => {
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid, reason: 'error_463' })
})
.catch(() => {
/* fire-and-forget */
})
.catch(() => { /* fire-and-forget */ })
// Single-retry: wait 1.5s for the server's tctoken notification to arrive,
// then resend. A Set prevents infinite retry loops.
@@ -2917,22 +2913,20 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logTcToken('error_479', { jid: jid479, msgId: attrs.id })
// WABA Android: error 479 (SmaxInvalid) also triggers token re-fetch
getPrivacyTokens([jid479])
.then(async result => {
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid479,
keys: authState.keys,
getLIDForPN,
onNewJidStored: storedJid => {
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid: jid479, reason: 'error_479' })
})
.catch(() => {
/* fire-and-forget */
})
.catch(() => { /* fire-and-forget */ })
} else {
logger.warn({ attrs }, 'received error in ack')
}
@@ -3066,20 +3060,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// (call link relays may arrive without these attrs — just log them)
if (callId && rawCallCreator) {
// Resolve LID→PN for call creator
const callCreator = (await resolveLidToPn(rawCallCreator, signalRepository.lidMapping, logger)) || rawCallCreator
logger.debug({ callId, callCreator, uuid: node.attrs.uuid }, 'received relay info')
ev.emit('call', [
{
chatId: callCreator,
from: callCreator,
id: callId,
date: new Date(),
offline: false,
status: 'relay' as WACallUpdateType
}
])
const callCreator = await resolveLidToPn(rawCallCreator, signalRepository.lidMapping, logger) || rawCallCreator
logger.debug(
{ callId, callCreator, uuid: node.attrs.uuid },
'received relay info'
)
ev.emit('call', [{
chatId: callCreator,
from: callCreator,
id: callId,
date: new Date(),
offline: false,
status: 'relay' as WACallUpdateType,
}])
} else {
logger.debug({ attrs: node.attrs }, 'received relay stanza without call-id/call-creator')
logger.debug(
{ attrs: node.attrs },
'received relay stanza without call-id/call-creator'
)
}
})
+94 -35
View File
@@ -1,6 +1,6 @@
import { randomBytes } from 'crypto'
import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { randomBytes } from 'crypto'
import { proto } from '../../WAProto/index.js'
import { DEFAULT_CACHE_TTLS, WA_DEFAULT_EPHEMERAL } from '../Defaults'
import type {
@@ -9,6 +9,7 @@ import type {
AlbumMessageOptions,
AlbumSendResult,
AnyMessageContent,
LIDMapping,
MediaConnInfo,
MessageReceiptType,
MessageRelayOptions,
@@ -338,6 +339,33 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// 4th LID→PN source: device-list entries sharing the same raw_id
// WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings
const allEntries = [...result.list, ...result.sideList]
const rawIdMap = new Map<number, { pn?: string; lid?: string }>()
for (const item of allEntries) {
if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue
const decoded = jidDecode(item.id)
if (!decoded) continue
const entry = rawIdMap.get(item.rawId) || {}
if (decoded.server === 'lid' || decoded.server === 'hosted.lid') {
entry.lid = item.id
} else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') {
entry.pn = item.id
}
rawIdMap.set(item.rawId, entry)
}
const rawIdMappings: LIDMapping[] = []
for (const { pn, lid } of rawIdMap.values()) {
if (pn && lid) {
rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) })
}
}
if (rawIdMappings.length > 0) {
await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings)
logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing')
}
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || ''
@@ -599,6 +627,57 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return patchedMessage
}
const encryptPatchedMessageForRecipient = async ({
jid,
patchedMessage,
dsmMessage,
meId,
meLid,
meLidUser,
extraAttrs,
onPkmsg
}: {
jid: string
patchedMessage: proto.IMessage
dsmMessage: proto.IMessage | undefined
meId: string
meLid: string | undefined
meLidUser: string | null | undefined
extraAttrs: BinaryNode['attrs'] | undefined
onPkmsg: () => void
}) => {
if (!jid) return null
try {
const msgToEncrypt = resolveDsmMessageForRecipient(jid, patchedMessage, dsmMessage, meId, meLid, meLidUser)
const bytes = encodeWAMessage(msgToEncrypt)
const mutexKey = jid
return await encryptionMutex.mutex(mutexKey, async () => {
const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes })
if (type === 'pkmsg') {
onPkmsg()
}
return {
tag: 'to',
attrs: { jid },
content: [
{
tag: 'enc',
attrs: { v: '2', type, ...(extraAttrs || {}) },
content: ciphertext
}
]
} as BinaryNode
})
} catch (err) {
logger.error({ jid, err }, 'Failed to encrypt for recipient')
return null
}
}
const createParticipantNodes = async (
recipientJids: string[],
message: proto.IMessage,
@@ -619,42 +698,22 @@ export const makeMessagesSocket = (config: SocketConfig) => {
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid
const meLidUser = meLid ? (jidDecode(meLid)?.user ?? null) : null
const onPkmsg = () => {
shouldIncludeDeviceIdentity = true
}
const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map(
async ({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => {
try {
if (!jid) return null
const msgToEncrypt = resolveDsmMessageForRecipient(jid, patchedMessage, dsmMessage, meId, meLid, meLidUser)
const bytes = encodeWAMessage(msgToEncrypt)
const mutexKey = jid
const node = await encryptionMutex.mutex(mutexKey, async () => {
const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes })
if (type === 'pkmsg') {
shouldIncludeDeviceIdentity = true
}
return {
tag: 'to',
attrs: { jid },
content: [
{
tag: 'enc',
attrs: { v: '2', type, ...(extraAttrs || {}) },
content: ciphertext
}
]
}
})
return node
} catch (err) {
logger.error({ jid, err }, 'Failed to encrypt for recipient')
return null
}
}
({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) =>
encryptPatchedMessageForRecipient({
jid,
patchedMessage,
dsmMessage,
meId,
meLid,
meLidUser,
extraAttrs,
onPkmsg
})
)
const nodes = (await Promise.all(encryptionPromises)).filter(node => node !== null) as BinaryNode[]
+2 -2
View File
@@ -123,11 +123,11 @@ export const makeNewsletterSocket = (config: SocketConfig) => {
},
newsletterMute: (jid: string) => {
return executeWMexQuery({ newsletter_id: jid }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'OFF' } }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
},
newsletterUnmute: (jid: string) => {
return executeWMexQuery({ newsletter_id: jid }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'ON' } }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
},
newsletterUpdateName: async (jid: string, name: string) => {
+36 -21
View File
@@ -701,10 +701,12 @@ export const makeSocket = (config: SocketConfig) => {
}
}
// Prevent multiple concurrent uploads
// Prevent multiple concurrent uploads — if one is already running, wait for it and return:
// the concurrent upload already replenished the pool, so there is nothing left to do.
if (uploadPreKeysPromise) {
logger.debug('Pre-key upload already in progress, waiting for completion')
await uploadPreKeysPromise
return
}
const uploadLogic = async () => {
@@ -784,14 +786,15 @@ export const makeSocket = (config: SocketConfig) => {
try {
let count = 0
const preKeyCount = await getAvailablePreKeysOnServer()
if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT
else count = MIN_PREKEY_COUNT
// How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server
count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount)
const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
logger.info(`${preKeyCount} pre-keys found on server`)
logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`)
const lowServerCount = preKeyCount <= count
// Trigger upload when below the replenishment threshold, not when count < topUp amount
const lowServerCount = preKeyCount < MIN_PREKEY_COUNT
const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
const shouldUpload = lowServerCount || missingCurrentPreKey
@@ -1151,7 +1154,7 @@ export const makeSocket = (config: SocketConfig) => {
// Decrement active connections
decrementActiveConnections()
clearInterval(keepAliveReq)
clearTimeout(keepAliveReq)
clearTimeout(qrTimer)
// Clear offline-buffer safety timer so its callback cannot call ev.flush()
@@ -1262,8 +1265,16 @@ export const makeSocket = (config: SocketConfig) => {
})
}
const startKeepAliveRequest = () =>
(keepAliveReq = setInterval(() => {
const startKeepAliveRequest = () => {
// Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour
// (WA Business Desktop: ~25-30s intervals with natural variance)
const scheduleNextKeepAlive = () => {
const jitter = keepAliveIntervalMs * 0.15
const delay = keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter)
keepAliveReq = setTimeout(onKeepAliveTick, delay)
}
const onKeepAliveTick = () => {
if (!lastDateRecv) {
lastDateRecv = new Date()
}
@@ -1275,6 +1286,7 @@ export const makeSocket = (config: SocketConfig) => {
*/
if (diff > keepAliveIntervalMs + 5000) {
void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
return // connection closing — do not reschedule
} else if (ws.isOpen) {
// Send keep-alive ping via sendNode() (fire-and-forget) instead of query().
// query() wraps the ping in the query circuit breaker — when that breaker is
@@ -1297,7 +1309,15 @@ export const makeSocket = (config: SocketConfig) => {
} else {
logger.warn('keep alive called when WS not open')
}
}, keepAliveIntervalMs))
// Do not reschedule once shutdown has started (closed set by end() on any concurrent path)
if (!closed) {
scheduleNextKeepAlive()
}
}
scheduleNextKeepAlive()
}
/** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') =>
query({
@@ -1361,16 +1381,13 @@ export const makeSocket = (config: SocketConfig) => {
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
logger.info(
{
pairCode: pairingCode,
jid: authState.creds.me.id,
companionPlatformId: pairPlatformId,
companionPlatformDisplay: pairPlatformDisplay,
isAndroid
},
`pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override -> Chrome' : 'native platform'}`
)
logger.info({
pairCode: pairingCode,
jid: authState.creds.me.id,
companionPlatformId: pairPlatformId,
companionPlatformDisplay: pairPlatformDisplay,
isAndroid,
}, `pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override -> Chrome' : 'native platform'}`)
ev.emit('creds.update', authState.creds)
await sendNode({
@@ -1541,9 +1558,7 @@ export const makeSocket = (config: SocketConfig) => {
ws.on('CB:success', async (node: BinaryNode) => {
const isAndroid = isAndroidBrowser(browser)
const phoneId = authState.creds.me?.id?.split(':')[0]?.split('@')[0] || 'new session'
logger.info(
`${isAndroid ? '\uD83D\uDCF1' : '\uD83D\uDDA5\uFE0F'} Connected to WA | ${phoneId} | platform: ${isAndroid ? 'SMB_ANDROID' : 'MACOS'} | device: ${isAndroid ? 'Android' : 'Desktop'} | platformType: ${isAndroid ? 'ANDROID_PHONE' : 'CHROME'}`
)
logger.info(`${isAndroid ? '\uD83D\uDCF1' : '\uD83D\uDDA5\uFE0F'} Connected to WA | ${phoneId} | platform: ${isAndroid ? 'SMB_ANDROID' : 'MACOS'} | device: ${isAndroid ? 'Android' : 'Desktop'} | platformType: ${isAndroid ? 'ANDROID_PHONE' : 'CHROME'}`)
clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try
ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } })
+4
View File
@@ -27,6 +27,8 @@ export type BaileysEventMap = {
chats: Chat[]
contacts: Contact[]
messages: WAMessage[]
/** Past participants for group chats (people who left/were removed). userJid is always a phone number (PN), never a LID. */
pastParticipants?: proto.IPastParticipants[] | null
isLatest?: boolean
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null
@@ -185,6 +187,8 @@ export type BufferedEventData = {
chats: { [jid: string]: Chat }
contacts: { [jid: string]: Contact }
messages: { [uqId: string]: WAMessage }
/** Keyed by groupJid for O(1) deduplication across chunks */
pastParticipants: { [groupJid: string]: proto.IPastParticipant[] }
empty: boolean
isLatest: boolean
progress?: number | null
+8 -8
View File
@@ -4,10 +4,10 @@ export enum XWAPaths {
xwa2_newsletter_view = 'xwa2_newsletter_view',
xwa2_newsletter_metadata = 'xwa2_newsletter',
xwa2_newsletter_admin_count = 'xwa2_newsletter_admin',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_mute_v2',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_unmute_v2',
xwa2_newsletter_follow = 'xwa2_newsletter_follow',
xwa2_newsletter_unfollow = 'xwa2_newsletter_unfollow',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_update_user_setting',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_update_user_setting',
xwa2_newsletter_follow = 'xwa2_newsletter_join_v2',
xwa2_newsletter_unfollow = 'xwa2_newsletter_leave_v2',
xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner',
xwa2_newsletter_demote = 'xwa2_newsletter_demote',
xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2'
@@ -17,10 +17,10 @@ export enum QueryIds {
UPDATE_METADATA = '24250201037901610',
METADATA = '6563316087068696',
SUBSCRIBERS = '9783111038412085',
FOLLOW = '7871414976211147',
UNFOLLOW = '7238632346214362',
MUTE = '29766401636284406',
UNMUTE = '9864994326891137',
FOLLOW = '24404358912487870',
UNFOLLOW = '9767147403369991',
MUTE = '31938993655691868',
UNMUTE = '31938993655691868',
ADMIN_COUNT = '7130823597031706',
CHANGE_OWNER = '7341777602580933',
DEMOTE = '6551828931592903',
+1 -1
View File
@@ -716,7 +716,7 @@ export function createConnectionCircuitBreaker(customOptions?: Partial<CircuitBr
const WS_LIFECYCLE_STATUS_CODES = new Set([
428, // connectionClosed
408, // connectionLost / timedOut
440 // connectionReplaced
440, // connectionReplaced
])
return new CircuitBreaker({
+2 -2
View File
@@ -13,7 +13,7 @@ import {
isJidNewsletter,
isJidStatusBroadcast,
isLidUser,
isPnUser
isPnUser,
// transferDevice
} from '../WABinary'
import { unpadRandomMax16 } from './generics'
@@ -274,7 +274,7 @@ export const decryptMessageNode = (
meId: string,
meLid: string,
repository: SignalRepositoryWithLIDStore,
logger: ILogger
logger: ILogger,
) => {
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
return {
+28
View File
@@ -1,4 +1,5 @@
import EventEmitter from 'events'
import { proto } from '../../WAProto/index.js'
import type {
BaileysEvent,
BaileysEventEmitter,
@@ -890,6 +891,7 @@ const makeBufferData = (): BufferedEventData => {
chats: {},
messages: {},
contacts: {},
pastParticipants: {},
isLatest: false,
empty: true
},
@@ -967,6 +969,28 @@ function append<E extends BufferableEvent>(
}
}
// Merge pastParticipants with deduplication by groupJid and by userJid within each group.
// Multiple HistorySync chunks can carry the same group -- we merge rather than concatenate
// to avoid duplicate entries in the final event delivered to the consumer.
for (const group of (eventData.pastParticipants ?? []) as proto.IPastParticipants[]) {
const groupJid = group.groupJid
if (!groupJid) continue
if (!data.historySets.pastParticipants[groupJid]) {
data.historySets.pastParticipants[groupJid] = []
}
const existing = data.historySets.pastParticipants[groupJid]
const seenJids = new Set(existing.map(p => p.userJid).filter(Boolean))
for (const participant of (group.pastParticipants ?? []) as proto.IPastParticipant[]) {
if (participant.userJid && !seenJids.has(participant.userJid)) {
existing.push(participant)
seenJids.add(participant.userJid)
}
}
}
data.historySets.empty = false
data.historySets.syncType = eventData.syncType
data.historySets.progress = eventData.progress
@@ -1292,6 +1316,10 @@ function consolidateEvents(data: BufferedEventData) {
chats: Object.values(data.historySets.chats),
messages: Object.values(data.historySets.messages),
contacts: Object.values(data.historySets.contacts),
// Convert dedup map back to array. Each entry has groupJid + participants (all unique userJids).
pastParticipants: Object.entries(data.historySets.pastParticipants).map(
([groupJid, participants]) => ({ groupJid, pastParticipants: participants })
),
syncType: data.historySets.syncType,
progress: data.historySets.progress,
isLatest: data.historySets.isLatest,
-1
View File
@@ -246,7 +246,6 @@ export const fetchLatestBaileysVersion = async (options: RequestInit & { timeout
} finally {
clearTimeout(timeout)
}
if (!response.ok) {
throw new Boom(`Failed to fetch latest Baileys version: ${response.statusText}`, { statusCode: response.status })
}
+34 -2
View File
@@ -15,7 +15,8 @@ import {
import { toNumber } from './generics'
import type { ILogger } from './logger.js'
import { normalizeMessageContent } from './messages'
import { downloadContentFromMessage } from './messages-media'
import { DEFAULT_ORIGIN } from '../Defaults'
import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media'
const inflatePromise = promisify(inflate)
@@ -374,10 +375,25 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
// Convert Map back to array for return
const lidPnMappings = Array.from(lidPnMap.values())
// Normalize pastParticipants: resolve LID userJids → PN so the consumer always
// receives a phone number, never an opaque LID identifier.
// Uses the lidPnMap built above (populated from phoneNumberToLidMappings + conversations).
// If a LID cannot be resolved, the original value is kept rather than dropping the participant.
const pastParticipants: proto.IPastParticipants[] = (item.pastParticipants ?? []).map(group => ({
groupJid: group.groupJid,
pastParticipants: (group.pastParticipants ?? []).map(participant => {
const userJid = participant.userJid
if (!userJid || !isAnyLidUser(userJid)) return participant
const mapping = lidPnMap.get(jidNormalizedUser(userJid))
return mapping?.pn ? { ...participant, userJid: mapping.pn } : participant
})
}))
return {
chats,
contacts,
messages,
pastParticipants,
lidPnMappings,
syncType: item.syncType,
progress: item.progress
@@ -408,7 +424,23 @@ export const downloadAndProcessHistorySyncNotification = async (
historyMsg = await downloadHistory(msg, options)
}
return processHistoryMessage(historyMsg, logger)
const result = processHistoryMessage(historyMsg, logger)
// Mirror WA Desktop behaviour: DELETE the CDN blob only after processing succeeds.
// Doing this earlier (e.g. inside downloadHistory) risks permanent history loss if
// processing throws — the server copy would be gone and retry after reconnect would fail.
if (msg.directPath) {
const cdnUrl = getUrlFromDirectPath(msg.directPath)
fetch(cdnUrl, {
...options,
method: 'DELETE',
headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN }
}).catch(() => {
// non-fatal — server will expire it anyway
})
}
return result
}
/**
+55 -40
View File
@@ -45,8 +45,8 @@ import {
generateThumbnail,
getAudioDuration,
getAudioWaveform,
getRawMediaUploadData,
getStream,
getRawMediaUploadData,
type MediaDownloadOptions
} from './messages-media'
import { shouldIncludeReportingToken } from './reporting-utils'
@@ -635,6 +635,59 @@ export const generateCarouselMessage = async (
)
}
const recoverCarouselImageMetadata = async (
imageMessage: proto.Message.IImageMessage | null | undefined,
cardImage: WAMediaUpload,
cardTitle: string | undefined,
uploadOptions: MessageContentGenerationOptions
) => {
if (!imageMessage) {
return
}
const missingThumbnail = !imageMessage.jpegThumbnail
const missingWidth = !imageMessage.width
const missingHeight = !imageMessage.height
if (!missingThumbnail && !missingWidth && !missingHeight) {
return
}
try {
const { stream } = await getStream(cardImage, uploadOptions.options)
const { buffer, original } = await extractImageThumb(stream)
if (missingThumbnail) {
imageMessage.jpegThumbnail = buffer.toString('base64')
}
if (missingWidth && original.width) {
imageMessage.width = original.width
}
if (missingHeight && original.height) {
imageMessage.height = original.height
}
uploadOptions.logger?.info(
{
cardTitle,
recoveredThumbnail: !!imageMessage.jpegThumbnail,
width: imageMessage.width,
height: imageMessage.height
},
'[CAROUSEL] Recovered image metadata from source media'
)
} catch (error) {
uploadOptions.logger?.warn(
{
cardTitle,
trace: error instanceof Error ? error.stack : String(error)
},
'[CAROUSEL] Failed source-media thumbnail fallback'
)
}
}
// Map cards to the carousel format (processing media)
const carouselCards = await Promise.all(
cards.map(async card => {
@@ -653,45 +706,7 @@ export const generateCarouselMessage = async (
// Mirror the working Pastorini-style result: every carousel image card should
// carry a jpegThumbnail and dimensions before it reaches the Web live renderer.
if (imageMessage && (!imageMessage.jpegThumbnail || !imageMessage.height || !imageMessage.width)) {
try {
const { stream } = await getStream(card.image, mediaOptions.options)
const { buffer, original } = await extractImageThumb(stream)
// eslint-disable-next-line max-depth
if (!imageMessage.jpegThumbnail) {
imageMessage.jpegThumbnail = buffer.toString('base64')
}
// eslint-disable-next-line max-depth
if (!imageMessage.width && original.width) {
imageMessage.width = original.width
}
// eslint-disable-next-line max-depth
if (!imageMessage.height && original.height) {
imageMessage.height = original.height
}
mediaOptions.logger?.info(
{
cardTitle: card.title,
recoveredThumbnail: !!imageMessage.jpegThumbnail,
width: imageMessage.width,
height: imageMessage.height
},
'[CAROUSEL] Recovered image metadata from source media'
)
} catch (error) {
mediaOptions.logger?.warn(
{
cardTitle: card.title,
trace: error instanceof Error ? error.stack : String(error)
},
'[CAROUSEL] Failed source-media thumbnail fallback'
)
}
}
await recoverCarouselImageMetadata(imageMessage, card.image, card.title, mediaOptions)
// Validate image fields needed for WhatsApp rendering
if (imageMessage && !imageMessage.jpegThumbnail) {
+2 -3
View File
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import Long from 'long'
import { proto } from '../../WAProto/index.js'
import type { LIDMappingStore } from '../Signal/lid-mapping'
import type {
AuthenticationCreds,
BaileysEventEmitter,
@@ -20,6 +19,7 @@ import type {
WAMessage,
WAMessageKey
} from '../Types'
import type { LIDMappingStore } from '../Signal/lid-mapping'
import { WAMessageStubType } from '../Types'
import { getContentType, normalizeMessageContent } from '../Utils/messages'
import {
@@ -784,8 +784,7 @@ const processMessage = async (
})
}
const participantsIncludesMe = () =>
participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER:
+5 -4
View File
@@ -2,7 +2,7 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto'
import { zipSync } from 'fflate'
import { promises as fs } from 'fs'
import { gunzipSync, gzipSync } from 'zlib'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -728,9 +728,10 @@ export const prepareStickerPackMessage = async (
// Tray icon uses PNG format, 96x96 pixels (official client standard)
const lib = await getImageProcessingLibrary()
if (!lib?.sharp) {
throw new Boom('Sharp library is required for cover/tray icon processing. Install with: yarn add sharp', {
statusCode: 400
})
throw new Boom(
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
{ statusCode: 400 }
)
}
coverWebP = await lib.sharp
+15 -8
View File
@@ -10,7 +10,7 @@ import {
} from './Protocols'
import { USyncUser } from './USyncUser'
export type USyncQueryResultList = { [protocol: string]: unknown; id: string }
export type USyncQueryResultList = { [protocol: string]: unknown; id: string; rawId?: number }
export type USyncQueryResult = {
list: USyncQueryResultList[]
@@ -68,10 +68,8 @@ export class USyncQuery {
//TODO: see if there are any errors in the result node
//const resultNode = getBinaryNodeChild(usyncNode, 'result')
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => {
const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] =>
content.reduce((acc: USyncQueryResultList[], node) => {
const id = node?.attrs.jid
if (id) {
const data = Array.isArray(node?.content)
@@ -89,15 +87,24 @@ export class USyncQuery {
.filter(([, b]) => b !== null) as [string, unknown][]
)
: {}
acc.push({ ...data, id })
const rawIdAttr = node?.attrs?.['raw_id']
const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined
acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) })
}
return acc
}, [])
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = parseNodeList(listNode.content)
}
const sideListNode = usyncNode ? getBinaryNodeChild(usyncNode, 'side_list') : undefined
if (sideListNode?.content && Array.isArray(sideListNode.content)) {
queryResult.sideList = parseNodeList(sideListNode.content)
}
//TODO: implement side list
//const sideListNode = getBinaryNodeChild(usyncNode, 'side_list')
return queryResult
}