whalibmob 5.5.21 → 5.5.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,9 @@
1
1
  'use strict';
2
2
 
3
- const { BinaryNode } = require('./BinaryNode');
3
+ const { BinaryNode } = require('./BinaryNode');
4
+ const { NodeCache } = require('@cacheable/node-cache');
5
+
6
+ const DEVICE_CACHE_TTL = '5m';
4
7
 
5
8
  // ─── JID helpers ─────────────────────────────────────────────────────────────
6
9
 
@@ -18,6 +21,28 @@ function makeDeviceJid(user, device, server) {
18
21
  return `${user}:${device}@${server}`;
19
22
  }
20
23
 
24
+ // Convert a JID string like "user@server" or "user:device@server" into the
25
+ // BinaryNode JID object format so _writeJid() emits proper binary JID_PAIR /
26
+ // AD_JID bytes instead of raw UTF-8. The server requires binary JID encoding
27
+ // for routing attributes — raw UTF-8 strings are silently ignored for @lid JIDs.
28
+ function jidStrToObj(jidStr) {
29
+ const str = typeof jidStr === 'string' ? jidStr : String(jidStr);
30
+ const at = str.indexOf('@');
31
+ const raw = at >= 0 ? str.slice(0, at) : str;
32
+ const server = at >= 0 ? str.slice(at + 1) : 's.whatsapp.net';
33
+ const colon = raw.indexOf(':');
34
+ const user = colon >= 0 ? raw.slice(0, colon) : raw;
35
+ const device = colon >= 0 ? (parseInt(raw.slice(colon + 1), 10) || 0) : 0;
36
+ const self = str;
37
+ // AD_JID binary format requires both agent AND device fields — use only for
38
+ // multi-device @s.whatsapp.net JIDs where device > 0.
39
+ if (server === 's.whatsapp.net' && device > 0) {
40
+ return { user, agent: 0, device, server, toString() { return self; } };
41
+ }
42
+ // JID_PAIR: used for @lid, @g.us, and primary (device-0) @s.whatsapp.net
43
+ return { user, server, toString() { return self; } };
44
+ }
45
+
21
46
  function phoneFromJid(jid) {
22
47
  const { user } = stripUser(jid);
23
48
  return user;
@@ -109,40 +134,105 @@ function parseBundleFromUserNode(userNode) {
109
134
  class DeviceManager {
110
135
  constructor(client) {
111
136
  this._client = client;
112
- // phone → Set<deviceId> (populated after first usync)
113
- this._deviceCache = new Map();
137
+ // phone → deviceId[] (TTL-based: expires after 5 min, triggers fresh usync)
138
+ this._deviceCache = new NodeCache({ ttl: DEVICE_CACHE_TTL });
114
139
  // phone → Promise<jids[]> (in-flight usync dedup)
115
140
  this._usyncInflight = new Map();
116
- // own device list cache (populated once per connection)
117
- this._ownDeviceJids = null;
141
+ // own device list cache + expiry timestamp (refreshed every 5 min)
142
+ this._ownDeviceJids = null;
143
+ this._ownDeviceExpiry = 0;
144
+ }
145
+
146
+ // ─── NodeCache helper methods ─────────────────────────────────────────────
147
+ // _deviceCache stores deviceId arrays (not Sets) because NodeCache serialises
148
+ // values and Sets don't round-trip reliably. All callers use these helpers.
149
+
150
+ _dcHas(key) { return this._deviceCache.has(key); }
151
+ _dcGet(key) { return this._deviceCache.get(key) || null; } // number[] | null
152
+ _dcSet(key, arr) { this._deviceCache.set(key, arr); }
153
+ _dcAdd(key, id) {
154
+ const arr = this._deviceCache.get(key) || [];
155
+ if (!arr.includes(id)) arr.push(id);
156
+ this._deviceCache.set(key, arr);
157
+ }
158
+ _dcEnsure(key) {
159
+ if (!this._deviceCache.has(key)) this._deviceCache.set(key, []);
160
+ }
161
+ _dcDel(keys) {
162
+ for (const k of keys) this._deviceCache.del(k);
163
+ }
164
+ _dcFlush() {
165
+ this._deviceCache.flushAll();
166
+ this._ownDeviceJids = null;
167
+ this._ownDeviceExpiry = 0;
118
168
  }
119
169
 
120
170
  // ─── Fetch pre-key bundles for a list of JIDs via encrypt IQ ───────────────
121
171
  // Only called for JIDs where no Signal session exists yet.
122
- // Returns Map<jid, bundle>
172
+ // Returns Map<string_jid, bundle> — keys are always normalised strings.
123
173
  async fetchBundles(jids) {
124
174
  if (!jids || jids.length === 0) return new Map();
125
175
 
126
- const userNodes = jids.map(jid => { const lidMatch = jid.match(/(\d{15,})@s\.whatsapp\.net/); return new BinaryNode('user', { jid }, null); });
176
+ // Convert a string JID like "user@server" or "user:device@server" into
177
+ // a proper JID object so the binary encoder uses JID_PAIR / AD_JID format.
178
+ // Raw UTF-8 strings are NOT recognised by the WA server for @lid JIDs — the
179
+ // server silently ignores unknown JIDs and returns an empty bundle list.
180
+ const toJidObj = jidStr => {
181
+ const str = typeof jidStr === 'string' ? jidStr : String(jidStr);
182
+ const at = str.indexOf('@');
183
+ const raw = at >= 0 ? str.slice(0, at) : str;
184
+ const server = at >= 0 ? str.slice(at + 1) : 's.whatsapp.net';
185
+ const colon = raw.indexOf(':');
186
+ const user = colon >= 0 ? raw.slice(0, colon) : raw;
187
+ const device = colon >= 0 ? (parseInt(raw.slice(colon + 1), 10) || 0) : 0;
188
+ const self = str;
189
+ // AD_JID binary format is for @s.whatsapp.net with device > 0.
190
+ // JID_PAIR binary format is used for @lid and for device-0 @s.whatsapp.net.
191
+ if (server === 's.whatsapp.net' && device > 0) {
192
+ // AD_JID: agent=0, device=N
193
+ return { user, agent: 0, device, server, toString() { return self; } };
194
+ }
195
+ // JID_PAIR: user@server — server may be 'lid' or 's.whatsapp.net'
196
+ return { user, server, toString() { return self; } };
197
+ };
198
+
199
+ const userNodes = jids.map(jid => new BinaryNode('user', { jid: toJidObj(jid) }, null));
127
200
  const iqId = this._client._genMsgId();
128
201
  const iqNode = new BinaryNode('iq',
129
202
  { id: iqId, xmlns: 'encrypt', type: 'get', to: 's.whatsapp.net' },
130
203
  [new BinaryNode('key', {}, userNodes)]
131
204
  );
132
205
 
206
+ process.stderr.write('[DBG] FETCH_BUNDLES jids=[' + jids.join(',') + ']\n');
207
+
133
208
  const response = await this._client._sendIq(iqNode);
134
209
  const bundles = new Map();
135
- if (!response) return bundles;
210
+ if (!response) {
211
+ process.stderr.write('[DBG] FETCH_BUNDLES_NULL\n');
212
+ return bundles;
213
+ }
136
214
 
137
215
  const listNode = findChild(response, 'list');
138
216
  const children = listNode ? listNode.content : (response.content || []);
217
+
218
+ process.stderr.write('[DBG] FETCH_BUNDLES_RESP childCount=' +
219
+ (Array.isArray(children) ? children.length : 0) + '\n');
220
+
139
221
  for (const userNode of (Array.isArray(children) ? children : [])) {
140
222
  if (!userNode || userNode.description !== 'user') continue;
141
- const jid = userNode.attrs && userNode.attrs.jid;
142
- if (!jid) continue;
223
+ const jidRaw = userNode.attrs && userNode.attrs.jid;
224
+ if (!jidRaw) continue;
225
+ // Always normalise to a string key so Map lookups are consistent
226
+ const jidStr = String(jidRaw);
227
+ process.stderr.write('[DBG] FETCH_BUNDLES_USER jid=' + jidStr +
228
+ ' childTags=' + (Array.isArray(userNode.content)
229
+ ? userNode.content.map(c => c && c.description).filter(Boolean).join(',')
230
+ : '') + '\n');
143
231
  const bundle = parseBundleFromUserNode(userNode);
144
- if (bundle) bundles.set(jid, bundle);
232
+ if (bundle) bundles.set(jidStr, bundle);
233
+ else process.stderr.write('[DBG] FETCH_BUNDLES_NO_BUNDLE jid=' + jidStr + '\n');
145
234
  }
235
+ process.stderr.write('[DBG] FETCH_BUNDLES_DONE count=' + bundles.size + '\n');
146
236
  return bundles;
147
237
  }
148
238
 
@@ -160,8 +250,8 @@ class DeviceManager {
160
250
  // Only truly unknown phones trigger a network IQ.
161
251
 
162
252
  async _usyncGetDevices(phones) {
163
- // Fast path: all phones cached
164
- const uncached = phones.filter(p => !this._deviceCache.has(p));
253
+ // Fast path: all phones cached (TTL-based: NodeCache returns null for expired entries)
254
+ const uncached = phones.filter(p => !this._dcHas(p));
165
255
  if (uncached.length === 0) {
166
256
  return this._jidsFromCache(phones);
167
257
  }
@@ -188,8 +278,8 @@ class DeviceManager {
188
278
  _jidsFromCache(phones) {
189
279
  const jids = [];
190
280
  for (const p of phones) {
191
- const devices = this._deviceCache.get(p);
192
- if (devices && devices.size > 0) {
281
+ const devices = this._dcGet(p);
282
+ if (devices && devices.length > 0) {
193
283
  for (const dev of devices) jids.push(makeDeviceJid(p, dev));
194
284
  } else {
195
285
  jids.push(makeDeviceJid(p, 0));
@@ -198,54 +288,156 @@ class DeviceManager {
198
288
  return jids.length > 0 ? jids : phones.map(p => makeDeviceJid(p, 0));
199
289
  }
200
290
 
201
- // Actual usync IQ — only called for phones NOT in _deviceCache
291
+ // ─── FIXED usync IQ — corrects 4 bugs in the original implementation ────────
292
+ //
293
+ // Bug 1 — Wrong <list> node format:
294
+ // Was: <user><contact>+phone</contact></user>
295
+ // Fix: <user jid="phone@s.whatsapp.net"/> (jid ATTRIBUTE, not contact child)
296
+ //
297
+ // Bug 2 — Wrong <query><devices> node:
298
+ // Was: <devices version="2"><device jid="phone@s.whatsapp.net"/></devices>
299
+ // Fix: <devices version="2"/> (NO device children in query)
300
+ //
301
+ // Bug 3 — Missing <side_list/> node:
302
+ // Was: (absent)
303
+ // Fix: <side_list/> (required for LID device discovery)
304
+ //
305
+ // Bug 4 — Wrong response parser:
306
+ // Was: deep walk looking for <device jid="..."> attrs
307
+ // Fix: navigate usync → list → user[jid=...] → devices → device-list → device[id=N]
308
+ // Server returns <device id="0"/> NOT <device jid="..."/>
309
+ // The phone/LID identifier is on the PARENT <user jid="..."> node, not on device.
310
+ //
202
311
  async _doUsyncIq(phones) {
203
312
  const iqId = this._client._genMsgId();
204
313
  const sid = this._client._genMsgId();
205
314
 
315
+ // FIX Bug 1: jid attribute on <user>, not a <contact> child node
316
+ // FIX Bug 2: <devices version="2"/> with NO children
206
317
  const listChildren = phones.map(p =>
207
- new BinaryNode('user', {},
208
- [new BinaryNode('contact', {}, Buffer.from('+' + p))]
209
- )
210
- );
211
- const deviceChildren = phones.map(p =>
212
- new BinaryNode('device', { jid: makeDeviceJid(p, 0) }, null)
318
+ new BinaryNode('user', { jid: `${p}@s.whatsapp.net` }, null)
213
319
  );
214
320
 
215
321
  const iqNode = new BinaryNode('iq',
216
322
  { id: iqId, to: 's.whatsapp.net', type: 'get', xmlns: 'usync' },
217
323
  [new BinaryNode('usync',
218
- { sid, mode: 'query', last: 'true', index: '0', context: 'message' },
324
+ { sid, mode: 'query', last: 'true', index: '0', context: 'interactive' },
219
325
  [
220
326
  new BinaryNode('query', {},
221
- [new BinaryNode('devices', { version: '2' }, deviceChildren)]
327
+ [new BinaryNode('devices', { version: '2' }, null)] // FIX Bug 2: null, not deviceChildren
222
328
  ),
223
- new BinaryNode('list', {}, listChildren)
329
+ new BinaryNode('list', {}, listChildren),
330
+ new BinaryNode('side_list', {}, null) // FIX Bug 3: required node
224
331
  ]
225
332
  )]
226
333
  );
227
334
 
228
- const response = await this._client._sendIq(iqNode).catch(() => null);
229
-
230
- // Walk response and populate cache
231
- const found = new Set();
232
- function walk(node) {
233
- if (!node) return;
234
- if (node.description === 'device' && node.attrs && node.attrs.jid) {
235
- const jid = node.attrs.jid;
236
- const { user, device } = stripUser(jid);
237
- if (!found.has(user)) found.add(user);
238
- if (!this._deviceCache.has(user)) this._deviceCache.set(user, new Set());
239
- this._deviceCache.get(user).add(device);
335
+ process.stderr.write('[DBG] USYNC_IQ phones=[' + phones.join(',') + ']\n');
336
+
337
+ const response = await this._client._sendIq(iqNode).catch(err => {
338
+ process.stderr.write('[DBG] USYNC_ERR ' + (err && err.message) + '\n');
339
+ return null;
340
+ });
341
+
342
+ // FIX Bug 4: Correct response structure is:
343
+ // <usync>
344
+ // <list>
345
+ // <user jid="phone@s.whatsapp.net" [lid="lid_user@lid"]>
346
+ // <devices>
347
+ // <device-list>
348
+ // <device id="0"/>
349
+ // <device id="2"/>
350
+ // </device-list>
351
+ // </devices>
352
+ // </user>
353
+ // </list>
354
+ // </usync>
355
+ if (response) {
356
+ const usyncNode = findChild(response, 'usync');
357
+ const listNode = usyncNode
358
+ ? findChild(usyncNode, 'list')
359
+ : findChild(response, 'list');
360
+
361
+ if (listNode && Array.isArray(listNode.content)) {
362
+ for (const userNode of listNode.content) {
363
+ if (!userNode || userNode.description !== 'user') continue;
364
+
365
+ const userJid = userNode.attrs && (userNode.attrs.jid || userNode.attrs.value);
366
+ if (!userJid) continue;
367
+
368
+ const isLidUser = String(userJid).endsWith('@lid');
369
+ const { user: rawUser } = stripUser(String(userJid));
370
+
371
+ // Determine phone-based cache key (always a phone number, never a LID)
372
+ let cachePhone;
373
+ if (isLidUser) {
374
+ // Modern account: server returned LID JID in the user node
375
+ const pn = this._client._lidToPn && this._client._lidToPn.get(rawUser);
376
+ if (pn) {
377
+ cachePhone = pn;
378
+ } else if (phones.length === 1) {
379
+ // Single phone queried — this LID must belong to it
380
+ cachePhone = phones[0];
381
+ if (this._client._lidToPn) this._client._lidToPn.set(rawUser, phones[0]);
382
+ if (this._client._pnToLid) this._client._pnToLid.set(phones[0], rawUser);
383
+ } else {
384
+ cachePhone = rawUser; // last resort: use LID user as key
385
+ }
386
+ process.stderr.write('[DBG] USYNC_LID_USER userJid=' + userJid + ' → cachePhone=' + cachePhone + '\n');
387
+ } else {
388
+ // Normal account: user JID is phone@s.whatsapp.net
389
+ cachePhone = rawUser;
390
+
391
+ // Also extract LID from user node's `lid` attribute if the server provided it
392
+ const lidAttr = userNode.attrs && userNode.attrs.lid;
393
+ if (lidAttr) {
394
+ const lidUser = String(lidAttr).split('@')[0].split(':')[0];
395
+ if (this._client._lidToPn) this._client._lidToPn.set(lidUser, cachePhone);
396
+ if (this._client._pnToLid) this._client._pnToLid.set(cachePhone, lidUser);
397
+ process.stderr.write('[DBG] USYNC_LID_MAP phone=' + cachePhone + ' ↔ lid=' + lidUser + '\n');
398
+ }
399
+ }
400
+
401
+ // Navigate user → devices → device-list → device[id=N]
402
+ const devicesNode = findChild(userNode, 'devices');
403
+ const deviceListNode = devicesNode ? findChild(devicesNode, 'device-list') : null;
404
+
405
+ this._dcEnsure(cachePhone);
406
+
407
+ if (deviceListNode && Array.isArray(deviceListNode.content)) {
408
+ for (const devNode of deviceListNode.content) {
409
+ if (!devNode || devNode.description !== 'device') continue;
410
+ // FIX: server uses `id` attribute (e.g. id="0"), NOT `jid` attribute
411
+ const devId = devNode.attrs && devNode.attrs.id != null
412
+ ? parseInt(String(devNode.attrs.id), 10)
413
+ : 0;
414
+ this._dcAdd(cachePhone, devId);
415
+ }
416
+ process.stderr.write('[DBG] USYNC_DEVICES phone=' + cachePhone +
417
+ ' ids=[' + (this._dcGet(cachePhone) || []).join(',') + ']\n');
418
+ } else {
419
+ // No device-list in response — fall back to device 0
420
+ this._dcAdd(cachePhone, 0);
421
+ process.stderr.write('[DBG] USYNC_NO_DEVLIST phone=' + cachePhone + ' → fallback id=0\n');
422
+ }
423
+ }
424
+ } else {
425
+ process.stderr.write('[DBG] USYNC_RESP_NO_LIST\n');
240
426
  }
241
- if (Array.isArray(node.content)) node.content.forEach(walk.bind(this));
427
+ } else {
428
+ process.stderr.write('[DBG] USYNC_NULL_RESP\n');
429
+ // Device usync timed out — fall back to a contact usync (query/contact).
430
+ // This DOES receive a server response and resolves the phone → LID mapping.
431
+ // Once _pnToLid is populated here, _sendDMMessage (after its await) will
432
+ // use the LID JID for routing instead of the stale phone JID.
433
+ await this._doContactUsync(phones).catch(() => {});
242
434
  }
243
- if (response) walk.call(this, response);
244
435
 
245
- // For any phone that returned no devices, cache device=0 so we don't query again
436
+ // For phones with no usync response at all, cache device 0 so we don't re-query
246
437
  for (const p of phones) {
247
- if (!this._deviceCache.has(p)) {
248
- this._deviceCache.set(p, new Set([0]));
438
+ if (!this._dcHas(p)) {
439
+ this._dcSet(p, [0]);
440
+ process.stderr.write('[DBG] USYNC_FALLBACK phone=' + p + '\n');
249
441
  }
250
442
  }
251
443
  }
@@ -297,6 +489,219 @@ class DeviceManager {
297
489
  return readyJids;
298
490
  }
299
491
 
492
+ // ─── Ensure sessions for a LID-addressed recipient ────────────────────────
493
+ //
494
+ // For accounts migrated to WhatsApp LID, devices are registered under the
495
+ // LID identity (e.g. 139471160877194@lid), NOT the phone number.
496
+ // This method:
497
+ // 1. Queries usync for device list by LID JID
498
+ // 2. Establishes Signal sessions for LID device JIDs (e.g. 139471160877194:0@lid)
499
+ // 3. Returns the list of ready LID device JIDs for participant fanout
500
+ //
501
+ async bulkEnsureSessionsForLid(lidUser, signalProto, allowPkmsg = true, skipUsync = false) {
502
+ const lidJid = `${lidUser}@lid`;
503
+ const cacheKey = `lid:${lidUser}`;
504
+
505
+ // skipUsync=true: skip usync ONLY if we already have a cache hit (i.e. we
506
+ // fetched this LID's devices in the last 5 min). On a cache miss we MUST
507
+ // run a real usync even with skipUsync=true, otherwise linked devices (tablets,
508
+ // desktop, web) are silently ignored and only device 0 (primary phone) is used.
509
+ if (skipUsync) {
510
+ if (!this._dcHas(cacheKey)) {
511
+ process.stderr.write('[DBG] LID_SKIP_USYNC_CACHE_MISS lidUser=' + lidUser + ' → real usync\n');
512
+ await this._doUsyncIqByJid(lidJid, cacheKey, lidUser);
513
+ } else {
514
+ process.stderr.write('[DBG] LID_SKIP_USYNC_HIT lidUser=' + lidUser + ' → cache\n');
515
+ }
516
+ } else if (!this._dcHas(cacheKey)) {
517
+ await this._doUsyncIqByJid(lidJid, cacheKey, lidUser);
518
+ }
519
+
520
+ const deviceIds = this._dcGet(cacheKey) || [0];
521
+ const deviceJids = deviceIds.map(d => makeDeviceJid(lidUser, d, 'lid'));
522
+
523
+ process.stderr.write('[DBG] LID_DEVICES lidUser=' + lidUser +
524
+ ' deviceJids=[' + deviceJids.join(',') + ']\n');
525
+
526
+ // Split: existing sessions (ready) vs new (need bundle fetch)
527
+ const readyJids = [];
528
+ const fetchJids = [];
529
+ for (const jid of deviceJids) {
530
+ if (signalProto.store.hasSession(this._jidToAddr(jid))) {
531
+ readyJids.push(jid);
532
+ } else {
533
+ fetchJids.push(jid);
534
+ }
535
+ }
536
+
537
+ if (allowPkmsg && fetchJids.length > 0) {
538
+ const bundles = await this.fetchBundles(fetchJids);
539
+ for (const [jid, bundle] of bundles) {
540
+ try {
541
+ await signalProto.buildSessionFromBundle(jid, bundle);
542
+ readyJids.push(jid);
543
+ process.stderr.write('[DBG] LID_SESSION_BUILT jid=' + jid + '\n');
544
+ } catch (e) {
545
+ process.stderr.write('[DBG] LID_SESSION_ERR jid=' + jid + ' err=' + e.message + '\n');
546
+ }
547
+ }
548
+ }
549
+
550
+ // Fallback: use primary LID device 0 directly (no session yet, will use pkmsg)
551
+ if (readyJids.length === 0) {
552
+ readyJids.push(makeDeviceJid(lidUser, 0, 'lid'));
553
+ }
554
+
555
+ return readyJids;
556
+ }
557
+
558
+ // ─── Contact usync fallback ────────────────────────────────────────────────
559
+ // When query/devices usync times out (server silently ignores it for LID
560
+ // accounts), we fall back to query/contact which uses the older
561
+ // <user><contact>+phone</contact></user> format and DOES get a response.
562
+ // The response includes the actual JID (LID or PN) for each phone.
563
+ // Calling this populates _pnToLid / _lidToPn in the Client, which is used
564
+ // by _sendDMMessage (checked AFTER the await of _usyncGetDevices).
565
+ async _doContactUsync(phones) {
566
+ const iqId = this._client._genMsgId();
567
+ const sid = this._client._genMsgId();
568
+
569
+ const userNodes = phones.map(p =>
570
+ new BinaryNode('user', {}, [
571
+ new BinaryNode('contact', {}, Buffer.from('+' + p, 'utf8'))
572
+ ])
573
+ );
574
+
575
+ const iqNode = new BinaryNode('iq',
576
+ { id: iqId, to: 's.whatsapp.net', type: 'get', xmlns: 'usync' },
577
+ [new BinaryNode('usync',
578
+ { sid, mode: 'query', last: 'true', index: '0', context: 'interactive' },
579
+ [
580
+ new BinaryNode('query', {}, [new BinaryNode('contact', {}, null)]),
581
+ new BinaryNode('list', {}, userNodes),
582
+ new BinaryNode('side_list', {}, null)
583
+ ]
584
+ )]
585
+ );
586
+
587
+ process.stderr.write('[DBG] CONTACT_USYNC phones=[' + phones.join(',') + ']\n');
588
+
589
+ const response = await this._client._sendIq(iqNode).catch(err => {
590
+ process.stderr.write('[DBG] CONTACT_USYNC_ERR ' + (err && err.message) + '\n');
591
+ return null;
592
+ });
593
+
594
+ if (!response) {
595
+ process.stderr.write('[DBG] CONTACT_USYNC_NULL\n');
596
+ return;
597
+ }
598
+
599
+ const usyncNode = findChild(response, 'usync');
600
+ const listNode = usyncNode ? findChild(usyncNode, 'list') : findChild(response, 'list');
601
+
602
+ if (!listNode || !Array.isArray(listNode.content)) {
603
+ process.stderr.write('[DBG] CONTACT_USYNC_NO_LIST\n');
604
+ return;
605
+ }
606
+
607
+ for (let i = 0; i < listNode.content.length; i++) {
608
+ const userNode = listNode.content[i];
609
+ if (!userNode || userNode.description !== 'user') continue;
610
+
611
+ const actualJid = userNode.attrs && (userNode.attrs.jid || userNode.attrs.value);
612
+ if (!actualJid) continue;
613
+
614
+ const actualJidStr = String(actualJid);
615
+ const isLid = actualJidStr.endsWith('@lid');
616
+
617
+ if (isLid) {
618
+ const { user: lidUser } = stripUser(actualJidStr);
619
+ // Determine the corresponding phone. For single-phone queries it's
620
+ // unambiguous; for multi-phone queries match by index.
621
+ const phone = phones.length === 1 ? phones[0] : phones[i];
622
+ if (phone) {
623
+ process.stderr.write('[DBG] CONTACT_USYNC_LID phone=' + phone + ' → lid=' + lidUser + '\n');
624
+ if (this._client._pnToLid) this._client._pnToLid.set(phone, lidUser);
625
+ if (this._client._lidToPn) this._client._lidToPn.set(lidUser, phone);
626
+ // Persist the mapping so future sessions don't need to re-query
627
+ const store = this._client._signal && this._client._signal.store;
628
+ if (store && store.setLidMapping) store.setLidMapping(phone, lidUser);
629
+ }
630
+ } else {
631
+ // PN account — note it (still on phone-based routing, no LID needed)
632
+ const { user: pnUser } = stripUser(actualJidStr);
633
+ const phone = phones.length === 1 ? phones[0] : phones[i];
634
+ process.stderr.write('[DBG] CONTACT_USYNC_PN phone=' + phone + ' jid=' + actualJidStr + '\n');
635
+ }
636
+ }
637
+ }
638
+
639
+ // ─── usync IQ for a specific JID (LID or phone-based) ─────────────────────
640
+ // Used when we already know the routing JID and want to look up devices for it.
641
+ async _doUsyncIqByJid(jid, cacheKey, lidUser) {
642
+ const iqId = this._client._genMsgId();
643
+ const sid = this._client._genMsgId();
644
+
645
+ const iqNode = new BinaryNode('iq',
646
+ { id: iqId, to: 's.whatsapp.net', type: 'get', xmlns: 'usync' },
647
+ [new BinaryNode('usync',
648
+ { sid, mode: 'query', last: 'true', index: '0', context: 'interactive' },
649
+ [
650
+ new BinaryNode('query', {},
651
+ [new BinaryNode('devices', { version: '2' }, null)]
652
+ ),
653
+ new BinaryNode('list', {},
654
+ [new BinaryNode('user', { jid }, null)]
655
+ ),
656
+ new BinaryNode('side_list', {}, null)
657
+ ]
658
+ )]
659
+ );
660
+
661
+ process.stderr.write('[DBG] USYNC_LID_IQ jid=' + jid + '\n');
662
+
663
+ const response = await this._client._sendIq(iqNode).catch(err => {
664
+ process.stderr.write('[DBG] USYNC_LID_IQ_ERR ' + (err && err.message) + '\n');
665
+ return null;
666
+ });
667
+
668
+ this._dcEnsure(cacheKey);
669
+
670
+ if (response) {
671
+ const usyncNode = findChild(response, 'usync');
672
+ const listNode = usyncNode ? findChild(usyncNode, 'list') : findChild(response, 'list');
673
+
674
+ if (listNode && Array.isArray(listNode.content)) {
675
+ for (const userNode of listNode.content) {
676
+ if (!userNode || userNode.description !== 'user') continue;
677
+
678
+ const devicesNode = findChild(userNode, 'devices');
679
+ const deviceListNode = devicesNode ? findChild(devicesNode, 'device-list') : null;
680
+
681
+ if (deviceListNode && Array.isArray(deviceListNode.content)) {
682
+ for (const devNode of deviceListNode.content) {
683
+ if (!devNode || devNode.description !== 'device') continue;
684
+ const devId = devNode.attrs && devNode.attrs.id != null
685
+ ? parseInt(String(devNode.attrs.id), 10) : 0;
686
+ this._dcAdd(cacheKey, devId);
687
+ }
688
+ process.stderr.write('[DBG] USYNC_LID_DEVICES jid=' + jid +
689
+ ' ids=[' + (this._dcGet(cacheKey) || []).join(',') + ']\n');
690
+ } else {
691
+ this._dcAdd(cacheKey, 0);
692
+ process.stderr.write('[DBG] USYNC_LID_NO_DEVLIST jid=' + jid + ' → fallback id=0\n');
693
+ }
694
+ }
695
+ } else {
696
+ process.stderr.write('[DBG] USYNC_LID_NO_LIST jid=' + jid + '\n');
697
+ this._dcAdd(cacheKey, 0);
698
+ }
699
+ } else {
700
+ process.stderr.write('[DBG] USYNC_LID_NULL_RESP jid=' + jid + '\n');
701
+ this._dcAdd(cacheKey, 0);
702
+ }
703
+ }
704
+
300
705
  // ─── Ensure sessions for own linked devices (device != 0) ─────────────────
301
706
  //
302
707
  // OPTIMISATION: own device list is cached for the entire connection lifetime.
@@ -304,15 +709,16 @@ class DeviceManager {
304
709
  // from cache — zero extra network round-trips.
305
710
  //
306
711
  async ensureOwnDeviceSessions(ownPhone, signalProto, allowPkmsg = true) {
307
- // If own device list is already cached, use it directly
308
- if (this._ownDeviceJids !== null) {
712
+ // Use cached own device list if it hasn't expired (5-min TTL matches _deviceCache)
713
+ if (this._ownDeviceJids !== null && Date.now() < this._ownDeviceExpiry) {
309
714
  const others = this._ownDeviceJids.filter(j => stripUser(j).device !== 0);
310
715
  return this._ensureSessions(others, signalProto, allowPkmsg);
311
716
  }
312
717
 
313
- // First call: query usync for own devices (same as before, but we cache the result)
314
- const deviceJids = await this._usyncGetDevices([ownPhone]);
315
- this._ownDeviceJids = deviceJids;
718
+ // Cache miss or expired: re-query usync for own devices
719
+ const deviceJids = await this._usyncGetDevices([ownPhone]);
720
+ this._ownDeviceJids = deviceJids;
721
+ this._ownDeviceExpiry = Date.now() + 5 * 60 * 1000;
316
722
 
317
723
  const others = deviceJids.filter(j => stripUser(j).device !== 0);
318
724
  return this._ensureSessions(others, signalProto, allowPkmsg);
@@ -353,10 +759,9 @@ class DeviceManager {
353
759
  // ─── Invalidate cached device list (used on phash mismatch / 421 retry) ───
354
760
  clearCache(phones) {
355
761
  if (!phones || phones.length === 0) {
356
- this._deviceCache.clear();
357
- this._ownDeviceJids = null;
762
+ this._dcFlush();
358
763
  } else {
359
- for (const p of phones) this._deviceCache.delete(p);
764
+ this._dcDel(phones);
360
765
  }
361
766
  }
362
767
 
@@ -365,7 +770,11 @@ class DeviceManager {
365
770
  const toNodes = encryptedList.map(({ jid, type, ciphertext }) => {
366
771
  const encAttrs = { type, v: '2' };
367
772
  if (mediaSubtype) encAttrs.mediatype = mediaSubtype;
368
- return new BinaryNode('to', { jid },
773
+ // Always encode the jid as a binary JID object (JID_PAIR / AD_JID) so the
774
+ // server can route the per-device enc payload to the correct Signal session.
775
+ // Raw UTF-8 strings are silently dropped for @lid recipients.
776
+ const jidAttr = jidStrToObj(jid);
777
+ return new BinaryNode('to', { jid: jidAttr },
369
778
  [new BinaryNode('enc', encAttrs, ciphertext)]
370
779
  );
371
780
  });
@@ -373,4 +782,4 @@ class DeviceManager {
373
782
  }
374
783
  }
375
784
 
376
- module.exports = { DeviceManager, makeDeviceJid, stripUser, phoneFromJid, parseBundleFromUserNode };
785
+ module.exports = { DeviceManager, makeDeviceJid, stripUser, phoneFromJid, parseBundleFromUserNode, jidStrToObj };
@@ -370,6 +370,7 @@ function toBase64Url(buf) {
370
370
 
371
371
  function buildPayload(store, waVersion, useToken, extraPairs) {
372
372
  const { cc, national } = parsePhone(store.phoneNumber);
373
+ const meta = getCountryMeta(cc);
373
374
  const token = useToken ? computeToken(waVersion, national) : null;
374
375
  const fdid = store.fdid.toUpperCase();
375
376
 
@@ -377,8 +378,8 @@ function buildPayload(store, waVersion, useToken, extraPairs) {
377
378
  'cc', cc,
378
379
  'in', national,
379
380
  'rc', String(RELEASE_CHANNEL),
380
- 'lg', 'en',
381
- 'lc', 'US',
381
+ 'lg', meta.lg,
382
+ 'lc', meta.lc,
382
383
  'authkey', toBase64Url(stripKeyPrefix(store.noiseKeyPair.public)),
383
384
  'e_regid', toBase64Url(intToBytes(store.registrationId, 4)),
384
385
  'e_keytype', toBase64Url(Buffer.from([SIGNAL_KEY_TYPE])),
@@ -601,11 +602,15 @@ async function requestSmsCode(store, method) {
601
602
  const fallbackMethod = method === 'wa_old' ? 'sms' : (method === 'sms' ? 'wa_old' : 'sms');
602
603
  let autoFallbackDone = false;
603
604
 
605
+ // Derive real MCC/MNC from the phone number being registered.
606
+ const { cc: _regCc } = parsePhone(store.phoneNumber);
607
+ const _regMeta = getCountryMeta(_regCc);
608
+
604
609
  async function _tryMethod(m) {
605
610
  const extra = [
606
611
  'method', m,
607
- 'sim_mcc', '000',
608
- 'sim_mnc', '000',
612
+ 'sim_mcc', _regMeta.mcc,
613
+ 'sim_mnc', _regMeta.mnc,
609
614
  'reason', '',
610
615
  'cellular_strength', '1'
611
616
  ];
@@ -706,4 +711,4 @@ async function verifyCode(store, code) {
706
711
  throw new Error(`Verification failed: ${reason || JSON.stringify(result)}`);
707
712
  }
708
713
 
709
- module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, assertRegistrationKeys };
714
+ module.exports = { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode, fetchIosVersion, fetchAndroidVersion, fetchWaVersion, parsePhone, getCountryMeta, assertRegistrationKeys };