whalibmob 5.1.17 → 5.1.19

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.
package/lib/Client.js CHANGED
@@ -103,6 +103,27 @@ function toSignalKey(raw) {
103
103
  return Buffer.from(raw);
104
104
  }
105
105
 
106
+ // ─── JID helpers ─────────────────────────────────────────────────────────────
107
+
108
+ // Convert a BinaryNode JID value to its canonical string form.
109
+ // BinaryNode's _readAdJid() returns an object { user, agent, device, server }
110
+ // with a toString() that yields "user@s.whatsapp.net". For LID-mode group
111
+ // participants those objects represent LID identifiers and must be stored as
112
+ // "user@lid" so the rest of the code can detect them with endsWith('@lid').
113
+ // Regular pair-JIDs ({ user, server, toString }) are returned verbatim.
114
+ function _adJidToLid(jidVal) {
115
+ if (!jidVal) return '';
116
+ // AD JID objects (from _readAdJid) have an `agent` property — these are LIDs
117
+ if (typeof jidVal === 'object' && 'agent' in jidVal) {
118
+ return (jidVal.user || '') + '@lid';
119
+ }
120
+ // Pair JID objects (from _readJidPair) have user + server but no agent
121
+ if (typeof jidVal === 'object' && 'server' in jidVal) {
122
+ return jidVal.user ? `${jidVal.user}@${jidVal.server}` : `@${jidVal.server}`;
123
+ }
124
+ return String(jidVal);
125
+ }
126
+
106
127
  // ─── WhalibmobClient ──────────────────────────────────────────────────────────
107
128
 
108
129
  class WhalibmobClient extends EventEmitter {
@@ -128,6 +149,7 @@ class WhalibmobClient extends EventEmitter {
128
149
  this._lidToPn = new Map(); // LID user → phone number
129
150
  this._pnToLid = new Map(); // phone number → LID user
130
151
  this._myLid = null; // own LID JID received from <success> node
152
+ this._myLidAgent = 0; // agent field from own LID JID (usually 1)
131
153
  this._groupAddressingMode = new Map(); // groupJid → 'lid' | 'pn'
132
154
  this._retryPending = new Map(); // msgId → {node, retryCount}
133
155
  this._retryPreKeyIdx = 0; // rotating index for assigning unique prekeys to retries
@@ -284,7 +306,10 @@ class WhalibmobClient extends EventEmitter {
284
306
  }
285
307
  }
286
308
  if (attrs.platform) this._platform = attrs.platform;
287
- if (attrs.lid) this._myLid = String(attrs.lid);
309
+ if (attrs.lid) {
310
+ this._myLid = _adJidToLid(attrs.lid);
311
+ this._myLidAgent = (typeof attrs.lid === 'object' && attrs.lid.agent) ? attrs.lid.agent : 0;
312
+ }
288
313
 
289
314
  const devIdNode = findChild(node, 'device-identity');
290
315
  if (devIdNode) {
@@ -797,6 +822,9 @@ class WhalibmobClient extends EventEmitter {
797
822
  const cls = attrs.class || '';
798
823
 
799
824
  if (cls === 'message') {
825
+ if (attrs.error) {
826
+ process.stderr.write('[DBG] ACK_ERR full node=' + JSON.stringify(node, (k,v) => Buffer.isBuffer(v) ? '<buf>' : v) + '\n');
827
+ }
800
828
  const handler = this._pendingAcks.get(id);
801
829
  if (handler) {
802
830
  this._pendingAcks.delete(id);
@@ -1184,12 +1212,17 @@ class WhalibmobClient extends EventEmitter {
1184
1212
  const participants = children
1185
1213
  .filter(n => n && n.description === 'participant' && n.attrs && n.attrs.jid)
1186
1214
  .map(n => {
1187
- const pJid = String(n.attrs.jid);
1215
+ // AD JID objects (LID participants) have an `agent` property → convert to @lid form
1216
+ const pJid = _adJidToLid(n.attrs.jid);
1188
1217
  const pRole = n.attrs.type || 'member';
1189
- const pPhone = n.attrs.phone_number ? String(n.attrs.phone_number) : null;
1190
- const pLid = n.attrs.lid ? String(n.attrs.lid) : null;
1218
+ // phone_number may also be a JID object → extract user part (PN without server)
1219
+ const rawPhone = n.attrs.phone_number;
1220
+ const pPhone = rawPhone
1221
+ ? (typeof rawPhone === 'object' ? rawPhone.user : String(rawPhone).split('@')[0])
1222
+ : null;
1223
+ const pLid = n.attrs.lid ? _adJidToLid(n.attrs.lid) : null;
1191
1224
  // Populate LID↔PN maps from participant attributes
1192
- // Case A: participant JID is a LID, phone_number is their PN
1225
+ // Case A: participant JID is a LID (ends with @lid), phone_number is their PN
1193
1226
  if (pJid.endsWith('@lid')) {
1194
1227
  const lidUser = pJid.split('@')[0].split(':')[0];
1195
1228
  if (pPhone) {
@@ -1297,12 +1330,18 @@ class WhalibmobClient extends EventEmitter {
1297
1330
  const response = await this._sendIq(node);
1298
1331
  if (!response) return [];
1299
1332
 
1300
- // Response content is a list of <group> nodes
1301
- const children = Array.isArray(response.content) ? response.content : [];
1302
- const groups = children
1303
- .filter(n => n && n.description === 'group')
1304
- .map(n => this._parseGroupNode(n))
1305
- .filter(Boolean);
1333
+ // Response structure: <iq><groups><group/><group/>...</groups></iq>
1334
+ // Unwrap the <groups> container first, then collect <group> nodes
1335
+ const iqChildren = Array.isArray(response.content) ? response.content : [];
1336
+ let groupNodes = [];
1337
+ const groupsWrapper = iqChildren.find(n => n && n.description === 'groups');
1338
+ if (groupsWrapper && Array.isArray(groupsWrapper.content)) {
1339
+ groupNodes = groupsWrapper.content.filter(n => n && n.description === 'group');
1340
+ } else {
1341
+ // Fallback: direct <group> children (older protocol variant)
1342
+ groupNodes = iqChildren.filter(n => n && n.description === 'group');
1343
+ }
1344
+ const groups = groupNodes.map(n => this._parseGroupNode(n)).filter(Boolean);
1306
1345
  return groups;
1307
1346
  }
1308
1347
 
@@ -5,17 +5,24 @@ const { BinaryNode } = require('./BinaryNode');
5
5
  // ─── JID helpers ─────────────────────────────────────────────────────────────
6
6
 
7
7
  function stripUser(jid) {
8
- const at = jid.indexOf('@');
9
- const user = at >= 0 ? jid.slice(0, at) : jid;
10
- const colon = user.indexOf(':');
11
- if (colon >= 0) return { user: user.slice(0, colon), device: parseInt(user.slice(colon + 1), 10) };
12
- return { user, device: 0 };
8
+ const at = jid.indexOf('@');
9
+ const full = at >= 0 ? jid.slice(0, at) : jid;
10
+ const colon = full.indexOf(':');
11
+ const base = colon >= 0 ? full.slice(0, colon) : full;
12
+ const device = colon >= 0 ? parseInt(full.slice(colon + 1), 10) : 0;
13
+ const under = base.lastIndexOf('_');
14
+ if (under >= 0 && /^\d+$/.test(base.slice(under + 1))) {
15
+ return { user: base.slice(0, under), agent: parseInt(base.slice(under + 1), 10), device };
16
+ }
17
+ return { user: base, agent: 0, device };
13
18
  }
14
19
 
15
- function makeDeviceJid(user, device, server) {
20
+ function makeDeviceJid(user, device, server, agent) {
16
21
  server = server || 's.whatsapp.net';
17
- if (!device || device === 0) return `${user}@${server}`;
18
- return `${user}:${device}@${server}`;
22
+ agent = agent || 0;
23
+ const agentStr = agent !== 0 ? `_${agent}` : '';
24
+ if (!device || device === 0) return `${user}${agentStr}@${server}`;
25
+ return `${user}${agentStr}:${device}@${server}`;
19
26
  }
20
27
 
21
28
  function phoneFromJid(jid) {
@@ -123,7 +130,22 @@ class DeviceManager {
123
130
  async fetchBundles(jids) {
124
131
  if (!jids || jids.length === 0) return new Map();
125
132
 
126
- const userNodes = jids.map(jid => { const lidMatch = jid.match(/(\d{15,})@s\.whatsapp\.net/); return new BinaryNode('user', { jid }, null); });
133
+ // Build proper JID objects for binary encoding. Passing a plain string produces a raw
134
+ // bytes attribute, but the server requires a binary-encoded JID (JID_PAIR or AD_JID).
135
+ // E.g. "112713111982325_1:86@s.whatsapp.net" → { user:"112713111982325", agent:1, device:86, server:"s.whatsapp.net" }
136
+ const userNodes = jids.map(jid => {
137
+ const atIdx = jid.indexOf('@');
138
+ const server = atIdx >= 0 ? jid.slice(atIdx + 1) : 's.whatsapp.net';
139
+ const userPart = atIdx >= 0 ? jid.slice(0, atIdx) : jid;
140
+ const colonIdx = userPart.indexOf(':');
141
+ const base = colonIdx >= 0 ? userPart.slice(0, colonIdx) : userPart;
142
+ const device = colonIdx >= 0 ? parseInt(userPart.slice(colonIdx + 1), 10) : 0;
143
+ const underIdx = base.lastIndexOf('_');
144
+ const hasAgent = underIdx >= 0 && /^\d+$/.test(base.slice(underIdx + 1));
145
+ const user = hasAgent ? base.slice(0, underIdx) : base;
146
+ const agent = hasAgent ? parseInt(base.slice(underIdx + 1), 10) : 0;
147
+ return new BinaryNode('user', { jid: { user, agent: agent || 0, device: device || 0, server } }, null);
148
+ });
127
149
  const iqId = this._client._genMsgId();
128
150
  const iqNode = new BinaryNode('iq',
129
151
  { id: iqId, xmlns: 'encrypt', type: 'get', to: 's.whatsapp.net' },
@@ -138,10 +160,28 @@ class DeviceManager {
138
160
  const children = listNode ? listNode.content : (response.content || []);
139
161
  for (const userNode of (Array.isArray(children) ? children : [])) {
140
162
  if (!userNode || userNode.description !== 'user') continue;
141
- const jid = userNode.attrs && userNode.attrs.jid;
142
- if (!jid) continue;
163
+ const rawJid = userNode.attrs && userNode.attrs.jid;
164
+ if (!rawJid) continue;
165
+ // Convert the binary JID object to a canonical string "user_agent:device@server"
166
+ let jidKey;
167
+ if (typeof rawJid === 'string') {
168
+ jidKey = rawJid;
169
+ } else {
170
+ const u = rawJid.user || '';
171
+ const ag = rawJid.agent || 0;
172
+ const dv = rawJid.device || 0;
173
+ const sv = rawJid.server || 's.whatsapp.net';
174
+ const agStr = ag !== 0 ? `_${ag}` : '';
175
+ const dvStr = dv !== 0 ? `:${dv}` : '';
176
+ jidKey = `${u}${agStr}${dvStr}@${sv}`;
177
+ }
143
178
  const bundle = parseBundleFromUserNode(userNode);
144
- if (bundle) bundles.set(jid, bundle);
179
+ if (bundle) {
180
+ process.stderr.write('[DBG] BUNDLE jid=' + jidKey + ' regId=' + bundle.registrationId + ' preKeyId=' + (bundle.preKey && bundle.preKey.keyId) + ' spkId=' + (bundle.signedPreKey && bundle.signedPreKey.keyId) + '\n');
181
+ bundles.set(jidKey, bundle);
182
+ } else {
183
+ process.stderr.write('[DBG] BUNDLE_FAIL jid=' + jidKey + ' no bundle parsed\n');
184
+ }
145
185
  }
146
186
  return bundles;
147
187
  }
@@ -199,17 +239,17 @@ class DeviceManager {
199
239
  }
200
240
 
201
241
  // Actual usync IQ — only called for phones NOT in _deviceCache
242
+ // Uses the mobile API format (cobalt-compatible):
243
+ // <list> uses <user jid="phone@s.whatsapp.net"/> (not <user><contact>+phone</contact></user>)
244
+ // <query> uses empty <devices version="2"/> (no individual <device> children)
245
+ // Includes <side_list/> sibling required by the mobile API
202
246
  async _doUsyncIq(phones) {
203
247
  const iqId = this._client._genMsgId();
204
248
  const sid = this._client._genMsgId();
205
249
 
250
+ // Mobile-API format: <user jid="{user}@s.whatsapp.net"/> as JID pair object
206
251
  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)
252
+ new BinaryNode('user', { jid: { user: String(p), server: 's.whatsapp.net' } }, null)
213
253
  );
214
254
 
215
255
  const iqNode = new BinaryNode('iq',
@@ -218,34 +258,77 @@ class DeviceManager {
218
258
  { sid, mode: 'query', last: 'true', index: '0', context: 'message' },
219
259
  [
220
260
  new BinaryNode('query', {},
221
- [new BinaryNode('devices', { version: '2' }, deviceChildren)]
261
+ [new BinaryNode('devices', { version: '2' }, null)]
222
262
  ),
223
- new BinaryNode('list', {}, listChildren)
263
+ new BinaryNode('list', {}, listChildren),
264
+ new BinaryNode('side_list', {}, null)
224
265
  ]
225
266
  )]
226
267
  );
227
268
 
228
269
  const response = await this._client._sendIq(iqNode).catch(() => null);
270
+ process.stderr.write('[DBG] _doUsyncIq phones=' + phones.join(',') + ' response=' + (response ? response.description : 'null') + '\n');
271
+ if (!response) {
272
+ // Timeout: cache device=0 as fallback
273
+ for (const p of phones) {
274
+ if (!this._deviceCache.has(p)) this._deviceCache.set(p, new Set([0]));
275
+ }
276
+ return;
277
+ }
278
+
279
+ // Cobalt response structure:
280
+ // <iq><usync><list><user jid="..."><devices><device-list><device id="N"/></device-list></devices></user></list></usync></iq>
281
+ this._parseUsyncResponse(response, phones);
282
+ }
229
283
 
230
- // Walk response and populate cache
231
- const found = new Set();
284
+ // Parse a usync IQ response (works for both PN and LID usync)
285
+ // Populates _deviceCache: phone/user Set<deviceId>
286
+ _parseUsyncResponse(response, expectedUsers) {
287
+ const self = this;
288
+ function walkUser(userNode) {
289
+ const rawJid = userNode.attrs && userNode.attrs.jid;
290
+ if (!rawJid) return;
291
+ process.stderr.write('[DBG] _parseUsyncResponse walkUser rawJid=' + JSON.stringify(rawJid) + '\n');
292
+ const user = typeof rawJid === 'object' ? rawJid.user : String(rawJid).split('@')[0];
293
+ if (!user) return;
294
+
295
+ // Look for <devices><device-list><device id="N"/>
296
+ const devicesNode = Array.isArray(userNode.content)
297
+ ? userNode.content.find(n => n && n.description === 'devices') : null;
298
+ if (!devicesNode || !Array.isArray(devicesNode.content)) {
299
+ // No devices node: still cache device=0 so we don't query again
300
+ if (!self._deviceCache.has(user)) self._deviceCache.set(user, new Set([0]));
301
+ return;
302
+ }
303
+ const deviceListNode = devicesNode.content.find(n => n && n.description === 'device-list');
304
+ const deviceNodes = (deviceListNode && Array.isArray(deviceListNode.content))
305
+ ? deviceListNode.content.filter(n => n && n.description === 'device')
306
+ : [];
307
+
308
+ if (!self._deviceCache.has(user)) self._deviceCache.set(user, new Set());
309
+ const devSet = self._deviceCache.get(user);
310
+ if (deviceNodes.length === 0) {
311
+ devSet.add(0);
312
+ } else {
313
+ for (const dn of deviceNodes) {
314
+ const devId = parseInt((dn.attrs && dn.attrs.id) || '0', 10);
315
+ devSet.add(isNaN(devId) ? 0 : devId);
316
+ }
317
+ }
318
+ }
319
+
320
+ // Walk: iq → usync → list → user
232
321
  function walk(node) {
233
322
  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);
240
- }
241
- if (Array.isArray(node.content)) node.content.forEach(walk.bind(this));
323
+ if (node.description === 'user') { walkUser(node); return; }
324
+ if (Array.isArray(node.content)) node.content.forEach(walk);
242
325
  }
243
- if (response) walk.call(this, response);
326
+ walk(response);
244
327
 
245
- // For any phone that returned no devices, cache device=0 so we don't query again
246
- for (const p of phones) {
247
- if (!this._deviceCache.has(p)) {
248
- this._deviceCache.set(p, new Set([0]));
328
+ // Ensure any expected user not seen in response gets device=0 cached
329
+ if (expectedUsers) {
330
+ for (const u of expectedUsers) {
331
+ if (!self._deviceCache.has(u)) self._deviceCache.set(u, new Set([0]));
249
332
  }
250
333
  }
251
334
  }
@@ -350,6 +433,166 @@ class DeviceManager {
350
433
  return `${user}.${device}`;
351
434
  }
352
435
 
436
+ // ─── usync device list query by full JID (LID or PN) ─────────────────────
437
+ //
438
+ // Used for LID-mode group members that have no LID→PN mapping in _lidToPn.
439
+ // Sends <user jid="..."/> nodes directly instead of <user><contact>+phone</contact></user>,
440
+ // which allows the server to resolve devices for LID JIDs natively.
441
+ //
442
+ async _usyncGetDevicesByJid(jids) {
443
+ if (!jids || jids.length === 0) return [];
444
+
445
+ const iqId = this._client._genMsgId();
446
+ const sid = this._client._genMsgId();
447
+
448
+ // Build proper JID objects for each input JID (e.g. "112713111982325@lid")
449
+ const listChildren = jids.map(jidStr => {
450
+ const at = jidStr.indexOf('@');
451
+ const user = at >= 0 ? jidStr.slice(0, at) : jidStr;
452
+ const server = at >= 0 ? jidStr.slice(at + 1) : 's.whatsapp.net';
453
+ return new BinaryNode('user', { jid: { user, server } }, null);
454
+ });
455
+
456
+ const iqNode = new BinaryNode('iq',
457
+ { id: iqId, to: 's.whatsapp.net', type: 'get', xmlns: 'usync' },
458
+ [new BinaryNode('usync',
459
+ { sid, mode: 'query', last: 'true', index: '0', context: 'message' },
460
+ [
461
+ new BinaryNode('query', {},
462
+ [new BinaryNode('devices', { version: '2' }, null)]
463
+ ),
464
+ new BinaryNode('list', {}, listChildren),
465
+ new BinaryNode('side_list', {}, null)
466
+ ]
467
+ )]
468
+ );
469
+
470
+ const response = await this._client._sendIq(iqNode).catch(() => null);
471
+
472
+ if (!response) {
473
+ process.stderr.write('[DBG] _usyncGetDevicesByJid TIMEOUT for jids=' + jids.join(',') + '\n');
474
+ // Fallback: return main device for each input JID
475
+ return jids.map(j => {
476
+ const { user } = stripUser(j);
477
+ const server = j.includes('@lid') ? 'lid' : 's.whatsapp.net';
478
+ return `${user}@${server}`;
479
+ });
480
+ }
481
+
482
+ // Parse response: collect (user, deviceId) pairs → build device JID strings
483
+ // Response structure: <iq><usync><list><user jid="..."><devices><device-list><device id="N"/></device-list></devices></user></list></usync></iq>
484
+ const foundJids = [];
485
+ function walkUser(userNode) {
486
+ const rawJid = userNode.attrs && userNode.attrs.jid;
487
+ if (!rawJid) return;
488
+ process.stderr.write('[DBG] walkUser rawJid=' + JSON.stringify(rawJid) + '\n');
489
+ const user = typeof rawJid === 'object' ? rawJid.user : String(rawJid).split('@')[0];
490
+ const server = typeof rawJid === 'object' ? rawJid.server : (String(rawJid).split('@')[1] || 's.whatsapp.net');
491
+ const agent = typeof rawJid === 'object' ? (rawJid.agent || 0) : 0;
492
+ const agentStr = agent !== 0 ? `_${agent}` : '';
493
+ if (!user) return;
494
+
495
+ const devicesNode = Array.isArray(userNode.content) ? userNode.content.find(n => n && n.description === 'devices') : null;
496
+ const deviceListNode = devicesNode && Array.isArray(devicesNode.content) ? devicesNode.content.find(n => n && n.description === 'device-list') : null;
497
+ const deviceNodes = deviceListNode && Array.isArray(deviceListNode.content) ? deviceListNode.content.filter(n => n && n.description === 'device') : [];
498
+
499
+ if (deviceNodes.length === 0) {
500
+ foundJids.push(`${user}${agentStr}@${server}`);
501
+ } else {
502
+ for (const dn of deviceNodes) {
503
+ const devId = parseInt((dn.attrs && dn.attrs.id) || '0', 10);
504
+ const dev = isNaN(devId) ? 0 : devId;
505
+ foundJids.push(dev === 0 ? `${user}${agentStr}@${server}` : `${user}${agentStr}:${dev}@${server}`);
506
+ }
507
+ }
508
+ }
509
+ function walk(node) {
510
+ if (!node) return;
511
+ if (node.description === 'user') { walkUser(node); return; }
512
+ if (Array.isArray(node.content)) node.content.forEach(walk);
513
+ }
514
+ walk(response);
515
+
516
+ process.stderr.write('[DBG] _usyncGetDevicesByJid jids=' + jids.length + ' found=' + foundJids.length + ' ' + foundJids.join(',') + '\n');
517
+
518
+ // Fallback: if server returned nothing, use the main device JID for each input
519
+ if (foundJids.length === 0) {
520
+ return jids.map(j => {
521
+ const { user } = stripUser(j);
522
+ const server = j.includes('@lid') ? 'lid' : 's.whatsapp.net';
523
+ return `${user}@${server}`;
524
+ });
525
+ }
526
+ return foundJids;
527
+ }
528
+
529
+ // ─── Ensure sessions for LID JID members (no PN mapping available) ─────────
530
+ //
531
+ // Called from _sendGroupMessage for any LID group member where _lidToPn has
532
+ // no entry. Queries usync by JID instead of phone number so the server can
533
+ // return the correct device list for LID-addressed participants.
534
+ //
535
+ async bulkEnsureSessionsByLidJids(lidJids, signalProto, allowPkmsg = true) {
536
+ if (!lidJids || lidJids.length === 0) return [];
537
+
538
+ const deviceJids = await this._usyncGetDevicesByJid(lidJids);
539
+
540
+ // In LID mode, ALL devices (including device=0) may have pre-keys and need
541
+ // Signal sessions. We try to fetch bundles for every device; if the server
542
+ // returns 406 for device=0 (no pre-keys available) we simply skip that JID —
543
+ // but we still include it in the result so it appears in phash/SKDM list.
544
+ const readyJids = [];
545
+ const fetchJids = [];
546
+ const _sessKeys = Object.keys(signalProto.store._sessions || {});
547
+ process.stderr.write('[DBG] bulkEnsureLid storedSessionKeys=' + JSON.stringify(_sessKeys) + '\n');
548
+ for (const jid of deviceJids) {
549
+ const addrKey = this._jidToAddr(jid);
550
+ const hasSess = signalProto.store.hasSession(addrKey);
551
+ process.stderr.write('[DBG] hasSession jid=' + jid + ' addrKey=' + addrKey + ' result=' + hasSess + '\n');
552
+ if (hasSess) {
553
+ readyJids.push(jid);
554
+ } else {
555
+ fetchJids.push(jid);
556
+ }
557
+ }
558
+
559
+ process.stderr.write('[DBG] bulkEnsureLid deviceJids=' + JSON.stringify(deviceJids) + ' fetchJids=' + JSON.stringify(fetchJids) + '\n');
560
+
561
+ if (allowPkmsg && fetchJids.length > 0) {
562
+ const bundles = await this.fetchBundles(fetchJids);
563
+ process.stderr.write('[DBG] bulkEnsureLid bundles.size=' + bundles.size + '\n');
564
+ for (const [jid, bundle] of bundles) {
565
+ try {
566
+ await signalProto.buildSessionFromBundle(jid, bundle);
567
+ readyJids.push(typeof jid === 'string' ? jid : String(jid));
568
+ } catch (e) {
569
+ process.stderr.write('[DBG] bulkEnsureSessionsByLidJids bundle err jid=' + jid + ' ' + e.message + '\n');
570
+ }
571
+ }
572
+ // Any device we couldn't build a session for (e.g. 406, no pre-keys) still
573
+ // needs to appear in the result for phash correctness.
574
+ const sessionBuilt = new Set(readyJids);
575
+ for (const jid of fetchJids) {
576
+ if (!sessionBuilt.has(jid)) readyJids.push(jid);
577
+ }
578
+ } else {
579
+ // All devices already have sessions; add any that were not in fetchJids
580
+ for (const jid of deviceJids) {
581
+ if (!readyJids.includes(jid)) readyJids.push(jid);
582
+ }
583
+ }
584
+
585
+ const result = readyJids;
586
+
587
+ // Fallback: if still no secondaries found, return ALL device JIDs so that at
588
+ // least an SKDM attempt is made.
589
+ if (result.length === 0) {
590
+ return deviceJids;
591
+ }
592
+
593
+ return result;
594
+ }
595
+
353
596
  // ─── Invalidate cached device list (used on phash mismatch / 421 retry) ───
354
597
  clearCache(phones) {
355
598
  if (!phones || phones.length === 0) {
@@ -365,7 +608,23 @@ class DeviceManager {
365
608
  const toNodes = encryptedList.map(({ jid, type, ciphertext }) => {
366
609
  const encAttrs = { type, v: '2' };
367
610
  if (mediaSubtype) encAttrs.mediatype = mediaSubtype;
368
- return new BinaryNode('to', { jid },
611
+ // Convert string JIDs to binary-encodable JID objects.
612
+ // "user_agent:device@server" → { user, agent, device, server }
613
+ let jidObj = jid;
614
+ if (typeof jid === 'string') {
615
+ const atIdx = jid.indexOf('@');
616
+ const server = atIdx >= 0 ? jid.slice(atIdx + 1) : 's.whatsapp.net';
617
+ const userPart = atIdx >= 0 ? jid.slice(0, atIdx) : jid;
618
+ const colonIdx = userPart.indexOf(':');
619
+ const base = colonIdx >= 0 ? userPart.slice(0, colonIdx) : userPart;
620
+ const device = colonIdx >= 0 ? parseInt(userPart.slice(colonIdx + 1), 10) : 0;
621
+ const underIdx = base.lastIndexOf('_');
622
+ const hasAgent = underIdx >= 0 && /^\d+$/.test(base.slice(underIdx + 1));
623
+ const user = hasAgent ? base.slice(0, underIdx) : base;
624
+ const agent = hasAgent ? parseInt(base.slice(underIdx + 1), 10) : 0;
625
+ jidObj = { user, agent: agent || 0, device: device || 0, server };
626
+ }
627
+ return new BinaryNode('to', { jid: jidObj },
369
628
  [new BinaryNode('enc', encAttrs, ciphertext)]
370
629
  );
371
630
  });
@@ -102,37 +102,22 @@ function _getCurve() {
102
102
  return _curve25519js;
103
103
  }
104
104
 
105
- // Build ADVSignedDeviceIdentity for our primary iOS device.
106
- // Returns Buffer of encoded proto WITH accountSignatureKey included.
107
- // Result is cached in store.advIdentity so we only build it once per registration.
105
+ // Return ADVSignedDeviceIdentity if we have one from the server (received at login).
106
+ // For phone-based (non-WA-Web) registrations the server never sends device-identity
107
+ // in the <success> node, so store.advIdentity stays null. In that case we return
108
+ // null — the pkmsg stanza is sent WITHOUT a <device-identity> child, which is valid
109
+ // for primary mobile devices and does NOT cause RETRY at the recipient.
110
+ //
111
+ // NOTE: Building a self-signed ADV from our own keys caused RETRY at recipient because
112
+ // WhatsApp verifies the signature chain against the server-known account key.
108
113
  function _buildOrGetAdvIdentity(store) {
109
- if (store.advIdentity && store.advIdentity.length > 0) return store.advIdentity;
110
-
111
- try {
112
- const curve = _getCurve();
113
- const pubRaw = Buffer.from(store.identityKeyPair.public, 'base64');
114
- const privRaw = Buffer.from(store.identityKeyPair.private, 'base64');
115
- const pub32 = pubRaw.length === 33 ? pubRaw.slice(1) : pubRaw;
116
- const priv32 = privRaw.length === 32 ? privRaw : privRaw.slice(0, 32);
117
-
118
- const ts = Math.floor(Date.now() / 1000);
119
- const regId = store.registrationId || 0;
120
- const details = _encodeADVDetails(regId, ts, 0);
121
-
122
- const acctMsg = Buffer.concat([WA_ADV_ACCOUNT_SIG_PREFIX, details, pub32]);
123
- const acctSig = Buffer.from(curve.sign(priv32, acctMsg));
124
-
125
- const devMsg = Buffer.concat([WA_ADV_DEVICE_SIG_PREFIX, details, pub32, pub32]);
126
- const devSig = Buffer.from(curve.sign(priv32, devMsg));
127
-
128
- const adv = _encodeADVSignedIdentity(details, pub32, acctSig, devSig);
129
- store.advIdentity = adv;
130
- process.stderr.write('[DBG] ADV_BUILT from primary iOS keys (' + adv.length + 'b)\n');
131
- return adv;
132
- } catch (e) {
133
- process.stderr.write('[DBG] ADV_BUILD_ERR: ' + e.message + '\n');
134
- return null;
135
- }
114
+ if (!store.advIdentity) return null;
115
+ const adv = Buffer.isBuffer(store.advIdentity)
116
+ ? store.advIdentity
117
+ : Buffer.from(store.advIdentity);
118
+ if (adv.length === 0) return null;
119
+ process.stderr.write('[DBG] ADV_PRESENT from server (' + adv.length + 'b)\n');
120
+ return adv;
136
121
  }
137
122
 
138
123
  function makeJid(input, server) {
@@ -621,6 +606,10 @@ class MessageSender {
621
606
  const ownPhone = String(this._store.phoneNumber);
622
607
  const ownJid = `${ownPhone}@s.whatsapp.net`;
623
608
  const mediaSubtype = options._mediaSubtype || null;
609
+ // LID-based own user number (e.g. "90079053799508"), used for phash in LID-mode groups.
610
+ // usync returns "@s.whatsapp.net" JIDs with the LID numeric ID as user when queried by
611
+ // LID JID, so the phash sender entry must use this number, NOT the phone number.
612
+ const myLidUser = this._client._myLid ? phoneFromJid(this._client._myLid) : null;
624
613
 
625
614
  // Auto-fetch group metadata if member cache is empty (first send to this group)
626
615
  let members = this._client._getGroupMembers(groupJid);
@@ -634,7 +623,12 @@ class MessageSender {
634
623
 
635
624
  // Determine addressing mode for this group (lid = modern groups, pn = legacy)
636
625
  const groupAddressingMode = this._client._groupAddressingMode.get(groupJid) || 'lid';
637
- // For LID-mode groups, sender identity is own LID JID; otherwise own PN JID
626
+ // senderIdentity: For LID-mode groups the server tags our outbound message with
627
+ // participant = our LID JID (e.g. "90079053799508@lid") because addressing_mode='lid'.
628
+ // The recipient looks up the sender key by that participant JID, so we MUST store
629
+ // the SKDM and encrypt under the SAME identity.
630
+ // For legacy PN-mode groups the server uses the phone JID — fall back to ownJid.
631
+ // This mirrors Baileys: groupSenderIdentity = groupAddressingMode==='lid' && meLid ? meLid : meId
638
632
  const senderIdentity = (groupAddressingMode === 'lid' && this._client._myLid)
639
633
  ? this._client._myLid
640
634
  : ownJid;
@@ -642,38 +636,70 @@ class MessageSender {
642
636
  const rawSKDM = this._signal.buildSKDM(groupJid, senderIdentity);
643
637
  const skdmMsg = encodeSenderKeyDistributionMessage(groupJid, rawSKDM);
644
638
 
645
- const memberPhones = [...new Set(
646
- members.map(jid => {
647
- const isLid = jid.endsWith('@lid');
648
- const raw = phoneFromJid(jid);
649
- if (isLid) {
650
- const pn = this._client._lidToPn && this._client._lidToPn.get(raw);
651
- if (!pn) {
652
- process.stderr.write('[DBG] GROUP_SEND skip LID member with no PN mapping: ' + jid + '\n');
653
- return null;
654
- }
655
- return pn;
656
- }
657
- return raw;
658
- }).filter(p => p !== null && p !== ownPhone)
659
- )];
639
+ // Separate members into two buckets:
640
+ // 1. knownPhones — members whose phone number is known (from LID→PN map or direct PN JID)
641
+ // 2. unknownLids — LID JIDs with no PN mapping yet (common on first send to a group)
642
+ //
643
+ // FIX: Previously, bucket-2 members were silently skipped (return null), which meant
644
+ // SKDM (Sender Key Distribution Message) was never sent to them. Recipients would
645
+ // receive the skmsg ciphertext but could not decrypt it — messages appeared "waiting".
646
+ //
647
+ // Cobalt solves this by querying usync directly with JID= nodes instead of phone numbers,
648
+ // allowing the server to resolve devices for LID-addressed participants. We mirror that
649
+ // approach via DeviceManager.bulkEnsureSessionsByLidJids().
650
+ const knownPhones = [];
651
+ const unknownLids = [];
652
+
653
+ for (const jid of members) {
654
+ const isLid = jid.endsWith('@lid');
655
+ const raw = phoneFromJid(jid);
656
+
657
+ if (isLid) {
658
+ // Skip own LID identity
659
+ if (raw === myLidUser) continue;
660
+ // KEY FIX: For LID-mode groups the server only responds to LID-based usync queries
661
+ // (i.e. <user jid="...@lid"/>), NOT to phone-number-based queries
662
+ // (<contact>+phone</contact>). Always route ALL LID members through
663
+ // bulkEnsureSessionsByLidJids() regardless of whether we know their PN.
664
+ unknownLids.push(jid);
665
+ } else if (raw !== ownPhone) {
666
+ // Legacy PN-mode group — use conventional phone-number usync
667
+ knownPhones.push(raw);
668
+ }
669
+ }
670
+
671
+ const uniquePhones = [...new Set(knownPhones)];
672
+ const uniqueLids = [...new Set(unknownLids)];
673
+
674
+ process.stderr.write('[DBG] GROUP_SEND ' + groupJid + ' knownPhones=' + uniquePhones.length + ' unknownLids=' + uniqueLids.length + '\n');
660
675
 
661
- const [memberDevices, ownDevices] = await Promise.all([
662
- memberPhones.length > 0
663
- ? this._devMgr.bulkEnsureSessions(memberPhones, this._signal)
676
+ const [memberDevicesPN, memberDevicesLid, ownDevices] = await Promise.all([
677
+ uniquePhones.length > 0
678
+ ? this._devMgr.bulkEnsureSessions(uniquePhones, this._signal)
679
+ : Promise.resolve([]),
680
+ uniqueLids.length > 0
681
+ ? this._devMgr.bulkEnsureSessionsByLidJids(uniqueLids, this._signal)
664
682
  : Promise.resolve([]),
665
683
  this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal)
666
684
  ]);
667
685
 
668
- const allTargets = [...memberDevices, ...ownDevices];
686
+ const allTargets = [...memberDevicesPN, ...memberDevicesLid, ...ownDevices];
687
+
688
+ process.stderr.write('[DBG] GROUP senderIdentity=' + senderIdentity + ' groupAddressingMode=' + groupAddressingMode + ' myLid=' + this._client._myLid + '\n');
669
689
 
670
- const phashTargets = allTargets.includes(ownJid)
671
- ? allTargets
672
- : [ownJid, ...allTargets];
690
+ // phash: Baileys uses device JIDs of all RECIPIENTS (own secondary devices + others).
691
+ // We don't have own secondary devices, so phash = memberDevices only.
692
+ // We're currently sending phash=null and let server compute it (see below).
693
+ const phashOwnJid = ownJid;
694
+ // memberTargets = only other participants' devices (PN + LID), no own secondary devices
695
+ const memberTargets = [...memberDevicesPN, ...memberDevicesLid];
696
+ const phashTargets = [phashOwnJid, ...memberTargets.filter(j => j !== phashOwnJid)];
697
+ process.stderr.write('[DBG] phashOwnJid=' + phashOwnJid + ' memberTargets=' + JSON.stringify(memberTargets) + ' ownDevices=' + JSON.stringify(ownDevices) + '\n');
673
698
 
674
699
  const skStore = this._signal.senderKeyStore;
675
700
  const existingSkdmMap = skStore.getSKDMMap(groupJid);
676
701
  const skdmRecipients = allTargets.filter(jid => !existingSkdmMap[jid]);
702
+ process.stderr.write('[DBG] allTargets=' + JSON.stringify(allTargets) + ' skdmRecipients=' + JSON.stringify(skdmRecipients) + ' existingSkdmKeys=' + JSON.stringify(Object.keys(existingSkdmMap)) + '\n');
677
703
 
678
704
  let skdmEncrypted = [];
679
705
  if (skdmRecipients.length > 0) {
@@ -682,10 +708,13 @@ class MessageSender {
682
708
  }
683
709
 
684
710
  const skmsgCiphertext = this._signal.senderKeyEncrypt(groupJid, senderIdentity, plaintext);
685
- const phash = phashTargets.length > 0 ? computePhash(phashTargets) : null;
711
+ const phashComputed = phashTargets.length > 0 ? computePhash(phashTargets) : null;
712
+ const phash = null; // TEST: force no phash to see server response
713
+ process.stderr.write('[DBG] phashTargets=' + JSON.stringify(phashTargets) + ' computed phash=' + phashComputed + ' (sending: ' + phash + ')\n');
686
714
 
687
715
  const skdmHasPkmsg = skdmEncrypted.some(e => e.type === 'pkmsg');
688
716
  const advBytes = skdmHasPkmsg ? _buildOrGetAdvIdentity(this._client._store) : null;
717
+ process.stderr.write('[DBG] skdmHasPkmsg=' + skdmHasPkmsg + ' advBytes=' + (advBytes ? advBytes.length + 'b' : 'null') + '\n');
689
718
 
690
719
  const msgContent = [];
691
720
  if (advBytes) {
@@ -380,12 +380,14 @@ function decodeMessageContainer(buf) {
380
380
  if (dsm[2]) return decodeMessageContainer(dsm[2]);
381
381
  }
382
382
 
383
- // Field 35: senderKeyDistributionMessage used to set up sender keys.
383
+ // Field 2: senderKeyDistributionMessage (WAProto: Message.senderKeyDistributionMessage = 2)
384
384
  // In WhatsApp Multi-Device, SKDM is bundled WITH the first real message in a new
385
385
  // session (even for 1-on-1 DMs). We extract it and continue decoding the real message.
386
+ // Also check field 15 (fastRatchetKeySenderKeyDistributionMessage) as a fallback.
386
387
  let skdmInfo = null;
387
- if (f[35]) {
388
- const skdm = _decodeFields(f[35]);
388
+ const skdmRaw = f[2] || f[15];
389
+ if (skdmRaw) {
390
+ const skdm = _decodeFields(skdmRaw);
389
391
  // field 1 = groupId (string), field 2 = axolotlSenderKeyDistributionMessage (bytes)
390
392
  skdmInfo = {
391
393
  groupId: _str(skdm[1]),
@@ -403,10 +405,10 @@ function decodeMessageContainer(buf) {
403
405
  if (text) msgResult = { type: 'text', text };
404
406
  }
405
407
 
406
- // Field 2: extendedTextMessage — field 1 = text
407
- if (!msgResult && f[2] && Buffer.isBuffer(f[2])) {
408
+ // Field 6: extendedTextMessage (WAProto: Message.extendedTextMessage = 6) — field 1 = text
409
+ if (!msgResult && f[6] && Buffer.isBuffer(f[6])) {
408
410
  try {
409
- const ext = _decodeFields(f[2]);
411
+ const ext = _decodeFields(f[6]);
410
412
  const text = _str(ext[1]);
411
413
  if (text) msgResult = { type: 'text', text };
412
414
  } catch (_) {}
@@ -570,11 +572,13 @@ function decodeMessageContainer(buf) {
570
572
  }
571
573
 
572
574
  function encodeSenderKeyDistributionMessage(groupId, axolotlBytes) {
575
+ // WAProto: Message.senderKeyDistributionMessage = field 2
576
+ // Inner: SenderKeyDistributionMessage { groupId=1 (string), axolotlSKDM=2 (bytes) }
573
577
  const inner = Buffer.concat([
574
578
  field(1, WIRE_LEN, str(groupId)),
575
579
  field(2, WIRE_LEN, bytes(axolotlBytes))
576
580
  ]);
577
- return field(35, WIRE_LEN, inner);
581
+ return field(2, WIRE_LEN, inner);
578
582
  }
579
583
 
580
584
  module.exports = {
@@ -178,6 +178,7 @@ class SenderKeyStore {
178
178
  }
179
179
 
180
180
  markSKDMSent(groupId, jids) {
181
+ process.stderr.write('[DBG] markSKDMSent groupId=' + groupId + ' jids=' + JSON.stringify(jids) + '\n' + new Error().stack.split('\n').slice(1,4).join('\n') + '\n');
181
182
  const map = this.getSKDMMap(groupId);
182
183
  for (const jid of jids) map[jid] = true;
183
184
  this.setSKDMMap(groupId, map);
@@ -170,6 +170,8 @@ class SignalProtocol {
170
170
  publicKey: bundle.preKey.publicKey
171
171
  } : undefined
172
172
  });
173
+ const hasSess = this.store.hasSession(addr.toString ? addr.toString() : (addr.name + '.' + addr.deviceId));
174
+ process.stderr.write('[DBG] SESSION_BUILT jid=' + jid + ' addr=' + addr.name + '.' + addr.deviceId + ' hasSess=' + hasSess + '\n');
173
175
  }
174
176
 
175
177
  async buildSessionsFromBundles(bundleMap) {
@@ -220,11 +222,18 @@ class SignalProtocol {
220
222
  // Returns [{jid, type, ciphertext}]
221
223
  async bulkEncryptForDevices(jids, plaintext) {
222
224
  const settled = await Promise.allSettled(
223
- jids.map(jid => this.encrypt(jid, plaintext).then(enc => ({ jid, type: enc.type, ciphertext: enc.ciphertext })))
225
+ jids.map(jid => this.encrypt(jid, plaintext).then(enc => {
226
+ process.stderr.write('[DBG] SKDM_ENC jid=' + jid + ' type=' + enc.type + ' len=' + (enc.ciphertext ? enc.ciphertext.length : 0) + '\n');
227
+ return { jid, type: enc.type, ciphertext: enc.ciphertext };
228
+ }))
224
229
  );
225
- return settled
230
+ const results = settled
226
231
  .filter(r => r.status === 'fulfilled')
227
232
  .map(r => r.value);
233
+ settled.filter(r => r.status === 'rejected').forEach((r, i) => {
234
+ process.stderr.write('[DBG] SKDM_ENC_ERR jid=' + jids[i] + ' err=' + r.reason + '\n');
235
+ });
236
+ return results;
228
237
  }
229
238
 
230
239
  // ─── SenderKey group encrypt / decrypt ────────────────────────────────────
@@ -293,6 +302,11 @@ function jidToAddress(jid) {
293
302
  deviceId = parseInt(user.slice(colonIdx + 1), 10) || 0;
294
303
  user = user.slice(0, colonIdx);
295
304
  }
305
+ // Strip agent suffix from user (e.g. "112713111982325_1" → "112713111982325")
306
+ const underIdx = user.lastIndexOf('_');
307
+ if (underIdx >= 0 && /^\d+$/.test(user.slice(underIdx + 1))) {
308
+ user = user.slice(0, underIdx);
309
+ }
296
310
  } else if (jid && jid.user) {
297
311
  user = jid.user;
298
312
  deviceId = jid.device || 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.1.17",
3
+ "version": "5.1.19",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",