zapo-js 1.4.0 → 1.5.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.
Files changed (94) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +10 -0
  3. package/dist/auth/WaAuthClient.js +17 -8
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/companion-host.d.ts +105 -0
  7. package/dist/auth/pairing/companion-host.js +148 -0
  8. package/dist/auth/types.d.ts +20 -9
  9. package/dist/client/WaClient.d.ts +15 -5
  10. package/dist/client/WaClient.js +46 -12
  11. package/dist/client/WaClientFactory.d.ts +3 -0
  12. package/dist/client/WaClientFactory.js +24 -6
  13. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  14. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  15. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  16. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  17. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  18. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  19. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  20. package/dist/client/messaging/companion-host.d.ts +43 -0
  21. package/dist/client/messaging/companion-host.js +61 -0
  22. package/dist/client/messaging/key-protocol.js +25 -1
  23. package/dist/client/persistence/companion-host.d.ts +36 -0
  24. package/dist/client/persistence/companion-host.js +57 -0
  25. package/dist/client/plugins/install.d.ts +2 -1
  26. package/dist/client/plugins/install.js +44 -11
  27. package/dist/client/types.d.ts +71 -7
  28. package/dist/esm/auth/WaAuthClient.js +18 -9
  29. package/dist/esm/auth/credentials-flow.js +36 -3
  30. package/dist/esm/auth/pairing/companion-host.js +140 -0
  31. package/dist/esm/client/WaClient.js +46 -12
  32. package/dist/esm/client/WaClientFactory.js +25 -7
  33. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  34. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  35. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  36. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  37. package/dist/esm/client/messaging/companion-host.js +56 -0
  38. package/dist/esm/client/messaging/key-protocol.js +25 -1
  39. package/dist/esm/client/persistence/companion-host.js +54 -0
  40. package/dist/esm/client/plugins/install.js +44 -11
  41. package/dist/esm/index.js +5 -2
  42. package/dist/esm/message/WaMessageClient.js +3 -2
  43. package/dist/esm/message/context-info.js +4 -0
  44. package/dist/esm/protocol/constants.js +4 -3
  45. package/dist/esm/protocol/device-identity.js +18 -0
  46. package/dist/esm/protocol/group.js +6 -0
  47. package/dist/esm/protocol/message.js +12 -0
  48. package/dist/esm/protocol/nodes.js +15 -1
  49. package/dist/esm/retry/replay.js +3 -3
  50. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  51. package/dist/esm/signal/attestation/constants.js +2 -0
  52. package/dist/esm/signal/index.js +1 -0
  53. package/dist/esm/transport/index.js +1 -1
  54. package/dist/esm/transport/node/builders/group.js +4 -3
  55. package/dist/esm/transport/node/builders/mobile.js +113 -0
  56. package/dist/esm/transport/noise/WaClientPayload.js +16 -7
  57. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  58. package/dist/esm/util/index.js +1 -0
  59. package/dist/index.d.ts +10 -4
  60. package/dist/index.js +13 -4
  61. package/dist/message/WaMessageClient.js +2 -1
  62. package/dist/message/context-info.d.ts +2 -0
  63. package/dist/message/context-info.js +5 -0
  64. package/dist/protocol/constants.d.ts +6 -4
  65. package/dist/protocol/constants.js +8 -2
  66. package/dist/protocol/device-identity.d.ts +25 -0
  67. package/dist/protocol/device-identity.js +21 -0
  68. package/dist/protocol/group.d.ts +6 -0
  69. package/dist/protocol/group.js +7 -1
  70. package/dist/protocol/message.d.ts +14 -0
  71. package/dist/protocol/message.js +13 -1
  72. package/dist/protocol/nodes.d.ts +14 -0
  73. package/dist/protocol/nodes.js +15 -1
  74. package/dist/retry/replay.js +2 -2
  75. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  76. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  77. package/dist/signal/attestation/constants.d.ts +2 -0
  78. package/dist/signal/attestation/constants.js +3 -1
  79. package/dist/signal/index.d.ts +1 -0
  80. package/dist/signal/index.js +13 -1
  81. package/dist/transport/index.d.ts +2 -2
  82. package/dist/transport/index.js +4 -3
  83. package/dist/transport/node/builders/group.js +4 -3
  84. package/dist/transport/node/builders/mobile.d.ts +68 -0
  85. package/dist/transport/node/builders/mobile.js +120 -0
  86. package/dist/transport/noise/WaClientPayload.js +16 -7
  87. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  88. package/dist/transport/wa-version-fetcher.js +156 -0
  89. package/dist/util/index.d.ts +1 -0
  90. package/dist/util/index.js +3 -1
  91. package/package.json +1 -1
  92. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  93. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  94. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodeHistorySync = encodeHistorySync;
4
+ exports.buildInlineHistorySyncNotification = buildInlineHistorySyncNotification;
5
+ exports.buildHistorySyncBootstrapMessage = buildHistorySyncBootstrapMessage;
6
+ const node_util_1 = require("node:util");
7
+ const node_zlib_1 = require("node:zlib");
8
+ const _proto_1 = require("../../proto");
9
+ const deflateAsync = (0, node_util_1.promisify)(node_zlib_1.deflate);
10
+ /**
11
+ * Serializes a `HistorySync` and zlib-deflates it. The phone uses DEFLATE
12
+ * level 1; any zlib-framed level inflates identically, so it is only a
13
+ * size/speed tradeoff.
14
+ */
15
+ async function encodeHistorySync(content) {
16
+ const serialized = _proto_1.proto.HistorySync.encode(content).finish();
17
+ const compressed = await deflateAsync(serialized, { level: 1 });
18
+ return new Uint8Array(compressed);
19
+ }
20
+ /**
21
+ * Wraps a deflated `HistorySync` inline in a `HISTORY_SYNC_NOTIFICATION` protocol
22
+ * message. Only small syncs are inlined; larger ones ride an external
23
+ * `md-msg-hist` MMS blob referenced by the same notification.
24
+ */
25
+ function buildInlineHistorySyncNotification(syncType, deflatedPayload, options = {}) {
26
+ return {
27
+ type: _proto_1.proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION,
28
+ historySyncNotification: {
29
+ syncType,
30
+ chunkOrder: options.chunkOrder ?? 0,
31
+ progress: options.progress ?? 100,
32
+ initialHistBootstrapInlinePayload: deflatedPayload
33
+ }
34
+ };
35
+ }
36
+ /**
37
+ * Builds the `INITIAL_BOOTSTRAP` `HISTORY_SYNC_NOTIFICATION` a mobile-primary
38
+ * pushes to a freshly-linked companion: a deflated `HistorySync` wrapped in a
39
+ * protocol message for delivery as an encrypted peer message. Note
40
+ * `HistorySync.syncType` and `HistorySyncNotification.syncType` are distinct
41
+ * enum types with shared values, so each field uses its own.
42
+ */
43
+ async function buildHistorySyncBootstrapMessage(input = {}) {
44
+ const historySync = {
45
+ syncType: _proto_1.proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP,
46
+ chunkOrder: 0,
47
+ progress: 100,
48
+ conversations: input.conversations ? [...input.conversations] : [],
49
+ pushnames: input.pushnames ? [...input.pushnames] : [],
50
+ phoneNumberToLidMappings: (input.phoneNumberToLidMappings ?? []).map((mapping) => ({
51
+ pnJid: mapping.pnJid,
52
+ lidJid: mapping.lidJid
53
+ })),
54
+ ...(input.companionMetaNonce !== undefined
55
+ ? { companionMetaNonce: input.companionMetaNonce }
56
+ : {})
57
+ };
58
+ const payload = await encodeHistorySync(historySync);
59
+ const message = buildInlineHistorySyncNotification(_proto_1.proto.Message.HistorySyncType.INITIAL_BOOTSTRAP, payload);
60
+ return { message, payloadBytes: payload.byteLength };
61
+ }
@@ -4,6 +4,15 @@ exports.createAppStateSyncKeyProtocol = createAppStateSyncKeyProtocol;
4
4
  const _proto_1 = require("../../proto");
5
5
  const jid_1 = require("../../protocol/jid");
6
6
  const bytes_1 = require("../../util/bytes");
7
+ /**
8
+ * Valid app-state sync key ids are exactly 6 bytes: a 2-byte device id followed
9
+ * by a 4-byte big-endian epoch (matching the phone primary's `crypto_info`
10
+ * (device_id, epoch) schema and WhatsApp Web's `handleAppStateSyncKeyShare`,
11
+ * which fatally rejects any share whose keyId byte length is not 6). A primary
12
+ * must never emit a malformed key, so legacy/short key ids are dropped from
13
+ * outgoing shares rather than poisoning the peer's syncd engine.
14
+ */
15
+ const APP_STATE_SYNC_KEY_ID_LENGTH = 6;
7
16
  function createAppStateSyncKeyProtocol(options) {
8
17
  const { publishProtocolMessageToDevice, fanoutResolver, getCurrentCredentials, logger } = options;
9
18
  const requireCurrentIdentity = (context) => {
@@ -67,7 +76,12 @@ function createAppStateSyncKeyProtocol(options) {
67
76
  const seenKeyIds = new Set();
68
77
  const keyShareEntries = [];
69
78
  let sharedKeyCount = 0;
79
+ const droppedKeyIds = [];
70
80
  for (const key of keys) {
81
+ if (key.keyId.byteLength !== APP_STATE_SYNC_KEY_ID_LENGTH) {
82
+ droppedKeyIds.push((0, bytes_1.bytesToHex)(key.keyId));
83
+ continue;
84
+ }
71
85
  const keyHex = (0, bytes_1.bytesToHex)(key.keyId);
72
86
  if (seenKeyIds.has(keyHex)) {
73
87
  continue;
@@ -84,7 +98,10 @@ function createAppStateSyncKeyProtocol(options) {
84
98
  });
85
99
  }
86
100
  for (const keyId of missingKeyIds) {
87
- if (keyId.byteLength === 0) {
101
+ if (keyId.byteLength !== APP_STATE_SYNC_KEY_ID_LENGTH) {
102
+ if (keyId.byteLength > 0) {
103
+ droppedKeyIds.push((0, bytes_1.bytesToHex)(keyId));
104
+ }
88
105
  continue;
89
106
  }
90
107
  const keyHex = (0, bytes_1.bytesToHex)(keyId);
@@ -96,6 +113,13 @@ function createAppStateSyncKeyProtocol(options) {
96
113
  keyId: { keyId }
97
114
  });
98
115
  }
116
+ if (droppedKeyIds.length > 0) {
117
+ logger.warn('app-state refusing to share malformed sync keys: keyId byte length must be 6', {
118
+ droppedCount: droppedKeyIds.length,
119
+ totalExpected: keys.length + missingKeyIds.length,
120
+ sample: droppedKeyIds.slice(0, 5)
121
+ });
122
+ }
99
123
  const protocolMessage = {
100
124
  type: _proto_1.proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_SHARE,
101
125
  appStateSyncKeyShare: {
@@ -0,0 +1,36 @@
1
+ /** A companion device this primary has linked. */
2
+ export interface CompanionRecord {
3
+ readonly deviceJid: string;
4
+ readonly keyIndex: number;
5
+ readonly companionIdentityPublicKey: Uint8Array;
6
+ readonly addedAtSeconds: number;
7
+ }
8
+ /**
9
+ * The primary's ADV epoch state. `rawId` is the account's stable ADV identity
10
+ * id; `currentKeyIndex` is the last-issued companion key index (companions get
11
+ * `currentKeyIndex + 1`). This MUST persist across restarts, otherwise reissuing
12
+ * an already-used key index breaks previously linked devices.
13
+ */
14
+ export interface CompanionHostEpochState {
15
+ readonly rawId: number;
16
+ readonly currentKeyIndex: number;
17
+ readonly companions: readonly CompanionRecord[];
18
+ }
19
+ /**
20
+ * Persistence hook for {@link CompanionHostEpochState}. Supply one via plugin
21
+ * options to survive restarts; without it the epoch is in-memory only (fine for
22
+ * a single-session smoke test, unsafe for production relinking).
23
+ */
24
+ export interface CompanionHostPersistence {
25
+ readonly load: () => Promise<CompanionHostEpochState | null> | CompanionHostEpochState | null;
26
+ readonly save: (state: CompanionHostEpochState) => Promise<void> | void;
27
+ }
28
+ /**
29
+ * File-backed {@link CompanionHostPersistence}: stores the ADV epoch as JSON at
30
+ * `filePath` (the companion identity keys are base64-encoded). Zero native
31
+ * dependencies - the state is tiny (the epoch header plus the linked-companion
32
+ * list). For a database-backed store, implement the two-method
33
+ * `CompanionHostPersistence` contract directly against your DB; there is no need
34
+ * for a dedicated core store domain.
35
+ */
36
+ export declare function createFileCompanionHostPersistence(filePath: string): CompanionHostPersistence;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createFileCompanionHostPersistence = createFileCompanionHostPersistence;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_path_1 = require("node:path");
6
+ const bytes_1 = require("../../util/bytes");
7
+ /**
8
+ * File-backed {@link CompanionHostPersistence}: stores the ADV epoch as JSON at
9
+ * `filePath` (the companion identity keys are base64-encoded). Zero native
10
+ * dependencies - the state is tiny (the epoch header plus the linked-companion
11
+ * list). For a database-backed store, implement the two-method
12
+ * `CompanionHostPersistence` contract directly against your DB; there is no need
13
+ * for a dedicated core store domain.
14
+ */
15
+ function createFileCompanionHostPersistence(filePath) {
16
+ return {
17
+ async load() {
18
+ let raw;
19
+ try {
20
+ raw = await (0, promises_1.readFile)(filePath, 'utf8');
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT') {
24
+ return null;
25
+ }
26
+ throw error;
27
+ }
28
+ const parsed = JSON.parse(raw);
29
+ return {
30
+ rawId: parsed.rawId,
31
+ currentKeyIndex: parsed.currentKeyIndex,
32
+ companions: parsed.companions.map((companion) => ({
33
+ deviceJid: companion.deviceJid,
34
+ keyIndex: companion.keyIndex,
35
+ companionIdentityPublicKey: (0, bytes_1.base64ToBytes)(companion.companionIdentityPublicKey),
36
+ addedAtSeconds: companion.addedAtSeconds
37
+ }))
38
+ };
39
+ },
40
+ async save(state) {
41
+ const serialized = {
42
+ rawId: state.rawId,
43
+ currentKeyIndex: state.currentKeyIndex,
44
+ companions: state.companions.map((companion) => ({
45
+ deviceJid: companion.deviceJid,
46
+ keyIndex: companion.keyIndex,
47
+ companionIdentityPublicKey: (0, bytes_1.bytesToBase64)(companion.companionIdentityPublicKey),
48
+ addedAtSeconds: companion.addedAtSeconds
49
+ }))
50
+ };
51
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(filePath), { recursive: true });
52
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
53
+ await (0, promises_1.writeFile)(tempPath, JSON.stringify(serialized), 'utf8');
54
+ await (0, promises_1.rename)(tempPath, filePath);
55
+ }
56
+ };
57
+ }
@@ -13,6 +13,7 @@ export interface WaClientPluginInstallInput {
13
13
  }
14
14
  /**
15
15
  * Installs {@link WaClientOptions.plugins} on `client`. Returns a dispose
16
- * function invoked by {@link WaClient.disconnect}.
16
+ * function invoked by {@link WaClient.disconnect}. Safe to call again after a
17
+ * dispose to reinstall the same plugins on reconnect.
17
18
  */
18
19
  export declare function installWaClientPlugins(client: WaClient, input: WaClientPluginInstallInput, plugins: readonly WaClientPluginDefinition[]): () => Promise<void>;
@@ -3,11 +3,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.installWaClientPlugins = installWaClientPlugins;
4
4
  const types_1 = require("../plugins/types");
5
5
  const primitives_1 = require("../../util/primitives");
6
+ const PLUGIN_REGISTRY = Symbol('waClientPluginRegistry');
7
+ /**
8
+ * Per-client store for exposed-plugin state that must survive a
9
+ * disconnect/connect cycle. Each `exposeAs` property is defined on the client
10
+ * exactly once (a non-configurable getter reading `instances`); reinstalls
11
+ * only repopulate the slot, so plugins can be torn down on disconnect and
12
+ * rebuilt on the next connect without redefining reserved members.
13
+ */
14
+ function getPluginRegistry(client) {
15
+ const holder = client;
16
+ const existing = holder[PLUGIN_REGISTRY];
17
+ if (existing) {
18
+ return existing;
19
+ }
20
+ const registry = { instances: new Map(), exposedDefined: new Set() };
21
+ Object.defineProperty(client, PLUGIN_REGISTRY, {
22
+ value: registry,
23
+ enumerable: false,
24
+ configurable: false,
25
+ writable: false
26
+ });
27
+ return registry;
28
+ }
6
29
  /**
7
30
  * Installs {@link WaClientOptions.plugins} on `client`. Returns a dispose
8
- * function invoked by {@link WaClient.disconnect}.
31
+ * function invoked by {@link WaClient.disconnect}. Safe to call again after a
32
+ * dispose to reinstall the same plugins on reconnect.
9
33
  */
10
34
  function installWaClientPlugins(client, input, plugins) {
35
+ const registry = getPluginRegistry(client);
11
36
  const seenIds = new Set();
12
37
  const seenExposeAs = new Set();
13
38
  const disposeCallbacks = [];
@@ -40,24 +65,32 @@ function installWaClientPlugins(client, input, plugins) {
40
65
  logger: input.logger.child({ plugin: plugin.id })
41
66
  };
42
67
  if ((0, types_1.isWaClientExposePluginDefinition)(plugin)) {
43
- if (seenExposeAs.has(plugin.exposeAs)) {
44
- throw new Error(`duplicate wa client plugin exposeAs: ${plugin.exposeAs}`);
68
+ const exposeAs = plugin.exposeAs;
69
+ if (seenExposeAs.has(exposeAs)) {
70
+ throw new Error(`duplicate wa client plugin exposeAs: ${exposeAs}`);
45
71
  }
46
- seenExposeAs.add(plugin.exposeAs);
47
- if (plugin.exposeAs in client) {
48
- throw new Error(`wa client plugin exposeAs "${plugin.exposeAs}" collides with a reserved client member`);
72
+ seenExposeAs.add(exposeAs);
73
+ if (!registry.exposedDefined.has(exposeAs)) {
74
+ if (exposeAs in client) {
75
+ throw new Error(`wa client plugin exposeAs "${exposeAs}" collides with a reserved client member`);
76
+ }
77
+ registry.exposedDefined.add(exposeAs);
78
+ Object.defineProperty(client, exposeAs, {
79
+ get: () => registry.instances.get(exposeAs),
80
+ enumerable: true,
81
+ configurable: false
82
+ });
49
83
  }
50
84
  const instance = plugin.setup(pluginCtx);
51
- Object.defineProperty(client, plugin.exposeAs, {
52
- get: () => instance,
53
- enumerable: true,
54
- configurable: false
85
+ registry.instances.set(exposeAs, instance);
86
+ registerDispose(() => {
87
+ registry.instances.delete(exposeAs);
55
88
  });
56
89
  if (plugin.dispose) {
57
90
  const dispose = plugin.dispose;
58
91
  registerDispose(() => dispose(instance, pluginCtx));
59
92
  }
60
- pluginCtx.logger.debug('wa client plugin installed', { exposeAs: plugin.exposeAs });
93
+ pluginCtx.logger.debug('wa client plugin installed', { exposeAs });
61
94
  }
62
95
  else {
63
96
  plugin.setup(pluginCtx);
@@ -4,6 +4,7 @@ import type { WaShortcakeAssertionSigner } from '../auth/pairing/WaShortcakeFlow
4
4
  import type { WaAuthClientOptions, WaAuthCredentials, WaAuthDangerousOptions, WaAuthSocketOptions } from '../auth/types';
5
5
  import type { WaCallGroupParticipant, WaCallType } from './events/call';
6
6
  import type { IncomingPresenceType, PresenceLastSeen } from './events/presence';
7
+ import type { CompanionHostPersistence } from './persistence/companion-host';
7
8
  import type { WaClientPluginDefinition } from './plugins/types';
8
9
  import type { WaMediaProcessor } from '../media/processor';
9
10
  import type { WaLinkPreviewOptions } from '../message/addons/link-preview/types';
@@ -83,6 +84,16 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
83
84
  * tests or pinning a specific edge.
84
85
  */
85
86
  readonly chatSocketUrls?: readonly string[];
87
+ /**
88
+ * Companion-hosting config for a mobile-primary session (see
89
+ * {@link WaClient.mobile}). `persistence` survives the ADV epoch across
90
+ * restarts (without it, relinking breaks previously linked devices);
91
+ * `includePem` adds the experimental `<pem>` node to the pair-device upload.
92
+ */
93
+ readonly companionHost?: {
94
+ readonly persistence?: CompanionHostPersistence;
95
+ readonly includePem?: boolean;
96
+ };
86
97
  /** Default timeout (ms) for IQ queries. Defaults to `WA_DEFAULTS.IQ_TIMEOUT_MS` (60s). */
87
98
  readonly iqTimeoutMs?: number;
88
99
  /** Default timeout (ms) for raw node `query()` calls when none is passed. */
@@ -119,8 +130,11 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
119
130
  * Automatically reconnect when the server rejects the noise handshake
120
131
  * with HTTP 405 / `failure_client_too_old`. On every 405 the client
121
132
  * logs a warning asking you to upgrade zapo, fetches the current
122
- * version from `web.whatsapp.com` via `fetchLatestWaWebVersion()`,
123
- * swaps it in for the next connect, and retries. Off by default.
133
+ * version, swaps it in for the next connect, and retries. Off by
134
+ * default. Web sessions fetch the WA Web version via
135
+ * `fetchLatestWaWebVersion()` (swapped in as the connect `version`);
136
+ * mobile sessions fetch the Android app version via
137
+ * `fetchLatestWaMobileVersion()` (swapped in as `deviceInfo.appVersion`).
124
138
  */
125
139
  readonly recoverFromClientTooOld?: boolean;
126
140
  /**
@@ -141,6 +155,7 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
141
155
  * Chat-event emission tuning. `emitSnapshotMutations: true` re-emits
142
156
  * `app_state_mutation` events for every mutation seen during a snapshot
143
157
  * sync (off by default – those mutations represent historical state).
158
+ * This client's own outbound app-state actions surface on `mutation_send`.
144
159
  */
145
160
  readonly chatEvents?: {
146
161
  readonly emitSnapshotMutations?: boolean;
@@ -540,6 +555,22 @@ export interface WaIncomingMessageEvent extends Omit<WaIncomingBaseEvent, 'chatJ
540
555
  readonly pushName?: string;
541
556
  readonly message?: Proto.IMessage;
542
557
  }
558
+ /**
559
+ * An outbound message this client sent, surfaced with its decrypted
560
+ * {@link Proto.IMessage} – the symmetric counterpart to
561
+ * {@link WaIncomingMessageEvent}. Fires from {@link WaMessageCoordinator.send}
562
+ * as the stanza is built, so plugins/loggers can observe outgoing content
563
+ * (forwards, reactions, polls, media) that the encrypted `<message>` on the
564
+ * wire does not reveal.
565
+ */
566
+ export interface WaOutgoingMessageEvent {
567
+ /** Destination jid (chat / group / status). */
568
+ readonly to: string;
569
+ /** Outgoing stanza id, when assigned. */
570
+ readonly id?: string;
571
+ /** The decrypted message proto being sent. */
572
+ readonly message: Proto.IMessage;
573
+ }
543
574
  export interface WaIncomingProtocolMessageEvent extends WaIncomingMessageEvent {
544
575
  readonly protocolMessage: Proto.Message.IProtocolMessage;
545
576
  }
@@ -1001,7 +1032,7 @@ export interface WaHistorySyncChunkEvent {
1001
1032
  /** Server-reported progress, 0–100. */
1002
1033
  readonly progress?: number;
1003
1034
  }
1004
- export type WaAppStateMutationSource = 'snapshot' | 'patch';
1035
+ export type WaAppStateMutationSource = 'snapshot' | 'patch' | 'local';
1005
1036
  type MutationEventBase = {
1006
1037
  readonly source: WaAppStateMutationSource;
1007
1038
  readonly collection: AppStateCollectionName;
@@ -1022,6 +1053,12 @@ export type WaAppStateMutationEvent = {
1022
1053
  readonly operation: 'remove';
1023
1054
  } & MutationEventBase & WaAppstateIndexArgs<K>);
1024
1055
  }[WaAppstateActionKey];
1056
+ /**
1057
+ * Listener shared by the inbound `mutation` and outbound `mutation_send`
1058
+ * events. A single named alias so the (large) `WaAppStateMutationEvent` union
1059
+ * is referenced once by the event map rather than expanded per event.
1060
+ */
1061
+ type WaAppStateMutationListener = (event: WaAppStateMutationEvent) => void;
1025
1062
  export type WaConnectionEvent = {
1026
1063
  readonly status: 'open';
1027
1064
  readonly reason: WaConnectionOpenReason;
@@ -1103,6 +1140,13 @@ export interface WaClientEventMap {
1103
1140
  * `options.quote` for threads).
1104
1141
  */
1105
1142
  readonly message: (event: WaIncomingMessageEvent) => void;
1143
+ /**
1144
+ * An outbound message this client is sending, with its decrypted
1145
+ * {@link Proto.IMessage} – the symmetric counterpart to `message`. Lets
1146
+ * plugins/loggers observe outgoing content (forwards, reactions, polls,
1147
+ * media) the encrypted wire stanza hides.
1148
+ */
1149
+ readonly message_send: (event: WaOutgoingMessageEvent) => void;
1106
1150
  /**
1107
1151
  * A decrypted addon (poll vote, reaction, edit, comment, ...) attached to
1108
1152
  * a previous message. Fires unless `addons.autoDecrypt` is explicitly
@@ -1162,11 +1206,20 @@ export interface WaClientEventMap {
1162
1206
  /** Profile/group/community picture change notification – the new picture must still be fetched explicitly. */
1163
1207
  readonly picture: (event: WaPictureEvent) => void;
1164
1208
  /**
1165
- * A parsed app-state mutation crossed the sync boundary – chat mute/star/
1166
- * read/pin/archive/contact/label/etc. Use the discriminator
1167
- * (`event.action`) to branch on the mutation kind.
1209
+ * A parsed app-state mutation arriving from a sync – chat mute/star/read/
1210
+ * pin/archive/contact/label/etc. changed on another device. Inbound only;
1211
+ * this client's own outbound actions surface on `mutation_send`. Use the
1212
+ * discriminator (`event.schema`) to branch on the mutation kind.
1168
1213
  */
1169
- readonly mutation: (event: WaAppStateMutationEvent) => void;
1214
+ readonly mutation: WaAppStateMutationListener;
1215
+ /**
1216
+ * An app-state action this client is sending (mute/star/read/pin/archive/
1217
+ * clear/delete/…) surfaced at action time – the outbound counterpart to
1218
+ * `mutation`, symmetric to how `message_send` pairs with `message`.
1219
+ * `event.source` is always `'local'`. Optimistic: emitted as the mutation
1220
+ * is enqueued, before the server flush confirms it.
1221
+ */
1222
+ readonly mutation_send: WaAppStateMutationListener;
1170
1223
  /**
1171
1224
  * One chunk of history-sync data, fired both during the initial
1172
1225
  * bootstrap that the primary device pushes after pairing and for any
@@ -1204,6 +1257,17 @@ export interface WaClientEventMap {
1204
1257
  * `mobileTransport`.
1205
1258
  */
1206
1259
  readonly mobile_account_takeover_notice: (event: WaAccountTakeoverNoticeEvent) => void;
1260
+ /** A companion device was linked from this mobile-primary account. */
1261
+ readonly companion_host_linked: (result: {
1262
+ readonly deviceJid: string;
1263
+ readonly keyIndex: number;
1264
+ }) => void;
1265
+ /** A hosted companion device was revoked / unlinked. */
1266
+ readonly companion_host_revoked: (payload: {
1267
+ readonly deviceJid: string;
1268
+ }) => void;
1269
+ /** A companion-host link or provisioning operation failed. */
1270
+ readonly companion_host_error: (error: Error) => void;
1207
1271
  /** **debug** – the success node closing the noise handshake; emitted before `connection: { status: 'open' }`. */
1208
1272
  readonly debug_connection_success: (event: {
1209
1273
  readonly node: BinaryNode;
@@ -2,12 +2,11 @@ import { buildCommsConfig, loadOrCreateCredentials, persistCredentials } from '.
2
2
  import { WaPairingFlow } from './pairing/WaPairingFlow.js';
3
3
  import { WaQrFlow } from './pairing/WaQrFlow.js';
4
4
  import { WaShortcakeFlow } from './pairing/WaShortcakeFlow.js';
5
- import { getWaCompanionPlatformId, WA_DEFAULTS, WA_NOTIFICATION_TYPES } from '../protocol/constants.js';
5
+ import { resolveWaDeviceIdentity, WA_NOTIFICATION_TYPES } from '../protocol/constants.js';
6
6
  import { buildAckNode } from '../transport/node/builders/global.js';
7
7
  import { resolveDevicePropsPlatformType } from '../transport/noise/WaClientPayload.js';
8
8
  import { uint8Equal } from '../util/bytes.js';
9
9
  import { toError } from '../util/primitives.js';
10
- import { getRuntimeOsDisplayName } from '../util/runtime.js';
11
10
  /**
12
11
  * Owns the auth/pairing lifecycle and persistence of {@link WaAuthCredentials}.
13
12
  * Exposed as `client.auth` on a {@link WaClient}.
@@ -20,12 +19,8 @@ import { getRuntimeOsDisplayName } from '../util/runtime.js';
20
19
  export class WaAuthClient {
21
20
  constructor(options, deps) {
22
21
  this.versionOverride = null;
23
- const deviceBrowser = options.deviceBrowser ?? WA_DEFAULTS.DEVICE_BROWSER;
24
- const device = Object.freeze({
25
- browser: deviceBrowser,
26
- osDisplayName: options.deviceOsDisplayName ?? getRuntimeOsDisplayName(),
27
- platform: options.devicePlatform ?? getWaCompanionPlatformId(deviceBrowser)
28
- });
22
+ this.mobileAppVersionOverride = null;
23
+ const device = resolveWaDeviceIdentity(options);
29
24
  this.options = Object.freeze({
30
25
  ...options,
31
26
  deviceBrowser: device.browser,
@@ -76,7 +71,7 @@ export class WaAuthClient {
76
71
  getCredentials: () => this.credentials,
77
72
  updateCredentials: this.updateCredentials.bind(this)
78
73
  },
79
- deviceType: resolveDevicePropsPlatformType(deviceBrowser)
74
+ deviceType: resolveDevicePropsPlatformType(device.browser)
80
75
  })
81
76
  : null;
82
77
  }
@@ -124,12 +119,15 @@ export class WaAuthClient {
124
119
  this.logger.trace('auth client building comms config');
125
120
  const override = this.versionOverride;
126
121
  this.versionOverride = null;
122
+ const mobileAppVersionOverride = this.mobileAppVersionOverride;
123
+ this.mobileAppVersionOverride = null;
127
124
  return buildCommsConfig(this.logger, this.requireCredentials(), socketOptions, {
128
125
  deviceBrowser: this.options.deviceBrowser,
129
126
  deviceOsDisplayName: this.options.deviceOsDisplayName,
130
127
  requireFullSync: this.options.requireFullSync,
131
128
  version: override ?? this.options.version,
132
129
  mobileTransport: this.options.mobileTransport,
130
+ mobileAppVersionOverride: mobileAppVersionOverride ?? undefined,
133
131
  noiseTrustedRootCa: overrides.noiseTrustedRootCa,
134
132
  disableNoiseCertificateChainVerification: overrides.disableNoiseCertificateChainVerification ??
135
133
  this.options.dangerous?.disableNoiseCertificateChainVerification
@@ -145,6 +143,17 @@ export class WaAuthClient {
145
143
  setNextConnectVersion(version) {
146
144
  this.versionOverride = version;
147
145
  }
146
+ /**
147
+ * One-shot override for the mobile `deviceInfo.appVersion` on the next
148
+ * {@link buildCommsConfig} call, then clears. The mobile transport reads
149
+ * its version from `deviceInfo.appVersion` (the login payload), not the
150
+ * `version` string, so {@link setNextConnectVersion} has no effect on a
151
+ * mobile session. Used by the `recoverFromClientTooOld` auto-retry to
152
+ * inject a fresh Android app version without mutating the user's options.
153
+ */
154
+ setNextConnectMobileAppVersion(appVersion) {
155
+ this.mobileAppVersionOverride = appVersion;
156
+ }
148
157
  /** Clears the in-memory QR and pairing sessions without touching storage. */
149
158
  // eslint-disable-next-line @typescript-eslint/require-await
150
159
  async clearTransientState() {
@@ -6,7 +6,7 @@ import { createAndStoreInitialKeys } from '../signal/index.js';
6
6
  import { WaMobileTcpSocketCtor } from '../transport/node/WaMobileTcpSocket.js';
7
7
  import { buildMobileLoginPayload } from '../transport/noise/WaMobileClientPayload.js';
8
8
  import { toProxyAgent, toProxyDispatcher } from '../transport/proxy.js';
9
- import { toError } from '../util/primitives.js';
9
+ import { parseOptionalInt, toError } from '../util/primitives.js';
10
10
  export async function loadOrCreateCredentials(args) {
11
11
  args.logger.trace('auth credentials loadOrCreate start');
12
12
  const existing = await args.authStore.load();
@@ -61,6 +61,26 @@ async function resolveVersion(version) {
61
61
  }
62
62
  return resolved;
63
63
  }
64
+ /**
65
+ * Validates the user-supplied `version` string against the transport it will
66
+ * feed. A mobile session sends the version as the four-part Android app
67
+ * version (`2.YY.WW.RR`) in the login payload, so it must have exactly four
68
+ * numeric parts. A web session advertises up to five parts (`2.3000.x[.y.z]`),
69
+ * so three to five numeric parts are accepted.
70
+ */
71
+ function assertValidVersion(version, mobile) {
72
+ const parts = version.split('.');
73
+ const allNumeric = parts.every((part) => parseOptionalInt(part) !== undefined);
74
+ if (mobile) {
75
+ if (parts.length !== 4 || !allNumeric) {
76
+ throw new Error(`mobile session requires a 4-part numeric version (e.g. 2.26.27.70), got: ${version}`);
77
+ }
78
+ return;
79
+ }
80
+ if (parts.length < 3 || parts.length > 5 || !allNumeric) {
81
+ throw new Error(`web session requires a 3- to 5-part numeric version (e.g. 2.3000.1040229458), got: ${version}`);
82
+ }
83
+ }
64
84
  export async function buildCommsConfig(logger, credentials, socketOptions, clientOptions) {
65
85
  const meJid = credentials.meJid;
66
86
  const registered = meJid !== null && meJid !== undefined;
@@ -81,6 +101,12 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
81
101
  ? 'credentials'
82
102
  : 'none'
83
103
  });
104
+ const resolvedVersion = effectiveMobileTransport && clientOptions.mobileAppVersionOverride !== undefined
105
+ ? undefined
106
+ : await resolveVersion(clientOptions.version);
107
+ if (resolvedVersion !== undefined) {
108
+ assertValidVersion(resolvedVersion, Boolean(effectiveMobileTransport));
109
+ }
84
110
  if (effectiveMobileTransport) {
85
111
  if (wsProxy) {
86
112
  throw new Error('mobileTransport does not support socketOptions.proxy.ws – remove the proxy option or open an issue to add TCP proxy support');
@@ -88,11 +114,18 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
88
114
  if (!loginIdentity) {
89
115
  throw new Error('mobileTransport requires registered credentials (meJid) – run the mobile bridge flow first');
90
116
  }
117
+ if (clientOptions.mobileAppVersionOverride !== undefined) {
118
+ assertValidVersion(clientOptions.mobileAppVersionOverride, true);
119
+ }
120
+ const appVersionOverride = clientOptions.mobileAppVersionOverride ?? resolvedVersion;
121
+ const deviceInfo = appVersionOverride
122
+ ? { ...effectiveMobileTransport.deviceInfo, appVersion: appVersionOverride }
123
+ : effectiveMobileTransport.deviceInfo;
91
124
  const loginPayload = buildMobileLoginPayload({
92
125
  username: loginIdentity.username,
93
126
  device: loginIdentity.device,
94
127
  passive: effectiveMobileTransport.passive ?? false,
95
- deviceInfo: effectiveMobileTransport.deviceInfo,
128
+ deviceInfo,
96
129
  pushName: effectiveMobileTransport.pushName,
97
130
  yearClass: effectiveMobileTransport.yearClass,
98
131
  memClass: effectiveMobileTransport.memClass
@@ -117,7 +150,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
117
150
  }
118
151
  };
119
152
  }
120
- const versionBase = await resolveVersion(clientOptions.version);
153
+ const versionBase = resolvedVersion;
121
154
  return {
122
155
  url: socketOptions.url,
123
156
  urls: socketOptions.urls,