whalibmob 5.5.80 → 5.5.82
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 +17 -1
- package/lib/DeviceManager.js +202 -58
- package/lib/messages/MessageSender.js +249 -54
- package/lib/signal/SignalProtocol.js +22 -3
- package/lib/signal/SignalStore.js +37 -1
- package/package.json +1 -1
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)
|
|
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) {
|
package/lib/DeviceManager.js
CHANGED
|
@@ -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(
|
|
699
|
-
readyJids.push(
|
|
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));
|
|
@@ -718,7 +737,7 @@ class DeviceManager {
|
|
|
718
737
|
// 2. Establishes Signal sessions for LID device JIDs (e.g. 139471160877194:0@lid)
|
|
719
738
|
// 3. Returns the list of ready LID device JIDs for participant fanout
|
|
720
739
|
//
|
|
721
|
-
async bulkEnsureSessionsForLid(lidUser, signalProto, allowPkmsg = true, skipUsync = false) {
|
|
740
|
+
async bulkEnsureSessionsForLid(lidUser, signalProto, allowPkmsg = true, skipUsync = false, _isRetry = false) {
|
|
722
741
|
const lidJid = `${lidUser}@lid`;
|
|
723
742
|
const cacheKey = `lid:${lidUser}`;
|
|
724
743
|
|
|
@@ -743,7 +762,47 @@ class DeviceManager {
|
|
|
743
762
|
_whaDbg('[DBG] LID_DEVICES lidUser=' + lidUser +
|
|
744
763
|
' deviceJids=[' + deviceJids.join(',') + ']\n');
|
|
745
764
|
|
|
746
|
-
|
|
765
|
+
const readyJids = await this._ensureLidSessions(lidUser, deviceJids, signalProto, allowPkmsg);
|
|
766
|
+
|
|
767
|
+
// Every enumerated device must end up with a session, because every one of
|
|
768
|
+
// them becomes a <to><enc> entry. Coming up short means the cached device
|
|
769
|
+
// list no longer matches the account: a device that has since been unlinked
|
|
770
|
+
// is still listed, the server has no bundle for it, and the stanza goes out
|
|
771
|
+
// missing an entry the server still expects — which it rejects with ack 479.
|
|
772
|
+
//
|
|
773
|
+
// Seen exactly that way in a live trace: five devices enumerated from cache
|
|
774
|
+
// (0, 49, 51, 55, 57), four bundles returned, device 51 left without a
|
|
775
|
+
// session, four participants sent, 479 back.
|
|
776
|
+
//
|
|
777
|
+
// Drop the stale list and re-enumerate once, so the participants match what
|
|
778
|
+
// the server currently believes the account has.
|
|
779
|
+
if (readyJids.length < deviceJids.length && !_isRetry) {
|
|
780
|
+
_whaDbg('[DBG] LID_DEVICE_LIST_STALE lidUser=' + lidUser +
|
|
781
|
+
' enumerated=' + deviceJids.length + ' sessions=' + readyJids.length +
|
|
782
|
+
' — re-running usync\n');
|
|
783
|
+
this._dcDel([cacheKey]);
|
|
784
|
+
return this.bulkEnsureSessionsForLid(
|
|
785
|
+
lidUser, signalProto, allowPkmsg, /* skipUsync */ false, /* _isRetry */ true);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Fallback: use primary LID device 0 directly (no session yet, will use pkmsg)
|
|
789
|
+
if (readyJids.length === 0) {
|
|
790
|
+
readyJids.push(makeDeviceJid(lidUser, 0, 'lid'));
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
return readyJids;
|
|
794
|
+
}
|
|
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) {
|
|
747
806
|
const readyJids = [];
|
|
748
807
|
const fetchJids = [];
|
|
749
808
|
for (const jid of deviceJids) {
|
|
@@ -754,70 +813,155 @@ class DeviceManager {
|
|
|
754
813
|
}
|
|
755
814
|
}
|
|
756
815
|
|
|
757
|
-
if (allowPkmsg
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
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());
|
|
778
899
|
} else {
|
|
779
|
-
|
|
900
|
+
await this._doUsyncIqByJid(`${lidUser}@lid`, cacheKey, lidUser);
|
|
780
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
|
+
}
|
|
781
911
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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;
|
|
789
940
|
try {
|
|
790
|
-
await signalProto.buildSessionFromBundle(
|
|
791
|
-
|
|
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);
|
|
941
|
+
await signalProto.buildSessionFromBundle(target, bundle);
|
|
942
|
+
ready.push(target);
|
|
809
943
|
} catch (e) {
|
|
810
|
-
_whaDbg('[DBG]
|
|
944
|
+
_whaDbg('[DBG] REBUILD_SESSION_ERR jid=' + target + ' err=' + e.message);
|
|
811
945
|
}
|
|
812
946
|
}
|
|
813
947
|
}
|
|
814
948
|
|
|
815
|
-
//
|
|
816
|
-
|
|
817
|
-
|
|
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);
|
|
818
961
|
}
|
|
819
962
|
|
|
820
|
-
|
|
963
|
+
_whaDbg('[DBG] REBUILD_SESSIONS requested=' + unique.length + ' ready=' + ready.length);
|
|
964
|
+
return ready;
|
|
821
965
|
}
|
|
822
966
|
|
|
823
967
|
// ─── Contact usync fallback ────────────────────────────────────────────────
|
|
@@ -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
|
|
171
|
-
return '2:' + hash.toString('base64').
|
|
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.
|
|
572
|
+
await this._clearSignalSessionsForJid(memberJid).catch(() => {});
|
|
546
573
|
}
|
|
547
|
-
|
|
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([
|
|
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.
|
|
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
|
-
//
|
|
718
|
-
//
|
|
719
|
-
// phone
|
|
720
|
-
//
|
|
721
|
-
//
|
|
722
|
-
//
|
|
723
|
-
//
|
|
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;
|
|
727
831
|
|
|
728
|
-
|
|
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
|
+
}
|
|
839
|
+
|
|
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
|
|
732
|
-
lidUser, this._signal, allowPkmsg, /* skipUsync */ true)
|
|
733
|
-
|
|
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
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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.
|
|
898
|
+
const other = await this._encryptForDevices(otherJids, plaintext);
|
|
755
899
|
const own = ownLinkedJids.length > 0
|
|
756
|
-
? await this.
|
|
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
|
-
|
|
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,
|
|
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,23 @@ class MessageSender {
|
|
|
945
1118
|
.catch(() => [])
|
|
946
1119
|
)).then(results => results.flat())
|
|
947
1120
|
: Promise.resolve([]),
|
|
948
|
-
|
|
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
1130
|
// Dedupe: a device reached through more than one path must still appear
|
|
958
|
-
// exactly once in <participants>.
|
|
959
|
-
|
|
1131
|
+
// exactly once in <participants>. The sending device is filtered out again
|
|
1132
|
+
// here in case a member list named us under an address the walk above did
|
|
1133
|
+
// not recognise.
|
|
1134
|
+
const selfAddrs = new Set([adString(`${ownPhone}@s.whatsapp.net`)]);
|
|
1135
|
+
if (myLidUser) selfAddrs.add(adString(`${myLidUser}@lid`));
|
|
1136
|
+
const allTargets = [...new Set([...memberDevices, ...lidDevices, ...ownTargets])]
|
|
1137
|
+
.filter(jid => !selfAddrs.has(adString(jid)));
|
|
960
1138
|
|
|
961
1139
|
const skStore = this._signal.senderKeyStore;
|
|
962
1140
|
const existingSkdmMap = skStore.getSKDMMap(groupJid);
|
|
@@ -980,8 +1158,13 @@ class MessageSender {
|
|
|
980
1158
|
let skmsgCiphertext;
|
|
981
1159
|
await this._encryptMutex.runExclusive(async () => {
|
|
982
1160
|
if (skdmRecipients.length > 0) {
|
|
983
|
-
skdmEncrypted = await this.
|
|
984
|
-
|
|
1161
|
+
skdmEncrypted = await this._encryptForDevices(skdmRecipients, skdmMsg);
|
|
1162
|
+
// Record only the devices whose SenderKey distribution actually made it
|
|
1163
|
+
// into the stanza. Marking the whole intended list meant a device we
|
|
1164
|
+
// failed to encrypt for was remembered as already holding our key, so it
|
|
1165
|
+
// never got another distribution and could not decrypt anything we sent
|
|
1166
|
+
// to the group from then on.
|
|
1167
|
+
skStore.markSKDMSent(groupJid, skdmEncrypted.map(e => e.jid));
|
|
985
1168
|
}
|
|
986
1169
|
skmsgCiphertext = await this._signal.senderKeyEncrypt(groupJid, senderIdentity, plaintext);
|
|
987
1170
|
});
|
|
@@ -1154,7 +1337,15 @@ class MessageSender {
|
|
|
1154
1337
|
if (error) {
|
|
1155
1338
|
reject(new Error('Send error ' + error + ' for ' + id));
|
|
1156
1339
|
} else {
|
|
1157
|
-
resolve({
|
|
1340
|
+
resolve({
|
|
1341
|
+
id,
|
|
1342
|
+
t: ackAttrs && ackAttrs.t,
|
|
1343
|
+
// The server's own participant hash, when it sends one back. The
|
|
1344
|
+
// caller compares it against the list it fanned out to and drops
|
|
1345
|
+
// its device cache when they disagree.
|
|
1346
|
+
phash: ackAttrs && ackAttrs.phash ? String(ackAttrs.phash) : undefined,
|
|
1347
|
+
status: 'sent'
|
|
1348
|
+
});
|
|
1158
1349
|
}
|
|
1159
1350
|
});
|
|
1160
1351
|
|
|
@@ -1334,4 +1525,8 @@ function guessMime(data, fallback) {
|
|
|
1334
1525
|
return map[ext] || fallback;
|
|
1335
1526
|
}
|
|
1336
1527
|
|
|
1337
|
-
module.exports = {
|
|
1528
|
+
module.exports = {
|
|
1529
|
+
MessageSender, makeJid, isGroupJid, generateMessageId,
|
|
1530
|
+
buildOrGetAdvIdentity: _buildOrGetAdvIdentity,
|
|
1531
|
+
computePhash, adString
|
|
1532
|
+
};
|
|
@@ -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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────────────────────
|