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/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
- // Query blocked contacts list
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
- const list = findChild(resp, 'list');
2620
- if (!list || !Array.isArray(list.content)) return [];
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
- // Change privacy settings
2669
- // type: 'last_seen'|'profile_picture'|'status'|'online'|'read_receipts'|'groups_add'
2670
- // value: 'all'|'contacts'|'contact_blacklist'|'none'|'match_last_seen'
2671
- // excluded: array of JIDs excluded from value (for contact_blacklist)
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 categoryAttrs = { name: type, value };
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
- const categoryNode = new BinaryNode('category', categoryAttrs, children.length > 0 ? children : null);
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', {}, [categoryNode])
2906
+ new BinaryNode('privacy', {}, [
2907
+ new BinaryNode('category', { name, value: String(value) },
2908
+ children.length > 0 ? children : null)
2909
+ ])
2689
2910
  ]);
2690
- return this._sendIq(node);
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
- // Send a WhatsApp Status/Story
2712
- async sendStatus(text, opts) {
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
- return this._sender.sendText('status@broadcast', text, opts);
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
- const id = this._genMsgId();
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
- const id = this._genMsgId();
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', { action: 'unblock', jid: makeJid(jid) }, null)
3532
+ new BinaryNode('item', { jid: jidStrToObj(target), action }, null)
3167
3533
  ]);
3168
- return this._sendIq(node);
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
  // ──────────────────────────────────────────────────────────────────────────