whalibmob 5.5.82 → 5.5.84
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/cli.js +38 -9
- package/lib/Client.js +498 -95
- package/lib/MediaService.js +3 -0
- package/lib/messages/MessageSender.js +54 -26
- package/lib/proto/MessageProto.js +39 -15
- package/lib/signal/SenderKey.js +33 -0
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -362,7 +362,9 @@ const HELP = `
|
|
|
362
362
|
|
|
363
363
|
Contacts
|
|
364
364
|
/whatsapp <phone...> check which numbers have WhatsApp
|
|
365
|
-
/picture <jid>
|
|
365
|
+
/picture <jid> [file] profile picture URL; downloads it when a file is given
|
|
366
|
+
/read <jid> <msgId...> mark messages as read (blue ticks)
|
|
367
|
+
/autoread on|off auto-send read receipts for incoming messages
|
|
366
368
|
/contact about <jid> get bio/status text of a contact
|
|
367
369
|
|
|
368
370
|
Chats
|
|
@@ -393,7 +395,7 @@ const HELP = `
|
|
|
393
395
|
/group desc <jid> <text> change description
|
|
394
396
|
/group invite <jid> get invite link
|
|
395
397
|
/group revoke <jid> revoke invite link
|
|
396
|
-
/group join <code>
|
|
398
|
+
/group join <code|link> join by invite code or full link
|
|
397
399
|
/group invite-info <code|url> preview group metadata from invite link
|
|
398
400
|
/group photo <jid> <file> change group picture (JPEG)
|
|
399
401
|
/group meta <jid> query group metadata + participants
|
|
@@ -1003,11 +1005,37 @@ async function handleLine(line) {
|
|
|
1003
1005
|
}
|
|
1004
1006
|
|
|
1005
1007
|
case '/picture': {
|
|
1008
|
+
requireConn();
|
|
1009
|
+
const jid = normalizeJid(p[1]);
|
|
1010
|
+
const dest = p[2];
|
|
1011
|
+
if (!jid) { fail('usage: /picture <jid> [file]'); break; }
|
|
1012
|
+
const info = await _client.queryPictureInfo(jid);
|
|
1013
|
+
if (!info || !info.url) { out(' (no picture or private)'); break; }
|
|
1014
|
+
out(' ' + info.url);
|
|
1015
|
+
if (info.id) out(' id ' + info.id);
|
|
1016
|
+
if (!dest) break;
|
|
1017
|
+
const buf = await _client.downloadProfilePicture(jid);
|
|
1018
|
+
if (!buf) { fail('download returned nothing'); break; }
|
|
1019
|
+
require('fs').writeFileSync(dest, buf);
|
|
1020
|
+
out(' saved ' + dest + ' (' + buf.length + ' bytes)');
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
case '/read': {
|
|
1006
1025
|
requireConn();
|
|
1007
1026
|
const jid = normalizeJid(p[1]);
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1027
|
+
const ids = p.slice(2);
|
|
1028
|
+
if (!jid || !ids.length) { fail('usage: /read <jid> <msgId...>'); break; }
|
|
1029
|
+
_client.markRead(jid, ids);
|
|
1030
|
+
out('marked read ' + ids.length + ' message(s) in ' + jid);
|
|
1031
|
+
break;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
case '/autoread': {
|
|
1035
|
+
const val = (p[1] || '').toLowerCase();
|
|
1036
|
+
if (val !== 'on' && val !== 'off') { fail('usage: /autoread on|off'); break; }
|
|
1037
|
+
if (_client) _client.autoRead = (val === 'on');
|
|
1038
|
+
out('auto read receipts ' + val);
|
|
1011
1039
|
break;
|
|
1012
1040
|
}
|
|
1013
1041
|
|
|
@@ -1232,14 +1260,15 @@ async function handleLine(line) {
|
|
|
1232
1260
|
else if (sub === 'revoke') {
|
|
1233
1261
|
const gj = asGroupJid(p[2]);
|
|
1234
1262
|
if (!gj) { fail('usage: /group revoke <jid>'); break; }
|
|
1235
|
-
await _client.revokeGroupInvite(gj);
|
|
1263
|
+
const fresh = await _client.revokeGroupInvite(gj);
|
|
1236
1264
|
out('invite link revoked');
|
|
1265
|
+
if (fresh) out(' new link ' + fresh);
|
|
1237
1266
|
}
|
|
1238
1267
|
else if (sub === 'join') {
|
|
1239
1268
|
const code = p[2];
|
|
1240
|
-
if (!code) { fail('usage: /group join <code>'); break; }
|
|
1241
|
-
const
|
|
1242
|
-
out('joined ' +
|
|
1269
|
+
if (!code) { fail('usage: /group join <code|link>'); break; }
|
|
1270
|
+
const r = await _client.joinGroupWithLink(code);
|
|
1271
|
+
out((r.pendingApproval ? 'join request sent ' : 'joined ') + r.jid);
|
|
1243
1272
|
}
|
|
1244
1273
|
else if (sub === 'invite-info') {
|
|
1245
1274
|
const code = p[2];
|
package/lib/Client.js
CHANGED
|
@@ -21,7 +21,7 @@ const { NodeCache } = require('@cacheable/node-cache');
|
|
|
21
21
|
// Group metadata cache lifetime — matches DeviceManager's device cache.
|
|
22
22
|
const GROUP_META_CACHE_TTL = '5m';
|
|
23
23
|
const { SignalProtocol } = require('./signal/SignalProtocol');
|
|
24
|
-
const { DeviceManager }
|
|
24
|
+
const { DeviceManager, jidStrToObj } = require('./DeviceManager');
|
|
25
25
|
const {
|
|
26
26
|
processHistorySyncNotification,
|
|
27
27
|
decodeHistorySyncNotification,
|
|
@@ -30,6 +30,18 @@ const {
|
|
|
30
30
|
HistorySyncType
|
|
31
31
|
} = require('./HistorySyncHandler');
|
|
32
32
|
|
|
33
|
+
// whatsmeow's InviteLinkPrefix — the one prefix group invite codes are wrapped in.
|
|
34
|
+
const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
|
|
35
|
+
|
|
36
|
+
// Accept a full invite URL or a bare code and return the code.
|
|
37
|
+
function stripInviteUrl(codeOrUrl) {
|
|
38
|
+
return String(codeOrUrl || '')
|
|
39
|
+
.trim()
|
|
40
|
+
.replace(/^https?:\/\/chat\.whatsapp\.com\//i, '')
|
|
41
|
+
.replace(/[?#].*$/, '')
|
|
42
|
+
.trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
33
45
|
const PING_INTERVAL_MS = 25000;
|
|
34
46
|
const KEEPALIVE_INTERVAL = 20000;
|
|
35
47
|
const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000];
|
|
@@ -163,6 +175,12 @@ class WhalibmobClient extends EventEmitter {
|
|
|
163
175
|
this._tcTokenStore = null; // TcTokenStore — loaded in init()
|
|
164
176
|
this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
|
|
165
177
|
this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
|
|
178
|
+
// Blue-tick every message as it arrives. Set to false for whatsmeow's model,
|
|
179
|
+
// where read is only ever sent by an explicit markRead() call.
|
|
180
|
+
this.autoRead = opts.autoRead !== false;
|
|
181
|
+
// Flipped by setOnline(). Delivery receipts sent while this is false carry
|
|
182
|
+
// type="inactive", which the sender's app records but does not render.
|
|
183
|
+
this._sendActiveReceipts = false;
|
|
166
184
|
if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
|
|
167
185
|
}
|
|
168
186
|
|
|
@@ -667,6 +685,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
667
685
|
|
|
668
686
|
if (skmsgNode && isGroup) {
|
|
669
687
|
this._sendMessageAck(id, fromRaw, partRaw);
|
|
688
|
+
this._sendDeliveryReceipt(id, fromRaw, partRaw);
|
|
670
689
|
// SKDM must be processed (awaited) before we attempt skmsg decryption,
|
|
671
690
|
// because the first message from a sender always bundles both together.
|
|
672
691
|
if (participantsNode) {
|
|
@@ -678,6 +697,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
678
697
|
|
|
679
698
|
if (dmEncNode && this._signal) {
|
|
680
699
|
this._sendMessageAck(id, fromRaw, partRaw);
|
|
700
|
+
this._sendDeliveryReceipt(id, fromRaw, partRaw);
|
|
681
701
|
this._decryptDMMessage({ node, from, participant, fromRaw, partRaw, senderPn, id, ts, dmEncNode });
|
|
682
702
|
return;
|
|
683
703
|
}
|
|
@@ -690,6 +710,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
690
710
|
) : null;
|
|
691
711
|
|
|
692
712
|
this._sendMessageAck(id, fromRaw, partRaw);
|
|
713
|
+
this._sendDeliveryReceipt(id, fromRaw, partRaw);
|
|
693
714
|
this.emit('message', { id, from, participant, ts, text, node });
|
|
694
715
|
this._sendReadReceipt(id, fromRaw, partRaw);
|
|
695
716
|
}
|
|
@@ -1009,15 +1030,55 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1009
1030
|
this._socket.sendNode(new BinaryNode('ack', attrs, null));
|
|
1010
1031
|
}
|
|
1011
1032
|
|
|
1012
|
-
//
|
|
1013
|
-
|
|
1033
|
+
// True when a JID names this account, in either address family.
|
|
1034
|
+
_isOwnUser(jid) {
|
|
1035
|
+
if (!jid) return false;
|
|
1036
|
+
const user = String(jid).split('@')[0].split(':')[0];
|
|
1037
|
+
if (!user) return false;
|
|
1038
|
+
if (user === String(this._store && this._store.phoneNumber)) return true;
|
|
1039
|
+
const ownLid = this._myLid ? String(this._myLid).split('@')[0].split(':')[0] : null;
|
|
1040
|
+
return !!ownLid && user === ownLid;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// Delivery receipt — the sender's two grey ticks.
|
|
1044
|
+
//
|
|
1045
|
+
// Distinct from the <ack> above: the ack tells the server we took the stanza,
|
|
1046
|
+
// the receipt tells the sender it reached a device. whatsmeow sends both for
|
|
1047
|
+
// every incoming message (sendAck + sendMessageReceipt) and leaves the blue
|
|
1048
|
+
// ticks to an explicit MarkRead. whalibmob only ever sent the ack and then
|
|
1049
|
+
// jumped straight to type="read", so a sender never saw the grey stage.
|
|
1050
|
+
//
|
|
1051
|
+
// A client that has not marked itself available sends this with
|
|
1052
|
+
// type="inactive": the sender's app records the delivery but does not render
|
|
1053
|
+
// it, which is how WhatsApp Web behaves in the background.
|
|
1054
|
+
_sendDeliveryReceipt(msgId, fromJid, partJid) {
|
|
1055
|
+
if (!this._socket || !this._connected) return;
|
|
1056
|
+
const attrs = { id: msgId, to: fromJid };
|
|
1057
|
+
if (partJid && String(partJid) !== String(fromJid)) attrs.participant = partJid;
|
|
1058
|
+
// A message that came from one of our own other devices is receipted with
|
|
1059
|
+
// type="sender", never as a delivery to ourselves.
|
|
1060
|
+
if (this._isOwnUser(partJid || fromJid)) {
|
|
1061
|
+
attrs.type = 'sender';
|
|
1062
|
+
} else if (!this._sendActiveReceipts) {
|
|
1063
|
+
attrs.type = 'inactive';
|
|
1064
|
+
}
|
|
1065
|
+
this._socket.sendNode(new BinaryNode('receipt', attrs, null));
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Read receipt — the sender's two blue ticks.
|
|
1069
|
+
//
|
|
1070
|
+
// Sent automatically after a message decodes unless autoRead is turned off, in
|
|
1071
|
+
// which case marking a chat read is up to the caller via markRead(). Turning
|
|
1072
|
+
// it off gives whatsmeow's model, where read is always explicit.
|
|
1014
1073
|
// fromJid / partJid are raw JID objects (AD_JID encoding for correct routing).
|
|
1015
1074
|
_sendReadReceipt(msgId, fromJid, partJid) {
|
|
1016
1075
|
if (!this._socket || !this._connected) return;
|
|
1076
|
+
if (this.autoRead === false) return;
|
|
1017
1077
|
const node = new BinaryNode('receipt', {
|
|
1018
1078
|
id: msgId,
|
|
1019
1079
|
type: 'read',
|
|
1020
|
-
to: fromJid
|
|
1080
|
+
to: fromJid,
|
|
1081
|
+
t: String(Math.floor(Date.now() / 1000))
|
|
1021
1082
|
}, null);
|
|
1022
1083
|
if (partJid && String(partJid) !== String(fromJid)) {
|
|
1023
1084
|
node.attrs.participant = partJid;
|
|
@@ -1523,8 +1584,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1523
1584
|
return { jid: pJid, role: pRole };
|
|
1524
1585
|
});
|
|
1525
1586
|
|
|
1526
|
-
// description
|
|
1587
|
+
// description — the id is the "topic id" the server wants echoed back as
|
|
1588
|
+
// prev= on the next edit, so it has to survive the parse.
|
|
1527
1589
|
const descNode = children.find(n => n && n.description === 'description');
|
|
1590
|
+
const descriptionId = (descNode && descNode.attrs && descNode.attrs.id)
|
|
1591
|
+
? String(descNode.attrs.id) : null;
|
|
1528
1592
|
let description = null;
|
|
1529
1593
|
if (descNode) {
|
|
1530
1594
|
const bodyNode = Array.isArray(descNode.content)
|
|
@@ -1563,6 +1627,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1563
1627
|
subjectTime: parseInt(a.s_t, 10) || 0,
|
|
1564
1628
|
subjectBy: a.s_o || null,
|
|
1565
1629
|
description,
|
|
1630
|
+
descriptionId,
|
|
1566
1631
|
ephemeral,
|
|
1567
1632
|
onlyAdminsSend: announce,
|
|
1568
1633
|
onlyAdminsEdit: restrict,
|
|
@@ -2182,12 +2247,54 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2182
2247
|
|
|
2183
2248
|
// ─── Public API ───────────────────────────────────────────────────────────
|
|
2184
2249
|
|
|
2250
|
+
// Alias kept from the original API — setOnline is the implementation.
|
|
2185
2251
|
setPresence(available) {
|
|
2186
|
-
|
|
2252
|
+
return this.setOnline(available);
|
|
2187
2253
|
}
|
|
2188
2254
|
|
|
2189
|
-
|
|
2190
|
-
|
|
2255
|
+
// Mark messages as read — the sender's two blue ticks.
|
|
2256
|
+
//
|
|
2257
|
+
// jid chat the messages are in (the group JID for a group)
|
|
2258
|
+
// messageIds one id or an array of ids, all from the SAME sender
|
|
2259
|
+
// opts { participant, type } — participant is who sent them and is
|
|
2260
|
+
// required in a group; type may be 'read' (default) or 'played'
|
|
2261
|
+
// to mark a voice note as listened to.
|
|
2262
|
+
//
|
|
2263
|
+
// Shaped the way whatsmeow's MarkRead builds it: the FIRST id on the receipt
|
|
2264
|
+
// and the rest as <item> children of a <list> node. whalibmob used the last id
|
|
2265
|
+
// and put the items straight into <receipt>, which is not a shape the server
|
|
2266
|
+
// reads back — the extra ids were silently ignored, so only one message per
|
|
2267
|
+
// call was ever marked. The participant was missing entirely, so a group read
|
|
2268
|
+
// never told the server whose message had been read.
|
|
2269
|
+
markRead(jid, messageIds, opts) {
|
|
2270
|
+
if (!this._socket || !this._connected) return;
|
|
2271
|
+
const ids = (Array.isArray(messageIds) ? messageIds : [messageIds])
|
|
2272
|
+
.map(String).filter(Boolean);
|
|
2273
|
+
if (ids.length === 0) return;
|
|
2274
|
+
|
|
2275
|
+
opts = opts || {};
|
|
2276
|
+
const chatJid = makeJid(jid);
|
|
2277
|
+
const isGroup = chatJid.endsWith('@g.us') || chatJid.endsWith('@community')
|
|
2278
|
+
|| chatJid.endsWith('@broadcast');
|
|
2279
|
+
|
|
2280
|
+
const attrs = {
|
|
2281
|
+
id: ids[0],
|
|
2282
|
+
type: opts.type === 'played' ? 'played' : 'read',
|
|
2283
|
+
to: jidStrToObj(chatJid),
|
|
2284
|
+
t: String(Math.floor(Date.now() / 1000))
|
|
2285
|
+
};
|
|
2286
|
+
// Only group and broadcast receipts carry a participant; on a 1:1 chat the
|
|
2287
|
+
// `to` already identifies the sender.
|
|
2288
|
+
if (isGroup && opts.participant) {
|
|
2289
|
+
attrs.participant = jidStrToObj(makeJid(opts.participant));
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const children = ids.length > 1
|
|
2293
|
+
? [new BinaryNode('list', {}, ids.slice(1).map(
|
|
2294
|
+
id => new BinaryNode('item', { id }, null)))]
|
|
2295
|
+
: null;
|
|
2296
|
+
|
|
2297
|
+
this._socket.sendNode(new BinaryNode('receipt', attrs, children));
|
|
2191
2298
|
}
|
|
2192
2299
|
|
|
2193
2300
|
async sendText(to, text, opts) {
|
|
@@ -2229,43 +2336,89 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2229
2336
|
// PRESENCE
|
|
2230
2337
|
// ──────────────────────────────────────────────────────────────────────────
|
|
2231
2338
|
|
|
2232
|
-
//
|
|
2339
|
+
// Our own JID in the family a chat is addressed with — the LID when the chat
|
|
2340
|
+
// is LID-addressed, the phone JID otherwise. chatstate carries a `from`, and
|
|
2341
|
+
// it has to be in the same space as the `to` beside it.
|
|
2342
|
+
_ownJidFor(chatJid) {
|
|
2343
|
+
const str = String(chatJid || '');
|
|
2344
|
+
if (str.endsWith('@lid') && this._myLid) {
|
|
2345
|
+
return String(this._myLid).split('@')[0].split(':')[0] + '@lid';
|
|
2346
|
+
}
|
|
2347
|
+
return String(this._store && this._store.phoneNumber) + '@s.whatsapp.net';
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
// Set global online/offline status.
|
|
2351
|
+
//
|
|
2352
|
+
// whatsmeow refuses to send this without a push name (ErrNoPushName): the
|
|
2353
|
+
// server takes the display name from this node, and sending it empty makes
|
|
2354
|
+
// every other user see "-" instead of a name. Going available also flips the
|
|
2355
|
+
// client into sending active delivery receipts — see _sendDeliveryReceipt.
|
|
2356
|
+
//
|
|
2357
|
+
// Returns true when the node went out, false when it was skipped.
|
|
2233
2358
|
setOnline(available) {
|
|
2234
|
-
if (!this._socket || !this._connected) return;
|
|
2235
|
-
const name = (this._store && this._store.pushName) || '';
|
|
2359
|
+
if (!this._socket || !this._connected) return false;
|
|
2360
|
+
const name = (this._store && (this._store.pushName || this._store.name)) || '';
|
|
2361
|
+
if (!name) {
|
|
2362
|
+
_whaDbg('[DBG] PRESENCE skipped: no push name set — other users would see "-"');
|
|
2363
|
+
return false;
|
|
2364
|
+
}
|
|
2365
|
+
this._sendActiveReceipts = !!available;
|
|
2236
2366
|
this._socket.sendNode(new BinaryNode('presence', {
|
|
2237
2367
|
name,
|
|
2238
2368
|
type: available ? 'available' : 'unavailable'
|
|
2239
2369
|
}, null));
|
|
2370
|
+
return true;
|
|
2240
2371
|
}
|
|
2241
2372
|
|
|
2242
|
-
// Set typing/recording presence in a specific chat
|
|
2373
|
+
// Set typing/recording presence in a specific chat.
|
|
2243
2374
|
// presence: 'composing' | 'paused' | 'recording'
|
|
2375
|
+
//
|
|
2376
|
+
// whatsmeow's SendChatPresence puts both `from` (our own JID) and `to` on the
|
|
2377
|
+
// chatstate node and expresses recording as composing with media="audio" —
|
|
2378
|
+
// there is no separate recording state in the protocol. The `from` was missing
|
|
2379
|
+
// here, and both JIDs were raw strings, which the server ignores for @lid
|
|
2380
|
+
// chats, so the indicator never showed for a LID-addressed contact.
|
|
2244
2381
|
setChatPresence(chatJid, presence) {
|
|
2245
2382
|
if (!this._socket || !this._connected) return;
|
|
2246
|
-
const jid
|
|
2383
|
+
const jid = makeJid(chatJid);
|
|
2384
|
+
const attrs = {
|
|
2385
|
+
from: jidStrToObj(this._ownJidFor(jid)),
|
|
2386
|
+
to: jidStrToObj(jid)
|
|
2387
|
+
};
|
|
2247
2388
|
if (presence === 'composing' || presence === 'recording') {
|
|
2248
|
-
const
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
new BinaryNode('composing', attrs, null)
|
|
2389
|
+
const stateAttrs = presence === 'recording' ? { media: 'audio' } : {};
|
|
2390
|
+
this._socket.sendNode(new BinaryNode('chatstate', attrs, [
|
|
2391
|
+
new BinaryNode('composing', stateAttrs, null)
|
|
2252
2392
|
]));
|
|
2253
2393
|
} else {
|
|
2254
|
-
this._socket.sendNode(new BinaryNode('chatstate',
|
|
2394
|
+
this._socket.sendNode(new BinaryNode('chatstate', attrs, [
|
|
2255
2395
|
new BinaryNode('paused', {}, null)
|
|
2256
2396
|
]));
|
|
2257
2397
|
}
|
|
2258
2398
|
}
|
|
2259
2399
|
|
|
2260
|
-
// Subscribe to presence updates for contacts
|
|
2400
|
+
// Subscribe to presence updates for contacts.
|
|
2401
|
+
//
|
|
2402
|
+
// whatsmeow attaches the contact's privacy token when it has one; without it
|
|
2403
|
+
// the server may decline to send their presence. It also notes that the server
|
|
2404
|
+
// only forwards presence to clients that have marked themselves available, so
|
|
2405
|
+
// call setOnline(true) first.
|
|
2261
2406
|
subscribeToPresence(jids) {
|
|
2262
2407
|
if (!this._socket || !this._connected) return;
|
|
2263
2408
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
2264
|
-
|
|
2409
|
+
const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
|
|
2410
|
+
for (const raw of arr) {
|
|
2411
|
+
const jid = makeJid(raw);
|
|
2412
|
+
const entry = this._tcTokenStore && this._tcTokenStore.get(
|
|
2413
|
+
normalizeJidForTcToken(jid)
|
|
2414
|
+
);
|
|
2415
|
+
const children = (entry && entry.token && entry.token.length)
|
|
2416
|
+
? [new BinaryNode('tctoken', {}, entry.token)]
|
|
2417
|
+
: null;
|
|
2265
2418
|
this._socket.sendNode(new BinaryNode('presence', {
|
|
2266
|
-
to:
|
|
2419
|
+
to: jidStrToObj(jid),
|
|
2267
2420
|
type: 'subscribe'
|
|
2268
|
-
},
|
|
2421
|
+
}, children));
|
|
2269
2422
|
}
|
|
2270
2423
|
}
|
|
2271
2424
|
|
|
@@ -2359,22 +2512,97 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2359
2512
|
}
|
|
2360
2513
|
|
|
2361
2514
|
// Query profile picture URL
|
|
2362
|
-
|
|
2515
|
+
// Profile picture URL, or null when there is none.
|
|
2516
|
+
// Unchanged signature; queryPictureInfo has the rest of what the server sends.
|
|
2517
|
+
async queryPicture(jid, opts) {
|
|
2518
|
+
const info = await this.queryPictureInfo(jid, opts);
|
|
2519
|
+
return (info && info.url) || null;
|
|
2520
|
+
}
|
|
2521
|
+
|
|
2522
|
+
// Full profile picture info, the way whatsmeow's GetProfilePictureInfo returns
|
|
2523
|
+
// it: { id, url, type, directPath, hash }, or null when the user has no
|
|
2524
|
+
// picture or it has not changed since opts.existingId.
|
|
2525
|
+
//
|
|
2526
|
+
// opts: { preview, existingId, isCommunity }
|
|
2527
|
+
// preview ask for the small thumbnail instead of the full image
|
|
2528
|
+
// existingId the id you already hold — the server answers 304 and this
|
|
2529
|
+
// returns null when it still matches
|
|
2530
|
+
// isCommunity resolve a community's picture, which goes through w:g2
|
|
2531
|
+
//
|
|
2532
|
+
// The target was a raw string before, which the server drops for an @lid, and
|
|
2533
|
+
// the reply's status codes were not read at all: a 204 (no picture set) has no
|
|
2534
|
+
// url attribute, so it came back as null with no way to tell it apart from a
|
|
2535
|
+
// failed query.
|
|
2536
|
+
async queryPictureInfo(jid, opts) {
|
|
2537
|
+
opts = opts || {};
|
|
2363
2538
|
const jidFull = makeJid(jid);
|
|
2364
|
-
const
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2539
|
+
const attrs = {
|
|
2540
|
+
query: 'url',
|
|
2541
|
+
type: opts.preview ? 'preview' : 'image'
|
|
2542
|
+
};
|
|
2543
|
+
if (opts.existingId) attrs.id = String(opts.existingId);
|
|
2544
|
+
if (opts.inviteCode) attrs.invite = String(opts.inviteCode);
|
|
2545
|
+
|
|
2546
|
+
let iqAttrs, content;
|
|
2547
|
+
if (opts.isCommunity) {
|
|
2548
|
+
attrs.parent_group_jid = jidStrToObj(jidFull);
|
|
2549
|
+
iqAttrs = { id: this._genMsgId(), xmlns: 'w:g2', to: jidStrToObj(jidFull), type: 'get' };
|
|
2550
|
+
content = [new BinaryNode('pictures', {}, [new BinaryNode('picture', attrs, null)])];
|
|
2551
|
+
} else {
|
|
2552
|
+
const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
|
|
2553
|
+
const entry = this._tcTokenStore &&
|
|
2554
|
+
this._tcTokenStore.get(normalizeJidForTcToken(jidFull));
|
|
2555
|
+
const picChildren = (entry && entry.token && entry.token.length)
|
|
2556
|
+
? [new BinaryNode('tctoken', {}, entry.token)]
|
|
2557
|
+
: null;
|
|
2558
|
+
iqAttrs = {
|
|
2559
|
+
id: this._genMsgId(),
|
|
2560
|
+
xmlns: 'w:profile:picture',
|
|
2561
|
+
to: 's.whatsapp.net',
|
|
2562
|
+
target: jidStrToObj(jidFull),
|
|
2563
|
+
type: 'get'
|
|
2564
|
+
};
|
|
2565
|
+
content = [new BinaryNode('picture', attrs, picChildren)];
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
const resp = await this._sendIq(new BinaryNode('iq', iqAttrs, content));
|
|
2375
2569
|
if (!resp) return null;
|
|
2376
|
-
|
|
2377
|
-
|
|
2570
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
2571
|
+
const errNode = findChild(resp, 'error');
|
|
2572
|
+
const code = errNode && errNode.attrs && String(errNode.attrs.code);
|
|
2573
|
+
// 404 = no picture set, 401/403 = their privacy settings hide it. Neither
|
|
2574
|
+
// is a failure worth throwing over.
|
|
2575
|
+
if (code === '404' || code === '401' || code === '403') return null;
|
|
2576
|
+
throw new Error('queryPictureInfo: server rejected the request' +
|
|
2577
|
+
(code ? ' (' + code + ')' : ''));
|
|
2578
|
+
}
|
|
2579
|
+
|
|
2580
|
+
const wrapper = opts.isCommunity ? findChild(resp, 'pictures') : resp;
|
|
2581
|
+
const pic = wrapper ? findChild(wrapper, 'picture') : null;
|
|
2582
|
+
if (!pic || !pic.attrs) return null;
|
|
2583
|
+
|
|
2584
|
+
const status = pic.attrs.status ? parseInt(pic.attrs.status, 10) : 0;
|
|
2585
|
+
if (status === 304 || status === 204) return null; // unchanged / not set
|
|
2586
|
+
|
|
2587
|
+
return {
|
|
2588
|
+
id: pic.attrs.id ? String(pic.attrs.id) : null,
|
|
2589
|
+
url: pic.attrs.url ? String(pic.attrs.url) : null,
|
|
2590
|
+
type: pic.attrs.type ? String(pic.attrs.type) : null,
|
|
2591
|
+
directPath: pic.attrs.direct_path ? String(pic.attrs.direct_path) : null,
|
|
2592
|
+
hash: pic.attrs.hash
|
|
2593
|
+
? Buffer.from(String(pic.attrs.hash), 'base64')
|
|
2594
|
+
: null
|
|
2595
|
+
};
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
// Download a profile picture and hand back the JPEG bytes, or null when the
|
|
2599
|
+
// user has none. Profile pictures are served unencrypted, unlike message
|
|
2600
|
+
// media, so this is a plain fetch of the URL the query returns.
|
|
2601
|
+
async downloadProfilePicture(jid, opts) {
|
|
2602
|
+
const info = await this.queryPictureInfo(jid, opts);
|
|
2603
|
+
if (!info || !info.url) return null;
|
|
2604
|
+
const { httpGetBuffer } = require('./MediaService');
|
|
2605
|
+
return httpGetBuffer(info.url);
|
|
2378
2606
|
}
|
|
2379
2607
|
|
|
2380
2608
|
// Query blocked contacts list
|
|
@@ -3042,18 +3270,69 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3042
3270
|
|
|
3043
3271
|
// Create a group
|
|
3044
3272
|
// participants: array of phone numbers or JIDs
|
|
3273
|
+
// Create a group.
|
|
3274
|
+
//
|
|
3275
|
+
// opts: { ephemeralSeconds, announce, locked, joinApprovalRequired,
|
|
3276
|
+
// memberAddMode, parent, linkedParentJid }
|
|
3277
|
+
//
|
|
3278
|
+
// Built the way whatsmeow's CreateGroup does: every participant is a binary
|
|
3279
|
+
// JID (a raw string is dropped for an @lid), a LID participant also carries
|
|
3280
|
+
// the phone number the server matches it against, and each carries our privacy
|
|
3281
|
+
// token so the invite is not treated as an anonymous reach-out. The setting
|
|
3282
|
+
// nodes are siblings of the participants inside <create>.
|
|
3045
3283
|
async createGroup(subject, participants, opts) {
|
|
3046
3284
|
opts = opts || {};
|
|
3047
3285
|
const id = this._genMsgId();
|
|
3286
|
+
const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
|
|
3048
3287
|
// participant nodes MUST use attr `jid`, not `value`
|
|
3049
3288
|
const partNodes = (participants || []).map(p => {
|
|
3050
|
-
const jid
|
|
3051
|
-
|
|
3289
|
+
const jid = makeJid(p);
|
|
3290
|
+
const attrs = { jid: jidStrToObj(jid) };
|
|
3291
|
+
if (jid.endsWith('@lid')) {
|
|
3292
|
+
const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
|
|
3293
|
+
if (pn) attrs.phone_number = jidStrToObj(pn + '@s.whatsapp.net');
|
|
3294
|
+
}
|
|
3295
|
+
const entry = this._tcTokenStore &&
|
|
3296
|
+
this._tcTokenStore.get(normalizeJidForTcToken(jid));
|
|
3297
|
+
const children = (entry && entry.token && entry.token.length)
|
|
3298
|
+
? [new BinaryNode('privacy', {}, entry.token)]
|
|
3299
|
+
: null;
|
|
3300
|
+
return new BinaryNode('participant', attrs, children);
|
|
3052
3301
|
});
|
|
3302
|
+
|
|
3303
|
+
const settingNodes = [];
|
|
3304
|
+
if (opts.parent) {
|
|
3305
|
+
settingNodes.push(new BinaryNode('parent', {
|
|
3306
|
+
default_membership_approval_mode:
|
|
3307
|
+
opts.defaultMembershipApprovalMode || 'request_required'
|
|
3308
|
+
}, null));
|
|
3309
|
+
} else if (opts.linkedParentJid) {
|
|
3310
|
+
settingNodes.push(new BinaryNode('linked_parent', {
|
|
3311
|
+
jid: jidStrToObj(makeJid(opts.linkedParentJid))
|
|
3312
|
+
}, null));
|
|
3313
|
+
}
|
|
3314
|
+
if (opts.locked) settingNodes.push(new BinaryNode('locked', {}, null));
|
|
3315
|
+
if (opts.announce) settingNodes.push(new BinaryNode('announcement', {}, null));
|
|
3316
|
+
if (opts.ephemeralSeconds) {
|
|
3317
|
+
// whatsmeow sends trigger="1" alongside the expiration; without it the
|
|
3318
|
+
// server accepts the group but leaves disappearing messages off.
|
|
3319
|
+
settingNodes.push(new BinaryNode('ephemeral', {
|
|
3320
|
+
expiration: String(opts.ephemeralSeconds),
|
|
3321
|
+
trigger: '1'
|
|
3322
|
+
}, null));
|
|
3323
|
+
}
|
|
3324
|
+
if (opts.joinApprovalRequired) {
|
|
3325
|
+
settingNodes.push(new BinaryNode('membership_approval_mode', {}, [
|
|
3326
|
+
new BinaryNode('group_join', { state: 'on' }, null)
|
|
3327
|
+
]));
|
|
3328
|
+
}
|
|
3329
|
+
if (opts.memberAddMode) {
|
|
3330
|
+
settingNodes.push(new BinaryNode('member_add_mode', {},
|
|
3331
|
+
Buffer.from(String(opts.memberAddMode), 'utf8')));
|
|
3332
|
+
}
|
|
3333
|
+
|
|
3053
3334
|
const baseAttrs = { subject, key: String(Math.floor(Date.now() / 1000)) };
|
|
3054
|
-
const createChildren =
|
|
3055
|
-
? [...partNodes, new BinaryNode('ephemeral', { expiration: String(opts.ephemeralSeconds) }, null)]
|
|
3056
|
-
: partNodes;
|
|
3335
|
+
const createChildren = [...partNodes, ...settingNodes];
|
|
3057
3336
|
const node = new BinaryNode('iq', {
|
|
3058
3337
|
id,
|
|
3059
3338
|
xmlns: 'w:g2',
|
|
@@ -3124,14 +3403,31 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3124
3403
|
async _groupParticipantAction(groupJid, action, jids) {
|
|
3125
3404
|
const gJid = makeJid(groupJid);
|
|
3126
3405
|
const arr = Array.isArray(jids) ? jids : [jids];
|
|
3127
|
-
const
|
|
3128
|
-
|
|
3129
|
-
|
|
3406
|
+
const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
|
|
3407
|
+
// Same shape whatsmeow's UpdateGroupParticipants builds: binary JIDs, and on
|
|
3408
|
+
// an add, the phone number behind a LID plus our privacy token for them.
|
|
3409
|
+
const participantNodes = arr.map(j => {
|
|
3410
|
+
const jid = makeJid(j);
|
|
3411
|
+
const attrs = { jid: jidStrToObj(jid) };
|
|
3412
|
+
let children = null;
|
|
3413
|
+
if (action === 'add') {
|
|
3414
|
+
if (jid.endsWith('@lid')) {
|
|
3415
|
+
const pn = this._lidToPn.get(jid.split('@')[0].split(':')[0]);
|
|
3416
|
+
if (pn) attrs.phone_number = jidStrToObj(pn + '@s.whatsapp.net');
|
|
3417
|
+
}
|
|
3418
|
+
const entry = this._tcTokenStore &&
|
|
3419
|
+
this._tcTokenStore.get(normalizeJidForTcToken(jid));
|
|
3420
|
+
if (entry && entry.token && entry.token.length) {
|
|
3421
|
+
children = [new BinaryNode('privacy', {}, entry.token)];
|
|
3422
|
+
}
|
|
3423
|
+
}
|
|
3424
|
+
return new BinaryNode('participant', attrs, children);
|
|
3425
|
+
});
|
|
3130
3426
|
const id = this._genMsgId();
|
|
3131
3427
|
const node = new BinaryNode('iq', {
|
|
3132
3428
|
id,
|
|
3133
3429
|
xmlns: 'w:g2',
|
|
3134
|
-
to: gJid,
|
|
3430
|
+
to: jidStrToObj(gJid),
|
|
3135
3431
|
type: 'set'
|
|
3136
3432
|
}, [
|
|
3137
3433
|
new BinaryNode(action, {}, participantNodes)
|
|
@@ -3161,19 +3457,38 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3161
3457
|
}
|
|
3162
3458
|
|
|
3163
3459
|
// Change group description
|
|
3164
|
-
// description: string or null to remove
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
|
|
3460
|
+
// description: string or null/'' to remove
|
|
3461
|
+
//
|
|
3462
|
+
// whatsmeow's SetGroupTopic sends prev=<current topic id> so the server can
|
|
3463
|
+
// reject an edit made against a description someone else has already replaced.
|
|
3464
|
+
// Without it the change is refused. The current id is looked up from the group
|
|
3465
|
+
// metadata when the caller does not supply one.
|
|
3466
|
+
async changeGroupDescription(groupJid, description, opts) {
|
|
3467
|
+
opts = opts || {};
|
|
3468
|
+
const gJid = makeJid(groupJid);
|
|
3469
|
+
const id = this._genMsgId();
|
|
3470
|
+
const isDelete = description == null || description === '';
|
|
3471
|
+
|
|
3472
|
+
let prevId = opts.prevId;
|
|
3473
|
+
if (prevId === undefined) {
|
|
3474
|
+
try {
|
|
3475
|
+
const meta = await this.getGroupMetadata(gJid);
|
|
3476
|
+
prevId = meta && meta.descriptionId;
|
|
3477
|
+
} catch (_) { prevId = null; }
|
|
3478
|
+
}
|
|
3479
|
+
|
|
3480
|
+
const descAttrs = { id: opts.newId || this._genMsgId() };
|
|
3481
|
+
if (prevId) descAttrs.prev = prevId;
|
|
3482
|
+
let children = [new BinaryNode('body', {}, Buffer.from(String(description), 'utf8'))];
|
|
3483
|
+
if (isDelete) {
|
|
3484
|
+
descAttrs.delete = 'true';
|
|
3485
|
+
children = null;
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3173
3488
|
const node = new BinaryNode('iq', {
|
|
3174
3489
|
id,
|
|
3175
3490
|
xmlns: 'w:g2',
|
|
3176
|
-
to:
|
|
3491
|
+
to: jidStrToObj(gJid),
|
|
3177
3492
|
type: 'set'
|
|
3178
3493
|
}, [
|
|
3179
3494
|
new BinaryNode('description', descAttrs, children)
|
|
@@ -3181,6 +3496,61 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3181
3496
|
return this._sendIq(node);
|
|
3182
3497
|
}
|
|
3183
3498
|
|
|
3499
|
+
// ─── Group settings ───────────────────────────────────────────────────────
|
|
3500
|
+
//
|
|
3501
|
+
// Both are a single empty node whose TAG carries the state, exactly as
|
|
3502
|
+
// whatsmeow's SetGroupAnnounce / SetGroupLocked send them — there is no
|
|
3503
|
+
// attribute form of these.
|
|
3504
|
+
|
|
3505
|
+
// announce=true → only admins can send messages
|
|
3506
|
+
async setGroupAnnounce(groupJid, announce) {
|
|
3507
|
+
return this._groupSettingIq(groupJid, announce ? 'announcement' : 'not_announcement');
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
// locked=true → only admins can edit group info (subject, description, icon)
|
|
3511
|
+
async setGroupLocked(groupJid, locked) {
|
|
3512
|
+
return this._groupSettingIq(groupJid, locked ? 'locked' : 'unlocked');
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
// mode=true → new members must be approved by an admin
|
|
3516
|
+
async setGroupJoinApprovalMode(groupJid, mode) {
|
|
3517
|
+
return this._groupSettingIq(groupJid, 'membership_approval_mode', [
|
|
3518
|
+
new BinaryNode('group_join', { state: mode ? 'on' : 'off' }, null)
|
|
3519
|
+
]);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
// mode: 'all_member_add' | 'admin_add'
|
|
3523
|
+
async setGroupMemberAddMode(groupJid, mode) {
|
|
3524
|
+
return this._groupSettingIq(groupJid, 'member_add_mode',
|
|
3525
|
+
Buffer.from(String(mode), 'utf8'));
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
// content is passed to the setting node verbatim: null for the flag settings
|
|
3529
|
+
// whose state lives in the tag, child nodes for membership_approval_mode, raw
|
|
3530
|
+
// bytes for member_add_mode.
|
|
3531
|
+
async _groupSettingIq(groupJid, tag, content) {
|
|
3532
|
+
const node = new BinaryNode('iq', {
|
|
3533
|
+
id: this._genMsgId(),
|
|
3534
|
+
xmlns: 'w:g2',
|
|
3535
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
3536
|
+
type: 'set'
|
|
3537
|
+
}, [
|
|
3538
|
+
new BinaryNode(tag, {}, content === undefined ? null : content)
|
|
3539
|
+
]);
|
|
3540
|
+
const resp = await this._sendIq(node);
|
|
3541
|
+
if (!resp) throw new Error(tag + ': no reply from server (IQ timed out)');
|
|
3542
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
3543
|
+
const errNode = findChild(resp, 'error');
|
|
3544
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
3545
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
3546
|
+
throw new Error(tag + ': server rejected the request' +
|
|
3547
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
3548
|
+
}
|
|
3549
|
+
// The group's own view of these flags is now stale.
|
|
3550
|
+
this.invalidateGroupMetadata(groupJid);
|
|
3551
|
+
return resp;
|
|
3552
|
+
}
|
|
3553
|
+
|
|
3184
3554
|
// Change group picture
|
|
3185
3555
|
// buf: Buffer with JPEG image data, or null to remove
|
|
3186
3556
|
async changeGroupPicture(groupJid, buf) {
|
|
@@ -3197,17 +3567,63 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3197
3567
|
return this._sendIq(node);
|
|
3198
3568
|
}
|
|
3199
3569
|
|
|
3200
|
-
// Get group invite link
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
|
|
3570
|
+
// Get the group's invite link.
|
|
3571
|
+
//
|
|
3572
|
+
// reset=true revokes the current link and returns a freshly minted one — the
|
|
3573
|
+
// same IQ with type="set" instead of "get", which is how whatsmeow's
|
|
3574
|
+
// GetGroupInviteLink handles its reset flag.
|
|
3575
|
+
async queryGroupInviteLink(groupJid, reset) {
|
|
3576
|
+
const code = await this._queryGroupInviteCode(groupJid, reset);
|
|
3577
|
+
return code ? INVITE_LINK_PREFIX + code : null;
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
// Revoke the current invite link and return the replacement.
|
|
3581
|
+
async revokeGroupInviteLink(groupJid) {
|
|
3582
|
+
return this.queryGroupInviteLink(groupJid, true);
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
// Join a group from an invite link or bare code.
|
|
3586
|
+
// Returns the JID of the group that was joined. When the group requires admin
|
|
3587
|
+
// approval the server answers with a membership_approval_request instead, and
|
|
3588
|
+
// that JID is returned — the join is pending, not complete.
|
|
3589
|
+
async joinGroupWithLink(inviteCodeOrUrl) {
|
|
3590
|
+
const code = stripInviteUrl(inviteCodeOrUrl);
|
|
3591
|
+
const node = new BinaryNode('iq', {
|
|
3592
|
+
id: this._genMsgId(),
|
|
3593
|
+
xmlns: 'w:g2',
|
|
3594
|
+
to: 'g.us',
|
|
3595
|
+
type: 'set'
|
|
3596
|
+
}, [new BinaryNode('invite', { code }, null)]);
|
|
3597
|
+
|
|
3598
|
+
const resp = await this._sendIq(node);
|
|
3599
|
+
if (!resp) throw new Error('joinGroupWithLink: no reply from server (IQ timed out)');
|
|
3600
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
3601
|
+
const errNode = findChild(resp, 'error');
|
|
3602
|
+
const codeAttr = errNode && errNode.attrs && errNode.attrs.code;
|
|
3603
|
+
// whatsmeow maps these two specifically: 410 is a revoked link, 406 an
|
|
3604
|
+
// invalid one. Anything else is passed through as the server worded it.
|
|
3605
|
+
const reason = String(codeAttr) === '410' ? 'the invite link has been revoked'
|
|
3606
|
+
: String(codeAttr) === '406' ? 'the invite link is not valid'
|
|
3607
|
+
: 'server rejected the request' +
|
|
3608
|
+
(codeAttr ? ' (' + codeAttr + ')' : '');
|
|
3609
|
+
throw new Error('joinGroupWithLink: ' + reason);
|
|
3610
|
+
}
|
|
3611
|
+
const pending = findChild(resp, 'membership_approval_request');
|
|
3612
|
+
if (pending && pending.attrs && pending.attrs.jid) {
|
|
3613
|
+
return { jid: String(pending.attrs.jid), pendingApproval: true };
|
|
3614
|
+
}
|
|
3615
|
+
const grp = findChild(resp, 'group');
|
|
3616
|
+
if (!grp || !grp.attrs || !grp.attrs.jid) {
|
|
3617
|
+
throw new Error('joinGroupWithLink: server reply contained no <group> node');
|
|
3618
|
+
}
|
|
3619
|
+
return { jid: String(grp.attrs.jid), pendingApproval: false };
|
|
3204
3620
|
}
|
|
3205
3621
|
|
|
3206
3622
|
// queryGroupInviteInfo — fetch group metadata from an invite link code
|
|
3207
3623
|
// Returns parsed group metadata (same shape as getGroupMetadata) or null.
|
|
3208
3624
|
// inviteCode is the bare code (no URL prefix needed; URL also accepted).
|
|
3209
3625
|
async queryGroupInviteInfo(inviteCodeOrUrl) {
|
|
3210
|
-
const code =
|
|
3626
|
+
const code = stripInviteUrl(inviteCodeOrUrl);
|
|
3211
3627
|
const id = this._genMsgId();
|
|
3212
3628
|
const node = new BinaryNode('iq', {
|
|
3213
3629
|
id,
|
|
@@ -3224,13 +3640,13 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3224
3640
|
return this._parseGroupNode(grp);
|
|
3225
3641
|
}
|
|
3226
3642
|
|
|
3227
|
-
async _queryGroupInviteCode(groupJid) {
|
|
3643
|
+
async _queryGroupInviteCode(groupJid, reset) {
|
|
3228
3644
|
const id = this._genMsgId();
|
|
3229
3645
|
const node = new BinaryNode('iq', {
|
|
3230
3646
|
id,
|
|
3231
3647
|
xmlns: 'w:g2',
|
|
3232
|
-
to: makeJid(groupJid),
|
|
3233
|
-
type: 'get'
|
|
3648
|
+
to: jidStrToObj(makeJid(groupJid)),
|
|
3649
|
+
type: reset ? 'set' : 'get'
|
|
3234
3650
|
}, [
|
|
3235
3651
|
new BinaryNode('invite', {}, null)
|
|
3236
3652
|
]);
|
|
@@ -3241,34 +3657,24 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3241
3657
|
}
|
|
3242
3658
|
|
|
3243
3659
|
// Revoke group invite
|
|
3660
|
+
// Revoke the current invite link. Returns the replacement link.
|
|
3661
|
+
//
|
|
3662
|
+
// This threw the reply away, so a caller had no way to learn the new code and
|
|
3663
|
+
// had to query the link again. It is the same IQ revokeGroupInviteLink sends.
|
|
3244
3664
|
async revokeGroupInvite(groupJid) {
|
|
3245
|
-
|
|
3246
|
-
const node = new BinaryNode('iq', {
|
|
3247
|
-
id,
|
|
3248
|
-
xmlns: 'w:g2',
|
|
3249
|
-
to: makeJid(groupJid),
|
|
3250
|
-
type: 'set'
|
|
3251
|
-
}, [
|
|
3252
|
-
new BinaryNode('invite', {}, null)
|
|
3253
|
-
]);
|
|
3254
|
-
return this._sendIq(node);
|
|
3665
|
+
return this.revokeGroupInviteLink(groupJid);
|
|
3255
3666
|
}
|
|
3256
3667
|
|
|
3257
|
-
// Join group by invite code
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
]);
|
|
3268
|
-
const resp = await this._sendIq(node);
|
|
3269
|
-
if (!resp) return null;
|
|
3270
|
-
const grp = findChild(resp, 'group');
|
|
3271
|
-
return (grp && grp.attrs && (grp.attrs.jid || grp.attrs.value)) || null;
|
|
3668
|
+
// Join a group by invite code or full link. Returns the group JID, or null.
|
|
3669
|
+
//
|
|
3670
|
+
// Accepts a full https://chat.whatsapp.com/... link as well as a bare code —
|
|
3671
|
+
// it used to pass whatever it was given straight through as the code, so a
|
|
3672
|
+
// pasted link was rejected. A group that gates joins on admin approval answers
|
|
3673
|
+
// with membership_approval_request rather than group; that JID is returned too
|
|
3674
|
+
// (use joinGroupWithLink when you need to tell the two apart).
|
|
3675
|
+
async acceptGroupInvite(inviteCodeOrUrl) {
|
|
3676
|
+
const r = await this.joinGroupWithLink(inviteCodeOrUrl).catch(() => null);
|
|
3677
|
+
return r ? r.jid : null;
|
|
3272
3678
|
}
|
|
3273
3679
|
|
|
3274
3680
|
// Change group settings
|
|
@@ -3297,13 +3703,10 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3297
3703
|
default:
|
|
3298
3704
|
throw new Error('Unknown group setting: ' + setting);
|
|
3299
3705
|
}
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
type: 'set'
|
|
3305
|
-
}, [body]);
|
|
3306
|
-
return this._sendIq(node);
|
|
3706
|
+
// Routed through the shared helper so a refusal from the server (not an
|
|
3707
|
+
// admin, not a member) surfaces as an error instead of resolving null, and
|
|
3708
|
+
// so the group's cached metadata does not keep reporting the old flags.
|
|
3709
|
+
return this._groupSettingIq(groupJid, body.description, body.content);
|
|
3307
3710
|
}
|
|
3308
3711
|
|
|
3309
3712
|
// Query participants pending approval
|
package/lib/MediaService.js
CHANGED
|
@@ -211,6 +211,9 @@ module.exports = {
|
|
|
211
211
|
decryptMedia,
|
|
212
212
|
uploadMedia,
|
|
213
213
|
downloadMedia,
|
|
214
|
+
// Plain unauthenticated GET. Profile pictures are served in the clear, unlike
|
|
215
|
+
// message media, so they need a fetch that does not try to decrypt anything.
|
|
216
|
+
httpGetBuffer: _httpGet,
|
|
214
217
|
deriveMediaKeyData,
|
|
215
218
|
getMediaKeyName
|
|
216
219
|
};
|
|
@@ -1127,14 +1127,30 @@ class MessageSender {
|
|
|
1127
1127
|
: this._devMgr.ensureOwnDeviceSessions(ownPhone, this._signal)
|
|
1128
1128
|
]);
|
|
1129
1129
|
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
//
|
|
1133
|
-
//
|
|
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.
|
|
1134
1151
|
const selfAddrs = new Set([adString(`${ownPhone}@s.whatsapp.net`)]);
|
|
1135
1152
|
if (myLidUser) selfAddrs.add(adString(`${myLidUser}@lid`));
|
|
1136
|
-
const allTargets =
|
|
1137
|
-
.filter(jid => !selfAddrs.has(adString(jid)));
|
|
1153
|
+
const allTargets = enumeratedDevices.filter(jid => !selfAddrs.has(adString(jid)));
|
|
1138
1154
|
|
|
1139
1155
|
const skStore = this._signal.senderKeyStore;
|
|
1140
1156
|
const existingSkdmMap = skStore.getSKDMMap(groupJid);
|
|
@@ -1192,16 +1208,16 @@ class MessageSender {
|
|
|
1192
1208
|
if (mediaSubtype) skmsgAttrs.mediatype = mediaSubtype;
|
|
1193
1209
|
msgContent.push(new BinaryNode('enc', skmsgAttrs, Buffer.isBuffer(skmsgCiphertext) ? skmsgCiphertext : Buffer.from(skmsgCiphertext)));
|
|
1194
1210
|
|
|
1195
|
-
//
|
|
1196
|
-
//
|
|
1197
|
-
//
|
|
1198
|
-
//
|
|
1199
|
-
// 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.
|
|
1200
1215
|
const stanzaAttrs = {
|
|
1201
1216
|
to: jidStrToObj(groupJid),
|
|
1202
1217
|
id: msgId,
|
|
1203
1218
|
type: mediaType,
|
|
1204
1219
|
addressing_mode: groupAddressingMode,
|
|
1220
|
+
phash,
|
|
1205
1221
|
t: String(msNow())
|
|
1206
1222
|
};
|
|
1207
1223
|
if (options.edit) stanzaAttrs.edit = String(options.edit);
|
|
@@ -1223,7 +1239,27 @@ class MessageSender {
|
|
|
1223
1239
|
}
|
|
1224
1240
|
|
|
1225
1241
|
const msgNode = new BinaryNode('message', stanzaAttrs, msgContent);
|
|
1226
|
-
|
|
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;
|
|
1227
1263
|
}
|
|
1228
1264
|
|
|
1229
1265
|
// ─── Dispatch and wait for ack ────────────────────────────────────────────
|
|
@@ -1416,23 +1452,15 @@ class MessageSender {
|
|
|
1416
1452
|
|
|
1417
1453
|
// ─── Utility: mark read, presence, receipt, ping ─────────────────────────
|
|
1418
1454
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
this.
|
|
1424
|
-
to: toJid,
|
|
1425
|
-
type: 'read',
|
|
1426
|
-
id: ids[ids.length - 1],
|
|
1427
|
-
t: String(msNow())
|
|
1428
|
-
}, extras));
|
|
1455
|
+
// Both of these live on Client now — it is the side that knows our LID, the
|
|
1456
|
+
// privacy tokens and the online flag the receipt shape depends on. Kept here
|
|
1457
|
+
// so anything holding a MessageSender keeps working.
|
|
1458
|
+
markRead(jid, messageIds, opts) {
|
|
1459
|
+
return this._client.markRead(jid, messageIds, opts);
|
|
1429
1460
|
}
|
|
1430
1461
|
|
|
1431
1462
|
sendPresence(available) {
|
|
1432
|
-
this.
|
|
1433
|
-
name: (this._store && this._store.name) || 'User',
|
|
1434
|
-
type: available ? 'available' : 'unavailable'
|
|
1435
|
-
}, null));
|
|
1463
|
+
return this._client.setOnline(available);
|
|
1436
1464
|
}
|
|
1437
1465
|
|
|
1438
1466
|
sendReceipt(msgId, from, type) {
|
|
@@ -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(
|
|
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
|
-
|
|
264
|
-
|
|
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
|
|
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[
|
|
388
|
-
const skdm = _decodeFields(f[
|
|
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
|
|
407
|
-
if (!msgResult && f[
|
|
422
|
+
// Field 6: extendedTextMessage — field 1 = text
|
|
423
|
+
if (!msgResult && f[6] && Buffer.isBuffer(f[6])) {
|
|
408
424
|
try {
|
|
409
|
-
const ext = _decodeFields(f[
|
|
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
|
|
571
|
+
// Field 49: pollCreationMessage
|
|
556
572
|
// encKey=1, name=2 (question), options=3 (repeated PollOption{name=1}), selectableOptionsCount=4
|
|
557
|
-
if (!msgResult && f[
|
|
573
|
+
if (!msgResult && f[49] && Buffer.isBuffer(f[49])) {
|
|
558
574
|
try {
|
|
559
|
-
const poll = _decodeFields(f[
|
|
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
|
|
578
|
-
if (!msgResult && f[
|
|
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(
|
|
627
|
+
return field(2, WIRE_LEN, inner);
|
|
604
628
|
}
|
|
605
629
|
|
|
606
630
|
module.exports = {
|
package/lib/signal/SenderKey.js
CHANGED
|
@@ -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() {
|