whalibmob 5.26.1 → 5.29.0

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
@@ -496,12 +496,30 @@ class WhalibmobClient extends EventEmitter {
496
496
  // Blue-tick every message as it arrives. Set to false to make read receipts
497
497
  // explicit, sent only by a markRead() call.
498
498
  this.autoRead = opts.autoRead !== false;
499
+
500
+ // Every incoming call is refused unless this is turned off. Off does not
501
+ // mean calls are answered — nothing here can answer one — it means the
502
+ // refusal is left to the caller, which is the only way to let some calls
503
+ // ring and reject the rest. See rejectCall().
504
+ this._autoRejectCalls = opts.autoRejectCalls !== false;
499
505
  // Flipped by setOnline(). Delivery receipts sent while this is false carry
500
506
  // type="inactive", which the sender's app records but does not render.
501
507
  this._sendActiveReceipts = false;
502
508
  // Cached privacy settings. Warmed after connect and refreshed whenever the
503
509
  // server reports a change; markRead reads it.
504
510
  this._privacySettings = null;
511
+ // The account's AB props, and the hash that turns the next sync into a
512
+ // delta. Both live for the session: a fresh process asks for the full set,
513
+ // which is what a fresh install does.
514
+ this._abProps = null;
515
+ this._abPropsHash = null;
516
+ // Whether to sync AB props on connect. On by default because the app does
517
+ // it on every login and a client that never does is running on guessed
518
+ // defaults, but it is one IQ a caller may not want on a short session.
519
+ this._syncAbPropsOnConnect = opts.syncAbPropsOnConnect !== false;
520
+ // noticeId → accepted. Absent means this session has not asked, which
521
+ // isTosAccepted reports as null rather than as "not accepted".
522
+ this._tosAccepted = new Map();
505
523
  if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
506
524
  }
507
525
 
@@ -1696,6 +1714,16 @@ class WhalibmobClient extends EventEmitter {
1696
1714
  this.queryPrivacySettings({ force: true }).catch(err =>
1697
1715
  _whaDbg('[DBG] PRIVACY_FETCH_FAILED ' + (err && err.message)));
1698
1716
 
1717
+ // Sync the account's AB props, as the app does on every login. On a
1718
+ // reconnect the stored hash makes this a delta rather than the whole
1719
+ // table. A failure is logged and nothing else: every reader of these has a
1720
+ // default to fall back on, so a client that could not fetch them still
1721
+ // works — it just works on the defaults.
1722
+ if (this._syncAbPropsOnConnect) {
1723
+ this.queryAbProps({ force: true }).catch(err =>
1724
+ _whaDbg('[DBG] AB_PROPS_FETCH_FAILED ' + (err && err.message)));
1725
+ }
1726
+
1699
1727
  // ── Reconnect: flush stale device cache + background re-usync ─────────────
1700
1728
  // On first connect _hasConnectedOnce is false — nothing extra to do.
1701
1729
  // On every reconnect (network drop / NAT reset / server restart) we flush the
@@ -3815,6 +3843,11 @@ class WhalibmobClient extends EventEmitter {
3815
3843
  // so a change made on the phone never reached the cache.
3816
3844
  const privacyNode = findChildDeep(node, 'privacy');
3817
3845
  if (privacyNode) this._handlePrivacySettingsNotification(privacyNode);
3846
+ // ...and Terms-of-Service acceptances. Accepting a notice on the phone
3847
+ // arrives here, and it is the only way this side learns about it without
3848
+ // asking.
3849
+ const tosNode = findChildDeep(node, 'tos');
3850
+ if (tosNode) this._handleTosNotification(tosNode);
3818
3851
  }
3819
3852
 
3820
3853
  if (type === 'w:gp2') {
@@ -4162,20 +4195,46 @@ class WhalibmobClient extends EventEmitter {
4162
4195
  }
4163
4196
  }
4164
4197
 
4165
- this.emit('call', { from: attrs.from || '', node });
4166
- // Reject call automatically
4167
- if (this._socket && this._connected) {
4168
- const offer = findChild(node, 'offer');
4169
- const callId = offer && offer.attrs && offer.attrs.call_id;
4170
- if (callId) {
4171
- this._socket.sendNode(new BinaryNode('call', {
4172
- to: attrs.from || '',
4173
- id: this._genMsgId()
4174
- }, [new BinaryNode('reject', { call_id: callId, call_creator: attrs.from || '' }, null)]));
4175
- }
4198
+ // What the stanza actually says, rather than the raw node alone: which call
4199
+ // it is, and what stage it is at. A caller deciding whether to reject needs
4200
+ // both, and digging them out of the node is not its job.
4201
+ const offer = findChild(node, 'offer');
4202
+ const relay = findChild(node, 'relaylatency');
4203
+ const term = findChild(node, 'terminate');
4204
+ const callId = (offer && offer.attrs && offer.attrs.call_id) ||
4205
+ (term && term.attrs && term.attrs.call_id) || null;
4206
+ const status = offer ? 'offer' : (term ? 'terminate' : (relay ? 'ringing' : 'unknown'));
4207
+
4208
+ this.emit('call', { from: attrs.from || '', id: callId, status, node });
4209
+
4210
+ // Reject every incoming call, unless the caller asked to decide for itself.
4211
+ // Turning this off does not answer calls — nothing here can — it only stops
4212
+ // the automatic refusal, so a caller can reject some and let the rest ring.
4213
+ if (this._autoRejectCalls && callId && status === 'offer') {
4214
+ this.rejectCall(callId, attrs.from || '');
4176
4215
  }
4177
4216
  }
4178
4217
 
4218
+ /**
4219
+ * Refuse one incoming call.
4220
+ *
4221
+ * Only needed when the client was built with `autoRejectCalls: false`; with
4222
+ * the default every call is refused before this could be reached. The call id
4223
+ * and the caller both come off the `call` event.
4224
+ *
4225
+ * @param {string} callId the `id` from the call event
4226
+ * @param {string} from the `from` from the call event
4227
+ * @returns {boolean} whether the refusal could be sent
4228
+ */
4229
+ rejectCall(callId, from) {
4230
+ if (!callId || !this._socket || !this._connected) return false;
4231
+ this._socket.sendNode(new BinaryNode('call', {
4232
+ to: from || '',
4233
+ id: this._genMsgId()
4234
+ }, [new BinaryNode('reject', { call_id: callId, call_creator: from || '' }, null)]));
4235
+ return true;
4236
+ }
4237
+
4179
4238
  // ─── Auth failure (server forces logout during active session) ──────────────
4180
4239
 
4181
4240
  // <failure> ends the connection, but not every reason means the session died.
@@ -6495,6 +6554,467 @@ class WhalibmobClient extends EventEmitter {
6495
6554
  return this._sender.deleteMessage(origMsgId, chatJid, fromMe, forEveryone, opts);
6496
6555
  }
6497
6556
 
6557
+ // ─── AB props — the server's own feature flags ────────────────────────────
6558
+ //
6559
+ // Every WhatsApp client asks the server which experiments and configuration
6560
+ // values apply to this account and then gates behaviour on the answer: how
6561
+ // many bytes an identity hash is truncated to, whether the LID migration has
6562
+ // started here, what sampling weight each telemetry event carries. A client
6563
+ // that never asks runs on guessed defaults wherever the server has an
6564
+ // opinion — and never asking is itself unlike the app, which asks on every
6565
+ // login.
6566
+ //
6567
+ // <iq xmlns="abt" to="s.whatsapp.net" type="get">
6568
+ // <props protocol="1" [hash="..."] [refresh_id="..."]/>
6569
+ // </iq>
6570
+ //
6571
+ // The reply carries a hash of the set it returned. Sending that hash back on
6572
+ // the next request asks for a delta rather than the whole table, which is
6573
+ // what the app does once it has synced. The hash is kept for the session
6574
+ // only — a fresh process asks for the full set, exactly as a fresh install
6575
+ // does — so nothing here has to be persisted or migrated.
6576
+ //
6577
+ // Values come back as strings because that is what the wire carries. Typing
6578
+ // them per prop would need a table this side does not have, and guessing
6579
+ // would be wrong more often than it is useful; `abPropInt` and `abPropBool`
6580
+ // are there for the two readings that are worth having.
6581
+
6582
+ /**
6583
+ * Sync this account's AB props.
6584
+ *
6585
+ * Served from the session cache unless `force` is set. A reply the server
6586
+ * marks `delta_update` is merged onto what is already held rather than
6587
+ * replacing it, which is the whole point of sending the hash.
6588
+ *
6589
+ * @param {object} [opts]
6590
+ * @param {boolean} [opts.force] re-ask even when a set is cached
6591
+ * @param {string} [opts.refreshId] the emergency-push branch. The server
6592
+ * announces a refresh id when a global update has to propagate at
6593
+ * once; sending it takes the place of the hash.
6594
+ * @returns {Promise<{hash: string|null, abKey: string|null,
6595
+ * refresh: number|null, refreshId: string|null,
6596
+ * delta: boolean, props: Object<string,string>,
6597
+ * sampling: Object<string,number>}>}
6598
+ */
6599
+ async queryAbProps(opts) {
6600
+ opts = opts || {};
6601
+ if (this._abProps && !opts.force && opts.refreshId == null) return this._abProps;
6602
+
6603
+ const propsAttrs = { protocol: '1' };
6604
+ // One or the other, never both: a refresh id names a specific update to
6605
+ // fetch, and a hash asks for whatever has changed since it. Sending both
6606
+ // asks two different questions in one stanza.
6607
+ if (opts.refreshId != null) propsAttrs.refresh_id = String(opts.refreshId);
6608
+ else if (this._abPropsHash) propsAttrs.hash = this._abPropsHash;
6609
+
6610
+ const resp = await this._sendIq(new BinaryNode('iq', {
6611
+ id: this._genMsgId(),
6612
+ to: 's.whatsapp.net',
6613
+ type: 'get',
6614
+ xmlns: 'abt'
6615
+ }, [new BinaryNode('props', propsAttrs, null)]));
6616
+
6617
+ if (!resp) throw new Error('queryAbProps: no reply from server (IQ timed out)');
6618
+ if (resp.attrs && resp.attrs.type === 'error') {
6619
+ const errNode = findChild(resp, 'error');
6620
+ const code = errNode && errNode.attrs && errNode.attrs.code;
6621
+ throw new Error('queryAbProps: server rejected the request' +
6622
+ (code ? ' (' + code + ')' : ''));
6623
+ }
6624
+
6625
+ const parsed = this._parseAbProps(findChild(resp, 'props'));
6626
+
6627
+ // A delta names only what moved. Replacing the cache with it would drop
6628
+ // every prop the server saw no reason to repeat.
6629
+ if (parsed.delta && this._abProps) {
6630
+ parsed.props = Object.assign({}, this._abProps.props, parsed.props);
6631
+ parsed.sampling = Object.assign({}, this._abProps.sampling, parsed.sampling);
6632
+ }
6633
+
6634
+ if (parsed.hash) this._abPropsHash = parsed.hash;
6635
+ this._abProps = parsed;
6636
+ _whaDbg('[DBG] AB_PROPS ' + (parsed.delta ? 'delta' : 'full') +
6637
+ ' props=' + Object.keys(parsed.props).length +
6638
+ ' sampling=' + Object.keys(parsed.sampling).length +
6639
+ (parsed.hash ? ' hash=' + parsed.hash : ''));
6640
+ return parsed;
6641
+ }
6642
+
6643
+ // <props hash ab_key refresh refresh_id delta_update>
6644
+ // <prop config_code="..." config_value="..."/> an experiment value
6645
+ // <prop event_code="..." sampling_weight="..."/> a telemetry weight
6646
+ // </props>
6647
+ //
6648
+ // The two shapes share a tag and are told apart by which attributes they
6649
+ // carry, so each child is tried as an experiment first and as a sampling
6650
+ // entry second. A child that is neither is skipped rather than allowed to
6651
+ // fail the whole sync — an unknown shape is a prop this version has not
6652
+ // learned about, not a broken reply.
6653
+ _parseAbProps(propsNode) {
6654
+ const out = {
6655
+ hash: null,
6656
+ abKey: null,
6657
+ refresh: null,
6658
+ refreshId: null,
6659
+ delta: false,
6660
+ props: {},
6661
+ sampling: {}
6662
+ };
6663
+ if (!propsNode || !propsNode.attrs) return out;
6664
+
6665
+ const a = propsNode.attrs;
6666
+ out.hash = a.hash ? String(a.hash) : null;
6667
+ out.abKey = a.ab_key ? String(a.ab_key) : null;
6668
+ out.refreshId = a.refresh_id ? String(a.refresh_id) : null;
6669
+
6670
+ const refresh = Number(a.refresh);
6671
+ if (Number.isFinite(refresh) && refresh > 0) out.refresh = refresh;
6672
+
6673
+ // The server writes this as a string, and "false" is a true string.
6674
+ out.delta = a.delta_update === 'true' || a.delta_update === true || a.delta_update === '1';
6675
+
6676
+ const children = Array.isArray(propsNode.content) ? propsNode.content : [];
6677
+ for (const child of children) {
6678
+ if (!child || child.description !== 'prop' || !child.attrs) continue;
6679
+ const c = child.attrs;
6680
+
6681
+ if (c.config_code != null && c.config_value != null) {
6682
+ out.props[String(c.config_code)] = String(c.config_value);
6683
+ continue;
6684
+ }
6685
+
6686
+ if (c.event_code != null && c.sampling_weight != null) {
6687
+ const code = Number(c.event_code);
6688
+ const weight = Number(c.sampling_weight);
6689
+ // Bounds the app itself applies. A code below 1 names no event, and a
6690
+ // weight outside the range is not a weight — both mean this entry was
6691
+ // misread rather than that the server sent something exotic.
6692
+ if (Number.isFinite(code) && code >= 1 &&
6693
+ Number.isFinite(weight) && weight >= 0 && weight <= 1000000) {
6694
+ out.sampling[String(code)] = weight;
6695
+ }
6696
+ }
6697
+ }
6698
+ return out;
6699
+ }
6700
+
6701
+ /**
6702
+ * One AB prop as a string, or `fallback` when the server has not set it.
6703
+ *
6704
+ * Props are addressed by their numeric code, which is what the wire uses.
6705
+ * Reading one before `queryAbProps` has run returns the fallback rather than
6706
+ * throwing: a caller gating on a flag wants the default, not an exception.
6707
+ *
6708
+ * @param {number|string} code
6709
+ * @param {string|null} [fallback=null]
6710
+ * @returns {string|null}
6711
+ */
6712
+ abProp(code, fallback) {
6713
+ const value = this._abProps && this._abProps.props[String(code)];
6714
+ return value === undefined || value === null ? (fallback === undefined ? null : fallback) : value;
6715
+ }
6716
+
6717
+ /** The same, read as an integer. Anything unparseable gives the fallback. */
6718
+ abPropInt(code, fallback) {
6719
+ const raw = this.abProp(code, null);
6720
+ const n = Number(raw);
6721
+ return raw === null || !Number.isFinite(n) ? (fallback === undefined ? null : fallback) : n;
6722
+ }
6723
+
6724
+ /**
6725
+ * The same, read as a flag.
6726
+ *
6727
+ * The wire spells these as "1"/"0" and as "true"/"false" depending on the
6728
+ * prop, so both are understood; anything else gives the fallback rather than
6729
+ * being coerced, since a prop carrying a version string is not a flag.
6730
+ */
6731
+ abPropBool(code, fallback) {
6732
+ const raw = this.abProp(code, null);
6733
+ if (raw === '1' || raw === 'true') return true;
6734
+ if (raw === '0' || raw === 'false') return false;
6735
+ return fallback === undefined ? null : fallback;
6736
+ }
6737
+
6738
+ // ─── Contact sync — telling the server which numbers are in the book ──────
6739
+ //
6740
+ // The app uploads its address book on first launch and re-syncs deltas
6741
+ // afterwards, which is how it learns who is reachable and how the server
6742
+ // learns who this account knows. whalibmob has had the whole usync machinery
6743
+ // for device discovery all along and never used it for this, so an account
6744
+ // could send to a hundred strangers having never synced a single contact —
6745
+ // a shape nothing on a phone produces.
6746
+ //
6747
+ // This is the same <iq xmlns="usync"> the device path builds, with the
6748
+ // contact protocol on its own and the mode and context that name an address
6749
+ // book upload rather than a lookup.
6750
+
6751
+ /**
6752
+ * Sync contacts, and learn which of them are on WhatsApp.
6753
+ *
6754
+ * @param {string[]} phones E.164 numbers. A leading `+` is added when
6755
+ * missing, since that is the only form the contact protocol takes.
6756
+ * @param {object} [opts]
6757
+ * @param {'full'|'delta'|'query'} [opts.mode] `full` — the whole book, what
6758
+ * the app sends on first launch; `delta` — what changed since;
6759
+ * `query` — a lookup that claims nothing about the book. Defaults to
6760
+ * `full`.
6761
+ * @param {string} [opts.context] defaults to `registration` for a full
6762
+ * sync (the app's own first-launch context) and `interactive`
6763
+ * otherwise.
6764
+ * @param {number} [opts.chunkSize=500] numbers per IQ.
6765
+ * @returns {Promise<{registered: string[], unregistered: string[],
6766
+ * jids: Object<string,string>}>}
6767
+ * `registered` and `unregistered` hold the numbers as passed in;
6768
+ * `jids` maps each registered number to the JID the server gave it.
6769
+ */
6770
+ async syncContacts(phones, opts) {
6771
+ if (!this._connected) throw new Error('Not connected');
6772
+ // _devMgr is torn down on close and rebuilt on init. Saying so beats the
6773
+ // null dereference a caller would otherwise get from a session that is
6774
+ // connected but has been through a teardown.
6775
+ if (!this._devMgr) throw new Error('syncContacts: no device manager — the session is not initialised');
6776
+ if (!Array.isArray(phones)) throw new Error('syncContacts: phones must be an array');
6777
+
6778
+ opts = opts || {};
6779
+ const mode = opts.mode || 'full';
6780
+ const context = opts.context || (mode === 'full' ? 'registration' : 'interactive');
6781
+ const chunkSize = Number(opts.chunkSize) > 0 ? Number(opts.chunkSize) : 500;
6782
+
6783
+ const { USyncQuery, USyncUser } = require('./WAUSync');
6784
+
6785
+ // Normalise once, and keep the caller's spelling to answer in. Duplicates
6786
+ // are dropped here rather than sent: the server counts what it is asked
6787
+ // about, and asking twice about one number in one book is not what a
6788
+ // phone does.
6789
+ const wanted = [];
6790
+ const seen = new Set();
6791
+ for (const raw of phones) {
6792
+ const digits = String(raw || '').replace(/[^\d]/g, '');
6793
+ if (!digits || seen.has(digits)) continue;
6794
+ seen.add(digits);
6795
+ wanted.push({ input: String(raw), digits });
6796
+ }
6797
+
6798
+ const out = { registered: [], unregistered: [], jids: {} };
6799
+ if (wanted.length === 0) return out;
6800
+
6801
+ for (let i = 0; i < wanted.length; i += chunkSize) {
6802
+ const chunk = wanted.slice(i, i + chunkSize);
6803
+
6804
+ const query = new USyncQuery()
6805
+ .withMode(mode)
6806
+ .withContext(context)
6807
+ .withContactProtocol();
6808
+ for (const c of chunk) query.withUser(new USyncUser().withPhone('+' + c.digits));
6809
+
6810
+ const parsed = await this._devMgr.executeUSyncQuery(query);
6811
+
6812
+ // A chunk the server did not answer leaves its numbers unclassified
6813
+ // rather than marked absent. Reporting "not on WhatsApp" for a timeout
6814
+ // is the one wrong answer here — a caller acts on it.
6815
+ if (!parsed || !Array.isArray(parsed.list)) {
6816
+ _whaDbg('[DBG] CONTACT_SYNC chunk ' + (i / chunkSize) + ' unanswered — ' +
6817
+ chunk.length + ' numbers left unclassified');
6818
+ continue;
6819
+ }
6820
+
6821
+ // The reply is keyed by JID, so match each entry back to the number it
6822
+ // came from by the JID's user part.
6823
+ const byUser = new Map();
6824
+ for (const entry of parsed.list) {
6825
+ if (!entry || !entry.id) continue;
6826
+ const user = String(entry.id).split('@')[0].split(':')[0];
6827
+ byUser.set(user, entry);
6828
+ }
6829
+
6830
+ for (const c of chunk) {
6831
+ const entry = byUser.get(c.digits);
6832
+ if (!entry) continue; // unclassified, as above
6833
+ if (entry.contact === true) {
6834
+ out.registered.push(c.input);
6835
+ out.jids[c.input] = String(entry.id);
6836
+ } else {
6837
+ out.unregistered.push(c.input);
6838
+ }
6839
+ }
6840
+ }
6841
+
6842
+ _whaDbg('[DBG] CONTACT_SYNC mode=' + mode + ' context=' + context +
6843
+ ' asked=' + wanted.length + ' on=' + out.registered.length +
6844
+ ' off=' + out.unregistered.length);
6845
+ return out;
6846
+ }
6847
+
6848
+ // ─── Terms-of-Service notices ─────────────────────────────────────────────
6849
+ //
6850
+ // WhatsApp gates some surfaces on the account having accepted a given legal
6851
+ // notice — a Terms update, a privacy-policy change, a regional disclosure.
6852
+ // The app pulls the acceptance state, shows the ones that are outstanding,
6853
+ // and posts back what the user accepted.
6854
+ //
6855
+ // read <iq xmlns="tos" type="get"><request><notice id="..."/></request></iq>
6856
+ // accept <iq xmlns="tos" type="set"><request type="session_update">
6857
+ // <notice id="..."/></request></iq>
6858
+ // clear <iq xmlns="tos" type="set"><delete id="..."/></iq>
6859
+ //
6860
+ // The reply's `state` attribute reads backwards from how it looks: it is
6861
+ // present and "false" for a notice that has NOT been accepted, and absent
6862
+ // for one that has. Treating a missing attribute as "unknown" would report
6863
+ // every accepted notice as outstanding.
6864
+
6865
+ /**
6866
+ * Read the acceptance state of one or more notices.
6867
+ *
6868
+ * @param {string[]} noticeIds
6869
+ * @returns {Promise<{refresh: number|null,
6870
+ * notices: Array<{id: string, accepted: boolean}>}>}
6871
+ * `refresh` is how many seconds the server suggests waiting before
6872
+ * asking again.
6873
+ */
6874
+ async queryTosNotices(noticeIds) {
6875
+ if (!this._connected) throw new Error('Not connected');
6876
+ if (!Array.isArray(noticeIds)) throw new Error('queryTosNotices: noticeIds must be an array');
6877
+
6878
+ const ids = noticeIds.map(id => String(id)).filter(Boolean);
6879
+ const resp = await this._sendIq(new BinaryNode('iq', {
6880
+ id: this._genMsgId(),
6881
+ to: 's.whatsapp.net',
6882
+ type: 'get',
6883
+ xmlns: 'tos'
6884
+ }, [new BinaryNode('request', {},
6885
+ ids.map(id => new BinaryNode('notice', { id }, null)))]));
6886
+
6887
+ if (!resp) throw new Error('queryTosNotices: no reply from server (IQ timed out)');
6888
+ if (resp.attrs && resp.attrs.type === 'error') {
6889
+ const errNode = findChild(resp, 'error');
6890
+ const code = errNode && errNode.attrs && errNode.attrs.code;
6891
+ throw new Error('queryTosNotices: server rejected the request' +
6892
+ (code ? ' (' + code + ')' : ''));
6893
+ }
6894
+
6895
+ const parsed = this._parseTosNotices(findChild(resp, 'tos'));
6896
+ for (const n of parsed.notices) this._tosAccepted.set(n.id, n.accepted);
6897
+ _whaDbg('[DBG] TOS_QUERY asked=' + ids.length + ' answered=' + parsed.notices.length +
6898
+ ' outstanding=' + parsed.notices.filter(n => !n.accepted).length);
6899
+ return parsed;
6900
+ }
6901
+
6902
+ // <tos refresh="86400"><notice id="..." [state="false"]/>...</tos>
6903
+ _parseTosNotices(tosNode) {
6904
+ const out = { refresh: null, notices: [] };
6905
+ if (!tosNode) return out;
6906
+
6907
+ const refresh = Number(tosNode.attrs && tosNode.attrs.refresh);
6908
+ if (Number.isFinite(refresh) && refresh > 0) out.refresh = refresh;
6909
+
6910
+ const children = Array.isArray(tosNode.content) ? tosNode.content : [];
6911
+ for (const child of children) {
6912
+ if (!child || child.description !== 'notice' || !child.attrs || !child.attrs.id) continue;
6913
+ const state = child.attrs.state;
6914
+ // Only an explicit "false" means not accepted; see the note above.
6915
+ out.notices.push({
6916
+ id: String(child.attrs.id),
6917
+ accepted: !(state === 'false' || state === false)
6918
+ });
6919
+ }
6920
+ return out;
6921
+ }
6922
+
6923
+ /**
6924
+ * Accept one or more notices.
6925
+ *
6926
+ * @param {string[]} noticeIds
6927
+ * @returns {Promise<{ok: true, raw: any}>}
6928
+ */
6929
+ async acceptTosNotices(noticeIds) {
6930
+ if (!this._connected) throw new Error('Not connected');
6931
+ if (!Array.isArray(noticeIds) || noticeIds.length === 0) {
6932
+ throw new Error('acceptTosNotices: noticeIds must be a non-empty array');
6933
+ }
6934
+
6935
+ const ids = noticeIds.map(id => String(id)).filter(Boolean);
6936
+ const resp = await this._sendIq(new BinaryNode('iq', {
6937
+ id: this._genMsgId(),
6938
+ to: 's.whatsapp.net',
6939
+ type: 'set',
6940
+ xmlns: 'tos'
6941
+ }, [new BinaryNode('request', { type: 'session_update' },
6942
+ ids.map(id => new BinaryNode('notice', { id }, null)))]));
6943
+
6944
+ if (!resp) throw new Error('acceptTosNotices: no reply from server (IQ timed out)');
6945
+ if (resp.attrs && resp.attrs.type === 'error') {
6946
+ const errNode = findChild(resp, 'error');
6947
+ const code = errNode && errNode.attrs && errNode.attrs.code;
6948
+ throw new Error('acceptTosNotices: server rejected the request' +
6949
+ (code ? ' (' + code + ')' : ''));
6950
+ }
6951
+
6952
+ for (const id of ids) this._tosAccepted.set(id, true);
6953
+ _whaDbg('[DBG] TOS_ACCEPT ' + ids.join(','));
6954
+ return { ok: true, raw: resp };
6955
+ }
6956
+
6957
+ /**
6958
+ * Clear the accepted state of one notice, so the server asks for it again.
6959
+ *
6960
+ * @param {string} noticeId
6961
+ * @returns {Promise<{ok: true, raw: any}>}
6962
+ */
6963
+ async clearTosNotice(noticeId) {
6964
+ if (!this._connected) throw new Error('Not connected');
6965
+ const id = String(noticeId || '');
6966
+ if (!id) throw new Error('clearTosNotice: noticeId is required');
6967
+
6968
+ const resp = await this._sendIq(new BinaryNode('iq', {
6969
+ id: this._genMsgId(),
6970
+ to: 's.whatsapp.net',
6971
+ type: 'set',
6972
+ xmlns: 'tos'
6973
+ }, [new BinaryNode('delete', { id }, null)]));
6974
+
6975
+ if (!resp) throw new Error('clearTosNotice: no reply from server (IQ timed out)');
6976
+ if (resp.attrs && resp.attrs.type === 'error') {
6977
+ const errNode = findChild(resp, 'error');
6978
+ const code = errNode && errNode.attrs && errNode.attrs.code;
6979
+ throw new Error('clearTosNotice: server rejected the request' +
6980
+ (code ? ' (' + code + ')' : ''));
6981
+ }
6982
+
6983
+ this._tosAccepted.set(id, false);
6984
+ _whaDbg('[DBG] TOS_CLEAR ' + id);
6985
+ return { ok: true, raw: resp };
6986
+ }
6987
+
6988
+ /**
6989
+ * Whether a notice is known to have been accepted.
6990
+ *
6991
+ * `null` means this session has not asked about it — which is not the same
6992
+ * as "not accepted", and a caller deciding whether to prompt needs to be
6993
+ * able to tell those apart.
6994
+ *
6995
+ * @param {string} noticeId
6996
+ * @returns {boolean|null}
6997
+ */
6998
+ isTosAccepted(noticeId) {
6999
+ const known = this._tosAccepted.get(String(noticeId));
7000
+ return known === undefined ? null : known;
7001
+ }
7002
+
7003
+ // The server pushing a notice's state at us, rather than us asking:
7004
+ // <notification type="account_sync"><tos><notice id=".." state=".."/></tos>
7005
+ //
7006
+ // This is how an acceptance made on another device reaches this one. It only
7007
+ // ever adds to what is known — a push naming three notices says nothing
7008
+ // about a fourth.
7009
+ _handleTosNotification(tosNode) {
7010
+ const parsed = this._parseTosNotices(tosNode);
7011
+ if (parsed.notices.length === 0) return;
7012
+ for (const n of parsed.notices) this._tosAccepted.set(n.id, n.accepted);
7013
+ _whaDbg('[DBG] TOS_NOTIFICATION ' + parsed.notices
7014
+ .map(n => n.id + '=' + (n.accepted ? 'accepted' : 'outstanding')).join(' '));
7015
+ this.emit('tos_notices', { notices: parsed.notices });
7016
+ }
7017
+
6498
7018
  // ─── Status / Stories ─────────────────────────────────────────────────────
6499
7019
 
6500
7020
  // Who your Status posts go to.
@@ -18,7 +18,7 @@
18
18
  // from that account.
19
19
 
20
20
  const crypto = require('crypto');
21
- const curveJs = require('curve25519-js');
21
+ const curveJs = require('./curve');
22
22
 
23
23
  const WA_ADV_ACCOUNT_SIG_PREFIX = Buffer.from([6, 0]);
24
24
  const WA_ADV_DEVICE_SIG_PREFIX = Buffer.from([6, 1]);
@@ -26,13 +26,13 @@
26
26
  */
27
27
 
28
28
  const crypto = require('crypto');
29
+ const { hkdfSha256 } = require('./hkdf');
29
30
  const https = require('https');
30
31
  const http = require('http');
31
32
  const zlib = require('zlib');
32
33
  const fs = require('fs');
33
34
  const path = require('path');
34
35
 
35
- const { hkdf } = require('@noble/hashes/hkdf');
36
36
  const { sha256 } = require('@noble/hashes/sha256');
37
37
 
38
38
  // ─── Constants ────────────────────────────────────────────────────────────────
@@ -442,7 +442,7 @@ function decodeHistorySync(buf) {
442
442
  function deriveHistoryKeys(mediaKeyBuf) {
443
443
  const ikm = Buffer.isBuffer(mediaKeyBuf) ? mediaKeyBuf : Buffer.from(mediaKeyBuf);
444
444
  const info = Buffer.from(HISTORY_HKDF_INFO, 'utf8');
445
- const expanded = Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0), info, EXPANDED_SIZE));
445
+ const expanded = hkdfSha256(ikm, Buffer.alloc(0), info, EXPANDED_SIZE);
446
446
  return {
447
447
  iv: expanded.slice(0, IV_LEN),
448
448
  cipherKey: expanded.slice(IV_LEN, IV_LEN + KEY_LEN),
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const crypto = require('crypto');
4
+ const { hkdfSha256 } = require('./hkdf');
4
5
  const https = require('https');
5
6
  const http = require('http');
6
7
  const fs = require('fs');
7
8
  const path = require('path');
8
- const { hkdf } = require('@noble/hashes/hkdf');
9
9
  const { sha256 } = require('@noble/hashes/sha256');
10
10
  const { getDeviceConfig } = require('./DeviceConfig');
11
11
 
@@ -69,7 +69,7 @@ const MAC_LEN = 10;
69
69
  function deriveMediaKeyData(mediaKey, keyName) {
70
70
  const ikm = Buffer.isBuffer(mediaKey) ? mediaKey : Buffer.from(mediaKey);
71
71
  const info = Buffer.from(keyName, 'utf8');
72
- return Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0), info, EXPANDED_SIZE));
72
+ return hkdfSha256(ikm, Buffer.alloc(0), info, EXPANDED_SIZE);
73
73
  }
74
74
 
75
75
  function encryptMedia(plaintext, mediaKey, keyName) {
@@ -25,7 +25,8 @@
25
25
  // in normally.
26
26
 
27
27
  const crypto = require('crypto');
28
- const curveJs = require('curve25519-js');
28
+ const { hkdfSha256 } = require('./hkdf');
29
+ const curveJs = require('./curve');
29
30
 
30
31
  // Crockford base32 minus the ambiguous characters — no I, O, U, and no 0.
31
32
  // Five random bytes become exactly eight characters.
@@ -85,15 +86,12 @@ function aesEncryptGCM(plaintext, key, iv, aad) {
85
86
  }
86
87
 
87
88
  function hkdf(input, length, info, salt) {
88
- const { hkdf: nobleHkdf } = require('@noble/hashes/hkdf');
89
- const { sha256 } = require('@noble/hashes/sha256');
90
- return Buffer.from(nobleHkdf(
91
- sha256,
89
+ return hkdfSha256(
92
90
  input,
93
91
  salt || Buffer.alloc(32),
94
92
  info ? Buffer.from(info, 'utf8') : Buffer.alloc(0),
95
93
  length
96
- ));
94
+ );
97
95
  }
98
96
 
99
97
  function stripKeyPrefix(pub) {