whalibmob 5.28.0 → 5.29.1

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 CHANGED
@@ -262,6 +262,10 @@ npm install -g whalibmob
262
262
  - [Read Privacy Settings](#read-privacy-settings)
263
263
  - [Update Privacy Settings](#update-privacy-settings)
264
264
  - [Update Default Disappearing Mode](#update-default-disappearing-mode)
265
+ - [Account State](#account-state)
266
+ - [AB Props](#ab-props)
267
+ - [Contact Sync](#contact-sync)
268
+ - [Terms-of-Service Notices](#terms-of-service-notices)
265
269
  - [Communities](#communities)
266
270
  - [Create a Community](#create-a-community)
267
271
  - [Deactivate / Delete a Community](#deactivate--delete-a-community)
@@ -4605,6 +4609,206 @@ await client.changeNewChatsEphemeralTimer(86400) // 1 day
4605
4609
  await client.changeNewChatsEphemeralTimer(0) // off
4606
4610
  ```
4607
4611
 
4612
+ ## Account State
4613
+
4614
+ Three things the app speaks on every account and whalibmob did not: the
4615
+ server's own feature flags, the address book, and the legal notices an account
4616
+ is asked to accept. Each is one IQ family; together they are part of what makes
4617
+ a session look like an app rather than a script.
4618
+
4619
+ ### AB Props
4620
+
4621
+ Every WhatsApp client asks the server which experiments and configuration
4622
+ values apply to *this* account, then gates behaviour on the answer — how many
4623
+ bytes an identity hash is truncated to, whether the LID migration has started
4624
+ here, what sampling weight each telemetry event carries. A client that never
4625
+ asks runs on guessed defaults wherever the server has an opinion.
4626
+
4627
+ This is synced automatically on every connect. You do not have to call it.
4628
+
4629
+ ```js
4630
+ const client = new WhalibmobClient({ sessionDir: './auth' })
4631
+ await client.connect()
4632
+
4633
+ // Already synced by the time 'connected' fires. Read a value:
4634
+ client.abProp(4405) // '16' — always a string on the wire
4635
+ client.abPropInt(4405, 8) // 16 — parsed, or 8 if unset/unparseable
4636
+ client.abPropBool(5010, false) // true — understands "1"/"0" and "true"/"false"
4637
+ ```
4638
+
4639
+ Reading a prop before the first sync gives the fallback rather than throwing,
4640
+ so gating on a flag never needs a guard:
4641
+
4642
+ ```js
4643
+ // Safe even on a client that has not connected yet.
4644
+ const hashLen = client.abPropInt(4405, 8)
4645
+ ```
4646
+
4647
+ To re-sync by hand, or to look at the whole set:
4648
+
4649
+ ```js
4650
+ const props = await client.queryAbProps({ force: true })
4651
+
4652
+ console.log(props.hash) // 'A1B2…' — rides on the next sync, asking for a delta
4653
+ console.log(props.refresh) // 86400 — seconds the server suggests waiting
4654
+ console.log(props.delta) // false — this reply was the whole table
4655
+ console.log(props.props) // { '4405': '16', '5010': 'true', … }
4656
+ console.log(props.sampling) // { '1200': 100, … } telemetry weights
4657
+ ```
4658
+
4659
+ The hash is what turns the next request into a delta. A delta is **merged**
4660
+ onto what is already held rather than replacing it — otherwise every prop the
4661
+ server saw no reason to repeat would be dropped. The hash lives for the session
4662
+ only: a fresh process asks for the full set again, which is what a fresh
4663
+ install does.
4664
+
4665
+ Turn the automatic sync off if you do not want the extra IQ on a short session:
4666
+
4667
+ ```js
4668
+ new WhalibmobClient({ sessionDir: './auth', syncAbPropsOnConnect: false })
4669
+ ```
4670
+
4671
+ Being straight about the scope: nothing inside the library consumes these
4672
+ values yet. What you get today is the ability to see what the server thinks
4673
+ about an account — useful when one number behaves differently from another and
4674
+ you would otherwise be guessing — plus one more request a real client makes.
4675
+
4676
+ ### Contact Sync
4677
+
4678
+ The address-book upload a phone does on first launch, and the delta syncs after
4679
+ it. It answers the question worth asking before a first message: **is this
4680
+ number on WhatsApp at all?**
4681
+
4682
+ ```js
4683
+ const out = await client.syncContacts([
4684
+ '40712345678',
4685
+ '+55 11 91234-5678',
4686
+ '40799999999'
4687
+ ])
4688
+
4689
+ out.registered // ['40712345678', '+55 11 91234-5678'] — on WhatsApp
4690
+ out.unregistered // ['40799999999'] — not on WhatsApp
4691
+ out.jids // { '40712345678': '40712345678@s.whatsapp.net', … }
4692
+ ```
4693
+
4694
+ Numbers come back in the spelling you passed in, however they were written —
4695
+ spaces, dashes and a leading `+` are all normalised before sending and restored
4696
+ in the answer.
4697
+
4698
+ **A number the server did not answer about appears in neither list.** That is
4699
+ deliberate. Reporting a timeout as "not on WhatsApp" is the one wrong answer
4700
+ available here, because a caller acts on it — skipping the number, or deleting
4701
+ it. Silence is not a verdict:
4702
+
4703
+ ```js
4704
+ const asked = phones.length
4705
+ const known = out.registered.length + out.unregistered.length
4706
+ if (known < asked) {
4707
+ console.log(`${asked - known} numbers went unanswered — try them again later`)
4708
+ }
4709
+ ```
4710
+
4711
+ Large books are chunked automatically, and one unanswered chunk does not cost
4712
+ the others their answers:
4713
+
4714
+ ```js
4715
+ await client.syncContacts(bigList, { chunkSize: 200 }) // default is 500
4716
+ ```
4717
+
4718
+ The mode says what the sync claims about your address book. `full` is the
4719
+ whole book — what the app sends on first launch — and carries the
4720
+ `registration` context by default. `delta` is what changed since. `query` is a
4721
+ plain lookup that claims nothing:
4722
+
4723
+ ```js
4724
+ await client.syncContacts(phones) // mode 'full', context 'registration'
4725
+ await client.syncContacts(phones, { mode: 'delta' }) // mode 'delta', context 'interactive'
4726
+ await client.syncContacts(phones, { mode: 'query' }) // a lookup, nothing claimed
4727
+ ```
4728
+
4729
+ This is **not** called automatically on connect. Uploading an address book is
4730
+ your decision, not a side effect of connecting.
4731
+
4732
+ Where it earns its place is in front of a batch of first messages, together
4733
+ with the token path described under
4734
+ [tcToken](#tctoken--error-463-defense):
4735
+
4736
+ ```js
4737
+ // 1. Which of these actually exist?
4738
+ const { registered } = await client.syncContacts(numbers)
4739
+
4740
+ // 2. Warm a trusted-contact token for each, so the first message carries one.
4741
+ for (const n of registered) {
4742
+ const jid = `${n.replace(/\D/g, '')}@s.whatsapp.net`
4743
+ await client.ensureTcTokenBeforeSend(jid, jid)
4744
+ await new Promise(r => setTimeout(r, 300))
4745
+ }
4746
+
4747
+ // 3. Send. No wasted sends to dead numbers, and no anonymous reach-outs.
4748
+ for (const n of registered) {
4749
+ await client.sendText(`${n.replace(/\D/g, '')}@s.whatsapp.net`, 'Hello')
4750
+ await new Promise(r => setTimeout(r, 1000))
4751
+ }
4752
+ ```
4753
+
4754
+ ### Terms-of-Service Notices
4755
+
4756
+ WhatsApp gates some surfaces on the account having accepted a given legal
4757
+ notice — a Terms update, a privacy-policy change, a regional disclosure. The
4758
+ app pulls the acceptance state, shows what is outstanding, and posts back what
4759
+ the user accepted.
4760
+
4761
+ ```js
4762
+ const out = await client.queryTosNotices(['20230324', '20240101'])
4763
+
4764
+ out.refresh // 86400 — seconds the server suggests waiting before asking again
4765
+ out.notices // [ { id: '20230324', accepted: true },
4766
+ // { id: '20240101', accepted: false } ]
4767
+
4768
+ // Anything still outstanding:
4769
+ const pending = out.notices.filter(n => !n.accepted).map(n => n.id)
4770
+ if (pending.length) await client.acceptTosNotices(pending)
4771
+ ```
4772
+
4773
+ Reading one back without asking again:
4774
+
4775
+ ```js
4776
+ client.isTosAccepted('20230324') // true
4777
+ client.isTosAccepted('20240101') // false
4778
+ client.isTosAccepted('unknown-id') // null — this session has not asked
4779
+ ```
4780
+
4781
+ `null` matters: it means *unknown*, not *not accepted*. Code deciding whether
4782
+ to accept something needs to be able to tell those apart.
4783
+
4784
+ Clearing an acceptance, so the server asks for it again:
4785
+
4786
+ ```js
4787
+ await client.clearTosNotice('20230324')
4788
+ ```
4789
+
4790
+ An acceptance made on another device arrives on its own, inside the same
4791
+ `account_sync` notification that carries device and privacy changes:
4792
+
4793
+ ```js
4794
+ client.on('tos_notices', ({ notices }) => {
4795
+ for (const n of notices) {
4796
+ console.log(n.id, n.accepted ? 'accepted' : 'outstanding')
4797
+ }
4798
+ })
4799
+ ```
4800
+
4801
+ A note on the wire, because it reads backwards from how it looks: the `state`
4802
+ attribute is present and `"false"` for a notice that has **not** been accepted,
4803
+ and **absent** for one that has. whalibmob reads it that way. If you parse these
4804
+ stanzas yourself, treating a missing attribute as "unknown" would report every
4805
+ accepted notice as outstanding.
4806
+
4807
+ Scope, honestly: no specific feature has been shown to be blocked by an
4808
+ unaccepted notice on a headless account. What this gives you is something to
4809
+ check rather than guess at — if a freshly registered number will not do
4810
+ something, you can now ask whether it has a notice pending.
4811
+
4608
4812
  ## Groups
4609
4813
 
4610
4814
  ### Create a Group
package/index.d.ts CHANGED
@@ -131,6 +131,15 @@ export interface WhalibmobClientOptions {
131
131
  * rest with `rejectCall()`.
132
132
  */
133
133
  autoRejectCalls?: boolean;
134
+ /**
135
+ * Sync the account's AB props on every connect. Default `true`, because the
136
+ * app does it on every login and a client that never asks runs on guessed
137
+ * defaults wherever the server has an opinion.
138
+ *
139
+ * Turning it off saves one IQ per connect and costs nothing else: every
140
+ * reader of these has a fallback.
141
+ */
142
+ syncAbPropsOnConnect?: boolean;
134
143
  /** Refresh the announced build from the platform's store before every handshake. Default `true`. */
135
144
  refreshVersion?: boolean;
136
145
  /** `true` enables debug logging; an object is handed to `pino` as-is. */
@@ -478,6 +487,36 @@ export type PrivacyValue =
478
487
  | 'on_standard'
479
488
  | 'off';
480
489
 
490
+ export interface AbProps {
491
+ /** Hash of the returned set. Sent back on the next sync to ask for a delta. */
492
+ hash: string | null;
493
+ abKey: string | null;
494
+ /** Seconds the server suggests waiting before syncing again. */
495
+ refresh: number | null;
496
+ refreshId: string | null;
497
+ /** Whether this reply named only what changed, rather than the whole table. */
498
+ delta: boolean;
499
+ /** Experiment values, keyed by their numeric config code. */
500
+ props: { [configCode: string]: string };
501
+ /** Telemetry sampling weights, keyed by their numeric event code. */
502
+ sampling: { [eventCode: string]: number };
503
+ }
504
+
505
+ export interface ContactSyncResult {
506
+ /** Numbers on WhatsApp, in the spelling they were passed in. */
507
+ registered: string[];
508
+ /** Numbers the server said are not on WhatsApp. */
509
+ unregistered: string[];
510
+ /** Each registered number mapped to the JID the server gave it. */
511
+ jids: { [phone: string]: string };
512
+ }
513
+
514
+ export interface TosNoticesResult {
515
+ /** Seconds the server suggests waiting before asking again. */
516
+ refresh: number | null;
517
+ notices: Array<{ id: string; accepted: boolean }>;
518
+ }
519
+
481
520
  export interface PrivacySettings {
482
521
  lastSeen: string | null;
483
522
  profile: string | null;
@@ -820,6 +859,46 @@ export declare class WhalibmobClient extends EventEmitter {
820
859
 
821
860
  // ─── Privacy ─────────────────────────────────────────────────────────────
822
861
  queryPrivacySettings(opts?: { force?: boolean }): Promise<PrivacySettings>;
862
+
863
+ // ─── AB props ────────────────────────────────────────────────────────────
864
+ //
865
+ // The per-account feature flags the server hands out. Synced on connect
866
+ // unless `syncAbPropsOnConnect: false` was passed to the constructor, and
867
+ // held for the session — a fresh process asks for the full set again, which
868
+ // is what a fresh install does.
869
+ //
870
+ // Values are strings because that is what the wire carries; abPropInt and
871
+ // abPropBool are the two readings worth having, and both fall back rather
872
+ // than coerce.
873
+ queryAbProps(opts?: { force?: boolean; refreshId?: string }): Promise<AbProps>;
874
+ abProp(code: number | string, fallback?: string | null): string | null;
875
+ abPropInt(code: number | string, fallback?: number | null): number | null;
876
+ abPropBool(code: number | string, fallback?: boolean | null): boolean | null;
877
+
878
+ // ─── Contact sync ────────────────────────────────────────────────────────
879
+ //
880
+ // The address-book upload a phone does on first launch, and the delta syncs
881
+ // after it. Numbers the server did not answer about appear in neither list:
882
+ // silence is not a verdict, and reporting a timeout as "not on WhatsApp"
883
+ // is the one wrong answer available here.
884
+ syncContacts(
885
+ phones: string[],
886
+ opts?: {
887
+ mode?: 'full' | 'delta' | 'query';
888
+ context?: string;
889
+ chunkSize?: number;
890
+ }
891
+ ): Promise<ContactSyncResult>;
892
+
893
+ // ─── Terms-of-Service notices ────────────────────────────────────────────
894
+ //
895
+ // `isTosAccepted` answers `null` for a notice this session has not asked
896
+ // about, which is not the same as "not accepted".
897
+ queryTosNotices(noticeIds: string[]): Promise<TosNoticesResult>;
898
+ acceptTosNotices(noticeIds: string[]): Promise<{ ok: true; raw: any }>;
899
+ clearTosNotice(noticeId: string): Promise<{ ok: true; raw: any }>;
900
+ isTosAccepted(noticeId: string): boolean | null;
901
+
823
902
  changePrivacySetting(type: PrivacyType, value: PrivacyValue, excluded?: Jid[]): Promise<PrivacySettings>;
824
903
  queryStatusPrivacy(): Promise<StatusPrivacyEntry[]>;
825
904
  blockContact(jid: Jid): Promise<Jid[]>;
@@ -933,7 +1012,20 @@ export declare class WhalibmobClient extends EventEmitter {
933
1012
  getLIDForPN(pn: Jid): Jid | null;
934
1013
 
935
1014
  // ─── Lower level ─────────────────────────────────────────────────────────
936
- ensureTcTokenBeforeSend(tcJid: Jid, routingToJid: Jid): Promise<any>;
1015
+ /**
1016
+ * Put a trusted-contact token on file *before* the next message is built, so
1017
+ * that message carries a `<tctoken>` like every later one.
1018
+ *
1019
+ * The token comes from the server, not from the contact: nothing here waits
1020
+ * on them replying, on having you in their address book, or on a conversation
1021
+ * existing. Called automatically by every send, so you rarely need it — reach
1022
+ * for it to warm a token ahead of time, before a burst of first messages.
1023
+ *
1024
+ * Returns whether a usable token is on file now. `false` is not a failure to
1025
+ * act on: the send goes ahead without one and the issuance continues in the
1026
+ * background for the next message.
1027
+ */
1028
+ ensureTcTokenBeforeSend(tcJid: Jid, routingToJid: Jid): Promise<boolean>;
937
1029
  sendPeerDataOperationMessage(pdo: any): Promise<any>;
938
1030
  requestPlaceholderResend(messageKey: any, msgData?: any): Promise<any>;
939
1031
  fetchMessageHistory(count: number, oldestMsgKey: any, oldestMsgTimestampMs: number): Promise<any>;
package/lib/Client.js CHANGED
@@ -508,6 +508,18 @@ class WhalibmobClient extends EventEmitter {
508
508
  // Cached privacy settings. Warmed after connect and refreshed whenever the
509
509
  // server reports a change; markRead reads it.
510
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();
511
523
  if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
512
524
  }
513
525
 
@@ -1702,6 +1714,16 @@ class WhalibmobClient extends EventEmitter {
1702
1714
  this.queryPrivacySettings({ force: true }).catch(err =>
1703
1715
  _whaDbg('[DBG] PRIVACY_FETCH_FAILED ' + (err && err.message)));
1704
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
+
1705
1727
  // ── Reconnect: flush stale device cache + background re-usync ─────────────
1706
1728
  // On first connect _hasConnectedOnce is false — nothing extra to do.
1707
1729
  // On every reconnect (network drop / NAT reset / server restart) we flush the
@@ -3821,6 +3843,11 @@ class WhalibmobClient extends EventEmitter {
3821
3843
  // so a change made on the phone never reached the cache.
3822
3844
  const privacyNode = findChildDeep(node, 'privacy');
3823
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);
3824
3851
  }
3825
3852
 
3826
3853
  if (type === 'w:gp2') {
@@ -6527,6 +6554,467 @@ class WhalibmobClient extends EventEmitter {
6527
6554
  return this._sender.deleteMessage(origMsgId, chatJid, fromMe, forEveryone, opts);
6528
6555
  }
6529
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
+
6530
7018
  // ─── Status / Stories ─────────────────────────────────────────────────────
6531
7019
 
6532
7020
  // Who your Status posts go to.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.28.0",
3
+ "version": "5.29.1",
4
4
  "description": "Node.js library for WhatsApp — register a number over SMS, or link as a companion by QR. Signal E2E encryption, media, groups, channels.",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",
@@ -49,7 +49,7 @@
49
49
  "messaging",
50
50
  "chatbot",
51
51
  "automation",
52
- "whatsapp-mobile",
52
+ "whatsapp-mobile",
53
53
  "media",
54
54
  "groups",
55
55
  "channels"