whalibmob 5.5.84 → 5.5.89
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/README.md +102 -4
- package/cli.js +36 -13
- package/lib/Client.js +467 -38
- package/lib/MediaThumbnail.js +394 -0
- package/lib/messages/MessageSender.js +149 -36
- package/lib/proto/MessageProto.js +132 -12
- package/package.json +5 -2
package/lib/Client.js
CHANGED
|
@@ -30,9 +30,57 @@ const {
|
|
|
30
30
|
HistorySyncType
|
|
31
31
|
} = require('./HistorySyncHandler');
|
|
32
32
|
|
|
33
|
+
// Status posts are messages addressed here.
|
|
34
|
+
const STATUS_BROADCAST_JID = 'status@broadcast';
|
|
35
|
+
|
|
36
|
+
// The two prefixes a call-link token is wrapped in, as WhatsApp Web uses them.
|
|
37
|
+
const CALL_AUDIO_PREFIX = 'https://call.whatsapp.com/voice/';
|
|
38
|
+
const CALL_VIDEO_PREFIX = 'https://call.whatsapp.com/video/';
|
|
39
|
+
|
|
33
40
|
// whatsmeow's InviteLinkPrefix — the one prefix group invite codes are wrapped in.
|
|
34
41
|
const INVITE_LINK_PREFIX = 'https://chat.whatsapp.com/';
|
|
35
42
|
|
|
43
|
+
// The names privacy settings actually go by on the wire, and the friendly
|
|
44
|
+
// aliases this library has always accepted for them. whatsmeow's
|
|
45
|
+
// PrivacySettingType constants are the right-hand column.
|
|
46
|
+
const PRIVACY_NAME_ALIASES = {
|
|
47
|
+
last_seen: 'last', last: 'last',
|
|
48
|
+
profile_picture: 'profile', profile_pic: 'profile', profile: 'profile',
|
|
49
|
+
status: 'status',
|
|
50
|
+
online: 'online',
|
|
51
|
+
read_receipts: 'readreceipts', readreceipts: 'readreceipts',
|
|
52
|
+
groups_add: 'groupadd', group_add: 'groupadd', groupadd: 'groupadd',
|
|
53
|
+
call_add: 'calladd', calladd: 'calladd',
|
|
54
|
+
messages: 'messages',
|
|
55
|
+
defense: 'defense',
|
|
56
|
+
stickers: 'stickers'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Wire name → the key it is reported under in the settings object.
|
|
60
|
+
const PRIVACY_WIRE_TO_KEY = {
|
|
61
|
+
last: 'lastSeen',
|
|
62
|
+
profile: 'profile',
|
|
63
|
+
status: 'status',
|
|
64
|
+
online: 'online',
|
|
65
|
+
readreceipts: 'readReceipts',
|
|
66
|
+
groupadd: 'groupAdd',
|
|
67
|
+
calladd: 'callAdd',
|
|
68
|
+
messages: 'messages',
|
|
69
|
+
defense: 'defense',
|
|
70
|
+
stickers: 'stickers'
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Reduce a JID to its bare user — whatsmeow's ToNonAD. Blocking, and anything
|
|
74
|
+
// else that names an account rather than one of its devices, wants this form.
|
|
75
|
+
function toNonAdJid(input) {
|
|
76
|
+
const s = String(input || '');
|
|
77
|
+
const at = s.indexOf('@');
|
|
78
|
+
const user = at >= 0 ? s.slice(0, at) : s;
|
|
79
|
+
const server = at >= 0 ? s.slice(at + 1) : 's.whatsapp.net';
|
|
80
|
+
const colon = user.indexOf(':');
|
|
81
|
+
return (colon >= 0 ? user.slice(0, colon) : user) + '@' + server;
|
|
82
|
+
}
|
|
83
|
+
|
|
36
84
|
// Accept a full invite URL or a bare code and return the code.
|
|
37
85
|
function stripInviteUrl(codeOrUrl) {
|
|
38
86
|
return String(codeOrUrl || '')
|
|
@@ -181,6 +229,9 @@ class WhalibmobClient extends EventEmitter {
|
|
|
181
229
|
// Flipped by setOnline(). Delivery receipts sent while this is false carry
|
|
182
230
|
// type="inactive", which the sender's app records but does not render.
|
|
183
231
|
this._sendActiveReceipts = false;
|
|
232
|
+
// Cached privacy settings, as whatsmeow caches them. Warmed after connect
|
|
233
|
+
// and refreshed whenever the server reports a change; markRead reads it.
|
|
234
|
+
this._privacySettings = null;
|
|
184
235
|
if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
|
|
185
236
|
}
|
|
186
237
|
|
|
@@ -321,6 +372,11 @@ class WhalibmobClient extends EventEmitter {
|
|
|
321
372
|
this._requestMediaConnection();
|
|
322
373
|
this._uploadPreKeys();
|
|
323
374
|
|
|
375
|
+
// Warm the privacy settings so read receipts respect them from the first
|
|
376
|
+
// message rather than from whenever something first asks for them.
|
|
377
|
+
this.queryPrivacySettings({ force: true }).catch(err =>
|
|
378
|
+
_whaDbg('[DBG] PRIVACY_FETCH_FAILED ' + (err && err.message)));
|
|
379
|
+
|
|
324
380
|
// ── Reconnect: flush stale device cache + background re-usync ─────────────
|
|
325
381
|
// On first connect _hasConnectedOnce is false — nothing extra to do.
|
|
326
382
|
// On every reconnect (network drop / NAT reset / server restart) we flush the
|
|
@@ -1030,6 +1086,19 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1030
1086
|
this._socket.sendNode(new BinaryNode('ack', attrs, null));
|
|
1031
1087
|
}
|
|
1032
1088
|
|
|
1089
|
+
// A read receipt only tells the sender we read it when read receipts are on.
|
|
1090
|
+
// whatsmeow's MarkRead checks the same setting and downgrades "read" to
|
|
1091
|
+
// "read-self", which marks the message read across our own devices without
|
|
1092
|
+
// reporting it to the other side. Nothing here looked at the setting, so a
|
|
1093
|
+
// user who had turned read receipts off still sent blue ticks.
|
|
1094
|
+
_readReceiptType(base) {
|
|
1095
|
+
const settings = this._privacySettings;
|
|
1096
|
+
if (settings && settings.readReceipts === 'none' && base === 'read') {
|
|
1097
|
+
return 'read-self';
|
|
1098
|
+
}
|
|
1099
|
+
return base;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1033
1102
|
// True when a JID names this account, in either address family.
|
|
1034
1103
|
_isOwnUser(jid) {
|
|
1035
1104
|
if (!jid) return false;
|
|
@@ -1076,7 +1145,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1076
1145
|
if (this.autoRead === false) return;
|
|
1077
1146
|
const node = new BinaryNode('receipt', {
|
|
1078
1147
|
id: msgId,
|
|
1079
|
-
type: 'read',
|
|
1148
|
+
type: this._readReceiptType('read'),
|
|
1080
1149
|
to: fromJid,
|
|
1081
1150
|
t: String(Math.floor(Date.now() / 1000))
|
|
1082
1151
|
}, null);
|
|
@@ -1197,6 +1266,46 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1197
1266
|
|
|
1198
1267
|
// ─── Notification handling ────────────────────────────────────────────────
|
|
1199
1268
|
|
|
1269
|
+
// <privacy><category name="..." value="..."/></privacy> inside an account_sync
|
|
1270
|
+
// notification. The node carries only what changed, so it is folded into the
|
|
1271
|
+
// cached settings rather than replacing them.
|
|
1272
|
+
_handlePrivacySettingsNotification(node) {
|
|
1273
|
+
const changed = this._parsePrivacySettings(node);
|
|
1274
|
+
const merged = Object.assign({}, this._privacySettings || {});
|
|
1275
|
+
const changes = {};
|
|
1276
|
+
for (const key of Object.keys(changed)) {
|
|
1277
|
+
if (changed[key] !== null) { merged[key] = changed[key]; changes[key] = changed[key]; }
|
|
1278
|
+
}
|
|
1279
|
+
this._privacySettings = merged;
|
|
1280
|
+
_whaDbg('[DBG] PRIVACY_CHANGE ' + JSON.stringify(changes));
|
|
1281
|
+
this.emit('privacy_settings', { changes, settings: merged });
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// <blocklist action="..." dhash="..." prev_dhash="..."><item jid=... action=.../></blocklist>
|
|
1285
|
+
// Mirrors whatsmeow's handleBlocklist: the action on the node describes the
|
|
1286
|
+
// whole change, each child names one JID and what happened to it.
|
|
1287
|
+
_handleBlocklistNotification(node) {
|
|
1288
|
+
const attrs = node.attrs || {};
|
|
1289
|
+
const changes = [];
|
|
1290
|
+
if (Array.isArray(node.content)) {
|
|
1291
|
+
for (const child of node.content) {
|
|
1292
|
+
if (!child || !child.attrs || !child.attrs.jid) continue;
|
|
1293
|
+
changes.push({
|
|
1294
|
+
jid: String(child.attrs.jid),
|
|
1295
|
+
action: child.attrs.action ? String(child.attrs.action) : null
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
_whaDbg('[DBG] BLOCKLIST_CHANGE action=' + (attrs.action || '') +
|
|
1300
|
+
' changes=' + changes.map(c => c.action + ':' + c.jid).join(','));
|
|
1301
|
+
this.emit('blocklist', {
|
|
1302
|
+
action: attrs.action ? String(attrs.action) : null,
|
|
1303
|
+
dhash: attrs.dhash ? String(attrs.dhash) : null,
|
|
1304
|
+
prevDhash: attrs.prev_dhash ? String(attrs.prev_dhash) : null,
|
|
1305
|
+
changes
|
|
1306
|
+
});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1200
1309
|
_handleNotification(node) {
|
|
1201
1310
|
const attrs = node.attrs || {};
|
|
1202
1311
|
const type = attrs.type || '';
|
|
@@ -1212,6 +1321,16 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1212
1321
|
// Account sync notification — may contain device list updates
|
|
1213
1322
|
const devicesNode = findChildDeep(node, 'devices');
|
|
1214
1323
|
if (devicesNode) this._processDeviceUpdate(devicesNode);
|
|
1324
|
+
// ...and block list changes. Blocking someone from the phone arrives
|
|
1325
|
+
// here; nothing was watching for it, so the client's idea of who is
|
|
1326
|
+
// blocked only ever changed when it did the blocking itself.
|
|
1327
|
+
const blocklistNode = findChildDeep(node, 'blocklist');
|
|
1328
|
+
if (blocklistNode) this._handleBlocklistNotification(blocklistNode);
|
|
1329
|
+
// ...and privacy setting changes, which arrive the same way. whatsmeow
|
|
1330
|
+
// re-reads the settings on this and raises an event; nothing was watching
|
|
1331
|
+
// the node, so a change made on the phone never reached the cache.
|
|
1332
|
+
const privacyNode = findChildDeep(node, 'privacy');
|
|
1333
|
+
if (privacyNode) this._handlePrivacySettingsNotification(privacyNode);
|
|
1215
1334
|
}
|
|
1216
1335
|
|
|
1217
1336
|
if (type === 'w:gp2') {
|
|
@@ -1392,6 +1511,20 @@ class WhalibmobClient extends EventEmitter {
|
|
|
1392
1511
|
|
|
1393
1512
|
_handleCall(node) {
|
|
1394
1513
|
const attrs = node.attrs || {};
|
|
1514
|
+
|
|
1515
|
+
// A <call> can also be the answer to something we asked for — creating a
|
|
1516
|
+
// call link, for one. Those come back on the same tag with the id we sent,
|
|
1517
|
+
// so they have to be handed to the waiting caller and not treated as an
|
|
1518
|
+
// incoming call to reject.
|
|
1519
|
+
if (attrs.id) {
|
|
1520
|
+
const handler = this._pendingIqs.get(attrs.id);
|
|
1521
|
+
if (handler) {
|
|
1522
|
+
this._pendingIqs.delete(attrs.id);
|
|
1523
|
+
handler(node);
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1395
1528
|
this.emit('call', { from: attrs.from || '', node });
|
|
1396
1529
|
// Reject call automatically
|
|
1397
1530
|
if (this._socket && this._connected) {
|
|
@@ -2279,7 +2412,7 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2279
2412
|
|
|
2280
2413
|
const attrs = {
|
|
2281
2414
|
id: ids[0],
|
|
2282
|
-
type: opts.type === 'played' ? 'played' : 'read',
|
|
2415
|
+
type: this._readReceiptType(opts.type === 'played' ? 'played' : 'read'),
|
|
2283
2416
|
to: jidStrToObj(chatJid),
|
|
2284
2417
|
t: String(Math.floor(Date.now() / 1000))
|
|
2285
2418
|
};
|
|
@@ -2605,7 +2738,29 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2605
2738
|
return httpGetBuffer(info.url);
|
|
2606
2739
|
}
|
|
2607
2740
|
|
|
2608
|
-
//
|
|
2741
|
+
// Parse the <list> a blocklist query or update comes back with.
|
|
2742
|
+
//
|
|
2743
|
+
// whatsmeow's parseBlocklist walks every child and takes its jid, without
|
|
2744
|
+
// caring what the tag is called, and keeps the dhash off the list itself.
|
|
2745
|
+
// The JIDs are stringified here: the binary decoder hands them back as JID
|
|
2746
|
+
// objects, and while those print correctly they are not strings, so the array
|
|
2747
|
+
// this used to return failed any includes() or === against a JID string —
|
|
2748
|
+
// which is exactly what the documented shape invites you to do.
|
|
2749
|
+
_parseBlocklist(listNode) {
|
|
2750
|
+
const jids = [];
|
|
2751
|
+
if (listNode && Array.isArray(listNode.content)) {
|
|
2752
|
+
for (const child of listNode.content) {
|
|
2753
|
+
if (!child || !child.attrs || !child.attrs.jid) continue;
|
|
2754
|
+
jids.push(String(child.attrs.jid));
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
const dhash = (listNode && listNode.attrs && listNode.attrs.dhash)
|
|
2758
|
+
? String(listNode.attrs.dhash) : null;
|
|
2759
|
+
return { dhash, jids };
|
|
2760
|
+
}
|
|
2761
|
+
|
|
2762
|
+
// Query blocked contacts list. Returns an array of JID strings; never throws,
|
|
2763
|
+
// an unreachable server yields an empty list.
|
|
2609
2764
|
async queryBlockList() {
|
|
2610
2765
|
const id = this._genMsgId();
|
|
2611
2766
|
const node = new BinaryNode('iq', {
|
|
@@ -2616,11 +2771,8 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2616
2771
|
}, null);
|
|
2617
2772
|
const resp = await this._sendIq(node);
|
|
2618
2773
|
if (!resp) return [];
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
return list.content
|
|
2622
|
-
.filter(n => n && n.description === 'item' && n.attrs && n.attrs.jid)
|
|
2623
|
-
.map(n => n.attrs.jid);
|
|
2774
|
+
if (resp.attrs && resp.attrs.type === 'error') return [];
|
|
2775
|
+
return this._parseBlocklist(findChild(resp, 'list')).jids;
|
|
2624
2776
|
}
|
|
2625
2777
|
|
|
2626
2778
|
// Change display name
|
|
@@ -2665,29 +2817,114 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2665
2817
|
return this._sendIq(node);
|
|
2666
2818
|
}
|
|
2667
2819
|
|
|
2668
|
-
//
|
|
2669
|
-
|
|
2670
|
-
//
|
|
2671
|
-
//
|
|
2820
|
+
// ─── Privacy settings ─────────────────────────────────────────────────────
|
|
2821
|
+
|
|
2822
|
+
// Read the current privacy settings.
|
|
2823
|
+
//
|
|
2824
|
+
// Returns { lastSeen, profile, status, online, readReceipts, groupAdd,
|
|
2825
|
+
// callAdd, messages, defense, stickers } — any the server did not
|
|
2826
|
+
// report come back null. Served from cache unless opts.force is set.
|
|
2827
|
+
//
|
|
2828
|
+
// There was no way to read these at all before, which also meant nothing
|
|
2829
|
+
// could act on them; see markRead for what that cost.
|
|
2830
|
+
async queryPrivacySettings(opts) {
|
|
2831
|
+
opts = opts || {};
|
|
2832
|
+
if (this._privacySettings && !opts.force) return this._privacySettings;
|
|
2833
|
+
|
|
2834
|
+
const node = new BinaryNode('iq', {
|
|
2835
|
+
id: this._genMsgId(),
|
|
2836
|
+
xmlns: 'privacy',
|
|
2837
|
+
to: 's.whatsapp.net',
|
|
2838
|
+
type: 'get'
|
|
2839
|
+
}, [new BinaryNode('privacy', {}, null)]);
|
|
2840
|
+
|
|
2841
|
+
const resp = await this._sendIq(node);
|
|
2842
|
+
if (!resp) throw new Error('queryPrivacySettings: no reply from server (IQ timed out)');
|
|
2843
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
2844
|
+
const errNode = findChild(resp, 'error');
|
|
2845
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
2846
|
+
throw new Error('queryPrivacySettings: server rejected the request' +
|
|
2847
|
+
(code ? ' (' + code + ')' : ''));
|
|
2848
|
+
}
|
|
2849
|
+
const privacyNode = findChild(resp, 'privacy');
|
|
2850
|
+
this._privacySettings = this._parsePrivacySettings(privacyNode);
|
|
2851
|
+
return this._privacySettings;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
// <privacy><category name="..." value="..."/>...</privacy>
|
|
2855
|
+
_parsePrivacySettings(privacyNode) {
|
|
2856
|
+
const out = {};
|
|
2857
|
+
for (const key of Object.keys(PRIVACY_WIRE_TO_KEY)) out[PRIVACY_WIRE_TO_KEY[key]] = null;
|
|
2858
|
+
if (privacyNode && Array.isArray(privacyNode.content)) {
|
|
2859
|
+
for (const child of privacyNode.content) {
|
|
2860
|
+
if (!child || child.description !== 'category' || !child.attrs) continue;
|
|
2861
|
+
const key = PRIVACY_WIRE_TO_KEY[String(child.attrs.name)];
|
|
2862
|
+
if (key) out[key] = child.attrs.value ? String(child.attrs.value) : null;
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
return out;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
// Change a privacy setting. Returns the settings as they now stand.
|
|
2869
|
+
//
|
|
2870
|
+
// type: 'last_seen' | 'profile_picture' | 'status' | 'online' |
|
|
2871
|
+
// 'read_receipts' | 'groups_add' | 'call_add' | 'messages' |
|
|
2872
|
+
// 'defense' | 'stickers' (the protocol's own spellings work too)
|
|
2873
|
+
// value: 'all' | 'contacts' | 'contact_blacklist' | 'contact_allowlist' |
|
|
2874
|
+
// 'none' | 'match_last_seen' | 'known' | 'on_standard' | 'off'
|
|
2875
|
+
// excluded: JIDs to exclude — see the note below
|
|
2876
|
+
//
|
|
2877
|
+
// The names above are friendly aliases. What goes on the wire is what
|
|
2878
|
+
// whatsmeow sends: last, profile, status, online, readreceipts, groupadd,
|
|
2879
|
+
// calladd, messages, defense, stickers. Four of the six names this used to
|
|
2880
|
+
// put on the wire — last_seen, profile_picture, read_receipts, groups_add —
|
|
2881
|
+
// are not names the server knows, so those settings never changed and the
|
|
2882
|
+
// discarded reply meant it never said so either.
|
|
2672
2883
|
async changePrivacySetting(type, value, excluded) {
|
|
2673
|
-
const
|
|
2884
|
+
const name = PRIVACY_NAME_ALIASES[String(type)];
|
|
2885
|
+
if (!name) {
|
|
2886
|
+
throw new Error('changePrivacySetting: unknown setting "' + type + '" — one of ' +
|
|
2887
|
+
Object.keys(PRIVACY_NAME_ALIASES).join(', '));
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
// Neither whatsmeow nor WhatsApp Web puts <user> children on this IQ; the
|
|
2891
|
+
// exclusion list is maintained separately. Kept for callers that pass it,
|
|
2892
|
+
// and only emitted when they do.
|
|
2674
2893
|
const children = [];
|
|
2675
2894
|
if (excluded && excluded.length > 0) {
|
|
2676
2895
|
for (const jid of excluded) {
|
|
2677
|
-
children.push(new BinaryNode('user', { jid: makeJid(jid) }, null));
|
|
2896
|
+
children.push(new BinaryNode('user', { jid: jidStrToObj(toNonAdJid(makeJid(jid))) }, null));
|
|
2678
2897
|
}
|
|
2679
2898
|
}
|
|
2680
|
-
|
|
2681
|
-
const id = this._genMsgId();
|
|
2899
|
+
|
|
2682
2900
|
const node = new BinaryNode('iq', {
|
|
2683
|
-
id,
|
|
2901
|
+
id: this._genMsgId(),
|
|
2684
2902
|
xmlns: 'privacy',
|
|
2685
2903
|
to: 's.whatsapp.net',
|
|
2686
2904
|
type: 'set'
|
|
2687
2905
|
}, [
|
|
2688
|
-
new BinaryNode('privacy', {}, [
|
|
2906
|
+
new BinaryNode('privacy', {}, [
|
|
2907
|
+
new BinaryNode('category', { name, value: String(value) },
|
|
2908
|
+
children.length > 0 ? children : null)
|
|
2909
|
+
])
|
|
2689
2910
|
]);
|
|
2690
|
-
|
|
2911
|
+
|
|
2912
|
+
const resp = await this._sendIq(node);
|
|
2913
|
+
if (!resp) throw new Error('changePrivacySetting: no reply from server (IQ timed out)');
|
|
2914
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
2915
|
+
const errNode = findChild(resp, 'error');
|
|
2916
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
2917
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
2918
|
+
throw new Error('changePrivacySetting: server rejected ' + name + '=' + value +
|
|
2919
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// whatsmeow folds the change into its cached copy and hands the whole thing
|
|
2923
|
+
// back rather than making the caller re-query.
|
|
2924
|
+
const key = PRIVACY_WIRE_TO_KEY[name];
|
|
2925
|
+
this._privacySettings = Object.assign({}, this._privacySettings || {},
|
|
2926
|
+
{ [key]: String(value) });
|
|
2927
|
+
return this._privacySettings;
|
|
2691
2928
|
}
|
|
2692
2929
|
|
|
2693
2930
|
// ──────────────────────────────────────────────────────────────────────────
|
|
@@ -2708,10 +2945,132 @@ class WhalibmobClient extends EventEmitter {
|
|
|
2708
2945
|
return this._sender.deleteMessage(origMsgId, chatJid, fromMe, forEveryone, opts);
|
|
2709
2946
|
}
|
|
2710
2947
|
|
|
2711
|
-
//
|
|
2712
|
-
|
|
2948
|
+
// ─── Status / Stories ─────────────────────────────────────────────────────
|
|
2949
|
+
|
|
2950
|
+
// Who your Status posts go to.
|
|
2951
|
+
//
|
|
2952
|
+
// Returns [{ type, isDefault, list }] with the default first, as whatsmeow's
|
|
2953
|
+
// GetStatusPrivacy does. type is 'contacts', 'blacklist' or 'whitelist'; list
|
|
2954
|
+
// holds the JIDs the last two are built from. A server that has never been
|
|
2955
|
+
// told otherwise answers nothing, which means the default: all contacts.
|
|
2956
|
+
async queryStatusPrivacy() {
|
|
2957
|
+
const node = new BinaryNode('iq', {
|
|
2958
|
+
id: this._genMsgId(),
|
|
2959
|
+
xmlns: 'status',
|
|
2960
|
+
to: 's.whatsapp.net',
|
|
2961
|
+
type: 'get'
|
|
2962
|
+
}, [new BinaryNode('privacy', {}, null)]);
|
|
2963
|
+
|
|
2964
|
+
const resp = await this._sendIq(node);
|
|
2965
|
+
if (!resp || (resp.attrs && resp.attrs.type === 'error')) {
|
|
2966
|
+
return [{ type: 'contacts', isDefault: true, list: [] }];
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
const privacyNode = findChild(resp, 'privacy');
|
|
2970
|
+
const out = [];
|
|
2971
|
+
if (privacyNode && Array.isArray(privacyNode.content)) {
|
|
2972
|
+
for (const listNode of privacyNode.content) {
|
|
2973
|
+
if (!listNode || listNode.description !== 'list' || !listNode.attrs) continue;
|
|
2974
|
+
const entry = {
|
|
2975
|
+
type: listNode.attrs.type ? String(listNode.attrs.type) : 'contacts',
|
|
2976
|
+
isDefault: String(listNode.attrs.default) === 'true',
|
|
2977
|
+
list: []
|
|
2978
|
+
};
|
|
2979
|
+
if (Array.isArray(listNode.content)) {
|
|
2980
|
+
for (const u of listNode.content) {
|
|
2981
|
+
if (u && u.description === 'user' && u.attrs && u.attrs.jid) {
|
|
2982
|
+
entry.list.push(String(u.attrs.jid));
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
// The default is always reported first.
|
|
2987
|
+
if (entry.isDefault) out.unshift(entry); else out.push(entry);
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
return out.length ? out : [{ type: 'contacts', isDefault: true, list: [] }];
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// Resolve the status privacy settings into the JIDs a post actually goes to,
|
|
2994
|
+
// the way whatsmeow's getStatusBroadcastRecipients does: a whitelist is the
|
|
2995
|
+
// list itself, anything else is every known contact minus the blacklist, and
|
|
2996
|
+
// our own JID is added so the post lands on our own devices too.
|
|
2997
|
+
//
|
|
2998
|
+
// Contacts come from the synced history store — this client has no separate
|
|
2999
|
+
// contact database — so a session that has not synced history yet has to be
|
|
3000
|
+
// given the list explicitly.
|
|
3001
|
+
async _getStatusRecipients() {
|
|
3002
|
+
let privacy;
|
|
3003
|
+
try {
|
|
3004
|
+
privacy = (await this.queryStatusPrivacy())[0];
|
|
3005
|
+
} catch (_) {
|
|
3006
|
+
privacy = { type: 'contacts', list: [] };
|
|
3007
|
+
}
|
|
3008
|
+
|
|
3009
|
+
const ownJid = String(this._store.phoneNumber) + '@s.whatsapp.net';
|
|
3010
|
+
let recipients;
|
|
3011
|
+
|
|
3012
|
+
if (privacy.type === 'whitelist') {
|
|
3013
|
+
recipients = privacy.list.slice();
|
|
3014
|
+
} else {
|
|
3015
|
+
const hist = this.getHistoryStore();
|
|
3016
|
+
const contacts = (hist && hist.contacts) ? Object.keys(hist.contacts) : [];
|
|
3017
|
+
// Only real contacts — an entry the history sync created purely to hold a
|
|
3018
|
+
// push name is not somebody you have in your address book.
|
|
3019
|
+
const named = contacts.filter(jid => {
|
|
3020
|
+
const c = hist.contacts[jid];
|
|
3021
|
+
return c && (c.name || c.displayName) && !jid.endsWith('@g.us') &&
|
|
3022
|
+
!jid.endsWith('@broadcast');
|
|
3023
|
+
});
|
|
3024
|
+
const blocked = new Set(privacy.type === 'blacklist' ? privacy.list : []);
|
|
3025
|
+
recipients = named.filter(jid => !blocked.has(jid));
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
if (!recipients.some(j => this._isOwnUser(j))) recipients.push(ownJid);
|
|
3029
|
+
_whaDbg('[DBG] STATUS_RECIPIENTS type=' + privacy.type +
|
|
3030
|
+
' count=' + recipients.length);
|
|
3031
|
+
return recipients;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
// Post a Status/Story.
|
|
3035
|
+
//
|
|
3036
|
+
// sendStatus('Good morning!') text
|
|
3037
|
+
// sendStatus({ image: './a.jpg', caption: 'hi' }) photo
|
|
3038
|
+
// sendStatus({ video: './v.mp4' }) video
|
|
3039
|
+
// sendStatus({ audio: './v.ogg' }) voice
|
|
3040
|
+
//
|
|
3041
|
+
// opts (also accepted as fields of the object form):
|
|
3042
|
+
// recipients post to these JIDs instead of the privacy-derived list
|
|
3043
|
+
// backgroundArgb background colour of a text status, e.g. 0xFF25D366
|
|
3044
|
+
// textArgb, font the rest of a text status' styling
|
|
3045
|
+
//
|
|
3046
|
+
// Text posted with a background or font becomes an ExtendedTextMessage, which
|
|
3047
|
+
// is what carries them; plain text stays a plain conversation.
|
|
3048
|
+
async sendStatus(content, opts) {
|
|
2713
3049
|
if (!this._sender || !this._connected) throw new Error('Not connected');
|
|
2714
|
-
|
|
3050
|
+
opts = Object.assign({},
|
|
3051
|
+
(content && typeof content === 'object') ? content : {},
|
|
3052
|
+
opts || {});
|
|
3053
|
+
|
|
3054
|
+
const recipients = opts.recipients || opts.statusJidList ||
|
|
3055
|
+
await this._getStatusRecipients();
|
|
3056
|
+
const sendOpts = Object.assign({}, opts, { _statusRecipients: recipients });
|
|
3057
|
+
|
|
3058
|
+
if (opts.image) return this._sender.sendImage(STATUS_BROADCAST_JID, opts.image, sendOpts);
|
|
3059
|
+
if (opts.video) return this._sender.sendVideo(STATUS_BROADCAST_JID, opts.video, sendOpts);
|
|
3060
|
+
if (opts.audio) return this._sender.sendAudio(STATUS_BROADCAST_JID, opts.audio, sendOpts);
|
|
3061
|
+
|
|
3062
|
+
const text = (typeof content === 'string') ? content : (opts.text || opts.caption || '');
|
|
3063
|
+
const styled = opts.backgroundArgb != null || opts.textArgb != null || opts.font != null;
|
|
3064
|
+
if (!styled) return this._sender.sendText(STATUS_BROADCAST_JID, text, sendOpts);
|
|
3065
|
+
|
|
3066
|
+
const { encodeMessage, encodeExtendedText } = require('./proto/MessageProto');
|
|
3067
|
+
const buf = encodeMessage('extendedText', encodeExtendedText(text, {
|
|
3068
|
+
backgroundArgb: opts.backgroundArgb,
|
|
3069
|
+
textArgb: opts.textArgb,
|
|
3070
|
+
font: opts.font
|
|
3071
|
+
}));
|
|
3072
|
+
return this._sender._sendMessage(STATUS_BROADCAST_JID,
|
|
3073
|
+
generateMessageId(), buf, 'text', sendOpts);
|
|
2715
3074
|
}
|
|
2716
3075
|
|
|
2717
3076
|
// Forward a message
|
|
@@ -3140,32 +3499,52 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3140
3499
|
return this._sendIq(node);
|
|
3141
3500
|
}
|
|
3142
3501
|
|
|
3143
|
-
// Block a contact
|
|
3502
|
+
// Block a contact. Returns the updated block list.
|
|
3144
3503
|
async blockContact(jid) {
|
|
3145
|
-
|
|
3146
|
-
const node = new BinaryNode('iq', {
|
|
3147
|
-
id,
|
|
3148
|
-
xmlns: 'blocklist',
|
|
3149
|
-
to: 's.whatsapp.net',
|
|
3150
|
-
type: 'set'
|
|
3151
|
-
}, [
|
|
3152
|
-
new BinaryNode('item', { action: 'block', jid: makeJid(jid) }, null)
|
|
3153
|
-
]);
|
|
3154
|
-
return this._sendIq(node);
|
|
3504
|
+
return this._updateBlocklist(jid, 'block');
|
|
3155
3505
|
}
|
|
3156
3506
|
|
|
3157
|
-
// Unblock a contact
|
|
3507
|
+
// Unblock a contact. Returns the updated block list.
|
|
3158
3508
|
async unblockContact(jid) {
|
|
3159
|
-
|
|
3509
|
+
return this._updateBlocklist(jid, 'unblock');
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3512
|
+
// The one IQ behind both, as whatsmeow's UpdateBlocklist sends it:
|
|
3513
|
+
//
|
|
3514
|
+
// <iq xmlns="blocklist" type="set" to="s.whatsapp.net">
|
|
3515
|
+
// <item jid="<JID>" action="block|unblock"/>
|
|
3516
|
+
// </iq>
|
|
3517
|
+
//
|
|
3518
|
+
// Three things were wrong with the pair this replaces. The jid went out as a
|
|
3519
|
+
// raw string rather than a binary JID. It was not reduced to its bare user
|
|
3520
|
+
// first, so blocking a contact you happened to name with a device suffix sent
|
|
3521
|
+
// the server a device JID it has nothing to block. And the reply was thrown
|
|
3522
|
+
// away without being looked at — including a type="error" reply — so a block
|
|
3523
|
+
// the server refused came back looking exactly like one that worked.
|
|
3524
|
+
async _updateBlocklist(jid, action) {
|
|
3525
|
+
const target = toNonAdJid(makeJid(jid));
|
|
3160
3526
|
const node = new BinaryNode('iq', {
|
|
3161
|
-
id,
|
|
3527
|
+
id: this._genMsgId(),
|
|
3162
3528
|
xmlns: 'blocklist',
|
|
3163
3529
|
to: 's.whatsapp.net',
|
|
3164
3530
|
type: 'set'
|
|
3165
3531
|
}, [
|
|
3166
|
-
new BinaryNode('item', {
|
|
3532
|
+
new BinaryNode('item', { jid: jidStrToObj(target), action }, null)
|
|
3167
3533
|
]);
|
|
3168
|
-
|
|
3534
|
+
|
|
3535
|
+
const resp = await this._sendIq(node);
|
|
3536
|
+
if (!resp) {
|
|
3537
|
+
throw new Error(action + 'Contact: no reply from server (IQ timed out)');
|
|
3538
|
+
}
|
|
3539
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
3540
|
+
const errNode = findChild(resp, 'error');
|
|
3541
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
3542
|
+
const text = errNode && errNode.attrs && errNode.attrs.text;
|
|
3543
|
+
throw new Error(action + 'Contact: server rejected the request' +
|
|
3544
|
+
(code ? ' (' + code + (text ? ' ' + text : '') + ')' : ''));
|
|
3545
|
+
}
|
|
3546
|
+
// The server answers with the whole list as it now stands.
|
|
3547
|
+
return this._parseBlocklist(findChild(resp, 'list')).jids;
|
|
3169
3548
|
}
|
|
3170
3549
|
|
|
3171
3550
|
// Pin / unpin a chat (mobile: local state)
|
|
@@ -3264,6 +3643,56 @@ class WhalibmobClient extends EventEmitter {
|
|
|
3264
3643
|
return this._chatStates[key];
|
|
3265
3644
|
}
|
|
3266
3645
|
|
|
3646
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
3647
|
+
// CALL LINKS
|
|
3648
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
3649
|
+
|
|
3650
|
+
// Ask the server for a call link that can be shared in a chat, so the other
|
|
3651
|
+
// side joins by tapping it instead of being rung.
|
|
3652
|
+
//
|
|
3653
|
+
// type 'audio' (default) or 'video'
|
|
3654
|
+
// opts { startTime } — seconds since the epoch, to schedule the call
|
|
3655
|
+
//
|
|
3656
|
+
// Returns { token, link, type }. The stanza is the one WhatsApp Web sends:
|
|
3657
|
+
// <call to="@call"><link_create media="..."/></call>, answered with the token
|
|
3658
|
+
// on a link_create of its own.
|
|
3659
|
+
async createCallLink(type, opts) {
|
|
3660
|
+
opts = opts || {};
|
|
3661
|
+
const media = type === 'video' ? 'video' : 'audio';
|
|
3662
|
+
const id = this._genMsgId();
|
|
3663
|
+
const children = opts.startTime
|
|
3664
|
+
? [new BinaryNode('event', { start_time: String(opts.startTime) }, null)]
|
|
3665
|
+
: null;
|
|
3666
|
+
|
|
3667
|
+
const resp = await this._sendIq(new BinaryNode('call', { id, to: '@call' }, [
|
|
3668
|
+
new BinaryNode('link_create', { media }, children)
|
|
3669
|
+
]));
|
|
3670
|
+
|
|
3671
|
+
if (!resp) throw new Error('createCallLink: no reply from server (timed out)');
|
|
3672
|
+
if (resp.attrs && resp.attrs.type === 'error') {
|
|
3673
|
+
const errNode = findChild(resp, 'error');
|
|
3674
|
+
const code = errNode && errNode.attrs && errNode.attrs.code;
|
|
3675
|
+
throw new Error('createCallLink: server rejected the request' +
|
|
3676
|
+
(code ? ' (' + code + ')' : ''));
|
|
3677
|
+
}
|
|
3678
|
+
const created = findChild(resp, 'link_create');
|
|
3679
|
+
const token = created && created.attrs && created.attrs.token;
|
|
3680
|
+
if (!token) throw new Error('createCallLink: server reply carried no token');
|
|
3681
|
+
|
|
3682
|
+
const prefix = media === 'video' ? CALL_VIDEO_PREFIX : CALL_AUDIO_PREFIX;
|
|
3683
|
+
return { token: String(token), link: prefix + token, type: media };
|
|
3684
|
+
}
|
|
3685
|
+
|
|
3686
|
+
// Create a call link and send it as a message in one step.
|
|
3687
|
+
// Returns the send result with the link attached.
|
|
3688
|
+
async sendCallLink(to, type, opts) {
|
|
3689
|
+
opts = opts || {};
|
|
3690
|
+
const { link } = await this.createCallLink(type, opts);
|
|
3691
|
+
const text = opts.text ? (opts.text + ' ' + link) : link;
|
|
3692
|
+
const res = await this.sendText(to, text, opts);
|
|
3693
|
+
return Object.assign({}, res, { link });
|
|
3694
|
+
}
|
|
3695
|
+
|
|
3267
3696
|
// ──────────────────────────────────────────────────────────────────────────
|
|
3268
3697
|
// GROUP OPERATIONS
|
|
3269
3698
|
// ──────────────────────────────────────────────────────────────────────────
|