whalibmob 5.5.81 → 5.5.83

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
@@ -359,7 +359,23 @@ class WhalibmobClient extends EventEmitter {
359
359
  }
360
360
  }
361
361
  if (attrs.platform) this._platform = attrs.platform;
362
- if (attrs.lid) this._myLid = String(attrs.lid);
362
+ if (attrs.lid) {
363
+ this._myLid = String(attrs.lid);
364
+ // Record our own PN ↔ LID pair alongside every contact's. Prekey bundles
365
+ // are only ever answered for phone JIDs, so opening a session with one of
366
+ // our own LID-addressed linked devices needs this mapping just as much as
367
+ // a contact's does — without it the bundle IQ goes out addressed to @lid
368
+ // and is never answered.
369
+ const ownLidUser = this._myLid.split('@')[0].split(':')[0];
370
+ const ownPnUser = String(this._store && this._store.phoneNumber || '');
371
+ if (ownLidUser && ownPnUser) {
372
+ this._lidToPn.set(ownLidUser, ownPnUser);
373
+ this._pnToLid.set(ownPnUser, ownLidUser);
374
+ if (this._signal && this._signal.store && this._signal.store.setLidMapping) {
375
+ this._signal.store.setLidMapping(ownPnUser, ownLidUser);
376
+ }
377
+ }
378
+ }
363
379
 
364
380
  const devIdNode = findChild(node, 'device-identity');
365
381
  if (devIdNode) {
@@ -674,7 +674,7 @@ class DeviceManager {
674
674
  // 3. fetchBundles(fetchList) — only for truly new sessions
675
675
  // 4. buildSessionFromBundle for each new session
676
676
  //
677
- async bulkEnsureSessions(phones, signalProto, allowPkmsg = true) {
677
+ async bulkEnsureSessions(phones, signalProto, allowPkmsg = true, _isRetry = false) {
678
678
  const uniquePhones = [...new Set(phones)];
679
679
  if (uniquePhones.length === 0) return [];
680
680
 
@@ -692,15 +692,34 @@ class DeviceManager {
692
692
  }
693
693
 
694
694
  if (allowPkmsg && fetchJids.length > 0) {
695
+ // Keep the requested string for each device: the server echoes JIDs back in
696
+ // its own normalised form and the participants node has to name the device
697
+ // the same way we enumerated it.
698
+ const wanted = new Map(fetchJids.map(j => [this._jidToAddr(j), j]));
695
699
  const bundles = await this.fetchBundles(fetchJids);
696
700
  for (const [jid, bundle] of bundles) {
701
+ const target = wanted.get(this._jidToAddr(jid)) || jid;
697
702
  try {
698
- await signalProto.buildSessionFromBundle(jid, bundle);
699
- readyJids.push(jid);
703
+ await signalProto.buildSessionFromBundle(target, bundle);
704
+ readyJids.push(target);
700
705
  } catch (_) {}
701
706
  }
702
707
  }
703
708
 
709
+ // The same guarantee the LID path makes: every enumerated device has to end
710
+ // up with a session, because every one of them becomes a <to><enc> entry and
711
+ // a participant list that does not cover the account's current devices comes
712
+ // back as ack 479. Coming up short means the cached list is out of date — a
713
+ // device has been unlinked since, so the server has no bundle for it. Drop
714
+ // the list and enumerate once more against what the account has now.
715
+ if (allowPkmsg && readyJids.length < deviceJids.length && !_isRetry) {
716
+ _whaDbg('[DBG] PN_DEVICE_LIST_STALE phones=[' + uniquePhones.join(',') + ']' +
717
+ ' enumerated=' + deviceJids.length + ' sessions=' + readyJids.length +
718
+ ' — re-running usync\n');
719
+ this.clearCache(uniquePhones);
720
+ return this.bulkEnsureSessions(uniquePhones, signalProto, allowPkmsg, /* _isRetry */ true);
721
+ }
722
+
704
723
  // Fallback: if still nothing, use main device JIDs directly
705
724
  if (readyJids.length === 0) {
706
725
  for (const phone of uniquePhones) readyJids.push(makeDeviceJid(phone, 0));
@@ -743,74 +762,7 @@ class DeviceManager {
743
762
  _whaDbg('[DBG] LID_DEVICES lidUser=' + lidUser +
744
763
  ' deviceJids=[' + deviceJids.join(',') + ']\n');
745
764
 
746
- // Split: existing sessions (ready) vs new (need bundle fetch)
747
- const readyJids = [];
748
- const fetchJids = [];
749
- for (const jid of deviceJids) {
750
- if (signalProto.store.hasSession(this._jidToAddr(jid))) {
751
- readyJids.push(jid);
752
- } else {
753
- fetchJids.push(jid);
754
- }
755
- }
756
-
757
- if (allowPkmsg && fetchJids.length > 0) {
758
- // Ask for the bundles by phone JID. The encrypt IQ is not answered at all
759
- // when its <user> entries are @lid — it just times out — while the same
760
- // request in phone form comes back in full. Translate through the known
761
- // LID→PN mapping, keeping a link back so each bundle is installed under
762
- // the LID address the participants node will use.
763
- const pnForLid = this._client._lidToPn && this._client._lidToPn.get(lidUser);
764
- const wireToLid = new Map();
765
- let wireJids = fetchJids;
766
- if (pnForLid) {
767
- wireJids = fetchJids.map(j => {
768
- const at = j.indexOf('@');
769
- const base = at >= 0 ? j.slice(0, at) : j;
770
- const colon = base.indexOf(':');
771
- const dev = colon >= 0 ? (parseInt(base.slice(colon + 1), 10) || 0) : 0;
772
- const wire = makeDeviceJid(pnForLid, dev, 's.whatsapp.net');
773
- wireToLid.set(wire, j);
774
- return wire;
775
- });
776
- _whaDbg('[DBG] LID_BUNDLE_VIA_PN lid=' + lidUser + ' pn=' + pnForLid +
777
- ' jids=[' + wireJids.join(',') + ']\n');
778
- } else {
779
- _whaDbg('[DBG] LID_BUNDLE_NO_PN lid=' + lidUser + ' — requesting by LID\n');
780
- }
781
-
782
- const bundles = await this.fetchBundles(wireJids);
783
- for (const [wireJid, bundle] of bundles) {
784
- // Install under the @lid address: that is what the outgoing
785
- // participants node — and therefore the encrypt step — looks the
786
- // session up by. Stored under the phone user it is a different Signal
787
- // address and would never be found.
788
- const jid = wireToLid.get(wireJid) || wireJid;
789
- try {
790
- await signalProto.buildSessionFromBundle(jid, bundle);
791
- // ── CRITICAL: remap bundle JID back to @lid format ────────────────────
792
- // fetchBundles always returns JIDs in @s.whatsapp.net format (because the
793
- // WA server uses its own AD_JID / JID_PAIR encoding in responses).
794
- // But the outer <message to="lidUser@lid"> requires ALL participant <to>
795
- // nodes to also use @lid format — mixing @s.whatsapp.net participants with
796
- // a @lid routing address causes the server to close the connection.
797
- //
798
- // A Signal address is (user, device) with the @server stripped — but
799
- // the user of a LID and the user of a phone JID are different strings,
800
- // so a session is NOT reachable under both. That assumption is what
801
- // left sends failing: bundles fetched in phone form were stored under
802
- // the phone user while the participants node looked them up by LID.
803
- // The session is installed under the @lid address above; this only
804
- // normalises the JID pushed into readyJids.
805
- const jidBase = jid.replace(/@(?:s\.whatsapp\.net|lid)$/, '');
806
- const lidJidForDevice = jidBase + '@lid';
807
- readyJids.push(lidJidForDevice);
808
- _whaDbg('[DBG] LID_SESSION_BUILT jid=' + jid + ' → participant=' + lidJidForDevice);
809
- } catch (e) {
810
- _whaDbg('[DBG] LID_SESSION_ERR jid=' + jid + ' err=' + e.message);
811
- }
812
- }
813
- }
765
+ const readyJids = await this._ensureLidSessions(lidUser, deviceJids, signalProto, allowPkmsg);
814
766
 
815
767
  // Every enumerated device must end up with a session, because every one of
816
768
  // them becomes a <to><enc> entry. Coming up short means the cached device
@@ -841,6 +793,177 @@ class DeviceManager {
841
793
  return readyJids;
842
794
  }
843
795
 
796
+ // ─── Open Signal sessions for a set of @lid device JIDs ────────────────────
797
+ //
798
+ // Shared by the recipient path (bulkEnsureSessionsForLid), the own-device path
799
+ // (ensureOwnLidDeviceSessions) and the repair path (rebuildSessionsForJids).
800
+ // Returns the device JIDs — always in @lid form — that ended up with a usable
801
+ // session.
802
+ //
803
+ // pnOverride lets a caller supply the phone number to fetch bundles with when
804
+ // the LID→PN map does not have it yet (our own LID, most notably).
805
+ async _ensureLidSessions(lidUser, deviceJids, signalProto, allowPkmsg, pnOverride) {
806
+ const readyJids = [];
807
+ const fetchJids = [];
808
+ for (const jid of deviceJids) {
809
+ if (signalProto.store.hasSession(this._jidToAddr(jid))) {
810
+ readyJids.push(jid);
811
+ } else {
812
+ fetchJids.push(jid);
813
+ }
814
+ }
815
+
816
+ if (!allowPkmsg || fetchJids.length === 0) return readyJids;
817
+
818
+ // Ask for the bundles by phone JID. The encrypt IQ is not answered at all
819
+ // when its <user> entries are @lid — it just times out — while the same
820
+ // request in phone form comes back in full. Translate through the known
821
+ // LID→PN mapping, keeping a link back so each bundle is installed under
822
+ // the LID address the participants node will use.
823
+ const pnForLid = pnOverride || (this._client._lidToPn && this._client._lidToPn.get(lidUser));
824
+ const wireToLid = new Map();
825
+ let wireJids = fetchJids;
826
+ if (pnForLid) {
827
+ wireJids = fetchJids.map(j => {
828
+ const at = j.indexOf('@');
829
+ const base = at >= 0 ? j.slice(0, at) : j;
830
+ const colon = base.indexOf(':');
831
+ const dev = colon >= 0 ? (parseInt(base.slice(colon + 1), 10) || 0) : 0;
832
+ const wire = makeDeviceJid(pnForLid, dev, 's.whatsapp.net');
833
+ wireToLid.set(wire, j);
834
+ return wire;
835
+ });
836
+ _whaDbg('[DBG] LID_BUNDLE_VIA_PN lid=' + lidUser + ' pn=' + pnForLid +
837
+ ' jids=[' + wireJids.join(',') + ']\n');
838
+ } else {
839
+ _whaDbg('[DBG] LID_BUNDLE_NO_PN lid=' + lidUser + ' — requesting by LID\n');
840
+ }
841
+
842
+ const bundles = await this.fetchBundles(wireJids);
843
+ for (const [wireJid, bundle] of bundles) {
844
+ // Install under the @lid address: that is what the outgoing
845
+ // participants node — and therefore the encrypt step — looks the
846
+ // session up by. Stored under the phone user it is a different Signal
847
+ // address and would never be found.
848
+ const jid = wireToLid.get(wireJid) || wireJid;
849
+ try {
850
+ await signalProto.buildSessionFromBundle(jid, bundle);
851
+ // ── CRITICAL: remap bundle JID back to @lid format ────────────────────
852
+ // fetchBundles always returns JIDs in @s.whatsapp.net format (because the
853
+ // WA server uses its own AD_JID / JID_PAIR encoding in responses).
854
+ // But the outer <message to="lidUser@lid"> requires ALL participant <to>
855
+ // nodes to also use @lid format — mixing @s.whatsapp.net participants with
856
+ // a @lid routing address causes the server to close the connection.
857
+ //
858
+ // A Signal address is (user, device) with the @server stripped — but
859
+ // the user of a LID and the user of a phone JID are different strings,
860
+ // so a session is NOT reachable under both. That assumption is what
861
+ // left sends failing: bundles fetched in phone form were stored under
862
+ // the phone user while the participants node looked them up by LID.
863
+ // The session is installed under the @lid address above; this only
864
+ // normalises the JID pushed into readyJids.
865
+ const jidBase = jid.replace(/@(?:s\.whatsapp\.net|lid)$/, '');
866
+ const lidJidForDevice = jidBase + '@lid';
867
+ readyJids.push(lidJidForDevice);
868
+ _whaDbg('[DBG] LID_SESSION_BUILT jid=' + jid + ' → participant=' + lidJidForDevice);
869
+ } catch (e) {
870
+ _whaDbg('[DBG] LID_SESSION_ERR jid=' + jid + ' err=' + e.message);
871
+ }
872
+ }
873
+
874
+ return readyJids;
875
+ }
876
+
877
+ // ─── Own linked devices, addressed in LID space ────────────────────────────
878
+ //
879
+ // ensureOwnDeviceSessions returns @s.whatsapp.net JIDs. Dropping those into a
880
+ // stanza that is routed to an @lid — a LID-addressed group, or a DM to an @lid
881
+ // — mixes two address families in one participant list, which the server
882
+ // rejects with ack 479. This is the LID-space equivalent: same device list,
883
+ // expressed as <ourLid>:<device>@lid.
884
+ //
885
+ // Device 0 is excluded exactly as it is on the phone path: it is the device
886
+ // doing the sending, and both Baileys and whatsmeow skip the exact sender
887
+ // device when building participants.
888
+ async ensureOwnLidDeviceSessions(lidUser, ownPhone, signalProto, allowPkmsg = true) {
889
+ if (!lidUser) return [];
890
+ const cacheKey = 'lid:' + lidUser;
891
+
892
+ if (!this._dcHas(cacheKey)) {
893
+ // Our LID and our phone number address the same account, so the device
894
+ // list is the same one. Reuse it when it is already known rather than
895
+ // spending a second usync IQ on ourselves.
896
+ const ownIds = ownPhone ? this._dcGet(ownPhone) : null;
897
+ if (ownIds && ownIds.length) {
898
+ this._dcSet(cacheKey, ownIds.slice());
899
+ } else {
900
+ await this._doUsyncIqByJid(`${lidUser}@lid`, cacheKey, lidUser);
901
+ }
902
+ }
903
+
904
+ const deviceIds = (this._dcGet(cacheKey) || []).filter(d => d !== 0);
905
+ const deviceJids = deviceIds.map(d => makeDeviceJid(lidUser, d, 'lid'));
906
+ if (deviceJids.length === 0) return [];
907
+
908
+ _whaDbg('[DBG] OWN_LID_DEVICES lid=' + lidUser + ' jids=[' + deviceJids.join(',') + ']');
909
+ return this._ensureLidSessions(lidUser, deviceJids, signalProto, allowPkmsg, ownPhone);
910
+ }
911
+
912
+ // ─── Rebuild sessions for devices that failed to encrypt ───────────────────
913
+ //
914
+ // Drops whatever Signal state is there and opens a brand-new session from a
915
+ // fresh prekey bundle, in whichever address family the JID came in. Used to
916
+ // close a gap in the participant list before the stanza is dispatched — a
917
+ // missing <to> entry is what the server answers with ack 479.
918
+ //
919
+ // Returns the JIDs that now have a session, in the same string form they were
920
+ // requested with, so the caller can use them as participant targets directly.
921
+ async rebuildSessionsForJids(jids, signalProto) {
922
+ const unique = [...new Set((jids || []).map(String))].filter(Boolean);
923
+ if (unique.length === 0) return [];
924
+
925
+ for (const jid of unique) {
926
+ try { await signalProto.deleteSession(jid); } catch (_) {}
927
+ }
928
+
929
+ const ready = [];
930
+ const lidJids = unique.filter(j => j.endsWith('@lid'));
931
+ const pnJids = unique.filter(j => !j.endsWith('@lid'));
932
+
933
+ if (pnJids.length > 0) {
934
+ // The server echoes JIDs in its own normalised form, so map each bundle
935
+ // back onto the exact string the caller asked for via (user, device).
936
+ const wanted = new Map(pnJids.map(j => [this._jidToAddr(j), j]));
937
+ const bundles = await this.fetchBundles(pnJids).catch(() => new Map());
938
+ for (const [jid, bundle] of bundles) {
939
+ const target = wanted.get(this._jidToAddr(jid)) || jid;
940
+ try {
941
+ await signalProto.buildSessionFromBundle(target, bundle);
942
+ ready.push(target);
943
+ } catch (e) {
944
+ _whaDbg('[DBG] REBUILD_SESSION_ERR jid=' + target + ' err=' + e.message);
945
+ }
946
+ }
947
+ }
948
+
949
+ // LID devices are grouped per LID user so each group can be translated
950
+ // through that user's phone number when the bundles are requested.
951
+ const byLidUser = new Map();
952
+ for (const j of lidJids) {
953
+ const u = phoneFromJid(j);
954
+ if (!byLidUser.has(u)) byLidUser.set(u, []);
955
+ byLidUser.get(u).push(j);
956
+ }
957
+ for (const [lidUser, group] of byLidUser) {
958
+ const got = await this._ensureLidSessions(lidUser, group, signalProto, true)
959
+ .catch(() => []);
960
+ ready.push(...got);
961
+ }
962
+
963
+ _whaDbg('[DBG] REBUILD_SESSIONS requested=' + unique.length + ' ready=' + ready.length);
964
+ return ready;
965
+ }
966
+
844
967
  // ─── Contact usync fallback ────────────────────────────────────────────────
845
968
  // When query/devices usync times out (server silently ignores it for LID
846
969
  // accounts), we fall back to query/contact which uses the older
@@ -165,10 +165,37 @@ function getContent(node) {
165
165
  return null;
166
166
  }
167
167
 
168
+ // The "AD string" form of a JID — user.<agent>:<device>@server — which is what
169
+ // the participant hash is computed over. The agent byte is 0 for both
170
+ // s.whatsapp.net and lid users, so "40712345678.0:2@s.whatsapp.net" and
171
+ // "112713111982325.0:0@lid".
172
+ function adString(jid) {
173
+ const str = String(jid);
174
+ const at = str.indexOf('@');
175
+ const raw = at >= 0 ? str.slice(0, at) : str;
176
+ const server = at >= 0 ? str.slice(at + 1) : 's.whatsapp.net';
177
+ const colon = raw.indexOf(':');
178
+ const user = colon >= 0 ? raw.slice(0, colon) : raw;
179
+ const device = colon >= 0 ? (parseInt(raw.slice(colon + 1), 10) || 0) : 0;
180
+ return `${user}.0:${device}@${server}`;
181
+ }
182
+
183
+ // Participant-list hash v2, as whatsmeow's participantListHashV2 computes it:
184
+ // sort the AD strings, sha256 the concatenation, then base64 the FIRST SIX
185
+ // BYTES of the digest — eight characters, prefixed "2:".
186
+ //
187
+ // This previously mirrored Baileys' generateParticipantHashV2, which hashes
188
+ // plain JID strings and then takes the first six CHARACTERS of the base64 of
189
+ // the whole digest. That produces a different, six-character value from
190
+ // different input. Baileys never puts the result on the wire — it assigns it to
191
+ // extraAttrs after the participant nodes are built, so nothing ever reads it —
192
+ // which is why the discrepancy has gone unnoticed there. whalibmob did put it
193
+ // on the wire, so every multi-device send carried a hash the server could not
194
+ // match against its own view of the participant list.
168
195
  function computePhash(jids) {
169
- const sorted = [...jids].sort();
170
- const hash = crypto.createHash('sha256').update(sorted.join('')).digest();
171
- return '2:' + hash.toString('base64').slice(0, 6);
196
+ const sorted = [...new Set(jids.map(adString))].sort();
197
+ const hash = crypto.createHash('sha256').update(sorted.join('')).digest();
198
+ return '2:' + hash.subarray(0, 6).toString('base64').replace(/=+$/, '');
172
199
  }
173
200
 
174
201
  // ─── MessageSender ────────────────────────────────────────────────────────────
@@ -542,9 +569,20 @@ class MessageSender {
542
569
  const members = this._client._getGroupMembers
543
570
  ? this._client._getGroupMembers(toJid) : [];
544
571
  for (const memberJid of members) {
545
- await this._clearSignalSessionsForRecipient(phoneFromJid(memberJid)).catch(() => {});
572
+ await this._clearSignalSessionsForJid(memberJid).catch(() => {});
546
573
  }
547
- return this._sendGroupMessage(toJid, msgId, plaintext, mediaType,
574
+ // The sessions the SenderKey distribution rode in on are gone, so the
575
+ // record of who already holds our key is meaningless — every member
576
+ // needs a fresh distribution on the retry. Without this the retry sent
577
+ // a bare skmsg to devices that had just lost their session and drew the
578
+ // same 479 straight back.
579
+ if (this._signal && this._signal.senderKeyStore) {
580
+ this._signal.senderKeyStore.invalidateSKDM(toJid);
581
+ }
582
+ // A new id for the same reason the DM path uses one: the server has
583
+ // already acked the original, and re-using it makes it drop the
584
+ // connection.
585
+ return this._sendGroupMessage(toJid, generateMessageId(), plaintext, mediaType,
548
586
  Object.assign({}, options, { _479retry: true }));
549
587
  }
550
588
  // No session could be built for a single member — usually a prekey IQ
@@ -568,9 +606,12 @@ class MessageSender {
568
606
  try {
569
607
  return await this._sendDMMessage(toJid, msgId, plaintext, mediaType, options);
570
608
  } catch (err) {
571
- // Same phash-mismatch retry for 1-to-1 messages.
609
+ // Same phash-mismatch retry for 1-to-1 messages. A LID recipient's device
610
+ // list is cached under the "lid:" key, not the bare user.
572
611
  if (err && /\b421\b/.test(err.message)) {
573
- this._devMgr.clearCache([phoneFromJid(toJid)]);
612
+ this._devMgr.clearCache([
613
+ toJid.endsWith('@lid') ? 'lid:' + phoneFromJid(toJid) : phoneFromJid(toJid)
614
+ ]);
574
615
  return this._sendDMMessage(toJid, msgId, plaintext, mediaType, options);
575
616
  }
576
617
  // ── Error 479: stale / dead Signal session ────────────────────────────────
@@ -587,7 +628,7 @@ class MessageSender {
587
628
  // _479retry flag prevents infinite loops: if the fresh session also gets
588
629
  // 479 (extremely rare — server bug or banned account) we fail cleanly.
589
630
  if (err && /\b479\b/.test(err.message) && !options._479retry) {
590
- await this._clearSignalSessionsForRecipient(phoneFromJid(toJid));
631
+ await this._clearSignalSessionsForJid(toJid);
591
632
  // CRITICAL: generate a NEW msgId — the server already ACK'd (with error=479)
592
633
  // the original ID and will close the connection if we re-send the same ID.
593
634
  const retryMsgId = generateMessageId();
@@ -612,6 +653,44 @@ class MessageSender {
612
653
  }
613
654
  }
614
655
 
656
+ // ─── Encrypt for a device list, closing any gap before it reaches the wire ──
657
+ //
658
+ // Every JID handed in becomes a <to><enc> entry, and a stanza whose
659
+ // participants do not cover the device list the server holds for those users
660
+ // is nacked with ack 479. bulkEncryptForDevices drops whatever it cannot
661
+ // encrypt, so a single unusable session silently shrank the participant list
662
+ // and the whole send failed.
663
+ //
664
+ // Anything that fails gets its session torn down and rebuilt from a fresh
665
+ // prekey bundle, then re-encrypted once. If it still fails there is nothing
666
+ // left to try — the device is genuinely unreachable — and it is logged rather
667
+ // than passed over in silence.
668
+ async _encryptForDevices(jids, plaintext) {
669
+ if (!jids || jids.length === 0) return [];
670
+
671
+ const first = await this._signal.bulkEncryptForDevicesDetailed(jids, plaintext);
672
+ if (first.failed.length === 0) return first.encrypted;
673
+
674
+ _whaDbg('[DBG] ENCRYPT_GAP ' +
675
+ first.failed.map(f => f.jid + ' (' + f.reason + ')').join(', ') +
676
+ ' — rebuilding sessions');
677
+
678
+ const repaired = await this._devMgr
679
+ .rebuildSessionsForJids(first.failed.map(f => f.jid), this._signal)
680
+ .catch(err => {
681
+ _whaDbg('[DBG] ENCRYPT_GAP_REBUILD_ERR ' + (err && err.message));
682
+ return [];
683
+ });
684
+ if (repaired.length === 0) return first.encrypted;
685
+
686
+ const second = await this._signal.bulkEncryptForDevicesDetailed(repaired, plaintext);
687
+ if (second.failed.length > 0) {
688
+ _whaDbg('[DBG] ENCRYPT_GAP_UNRESOLVED ' +
689
+ second.failed.map(f => f.jid).join(', '));
690
+ }
691
+ return [...first.encrypted, ...second.encrypted];
692
+ }
693
+
615
694
  // Drop every cached device list and Signal session for these JIDs so the next
616
695
  // send is forced to re-query devices, re-fetch prekey bundles and open new
617
696
  // pkmsg sessions from scratch.
@@ -678,6 +757,38 @@ class MessageSender {
678
757
  _whaDbg('[DBG] 479_CACHE_FLUSHED phone=' + phone);
679
758
  }
680
759
 
760
+ // ─── Clear Signal sessions for a JID in whichever family it is written in ───
761
+ //
762
+ // _clearSignalSessionsForRecipient takes a phone number. Group recovery used
763
+ // to feed it phoneFromJid(memberJid), which for a LID member is the LID user —
764
+ // it was then looked up as a phone, built @s.whatsapp.net JIDs out of it and
765
+ // deleted nothing at all, so 479 recovery on a LID-addressed group was a
766
+ // no-op that resent the identical stanza. This dispatches on the server part
767
+ // instead.
768
+ async _clearSignalSessionsForJid(jid) {
769
+ const str = String(jid);
770
+ if (!str.endsWith('@lid')) {
771
+ return this._clearSignalSessionsForRecipient(phoneFromJid(str));
772
+ }
773
+
774
+ const lidUser = phoneFromJid(str);
775
+ const devIds = (this._devMgr._dcGet && this._devMgr._dcGet('lid:' + lidUser)) || [0];
776
+ for (const devId of devIds) {
777
+ const devJid = (devId === 0) ? `${lidUser}@lid` : `${lidUser}:${devId}@lid`;
778
+ try {
779
+ await this._signal.deleteSession(devJid);
780
+ _whaDbg('[DBG] 479_DEL_LID_SESSION jid=' + devJid);
781
+ } catch (_) {}
782
+ }
783
+ try { await this._signal.deleteSession(`${lidUser}@lid`); } catch (_) {}
784
+ this._devMgr.clearCache(['lid:' + lidUser]);
785
+
786
+ // A LID member we also know by phone has phone-addressed sessions cached
787
+ // under that number too; clear both so the retry cannot pick up a stale one.
788
+ const pn = this._client._lidToPn && this._client._lidToPn.get(lidUser);
789
+ if (pn) await this._clearSignalSessionsForRecipient(pn);
790
+ }
791
+
681
792
  // ─── 1-to-1 multi-device fanout ───────────────────────────────────────────
682
793
 
683
794
  async _sendDMMessage(toJid, msgId, plaintext, mediaType, options) {
@@ -693,13 +804,6 @@ class MessageSender {
693
804
  // they simply omit the device-identity node (server accepts this for primaries).
694
805
  const allowPkmsg = true;
695
806
 
696
- // Enumerate the recipient's devices and our own in parallel — Baileys does
697
- // the same in one getUSyncDevices([senderIdentity, jid]) call.
698
- const [_recipientDevicesByPhone, ownDevices] = await Promise.all([
699
- this._devMgr.bulkEnsureSessions([recipientPhone], this._signal, allowPkmsg),
700
- this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal, allowPkmsg)
701
- ]);
702
-
703
807
  // ── Addressing family ─────────────────────────────────────────────────────
704
808
  // Baileys 7.0.0-rc13 — the release that fixed ack 479 on LID addressing —
705
809
  // derives the whole address family from the JID the caller passed, and
@@ -714,35 +818,75 @@ class MessageSender {
714
818
  // participant <to> entries — stays on phone JIDs. Send to an @lid and it
715
819
  // all stays LID.
716
820
  //
717
- // This used to look the recipient up in _pnToLid and rewrite a
718
- // phone-addressed DM into a LID-addressed one, on the reasoning that a
719
- // phone-addressed message is accepted but never delivered to a
720
- // LID-registered device. That rewrite is what produced the 479s: every
721
- // rejected stanza in the traces carried to="...@lid", and both
722
- // whatsmeow #859 and the Baileys LID reports describe the ack as appearing
723
- // specifically on @lid-addressed sends. Following the caller's JID removes
724
- // the mismatch instead of trying to compensate for it.
821
+ // "Everything" includes OUR OWN devices, which is what was missing here.
822
+ // The recipient's devices followed the caller's JID but ours were always
823
+ // enumerated by phone, so a DM to an @lid went out with @lid recipient
824
+ // entries and @s.whatsapp.net entries for our own linked devices in the same
825
+ // <participants> list. whatsmeow builds the pair as [to, ownID] with ownID
826
+ // switched to our LID for @lid destinations precisely to avoid that, and a
827
+ // participant list the server cannot reconcile is what comes back as 479.
725
828
  const isLidTarget = toJid.endsWith('@lid');
726
829
  const routingToJid = toJid;
830
+ const ownLidUser = this._client._myLid ? phoneFromJid(this._client._myLid) : null;
831
+
832
+ if (isLidTarget && !ownLidUser) {
833
+ throw new Error(
834
+ 'Cannot send to ' + toJid + ': the recipient is LID-addressed but our own LID ' +
835
+ 'is unknown (the server did not supply lid= on <success>). Reconnect so the ' +
836
+ 'LID is re-issued.'
837
+ );
838
+ }
727
839
 
728
- let otherJids;
840
+ // Enumerate the recipient's devices and our own in parallel, both in the
841
+ // address family the destination JID dictates — Baileys does the same in one
842
+ // getUSyncDevices([senderIdentity, jid]) call.
843
+ let otherJids, ownLinkedJids, ownPrimaryJid;
729
844
  if (isLidTarget) {
730
845
  const lidUser = phoneFromJid(toJid);
731
- const lidDevices = await this._devMgr.bulkEnsureSessionsForLid(
732
- lidUser, this._signal, allowPkmsg, /* skipUsync */ true);
733
- otherJids = lidDevices.length > 0 ? lidDevices : [toJid];
846
+ const [lidDevices, ownLidDevices] = await Promise.all([
847
+ this._devMgr.bulkEnsureSessionsForLid(lidUser, this._signal, allowPkmsg, /* skipUsync */ true),
848
+ this._devMgr.ensureOwnLidDeviceSessions(ownLidUser, ownPhone, this._signal, allowPkmsg)
849
+ ]);
850
+ otherJids = lidDevices.length > 0 ? lidDevices : [toJid];
851
+ ownLinkedJids = ownLidDevices;
852
+ ownPrimaryJid = `${ownLidUser}@lid`;
734
853
  } else {
735
- otherJids = _recipientDevicesByPhone.length > 0 ? _recipientDevicesByPhone : [toJid];
854
+ const [pnDevices, ownDevices] = await Promise.all([
855
+ this._devMgr.bulkEnsureSessions([recipientPhone], this._signal, allowPkmsg),
856
+ this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal, allowPkmsg)
857
+ ]);
858
+ otherJids = pnDevices.length > 0 ? pnDevices : [toJid];
859
+ ownLinkedJids = ownDevices.filter(j => j !== ownMainJid);
860
+ ownPrimaryJid = ownMainJid;
736
861
  }
737
862
 
863
+ // The device doing the sending is never one of its own recipients. Both
864
+ // Baileys ("Skipping exact sender device") and whatsmeow drop it explicitly.
865
+ const ownPrimaryAddr = adString(ownPrimaryJid);
866
+ otherJids = otherJids.filter(j => adString(j) !== ownPrimaryAddr);
867
+ ownLinkedJids = ownLinkedJids.filter(j => adString(j) !== ownPrimaryAddr);
868
+
869
+ // A device appears in <participants> exactly once. Writing to our own chat
870
+ // makes both lists resolve to the same devices; they belong to the own-device
871
+ // list, which sends them the DeviceSentMessage rather than the bare message.
872
+ const ownLinkedAddrs = new Set(ownLinkedJids.map(adString));
873
+ otherJids = otherJids.filter(j => !ownLinkedAddrs.has(adString(j)));
874
+
738
875
  _whaDbg('[DBG] DM_ROUTE to=' + toJid + ' family=' + (isLidTarget ? 'LID' : 'PN') +
739
- ' devices=[' + otherJids.join(',') + ']');
876
+ ' devices=[' + otherJids.join(',') + ']' +
877
+ ' ownLinked=[' + ownLinkedJids.join(',') + ']');
740
878
  // ─────────────────────────────────────────────────────────────────────────
741
879
 
742
- const ownLinkedJids = ownDevices.filter(j => j !== ownMainJid);
743
-
744
- const allParticipants = [...otherJids, ...ownLinkedJids];
745
- const phash = allParticipants.length > 1 ? computePhash(allParticipants) : null;
880
+ // The hash covers every device the server enumerates for this pair of users,
881
+ // our own primary included — whatsmeow hashes GetUserDevices([to, ownID]) in
882
+ // full and only skips the sending device when it encrypts. Excluding it here
883
+ // produced a hash over a different set than the server's.
884
+ //
885
+ // It is not put on the stanza: neither whatsmeow nor Baileys sends phash on a
886
+ // 1:1 message, only on a group one. It is carried inside the (encrypted)
887
+ // DeviceSentMessage for our own devices, and compared against the phash the
888
+ // server echoes back on the ack so a drifted device cache gets flushed.
889
+ const phash = computePhash([...otherJids, ownPrimaryJid, ...ownLinkedJids]);
746
890
 
747
891
  const dsmBuf = ownLinkedJids.length > 0
748
892
  ? encodeDeviceSentMessage(toJid, plaintext, phash)
@@ -751,9 +895,9 @@ class MessageSender {
751
895
  // Acquire mutex before touching Signal sessions — concurrent sends on the
752
896
  // same session corrupt the ratchet chain and produce undecryptable messages.
753
897
  const [otherEncrypted, ownEncrypted] = await this._encryptMutex.runExclusive(async () => {
754
- const other = await this._signal.bulkEncryptForDevices(otherJids, plaintext);
898
+ const other = await this._encryptForDevices(otherJids, plaintext);
755
899
  const own = ownLinkedJids.length > 0
756
- ? await this._signal.bulkEncryptForDevices(ownLinkedJids, dsmBuf)
900
+ ? await this._encryptForDevices(ownLinkedJids, dsmBuf)
757
901
  : [];
758
902
  return [other, own];
759
903
  });
@@ -764,7 +908,6 @@ class MessageSender {
764
908
  // Raw UTF-8 strings are not recognised by the server for LID recipients, causing
765
909
  // the message to be accepted (server ACK) but never delivered to the device.
766
910
  const stanzaAttrs = { to: jidStrToObj(routingToJid), id: msgId, type: mediaType, t: String(msNow()) };
767
- if (phash) stanzaAttrs.phash = phash;
768
911
  if (options.edit) stanzaAttrs.edit = String(options.edit);
769
912
 
770
913
  const hasPkmsg = encryptedList.some(e => e.type === 'pkmsg');
@@ -833,6 +976,20 @@ class MessageSender {
833
976
 
834
977
  const dispatchResult = await this._dispatchAndAck(msgNode, msgId);
835
978
 
979
+ // The server echoes its own participant hash on the ack. A difference means
980
+ // its device list for this chat is not the one we just fanned out to, so the
981
+ // cached list is stale — drop it and let the next send re-run usync, exactly
982
+ // as whatsmeow does when the hashes disagree.
983
+ if (dispatchResult && dispatchResult.phash && dispatchResult.phash !== phash) {
984
+ _whaDbg('[DBG] PHASH_MISMATCH ours=' + phash + ' server=' + dispatchResult.phash +
985
+ ' to=' + toJid + ' — flushing device cache');
986
+ if (isLidTarget) {
987
+ this._devMgr.clearCache(['lid:' + phoneFromJid(toJid)]);
988
+ } else {
989
+ this._devMgr.clearCache([recipientPhone]);
990
+ }
991
+ }
992
+
836
993
  // ─── Fire-and-forget: issue privacy token to contact ─────────────────────
837
994
  // After each DM send we ask WhatsApp to issue us a trusted-contact token
838
995
  // for this JID (one issuance per 7-day bucket is enough). The token is
@@ -900,7 +1057,10 @@ class MessageSender {
900
1057
  'Reconnect so the LID is re-issued.'
901
1058
  );
902
1059
  }
903
- const myLidUser = useLid ? phoneFromJid(this._client._myLid) : null;
1060
+ // Derived whenever the server has given us a LID, not only in LID mode: it
1061
+ // is also what recognises our own entry in a member list that names us by
1062
+ // LID while the group itself is still pn-addressed.
1063
+ const myLidUser = this._client._myLid ? phoneFromJid(this._client._myLid) : null;
904
1064
  // For LID-mode groups, sender identity is own LID JID; otherwise own PN JID.
905
1065
  // Normalised to the bare user so it matches the participant targets below.
906
1066
  const selfJid = useLid ? `${myLidUser}@lid` : ownJid;
@@ -909,12 +1069,28 @@ class MessageSender {
909
1069
  const rawSKDM = await this._signal.buildSKDM(groupJid, senderIdentity);
910
1070
  const skdmMsg = encodeSenderKeyDistributionMessage(groupJid, rawSKDM);
911
1071
 
1072
+ // We are a participant of our own group, so our own entry has to be taken
1073
+ // out of the member walk and put back through the own-device path below.
1074
+ // Left in, the walk enumerates our devices starting at device 0 — the very
1075
+ // device doing the sending — and it lands in <participants> as a recipient
1076
+ // of our own SenderKey. Baileys skips the exact sender device explicitly
1077
+ // ("Skipping exact sender device"), whatsmeow does the same with
1078
+ // `jid == ownJID || jid == ownLID`.
1079
+ //
1080
+ // The phone branch already dropped us via `p !== ownPhone`; the LID branch
1081
+ // never did, so this only ever misfired on LID-addressed groups.
1082
+ const isSelfMember = (jid) => {
1083
+ const raw = phoneFromJid(jid);
1084
+ return raw === ownPhone || (myLidUser && raw === myLidUser);
1085
+ };
1086
+
912
1087
  // Resolve member phones for session/device queries.
913
1088
  // For LID members without a PN mapping, keep the raw LID user part so
914
1089
  // bulkEnsureSessionsForLid can still acquire sessions for them.
915
1090
  const lidMembersWithoutPn = [];
916
1091
  const memberPhones = [...new Set(
917
1092
  members.map(jid => {
1093
+ if (isSelfMember(jid)) return null;
918
1094
  const isLid = jid.endsWith('@lid');
919
1095
  const raw = phoneFromJid(jid);
920
1096
  if (isLid) {
@@ -925,9 +1101,6 @@ class MessageSender {
925
1101
  const pn = this._client._lidToPn && this._client._lidToPn.get(raw);
926
1102
  if (pn) return pn;
927
1103
  }
928
- // We are a member of the group ourselves, so our own devices come out
929
- // of this same walk — Baileys likewise derives every group target
930
- // from the participant list and never appends own devices separately.
931
1104
  lidMembersWithoutPn.push(jid);
932
1105
  return null;
933
1106
  }
@@ -935,7 +1108,7 @@ class MessageSender {
935
1108
  }).filter(p => p !== null && p !== ownPhone)
936
1109
  )];
937
1110
 
938
- const [memberDevices, lidDevices, ownDevices] = await Promise.all([
1111
+ const [memberDevices, lidDevices, ownTargets] = await Promise.all([
939
1112
  memberPhones.length > 0
940
1113
  ? this._devMgr.bulkEnsureSessions(memberPhones, this._signal)
941
1114
  : Promise.resolve([]),
@@ -945,18 +1118,39 @@ class MessageSender {
945
1118
  .catch(() => [])
946
1119
  )).then(results => results.flat())
947
1120
  : Promise.resolve([]),
948
- this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal)
1121
+ // Our own linked devices, in the address family the stanza is routed with.
1122
+ // ensureOwnDeviceSessions hands back @s.whatsapp.net JIDs, which cannot go
1123
+ // into a stanza carrying addressing_mode='lid'; both helpers already leave
1124
+ // out device 0.
1125
+ useLid
1126
+ ? this._devMgr.ensureOwnLidDeviceSessions(myLidUser, ownPhone, this._signal)
1127
+ : this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal)
949
1128
  ]);
950
1129
 
951
- // Own devices come back as @s.whatsapp.net — re-express them in LID space
952
- // so every target in a LID stanza shares one address family. In LID mode
953
- // the participant walk above already covered us, so this only fills the gap
954
- // for legacy pn-addressed groups.
955
- const ownTargets = useLid ? [] : ownDevices;
956
-
957
- // Dedupe: a device reached through more than one path must still appear
958
- // exactly once in <participants>.
959
- const allTargets = [...new Set([...memberDevices, ...lidDevices, ...ownTargets])];
1130
+ // Every device this group resolves to, our own primary included, deduped so
1131
+ // a device reached through more than one path is counted once. whatsmeow
1132
+ // hashes GetUserDevices(groupParticipants) in exactly this shape — the whole
1133
+ // enumeration, sending device and all — and drops hosted endpoints first,
1134
+ // since those are bot-side and never real participants.
1135
+ const isHostedJid = (jid) => {
1136
+ const s = String(jid);
1137
+ return s.endsWith('@hosted') || s.endsWith('@hosted.lid');
1138
+ };
1139
+ const enumeratedDevices = [...new Set(
1140
+ [...memberDevices, ...lidDevices, ...ownTargets, selfJid]
1141
+ )].filter(jid => !isHostedJid(jid));
1142
+
1143
+ // The participant hash the server checks the fan-out against. whatsmeow puts
1144
+ // it on group stanzas (sendGroup sets node.Attrs["phash"]) and nowhere else;
1145
+ // it is computed over the enumeration above, not over the encryption targets,
1146
+ // so the sending device counts here even though it is not a recipient.
1147
+ const phash = computePhash(enumeratedDevices);
1148
+
1149
+ // Targets to actually encrypt for: everything but the sending device. Both
1150
+ // Baileys and whatsmeow skip it explicitly at this point and not before.
1151
+ const selfAddrs = new Set([adString(`${ownPhone}@s.whatsapp.net`)]);
1152
+ if (myLidUser) selfAddrs.add(adString(`${myLidUser}@lid`));
1153
+ const allTargets = enumeratedDevices.filter(jid => !selfAddrs.has(adString(jid)));
960
1154
 
961
1155
  const skStore = this._signal.senderKeyStore;
962
1156
  const existingSkdmMap = skStore.getSKDMMap(groupJid);
@@ -980,8 +1174,13 @@ class MessageSender {
980
1174
  let skmsgCiphertext;
981
1175
  await this._encryptMutex.runExclusive(async () => {
982
1176
  if (skdmRecipients.length > 0) {
983
- skdmEncrypted = await this._signal.bulkEncryptForDevices(skdmRecipients, skdmMsg);
984
- skStore.markSKDMSent(groupJid, skdmRecipients);
1177
+ skdmEncrypted = await this._encryptForDevices(skdmRecipients, skdmMsg);
1178
+ // Record only the devices whose SenderKey distribution actually made it
1179
+ // into the stanza. Marking the whole intended list meant a device we
1180
+ // failed to encrypt for was remembered as already holding our key, so it
1181
+ // never got another distribution and could not decrypt anything we sent
1182
+ // to the group from then on.
1183
+ skStore.markSKDMSent(groupJid, skdmEncrypted.map(e => e.jid));
985
1184
  }
986
1185
  skmsgCiphertext = await this._signal.senderKeyEncrypt(groupJid, senderIdentity, plaintext);
987
1186
  });
@@ -1009,16 +1208,16 @@ class MessageSender {
1009
1208
  if (mediaSubtype) skmsgAttrs.mediatype = mediaSubtype;
1010
1209
  msgContent.push(new BinaryNode('enc', skmsgAttrs, Buffer.isBuffer(skmsgCiphertext) ? skmsgCiphertext : Buffer.from(skmsgCiphertext)));
1011
1210
 
1012
- // No phash on group stanzas. Baileys computes a participant hash only on
1013
- // the 1:1 path; a group message carries id/to/type/addressing_mode and
1014
- // nothing else. Sending a hash the server did not ask for — over a
1015
- // participant set it derives itself from the group — is a way to have the
1016
- // stanza rejected outright.
1211
+ // phash belongs on a group stanza and only on a group stanza — whatsmeow's
1212
+ // sendGroup sets it, sendDM does not. It tells the server which device set we
1213
+ // fanned out to; the server compares it against its own and echoes its hash
1214
+ // back on the ack when they differ.
1017
1215
  const stanzaAttrs = {
1018
1216
  to: jidStrToObj(groupJid),
1019
1217
  id: msgId,
1020
1218
  type: mediaType,
1021
1219
  addressing_mode: groupAddressingMode,
1220
+ phash,
1022
1221
  t: String(msNow())
1023
1222
  };
1024
1223
  if (options.edit) stanzaAttrs.edit = String(options.edit);
@@ -1040,7 +1239,27 @@ class MessageSender {
1040
1239
  }
1041
1240
 
1042
1241
  const msgNode = new BinaryNode('message', stanzaAttrs, msgContent);
1043
- return this._dispatchAndAck(msgNode, msgId);
1242
+ const dispatchResult = await this._dispatchAndAck(msgNode, msgId);
1243
+
1244
+ // A hash on the ack that differs from ours means the server's participant
1245
+ // list for this group is not the one we just fanned out to, so some members
1246
+ // did not get the message. whatsmeow drops its group cache on exactly this
1247
+ // signal; the device lists behind the members are dropped too, so the next
1248
+ // send re-runs group metadata and usync rather than repeating the miss.
1249
+ if (dispatchResult && dispatchResult.phash && dispatchResult.phash !== phash) {
1250
+ _whaDbg('[DBG] GROUP_PHASH_MISMATCH ours=' + phash +
1251
+ ' server=' + dispatchResult.phash + ' group=' + groupJid +
1252
+ ' — invalidating group metadata and device caches');
1253
+ if (typeof this._client.invalidateGroupMetadata === 'function') {
1254
+ this._client.invalidateGroupMetadata(groupJid);
1255
+ }
1256
+ const staleKeys = [...new Set(enumeratedDevices.map(jid =>
1257
+ String(jid).endsWith('@lid') ? 'lid:' + phoneFromJid(jid) : phoneFromJid(jid)
1258
+ ))];
1259
+ if (staleKeys.length) this._devMgr.clearCache(staleKeys);
1260
+ }
1261
+
1262
+ return dispatchResult;
1044
1263
  }
1045
1264
 
1046
1265
  // ─── Dispatch and wait for ack ────────────────────────────────────────────
@@ -1154,7 +1373,15 @@ class MessageSender {
1154
1373
  if (error) {
1155
1374
  reject(new Error('Send error ' + error + ' for ' + id));
1156
1375
  } else {
1157
- resolve({ id, t: ackAttrs && ackAttrs.t, status: 'sent' });
1376
+ resolve({
1377
+ id,
1378
+ t: ackAttrs && ackAttrs.t,
1379
+ // The server's own participant hash, when it sends one back. The
1380
+ // caller compares it against the list it fanned out to and drops
1381
+ // its device cache when they disagree.
1382
+ phash: ackAttrs && ackAttrs.phash ? String(ackAttrs.phash) : undefined,
1383
+ status: 'sent'
1384
+ });
1158
1385
  }
1159
1386
  });
1160
1387
 
@@ -1334,4 +1561,8 @@ function guessMime(data, fallback) {
1334
1561
  return map[ext] || fallback;
1335
1562
  }
1336
1563
 
1337
- module.exports = { MessageSender, makeJid, isGroupJid, generateMessageId, buildOrGetAdvIdentity: _buildOrGetAdvIdentity };
1564
+ module.exports = {
1565
+ MessageSender, makeJid, isGroupJid, generateMessageId,
1566
+ buildOrGetAdvIdentity: _buildOrGetAdvIdentity,
1567
+ computePhash, adString
1568
+ };
@@ -60,13 +60,19 @@ function encodeDouble(fieldNum, value) {
60
60
  return Buffer.concat([t, buf]);
61
61
  }
62
62
 
63
+ // Plain text goes in conversation (field 1). Text that carries a contextInfo —
64
+ // a quoted reply, a mention — has to be an ExtendedTextMessage instead, which is
65
+ // field 6 of Message: { text=1, contextInfo=17 }.
66
+ //
67
+ // The wrapper was being written to field 38, which is orderMessage, so replies
68
+ // and mentions arrived as an unreadable order rather than as text.
63
69
  function encodeText(text, contextInfo) {
64
70
  if (contextInfo) {
65
71
  const inner = Buffer.concat([
66
72
  field(1, WIRE_LEN, str(text)),
67
73
  field(17, WIRE_LEN, encodeContextInfo(contextInfo))
68
74
  ]);
69
- return field(38, WIRE_LEN, inner);
75
+ return field(6, WIRE_LEN, inner);
70
76
  }
71
77
  return field(1, WIRE_LEN, str(text));
72
78
  }
@@ -260,8 +266,12 @@ function encodeMessage(type, payload) {
260
266
  case 'sticker': return field(26, WIRE_LEN, payload);
261
267
  case 'reaction': return field(46, WIRE_LEN, payload);
262
268
  case 'protocol': return field(12, WIRE_LEN, payload);
263
- case 'extendedText': return field(2, WIRE_LEN, payload);
264
- case 'poll': return field(85, WIRE_LEN, payload);
269
+ // extendedTextMessage is field 6. It used to be written to field 2, which is
270
+ // senderKeyDistributionMessage — so an edited message went out claiming to be
271
+ // a SenderKey distribution, and field 2 was not free for the real one.
272
+ case 'extendedText': return field(6, WIRE_LEN, payload);
273
+ // pollCreationMessage is field 49; field 85 is eventCoverImage.
274
+ case 'poll': return field(49, WIRE_LEN, payload);
265
275
  case 'location': return field(5, WIRE_LEN, payload);
266
276
  case 'contact': return field(4, WIRE_LEN, payload);
267
277
  default: return payload;
@@ -380,12 +390,18 @@ function decodeMessageContainer(buf) {
380
390
  if (dsm[2]) return decodeMessageContainer(dsm[2]);
381
391
  }
382
392
 
383
- // Field 35: senderKeyDistributionMessage — used to set up sender keys.
393
+ // Field 2: senderKeyDistributionMessage — used to set up sender keys.
384
394
  // In WhatsApp Multi-Device, SKDM is bundled WITH the first real message in a new
385
395
  // session (even for 1-on-1 DMs). We extract it and continue decoding the real message.
396
+ //
397
+ // This read field 35, which is messageContextInfo. Two things followed: a real
398
+ // distribution from another member (always at field 2) was never seen, so their
399
+ // group messages stayed undecryptable; and the messageContextInfo that ordinary
400
+ // messages carry was fed to processSKDM as if it were a distribution, which
401
+ // writes junk states into the sender-key record.
386
402
  let skdmInfo = null;
387
- if (f[35]) {
388
- const skdm = _decodeFields(f[35]);
403
+ if (f[2]) {
404
+ const skdm = _decodeFields(f[2]);
389
405
  // field 1 = groupId (string), field 2 = axolotlSenderKeyDistributionMessage (bytes)
390
406
  skdmInfo = {
391
407
  groupId: _str(skdm[1]),
@@ -403,10 +419,10 @@ function decodeMessageContainer(buf) {
403
419
  if (text) msgResult = { type: 'text', text };
404
420
  }
405
421
 
406
- // Field 2: extendedTextMessage — field 1 = text
407
- if (!msgResult && f[2] && Buffer.isBuffer(f[2])) {
422
+ // Field 6: extendedTextMessage — field 1 = text
423
+ if (!msgResult && f[6] && Buffer.isBuffer(f[6])) {
408
424
  try {
409
- const ext = _decodeFields(f[2]);
425
+ const ext = _decodeFields(f[6]);
410
426
  const text = _str(ext[1]);
411
427
  if (text) msgResult = { type: 'text', text };
412
428
  } catch (_) {}
@@ -552,11 +568,11 @@ function decodeMessageContainer(buf) {
552
568
  } catch (_) { msgResult = { type: 'reaction', emoji: '' }; }
553
569
  }
554
570
 
555
- // Field 85: pollCreationMessage
571
+ // Field 49: pollCreationMessage
556
572
  // encKey=1, name=2 (question), options=3 (repeated PollOption{name=1}), selectableOptionsCount=4
557
- if (!msgResult && f[85] && Buffer.isBuffer(f[85])) {
573
+ if (!msgResult && f[49] && Buffer.isBuffer(f[49])) {
558
574
  try {
559
- const poll = _decodeFields(f[85]);
575
+ const poll = _decodeFields(f[49]);
560
576
  const question = _str(poll[2]);
561
577
  const rawOpts = poll[3];
562
578
  const options = [];
@@ -574,8 +590,8 @@ function decodeMessageContainer(buf) {
574
590
  } catch (_) { msgResult = { type: 'poll', question: '', options: [] }; }
575
591
  }
576
592
 
577
- // Field 89: pollUpdateMessage (a vote on an existing poll)
578
- if (!msgResult && f[89] && Buffer.isBuffer(f[89])) {
593
+ // Field 50: pollUpdateMessage (a vote on an existing poll)
594
+ if (!msgResult && f[50] && Buffer.isBuffer(f[50])) {
579
595
  msgResult = { type: 'pollVote' };
580
596
  }
581
597
 
@@ -595,12 +611,20 @@ function decodeMessageContainer(buf) {
595
611
  }
596
612
  }
597
613
 
614
+ // ─── SenderKeyDistributionMessage (field 2 of Message) ───────────────────────
615
+ // SenderKeyDistributionMessage { groupId=1, axolotlSenderKeyDistributionMessage=2 }
616
+ //
617
+ // This was written to field 35, which in Message is messageContextInfo — an
618
+ // entirely different type. Every group SenderKey we distributed was therefore
619
+ // labelled as context metadata, so no recipient ever saw a distribution, no
620
+ // recipient could open the sender-key session, and every skmsg we sent showed up
621
+ // as "Waiting for this message" on their side. It is field 2 in WAProto.
598
622
  function encodeSenderKeyDistributionMessage(groupId, axolotlBytes) {
599
623
  const inner = Buffer.concat([
600
624
  field(1, WIRE_LEN, str(groupId)),
601
625
  field(2, WIRE_LEN, bytes(axolotlBytes))
602
626
  ]);
603
- return field(35, WIRE_LEN, inner);
627
+ return field(2, WIRE_LEN, inner);
604
628
  }
605
629
 
606
630
  module.exports = {
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { dbg: _whaDbg } = require('../logger');
4
+
3
5
  const crypto = require('crypto');
4
6
  const fs = require('fs');
5
7
  const path = require('path');
@@ -74,6 +76,16 @@ function deriveMessageKeys(messageKey) {
74
76
 
75
77
  const _SK_DEBOUNCE_MS = 300;
76
78
 
79
+ // Bumped whenever a change means the SenderKey distributions we already sent
80
+ // were never usable by their recipients. The stored "who already has our key"
81
+ // map is then dropped once on load, so the next send to each group distributes
82
+ // again instead of trusting a record of deliveries that never landed.
83
+ //
84
+ // v2: the distribution was written to Message field 35 (messageContextInfo)
85
+ // instead of field 2, so no recipient ever received a readable one.
86
+ const SKDM_DISTRIBUTION_FORMAT = 2;
87
+ const SKDM_FORMAT_KEY = '__skdmFormat__';
88
+
77
89
  class SenderKeyStore {
78
90
  constructor() {
79
91
  this._data = {};
@@ -96,6 +108,27 @@ class SenderKeyStore {
96
108
  this._data = {};
97
109
  }
98
110
  }
111
+ this._migrateSKDMDistribution();
112
+ }
113
+
114
+ // Drop the "already distributed" bookkeeping once when the format changes.
115
+ // The sender keys themselves are kept — only the record of who has them is
116
+ // reset, so the next send to a group re-distributes to every member.
117
+ _migrateSKDMDistribution() {
118
+ if (this._data[SKDM_FORMAT_KEY] === SKDM_DISTRIBUTION_FORMAT) return;
119
+ let dropped = 0;
120
+ for (const key of Object.keys(this._data)) {
121
+ if (key.startsWith('__skdm__')) {
122
+ delete this._data[key];
123
+ dropped++;
124
+ }
125
+ }
126
+ this._data[SKDM_FORMAT_KEY] = SKDM_DISTRIBUTION_FORMAT;
127
+ this._save();
128
+ if (dropped > 0) {
129
+ _whaDbg('[DBG] SKDM_FORMAT_MIGRATION dropped ' + dropped +
130
+ ' stale distribution maps — every group re-distributes on next send');
131
+ }
99
132
  }
100
133
 
101
134
  _flushSync() {
@@ -249,12 +249,31 @@ class SignalProtocol {
249
249
  // same JID are still serialized — no Signal session corruption possible.
250
250
  // Returns [{jid, type, ciphertext}]
251
251
  async bulkEncryptForDevices(jids, plaintext) {
252
+ const { encrypted } = await this.bulkEncryptForDevicesDetailed(jids, plaintext);
253
+ return encrypted;
254
+ }
255
+
256
+ // Same fanout, but also reports the devices that could NOT be encrypted.
257
+ //
258
+ // A dropped device is not a cosmetic loss: every device handed in here becomes
259
+ // a <to><enc> entry in the outgoing stanza, and a stanza whose participants do
260
+ // not cover the recipient's current device list is rejected by the server with
261
+ // ack 479. The caller needs to know which addresses failed so it can rebuild
262
+ // their sessions and fill the gap before the stanza goes out.
263
+ async bulkEncryptForDevicesDetailed(jids, plaintext) {
252
264
  const settled = await Promise.allSettled(
253
265
  jids.map(jid => this.encrypt(jid, plaintext).then(enc => ({ jid, type: enc.type, ciphertext: enc.ciphertext })))
254
266
  );
255
- return settled
256
- .filter(r => r.status === 'fulfilled')
257
- .map(r => r.value);
267
+ const encrypted = [];
268
+ const failed = [];
269
+ settled.forEach((r, i) => {
270
+ if (r.status === 'fulfilled') {
271
+ encrypted.push(r.value);
272
+ } else {
273
+ failed.push({ jid: jids[i], reason: (r.reason && r.reason.message) || String(r.reason) });
274
+ }
275
+ });
276
+ return { encrypted, failed };
258
277
  }
259
278
 
260
279
  // ─── SenderKey group encrypt / decrypt ────────────────────────────────────
@@ -27,6 +27,10 @@ class SignalStore {
27
27
  this._identities = {};
28
28
  this._filePath = null;
29
29
  this._lidMappings = {}; // phone → lid — persisted alongside sessions
30
+ // address → { value, open } — memoises the haveOpenSession() check below.
31
+ // Keyed on the stored record value, so storeSession() (which always assigns
32
+ // a freshly serialised object) invalidates the entry by identity.
33
+ this._openSessionMemo = new Map();
30
34
 
31
35
  // Debounce state
32
36
  this._dirty = false;
@@ -173,8 +177,40 @@ class SignalStore {
173
177
  this._save();
174
178
  }
175
179
 
180
+ // True only when the stored record still has an OPEN session — i.e. one we can
181
+ // actually encrypt with.
182
+ //
183
+ // A SessionRecord can hold sessions that libsignal has closed: session_builder
184
+ // closes the current one whenever it processes an incoming prekey bundle or
185
+ // installs a new outgoing one, and it stays in the record as archived state.
186
+ // The record therefore keeps existing long after it stops being usable.
187
+ //
188
+ // This used to answer `!!this._sessions[addr]`, so DeviceManager treated a
189
+ // closed session as ready, skipped the prekey fetch, and handed the address to
190
+ // SessionCipher.encrypt — which throws "No open session". bulkEncryptForDevices
191
+ // drops whatever it cannot encrypt, so the stanza went out with a <to> entry
192
+ // missing for that device and the server nacked the whole send with ack 479.
193
+ // Baileys hit the same class of bug with its peerSessionsCache and fixed it by
194
+ // validating through haveOpenSession() on every check.
176
195
  hasSession(encodedAddress) {
177
- return !!this._sessions[encodedAddress];
196
+ if (!this._openSessionMemo) this._openSessionMemo = new Map();
197
+ const data = this._sessions[encodedAddress];
198
+ if (!data) {
199
+ this._openSessionMemo.delete(encodedAddress);
200
+ return false;
201
+ }
202
+ const memo = this._openSessionMemo.get(encodedAddress);
203
+ if (memo && memo.value === data) return memo.open;
204
+
205
+ let open = false;
206
+ try {
207
+ const obj = typeof data === 'string' ? JSON.parse(data) : data;
208
+ open = SessionRecord.deserialize(obj).haveOpenSession();
209
+ } catch (_) {
210
+ open = false;
211
+ }
212
+ this._openSessionMemo.set(encodedAddress, { value: data, open });
213
+ return open;
178
214
  }
179
215
 
180
216
  // ─── PreKeys ──────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.5.81",
3
+ "version": "5.5.83",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto50",
6
6
  "main": "index.js",