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/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
@@ -123,6 +123,23 @@ export interface WhalibmobClientOptions {
123
123
  autoFixNumber?: boolean;
124
124
  /** Send read receipts for incoming messages. Default `true`. */
125
125
  autoRead?: boolean;
126
+ /**
127
+ * Refuse every incoming call. Default `true`.
128
+ *
129
+ * Turning it off does not answer calls — nothing here can — it leaves the
130
+ * refusal to you, which is the only way to let some calls ring and reject the
131
+ * rest with `rejectCall()`.
132
+ */
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;
126
143
  /** Refresh the announced build from the platform's store before every handshake. Default `true`. */
127
144
  refreshVersion?: boolean;
128
145
  /** `true` enables debug logging; an object is handed to `pino` as-is. */
@@ -227,8 +244,24 @@ export interface PollSendResult extends SendResult {
227
244
  messageSecret: Buffer;
228
245
  }
229
246
 
247
+ /** What a message was a reply to, and who it mentioned. */
248
+ export interface DecodedContextInfo {
249
+ /** Id of the quoted message. */
250
+ stanzaId?: string;
251
+ /** Who wrote the quoted message. */
252
+ participant?: Jid;
253
+ remoteJid?: Jid;
254
+ mentionedJid?: Jid[];
255
+ /** The quoted message, decoded the same way as any other. */
256
+ quotedMessage?: DecodedMessage;
257
+ /** The quoted message's raw protobuf bytes, for re-encoding it verbatim. */
258
+ quotedMessageRaw?: Buffer;
259
+ }
260
+
230
261
  export interface DecodedBase {
231
262
  type: string;
263
+ /** Present when the message quoted another or carried mentions. */
264
+ contextInfo?: DecodedContextInfo;
232
265
  /**
233
266
  * The message arrived inside a view-once envelope. The content is decoded
234
267
  * normally — media included — this only records how it was sent.
@@ -454,6 +487,36 @@ export type PrivacyValue =
454
487
  | 'on_standard'
455
488
  | 'off';
456
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
+
457
520
  export interface PrivacySettings {
458
521
  lastSeen: string | null;
459
522
  profile: string | null;
@@ -632,7 +695,7 @@ export interface WhalibmobEvents {
632
695
  message: (msg: IncomingMessage) => void;
633
696
  receipt: (r: { type: string; id: string; from: Jid }) => void;
634
697
  presence: (p: { from: Jid; available: boolean }) => void;
635
- call: (c: { from: Jid }) => void;
698
+ call: (c: { from: Jid; id: string | null; status: 'offer' | 'ringing' | 'terminate' | 'unknown'; node: any }) => void;
636
699
  notification: (node: any) => void;
637
700
  decrypt_error: (e: { id: string; from: Jid; participant?: Jid; err: Error }) => void;
638
701
  session_refresh: (e: { node: any }) => void;
@@ -796,6 +859,46 @@ export declare class WhalibmobClient extends EventEmitter {
796
859
 
797
860
  // ─── Privacy ─────────────────────────────────────────────────────────────
798
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
+
799
902
  changePrivacySetting(type: PrivacyType, value: PrivacyValue, excluded?: Jid[]): Promise<PrivacySettings>;
800
903
  queryStatusPrivacy(): Promise<StatusPrivacyEntry[]>;
801
904
  blockContact(jid: Jid): Promise<Jid[]>;
@@ -831,6 +934,12 @@ export declare class WhalibmobClient extends EventEmitter {
831
934
  */
832
935
  requestAppStateKeys(keyIds: string[]): Promise<string | null>;
833
936
 
937
+ /**
938
+ * Refuse one incoming call. Only reachable when the client was built with
939
+ * `autoRejectCalls: false`; the id and caller come off the `call` event.
940
+ */
941
+ rejectCall(callId: string, from: Jid): boolean;
942
+
834
943
  // ─── Account restriction ─────────────────────────────────────────────────
835
944
  fetchReachoutTimelock(): Promise<ReachoutTimelockState>;
836
945
  /** The last known state, recomputed; does not ask the server again. */
@@ -903,7 +1012,20 @@ export declare class WhalibmobClient extends EventEmitter {
903
1012
  getLIDForPN(pn: Jid): Jid | null;
904
1013
 
905
1014
  // ─── Lower level ─────────────────────────────────────────────────────────
906
- 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>;
907
1029
  sendPeerDataOperationMessage(pdo: any): Promise<any>;
908
1030
  requestPlaceholderResend(messageKey: any, msgData?: any): Promise<any>;
909
1031
  fetchMessageHistory(count: number, oldestMsgKey: any, oldestMsgTimestampMs: number): Promise<any>;
@@ -1314,6 +1436,38 @@ export declare const PushClient: LibModule;
1314
1436
  export declare const Fcm: LibModule;
1315
1437
  export declare const FcmMcs: LibModule;
1316
1438
 
1439
+ /**
1440
+ * X25519 through Node's own OpenSSL — a drop-in for `curve25519-js`, roughly
1441
+ * 19x faster on `sharedKey` and byte-identical to it.
1442
+ *
1443
+ * `sign` and `verify` are XEdDSA and stay on the JavaScript implementation:
1444
+ * Node's Ed25519 is a different scheme and would produce different bytes.
1445
+ */
1446
+ export declare const Curve: LibModule & {
1447
+ generateKeyPair(seed: Buffer | Uint8Array): { private: Buffer; public: Buffer };
1448
+ sharedKey(priv: Buffer | Uint8Array, pub: Buffer | Uint8Array): Buffer;
1449
+ sign(priv: Buffer | Uint8Array, message: Buffer | Uint8Array): Buffer;
1450
+ verify(pub: Buffer | Uint8Array, message: Buffer | Uint8Array, signature: Buffer | Uint8Array): boolean;
1451
+ /** Whether Node's X25519 is in use; `false` means the JavaScript fallback is. */
1452
+ NATIVE: boolean;
1453
+ };
1454
+
1455
+ /**
1456
+ * HKDF-SHA256 through Node's own OpenSSL — roughly 2.2x faster than the
1457
+ * JavaScript implementation and byte-identical to it. Returns a `Buffer`,
1458
+ * not the `ArrayBuffer` Node's own `hkdfSync` hands back.
1459
+ */
1460
+ export declare const Hkdf: LibModule & {
1461
+ hkdfSha256(
1462
+ ikm: Buffer | Uint8Array,
1463
+ salt: Buffer | Uint8Array | string,
1464
+ info: Buffer | Uint8Array | string,
1465
+ length: number
1466
+ ): Buffer;
1467
+ /** Whether Node's HKDF is in use; `false` means the JavaScript fallback is. */
1468
+ NATIVE: boolean;
1469
+ };
1470
+
1317
1471
  /** Linking to an account that already exists. */
1318
1472
  export declare const PairingCode: LibModule;
1319
1473
  export declare const CompanionPairing: LibModule;
package/index.js CHANGED
@@ -129,6 +129,8 @@ const WebVersion = require('./lib/WebVersion');
129
129
  const WebProto = require('./lib/webproto');
130
130
 
131
131
  const BinaryNode = require('./lib/BinaryNode');
132
+ const Curve = require('./lib/curve');
133
+ const Hkdf = require('./lib/hkdf');
132
134
  const Noise = require('./lib/noise');
133
135
  const WebSocketStream = require('./lib/WebSocketStream');
134
136
  const Socks = require('./lib/socks');
@@ -348,6 +350,11 @@ module.exports = {
348
350
  FcmMcs,
349
351
 
350
352
  // Linking to an account that already exists
353
+ // X25519, through Node's own OpenSSL. Drop-in for curve25519-js.
354
+ Curve,
355
+ // HKDF-SHA256, through Node's own OpenSSL.
356
+ Hkdf,
357
+
351
358
  PairingCode,
352
359
  CompanionPairing,
353
360
  QrPairing,