zapo-js 1.6.2 → 1.7.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 (141) hide show
  1. package/dist/abprops-spec.d.ts +10 -0
  2. package/dist/abprops-spec.js +25 -0
  3. package/dist/client/WaClient.d.ts +11 -0
  4. package/dist/client/WaClient.js +60 -0
  5. package/dist/client/WaClientFactory.d.ts +2 -2
  6. package/dist/client/WaClientFactory.js +50 -12
  7. package/dist/client/coordinators/WaAbPropsCoordinator.d.ts +2 -2
  8. package/dist/client/coordinators/WaAbPropsCoordinator.js +16 -4
  9. package/dist/client/coordinators/WaIncomingNodeCoordinator.d.ts +2 -1
  10. package/dist/client/coordinators/WaIncomingNodeCoordinator.js +4 -0
  11. package/dist/client/coordinators/WaMessageCoordinator.d.ts +96 -0
  12. package/dist/client/coordinators/WaMessageCoordinator.js +163 -8
  13. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +64 -0
  14. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +217 -30
  15. package/dist/client/coordinators/WaPrivacyCoordinator.d.ts +97 -19
  16. package/dist/client/coordinators/WaPrivacyCoordinator.js +152 -101
  17. package/dist/client/coordinators/WaRetryCoordinator.d.ts +22 -0
  18. package/dist/client/coordinators/WaRetryCoordinator.js +74 -11
  19. package/dist/client/events/dirty.d.ts +3 -0
  20. package/dist/client/events/dirty.js +12 -7
  21. package/dist/client/events/offline.d.ts +9 -0
  22. package/dist/client/events/offline.js +95 -0
  23. package/dist/client/events/privacy.d.ts +45 -0
  24. package/dist/client/events/privacy.js +130 -0
  25. package/dist/client/messaging/group-metadata.d.ts +12 -0
  26. package/dist/client/messaging/group-metadata.js +24 -8
  27. package/dist/client/messaging/link-preview.d.ts +7 -0
  28. package/dist/client/messaging/link-preview.js +27 -0
  29. package/dist/client/newsletter/content.d.ts +4 -1
  30. package/dist/client/newsletter/content.js +43 -18
  31. package/dist/client/newsletter/messaging.d.ts +2 -0
  32. package/dist/client/newsletter/messaging.js +2 -1
  33. package/dist/client/newsletter/mex.d.ts +2 -2
  34. package/dist/client/persistence/WriteBehindPersistence.js +2 -1
  35. package/dist/client/persistence/ephemeral-setting.d.ts +42 -0
  36. package/dist/client/persistence/ephemeral-setting.js +120 -0
  37. package/dist/client/persistence/group-history.d.ts +29 -0
  38. package/dist/client/persistence/group-history.js +149 -0
  39. package/dist/client/persistence/history-blob.d.ts +25 -0
  40. package/dist/client/persistence/history-blob.js +38 -0
  41. package/dist/client/persistence/history-sync.d.ts +2 -0
  42. package/dist/client/persistence/history-sync.js +31 -29
  43. package/dist/client/types.d.ts +150 -16
  44. package/dist/crypto/core/xeddsa.js +16 -0
  45. package/dist/crypto/curves/X25519.js +13 -0
  46. package/dist/crypto/nativeBackend.d.ts +20 -0
  47. package/dist/crypto/nativeBackend.js +95 -0
  48. package/dist/esm/abprops-spec.js +15 -0
  49. package/dist/esm/client/WaClient.js +60 -0
  50. package/dist/esm/client/WaClientFactory.js +51 -13
  51. package/dist/esm/client/coordinators/WaAbPropsCoordinator.js +17 -5
  52. package/dist/esm/client/coordinators/WaIncomingNodeCoordinator.js +4 -0
  53. package/dist/esm/client/coordinators/WaMessageCoordinator.js +166 -11
  54. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +218 -32
  55. package/dist/esm/client/coordinators/WaPrivacyCoordinator.js +145 -94
  56. package/dist/esm/client/coordinators/WaRetryCoordinator.js +74 -11
  57. package/dist/esm/client/events/dirty.js +12 -7
  58. package/dist/esm/client/events/offline.js +92 -0
  59. package/dist/esm/client/events/privacy.js +123 -0
  60. package/dist/esm/client/messaging/group-metadata.js +25 -9
  61. package/dist/esm/client/messaging/link-preview.js +29 -2
  62. package/dist/esm/client/newsletter/content.js +44 -19
  63. package/dist/esm/client/newsletter/messaging.js +2 -1
  64. package/dist/esm/client/persistence/WriteBehindPersistence.js +2 -1
  65. package/dist/esm/client/persistence/ephemeral-setting.js +116 -0
  66. package/dist/esm/client/persistence/group-history.js +145 -0
  67. package/dist/esm/client/persistence/history-blob.js +34 -0
  68. package/dist/esm/client/persistence/history-sync.js +24 -22
  69. package/dist/esm/crypto/core/xeddsa.js +17 -1
  70. package/dist/esm/crypto/curves/X25519.js +13 -0
  71. package/dist/esm/crypto/nativeBackend.js +93 -0
  72. package/dist/esm/index.js +3 -1
  73. package/dist/esm/message/context-info.js +39 -0
  74. package/dist/esm/message/encode/content.js +20 -4
  75. package/dist/esm/message/encode/media-payload.js +3 -1
  76. package/dist/esm/message/kinds/group-history.js +36 -0
  77. package/dist/esm/message/primitives/incoming.js +18 -6
  78. package/dist/esm/protocol/abprops.js +18 -162
  79. package/dist/esm/protocol/constants.js +4 -3
  80. package/dist/esm/protocol/defaults.js +6 -0
  81. package/dist/esm/protocol/media.js +1 -0
  82. package/dist/esm/protocol/message.js +8 -0
  83. package/dist/esm/protocol/notification.js +1 -0
  84. package/dist/esm/protocol/privacy.js +70 -4
  85. package/dist/esm/store/contracts/chat-metadata.store.js +1 -0
  86. package/dist/esm/store/createStore.js +19 -2
  87. package/dist/esm/store/index.js +1 -0
  88. package/dist/esm/store/memory/chat-metadata.store.js +66 -0
  89. package/dist/esm/store/noop.store.js +8 -0
  90. package/dist/esm/transport/node/builders/privacy.js +63 -4
  91. package/dist/esm/util/coercion.js +12 -4
  92. package/dist/esm/util/index.js +1 -1
  93. package/dist/index.d.ts +11 -6
  94. package/dist/index.js +15 -2
  95. package/dist/media/types.d.ts +1 -1
  96. package/dist/message/context-info.d.ts +10 -0
  97. package/dist/message/context-info.js +40 -0
  98. package/dist/message/encode/content.d.ts +7 -3
  99. package/dist/message/encode/content.js +20 -4
  100. package/dist/message/encode/media-payload.d.ts +5 -2
  101. package/dist/message/encode/media-payload.js +3 -1
  102. package/dist/message/kinds/group-history.d.ts +30 -0
  103. package/dist/message/kinds/group-history.js +40 -0
  104. package/dist/message/primitives/incoming.d.ts +5 -0
  105. package/dist/message/primitives/incoming.js +18 -6
  106. package/dist/protocol/abprops.d.ts +27 -140
  107. package/dist/protocol/abprops.js +18 -163
  108. package/dist/protocol/constants.d.ts +4 -3
  109. package/dist/protocol/constants.js +10 -3
  110. package/dist/protocol/defaults.d.ts +6 -0
  111. package/dist/protocol/defaults.js +6 -0
  112. package/dist/protocol/media.d.ts +1 -0
  113. package/dist/protocol/media.js +1 -0
  114. package/dist/protocol/message.d.ts +6 -0
  115. package/dist/protocol/message.js +9 -0
  116. package/dist/protocol/notification.d.ts +1 -0
  117. package/dist/protocol/notification.js +1 -0
  118. package/dist/protocol/privacy.d.ts +52 -0
  119. package/dist/protocol/privacy.js +71 -5
  120. package/dist/store/contracts/chat-metadata.store.d.ts +34 -0
  121. package/dist/store/contracts/chat-metadata.store.js +2 -0
  122. package/dist/store/contracts/group-metadata.store.d.ts +7 -0
  123. package/dist/store/contracts/thread.store.d.ts +6 -0
  124. package/dist/store/createStore.d.ts +10 -2
  125. package/dist/store/createStore.js +18 -1
  126. package/dist/store/index.d.ts +3 -1
  127. package/dist/store/index.js +3 -1
  128. package/dist/store/memory/chat-metadata.store.d.ts +26 -0
  129. package/dist/store/memory/chat-metadata.store.js +70 -0
  130. package/dist/store/noop.store.d.ts +2 -0
  131. package/dist/store/noop.store.js +9 -1
  132. package/dist/store/types.d.ts +190 -39
  133. package/dist/transport/node/builders/privacy.d.ts +26 -2
  134. package/dist/transport/node/builders/privacy.js +63 -3
  135. package/dist/util/coercion.d.ts +6 -0
  136. package/dist/util/coercion.js +13 -4
  137. package/dist/util/index.d.ts +1 -1
  138. package/dist/util/index.js +2 -1
  139. package/package.json +3 -1
  140. package/spec/abprops/index.d.ts +4402 -0
  141. package/spec/abprops/index.js +4372 -0
@@ -1,40 +1,90 @@
1
+ import { type WaBlocklistResult, type WaPrivacyDisallowedListResult, type WaPrivacySettings } from '../events/privacy';
2
+ import type { Logger } from '../../infra/log/types';
1
3
  import { type WaPrivacyDisallowedListSettingName, type WaPrivacySettingName, type WaPrivacySettingValueMap } from '../../protocol/privacy';
2
4
  import type { SignalUserJidPair } from '../../signal/api/SignalDeviceSyncApi';
3
5
  import type { BinaryNode } from '../../transport/types';
4
- export type WaPrivacySettings = {
5
- readonly [K in WaPrivacySettingName]?: WaPrivacySettingValueMap[K];
6
- };
7
- export interface WaPrivacyDisallowedListResult {
8
- readonly jids: readonly string[];
9
- readonly dhash?: string;
6
+ /** JIDs to add to / remove from a category's disallowed list. */
7
+ export interface WaPrivacyDisallowedListInput {
8
+ readonly add?: readonly string[];
9
+ readonly remove?: readonly string[];
10
10
  }
11
- export interface WaBlocklistResult {
12
- readonly jids: readonly string[];
13
- readonly dhash?: string;
11
+ /** A disallowed list the server reported as changed, tagged with its category. */
12
+ export interface WaPrivacyDisallowedListUpdate extends WaPrivacyDisallowedListResult {
13
+ readonly setting: WaPrivacyDisallowedListSettingName;
14
+ }
15
+ /** Everything an account-sync privacy refresh pulls back. */
16
+ export interface WaPrivacyAccountSyncResult {
17
+ readonly settings: WaPrivacySettings;
18
+ /**
19
+ * Only the lists the server reported as changed - a category sitting off
20
+ * `contact_blacklist` answers with no list at all and is left out.
21
+ */
22
+ readonly disallowedLists: readonly WaPrivacyDisallowedListUpdate[];
14
23
  }
15
24
  /**
16
25
  * Coordinates privacy queries/mutations: per-category settings, blocklist,
17
26
  * and the per-category disallowed lists. Accessed via {@link WaClient.privacy}.
18
27
  */
19
28
  export interface WaPrivacyCoordinator {
20
- /** Fetches the current value of every privacy category. */
29
+ /**
30
+ * Fetches the current value of every privacy category. A change made on
31
+ * another device reaches this client on its own and surfaces as a
32
+ * `privacy` event, so polling this is only needed for the initial read.
33
+ */
21
34
  readonly getPrivacySettings: () => Promise<WaPrivacySettings>;
22
35
  /**
23
- * Updates a single privacy category to a new {@link WaPrivacyValue}.
36
+ * Updates a single privacy category to a new value, returning the `dhash`
37
+ * the server echoes back - the version stamp of that category's
38
+ * disallowed list, present only while the category sits on
39
+ * `'contact_blacklist'` (`null` otherwise).
24
40
  *
25
41
  * The `'contact_blacklist'` value (a deny-list of specific contacts on
26
- * top of `'contacts'`/`'all'`) only flips the **mode** here - you must
27
- * separately populate the per-category disallowed list with
28
- * {@link getDisallowedList} + the corresponding app-state mutation, or
29
- * the deny-list stays empty.
42
+ * top of `'contacts'`/`'all'`) only flips the **mode** here and leaves the
43
+ * list empty. Populate it with {@link setDisallowedList}, which carries
44
+ * the mode and the entries in one stanza.
30
45
  */
31
- readonly setPrivacySetting: <S extends WaPrivacySettingName>(setting: S, value: WaPrivacySettingValueMap[S]) => Promise<void>;
46
+ readonly setPrivacySetting: <S extends WaPrivacySettingName>(setting: S, value: WaPrivacySettingValueMap[S]) => Promise<string | null>;
32
47
  /**
33
48
  * Fetches the per-category disallowed list (the JIDs explicitly excluded
34
- * from `contact_blacklist`/`contact_whitelist` style settings).
49
+ * while the category sits on `'contact_blacklist'`). Returns an empty
50
+ * list when the category is on any other value.
35
51
  */
36
52
  readonly getDisallowedList: (category: WaPrivacyDisallowedListSettingName) => Promise<WaPrivacyDisallowedListResult>;
37
- /** Returns the current account-wide blocklist. */
53
+ /**
54
+ * Adds/removes JIDs on a category's disallowed list, switching the
55
+ * category to `'contact_blacklist'` in the same stanza (the server has no
56
+ * separate deny-list endpoint). Inputs accept phone jids, LID jids, or
57
+ * bare phone numbers and are resolved to both addressing forms, like
58
+ * {@link blockUser}.
59
+ *
60
+ * The write is versioned by a `dhash` the coordinator reads right before
61
+ * sending; if another device mutated the list in between, the server
62
+ * answers `409` and the call refetches the stamp and retries once.
63
+ * Returns the new `dhash`.
64
+ */
65
+ readonly setDisallowedList: (category: WaPrivacyDisallowedListSettingName, input: WaPrivacyDisallowedListInput) => Promise<string | null>;
66
+ /**
67
+ * Refetches everything an account-sync privacy update covers: the whole
68
+ * category set plus the disallowed lists of
69
+ * {@link WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS}, queried in parallel.
70
+ * The result is both returned and emitted as the `privacy` event, so the
71
+ * client's view stays consistent no matter who triggered the refresh.
72
+ * Categories and lists are separate queries, so a change landing
73
+ * mid-refresh can leave the two halves a version apart.
74
+ *
75
+ * Concurrent calls are deduplicated: one issued while another is still in
76
+ * flight joins it and resolves with the same snapshot.
77
+ *
78
+ * This is what runs on its own when another device changes a setting -
79
+ * the notification carrying the change is a trigger, not a payload to act
80
+ * on. Call it directly only to force a refresh.
81
+ */
82
+ readonly refreshFromAccountSync: () => Promise<WaPrivacyAccountSyncResult>;
83
+ /**
84
+ * Returns the current account-wide blocklist. Blocks/unblocks performed
85
+ * on another device are refetched through the same dirty-bit path as
86
+ * {@link getPrivacySettings} and re-emitted as `blocklist`.
87
+ */
38
88
  readonly getBlocklist: () => Promise<WaBlocklistResult>;
39
89
  /**
40
90
  * Blocks a user (account-wide blocklist). Accepts a phone-number jid, a
@@ -57,10 +107,38 @@ export interface WaPrivacyCoordinator {
57
107
  */
58
108
  readonly unblockUser: (jid: string) => Promise<void>;
59
109
  }
110
+ /**
111
+ * Account-sync driving surface, kept off {@link WaPrivacyCoordinator} because
112
+ * it is the client's to call, not the consumer's: `WaClient.privacy` narrows
113
+ * to the public interface.
114
+ */
115
+ export interface WaPrivacyCoordinatorRuntime extends WaPrivacyCoordinator {
116
+ /**
117
+ * Queues a refresh, restarting the quiet window on every call so a burst
118
+ * of `account_sync` notifications collapses into a single refetch instead
119
+ * of one per stanza (each costs five queries). A refresh already in
120
+ * flight is waited out rather than joined: its queries went out before
121
+ * the change landed, so its snapshot would not carry the new value.
122
+ */
123
+ readonly scheduleAccountSyncRefresh: () => void;
124
+ /**
125
+ * Drops a queued refresh so a disconnect does not leave a refetch to fire
126
+ * against a closed connection. A refresh already in flight still runs to
127
+ * completion.
128
+ */
129
+ readonly stopAccountSyncRefresh: () => void;
130
+ }
60
131
  interface WaPrivacyCoordinatorOptions {
132
+ readonly logger: Logger;
61
133
  readonly queryWithContext: (context: string, node: BinaryNode, timeoutMs?: number, contextData?: Readonly<Record<string, unknown>>) => Promise<BinaryNode>;
62
134
  readonly resolveUserJidPair: (userJid: string) => Promise<SignalUserJidPair>;
135
+ /**
136
+ * The account's own LID, when it has one. Its presence marks the account
137
+ * as LID-migrated, which every disallowed-list stanza has to declare.
138
+ */
139
+ readonly getSelfLid: () => string | null;
140
+ readonly emitPrivacy: (event: WaPrivacyAccountSyncResult) => void;
63
141
  }
64
142
  /** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
65
- export declare function createPrivacyCoordinator(options: WaPrivacyCoordinatorOptions): WaPrivacyCoordinator;
143
+ export declare function createPrivacyCoordinator(options: WaPrivacyCoordinatorOptions): WaPrivacyCoordinatorRuntime;
66
144
  export {};
@@ -1,94 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createPrivacyCoordinator = createPrivacyCoordinator;
4
+ const privacy_1 = require("../events/privacy");
5
+ const PromiseDedup_1 = require("../../infra/perf/PromiseDedup");
6
+ const defaults_1 = require("../../protocol/defaults");
4
7
  const jid_1 = require("../../protocol/jid");
5
8
  const nodes_1 = require("../../protocol/nodes");
6
- const privacy_1 = require("../../protocol/privacy");
7
- const privacy_2 = require("../../transport/node/builders/privacy");
8
- const helpers_1 = require("../../transport/node/helpers");
9
+ const privacy_2 = require("../../protocol/privacy");
10
+ const privacy_3 = require("../../transport/node/builders/privacy");
9
11
  const query_1 = require("../../transport/node/query");
10
- const IGNORED_SERVER_CATEGORIES = new Set([
11
- 'pix',
12
- 'linked_profiles',
13
- 'stickers',
14
- 'dependentaccountmessages',
15
- 'cover_photo',
16
- 'dependent_account_calling',
17
- 'groupcreation'
18
- ]);
19
- const VALID_PRIVACY_VALUES = new Set(Object.values(privacy_1.WA_PRIVACY_VALUES));
20
- function isValidPrivacyValue(value) {
21
- return value !== privacy_1.WA_PRIVACY_VALUES.ERROR && VALID_PRIVACY_VALUES.has(value);
22
- }
23
- function parsePrivacySettings(result) {
24
- const privacyNode = (0, helpers_1.findNodeChild)(result, nodes_1.WA_NODE_TAGS.PRIVACY);
25
- if (!privacyNode) {
26
- return {};
27
- }
28
- const settings = {};
29
- const categories = (0, helpers_1.getNodeChildrenByTag)(privacyNode, privacy_1.WA_PRIVACY_TAGS.CATEGORY);
30
- for (let i = 0; i < categories.length; i += 1) {
31
- const node = categories[i];
32
- const name = node.attrs.name;
33
- const value = node.attrs.value;
34
- if (!name || !value) {
35
- continue;
36
- }
37
- if (IGNORED_SERVER_CATEGORIES.has(name)) {
38
- continue;
39
- }
40
- if (!isValidPrivacyValue(value)) {
41
- continue;
42
- }
43
- const settingName = privacy_1.WA_PRIVACY_CATEGORY_TO_SETTING[name];
44
- if (settingName) {
45
- settings[settingName] = value;
46
- }
47
- }
48
- return settings;
49
- }
50
- function parseDisallowedList(result) {
51
- const privacyNode = (0, helpers_1.findNodeChild)(result, nodes_1.WA_NODE_TAGS.PRIVACY);
52
- if (!privacyNode) {
53
- return { jids: [] };
54
- }
55
- const listNode = (0, helpers_1.findNodeChild)(privacyNode, privacy_1.WA_PRIVACY_TAGS.LIST);
56
- if (!listNode) {
57
- return { jids: [] };
58
- }
59
- const dhash = listNode.attrs.dhash;
60
- const userNodes = (0, helpers_1.getNodeChildrenByTag)(listNode, privacy_1.WA_PRIVACY_TAGS.USER);
61
- const jids = new Array(userNodes.length);
62
- let jidsCount = 0;
63
- for (let i = 0; i < userNodes.length; i += 1) {
64
- const jid = userNodes[i].attrs.jid;
65
- if (jid) {
66
- jids[jidsCount] = jid;
67
- jidsCount += 1;
68
- }
69
- }
70
- jids.length = jidsCount;
71
- return { jids, dhash };
72
- }
73
- function parseBlocklist(result) {
74
- const listNode = (0, helpers_1.findNodeChild)(result, nodes_1.WA_NODE_TAGS.LIST);
75
- if (!listNode) {
76
- return { jids: [] };
77
- }
78
- const dhash = listNode.attrs.dhash;
79
- const itemNodes = (0, helpers_1.getNodeChildren)(listNode);
80
- const jids = new Array(itemNodes.length);
81
- let jidsCount = 0;
82
- for (let i = 0; i < itemNodes.length; i += 1) {
83
- const jid = itemNodes[i].attrs.jid;
84
- if (jid) {
85
- jids[jidsCount] = jid;
86
- jidsCount += 1;
87
- }
88
- }
89
- jids.length = jidsCount;
90
- return { jids, dhash };
91
- }
12
+ const primitives_1 = require("../../util/primitives");
92
13
  /**
93
14
  * Resolves a blocklist input into both addressing forms via
94
15
  * `resolveUserJidPair` (device-list cache first, usync fallback for phone
@@ -106,43 +27,173 @@ async function resolveBlocklistTarget(options, jid) {
106
27
  }
107
28
  return { lidJid: null, pnJid: pair.pnJid ?? normalized };
108
29
  }
30
+ const ACCOUNT_SYNC_REFRESH_KEY = 'account-sync';
31
+ /**
32
+ * A `409` on a disallowed-list write means the `dhash` we sent no longer
33
+ * matches the server's: another device changed the list in between.
34
+ */
35
+ function isStaleDhashResult(node) {
36
+ if (node.tag !== nodes_1.WA_NODE_TAGS.IQ || node.attrs.type === nodes_1.WA_IQ_TYPES.RESULT) {
37
+ return false;
38
+ }
39
+ const error = (0, query_1.parseIqError)(node);
40
+ return error.numericCode === 409 || error.code === '409';
41
+ }
42
+ /**
43
+ * Resolves a disallowed-list input into `<user>` entries. Unlike the blocklist
44
+ * the server takes both actions in one stanza, so add/remove are resolved
45
+ * together and keep their relative order.
46
+ */
47
+ async function resolveDisallowedListEntries(options, input) {
48
+ const entries = [];
49
+ const actions = [
50
+ { action: privacy_2.WA_PRIVACY_LIST_ACTIONS.ADD, jids: input.add ?? [] },
51
+ { action: privacy_2.WA_PRIVACY_LIST_ACTIONS.REMOVE, jids: input.remove ?? [] }
52
+ ];
53
+ for (const { action, jids } of actions) {
54
+ for (const jid of jids) {
55
+ const target = await resolveBlocklistTarget(options, jid);
56
+ entries.push({ action, lidJid: target.lidJid, pnJid: target.pnJid });
57
+ }
58
+ }
59
+ if (entries.length === 0) {
60
+ throw new Error('setDisallowedList requires at least one add/remove entry');
61
+ }
62
+ return entries;
63
+ }
109
64
  /** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
110
65
  function createPrivacyCoordinator(options) {
111
66
  const { queryWithContext } = options;
67
+ const usesLidAddressing = () => options.getSelfLid() !== null;
68
+ let refreshTimer = null;
69
+ let refreshStopped = false;
70
+ let activeRefresh = null;
71
+ const refreshDedup = new PromiseDedup_1.PromiseDedup();
72
+ const queryDisallowedList = async (category) => {
73
+ const node = (0, privacy_3.buildGetPrivacyDisallowedListIq)(category, usesLidAddressing());
74
+ const result = await queryWithContext('privacy.getDisallowedList', node, undefined, {
75
+ category
76
+ });
77
+ (0, query_1.assertIqResult)(result, 'privacy.getDisallowedList');
78
+ return result;
79
+ };
80
+ const readDisallowedList = async (category) => (0, privacy_1.parseDisallowedList)(await queryDisallowedList(category));
81
+ const runAccountSyncRefresh = async () => {
82
+ const settingsNode = await queryWithContext('privacy.getSettings', (0, privacy_3.buildGetPrivacySettingsIq)());
83
+ (0, query_1.assertIqResult)(settingsNode, 'privacy.getSettings');
84
+ const listNodes = await Promise.all(privacy_2.WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS.map(async (setting) => ({
85
+ setting,
86
+ node: await queryDisallowedList(privacy_2.WA_PRIVACY_SETTING_TO_CATEGORY[setting])
87
+ })));
88
+ const disallowedLists = [];
89
+ for (const { setting, node } of listNodes) {
90
+ const update = (0, privacy_1.parseDisallowedListUpdate)(node);
91
+ if (update) {
92
+ disallowedLists.push({ setting, ...update });
93
+ }
94
+ }
95
+ const result = {
96
+ settings: (0, privacy_1.parsePrivacySettings)(settingsNode),
97
+ disallowedLists
98
+ };
99
+ options.emitPrivacy(result);
100
+ return result;
101
+ };
102
+ /**
103
+ * Joins a refresh already in flight instead of racing it, so concurrent
104
+ * callers share one round of queries and one emit.
105
+ */
106
+ const dedupedRefresh = () => {
107
+ const running = refreshDedup.run(ACCOUNT_SYNC_REFRESH_KEY, runAccountSyncRefresh);
108
+ activeRefresh = running;
109
+ return running;
110
+ };
111
+ /**
112
+ * Refresh for a change the server just announced. It must not join a
113
+ * refresh already in flight: that one's queries went out before the
114
+ * change landed, so joining it would emit a snapshot without the new
115
+ * value and nothing would refetch. Waiting for it first guarantees a read
116
+ * issued after the notification.
117
+ */
118
+ const followUpRefresh = async () => {
119
+ if (refreshStopped) {
120
+ return;
121
+ }
122
+ await activeRefresh?.catch(() => undefined);
123
+ if (refreshStopped) {
124
+ return;
125
+ }
126
+ await dedupedRefresh();
127
+ };
128
+ const writeDisallowedList = async (category, entries, dhash) => {
129
+ const node = (0, privacy_3.buildSetPrivacyDisallowedListIq)(category, entries, dhash, usesLidAddressing());
130
+ return queryWithContext('privacy.setDisallowedList', node, undefined, {
131
+ category,
132
+ entries: entries.length
133
+ });
134
+ };
112
135
  return {
113
136
  getPrivacySettings: async () => {
114
- const node = (0, privacy_2.buildGetPrivacySettingsIq)();
137
+ const node = (0, privacy_3.buildGetPrivacySettingsIq)();
115
138
  const result = await queryWithContext('privacy.getSettings', node);
116
139
  (0, query_1.assertIqResult)(result, 'privacy.getSettings');
117
- return parsePrivacySettings(result);
140
+ return (0, privacy_1.parsePrivacySettings)(result);
118
141
  },
119
142
  setPrivacySetting: async (setting, value) => {
120
- const category = privacy_1.WA_PRIVACY_SETTING_TO_CATEGORY[setting];
121
- const node = (0, privacy_2.buildSetPrivacyCategoryIq)(category, value);
143
+ const category = privacy_2.WA_PRIVACY_SETTING_TO_CATEGORY[setting];
144
+ const node = (0, privacy_3.buildSetPrivacyCategoryIq)(category, value);
122
145
  const result = await queryWithContext('privacy.setSetting', node, undefined, {
123
146
  category,
124
147
  value
125
148
  });
126
149
  (0, query_1.assertIqResult)(result, 'privacy.setSetting');
150
+ return (0, privacy_1.parsePrivacyCategoryDhash)(result, category);
127
151
  },
128
- getDisallowedList: async (setting) => {
129
- const category = privacy_1.WA_PRIVACY_SETTING_TO_CATEGORY[setting];
130
- const node = (0, privacy_2.buildGetPrivacyDisallowedListIq)(category);
131
- const result = await queryWithContext('privacy.getDisallowedList', node, undefined, {
132
- category
133
- });
134
- (0, query_1.assertIqResult)(result, 'privacy.getDisallowedList');
135
- return parseDisallowedList(result);
152
+ getDisallowedList: async (setting) => readDisallowedList(privacy_2.WA_PRIVACY_SETTING_TO_CATEGORY[setting]),
153
+ setDisallowedList: async (setting, input) => {
154
+ const category = privacy_2.WA_PRIVACY_SETTING_TO_CATEGORY[setting];
155
+ const entries = await resolveDisallowedListEntries(options, input);
156
+ const current = await readDisallowedList(category);
157
+ let result = await writeDisallowedList(category, entries, current.dhash ?? null);
158
+ if (isStaleDhashResult(result)) {
159
+ const refreshed = await readDisallowedList(category);
160
+ result = await writeDisallowedList(category, entries, refreshed.dhash ?? null);
161
+ }
162
+ (0, query_1.assertIqResult)(result, 'privacy.setDisallowedList');
163
+ return (0, privacy_1.parsePrivacyCategoryDhash)(result, category);
164
+ },
165
+ refreshFromAccountSync: () => dedupedRefresh(),
166
+ scheduleAccountSyncRefresh: () => {
167
+ refreshStopped = false;
168
+ if (refreshTimer !== null) {
169
+ clearTimeout(refreshTimer);
170
+ }
171
+ refreshTimer = setTimeout(() => {
172
+ refreshTimer = null;
173
+ void followUpRefresh().catch((error) => {
174
+ options.logger.warn('account_sync privacy refresh failed', {
175
+ message: (0, primitives_1.toError)(error).message
176
+ });
177
+ });
178
+ }, defaults_1.WA_DEFAULTS.PRIVACY_ACCOUNT_SYNC_DEBOUNCE_MS);
179
+ refreshTimer.unref?.();
180
+ },
181
+ stopAccountSyncRefresh: () => {
182
+ refreshStopped = true;
183
+ if (refreshTimer !== null) {
184
+ clearTimeout(refreshTimer);
185
+ refreshTimer = null;
186
+ }
136
187
  },
137
188
  getBlocklist: async () => {
138
- const node = (0, privacy_2.buildGetBlocklistIq)();
189
+ const node = (0, privacy_3.buildGetBlocklistIq)();
139
190
  const result = await queryWithContext('privacy.getBlocklist', node);
140
191
  (0, query_1.assertIqResult)(result, 'privacy.getBlocklist');
141
- return parseBlocklist(result);
192
+ return (0, privacy_1.parseBlocklist)(result);
142
193
  },
143
194
  blockUser: async (jid) => {
144
195
  const target = await resolveBlocklistTarget(options, jid);
145
- const node = (0, privacy_2.buildBlocklistBlockIq)(target);
196
+ const node = (0, privacy_3.buildBlocklistBlockIq)(target);
146
197
  const result = await queryWithContext('privacy.blockUser', node, undefined, {
147
198
  jid: target.lidJid ?? target.pnJid
148
199
  });
@@ -151,7 +202,7 @@ function createPrivacyCoordinator(options) {
151
202
  unblockUser: async (jid) => {
152
203
  const target = await resolveBlocklistTarget(options, jid);
153
204
  const unblockJid = target.lidJid ?? target.pnJid;
154
- const node = (0, privacy_2.buildBlocklistUnblockIq)(unblockJid);
205
+ const node = (0, privacy_3.buildBlocklistUnblockIq)(unblockJid);
155
206
  const result = await queryWithContext('privacy.unblockUser', node, undefined, {
156
207
  jid: unblockJid
157
208
  });
@@ -1,3 +1,4 @@
1
+ import type { WaAbPropName } from '../../abprops-spec';
1
2
  import type { WaAuthCredentials } from '../../auth/types';
2
3
  import type { WaIncomingMessageEvent } from '../types';
3
4
  import type { Logger } from '../../infra/log/types';
@@ -48,6 +49,11 @@ interface WaRetryCoordinatorOptions {
48
49
  * or privacy-gated recipients nack them with error 463.
49
50
  */
50
51
  readonly resolvePrivacyTokenNode?: (recipientJid: string) => Promise<BinaryNode | null>;
52
+ /**
53
+ * Server-synced AB props. Only `placeholder_message_resend_maximum_days_limit`
54
+ * is read; falls back to the spec default when unwired.
55
+ */
56
+ readonly getAbPropNumber?: (name: WaAbPropName) => number;
51
57
  }
52
58
  export declare class WaRetryCoordinator {
53
59
  private readonly deps;
@@ -88,6 +94,22 @@ export declare class WaRetryCoordinator {
88
94
  private getRetrySessionBaseKey;
89
95
  private cleanupRetrySessionBaseKeys;
90
96
  private isSenderFromOwnAccount;
97
+ /**
98
+ * Queues a `PLACEHOLDER_MESSAGE_RESEND` peer request for a message the server
99
+ * delivered as `<unavailable/>` (no `<enc>` to decrypt, so no retry receipt).
100
+ * Returns `false` when the placeholder is unrecoverable, too old, or this
101
+ * session is the primary. The payload surfaces later as a `message` event.
102
+ */
103
+ onUnavailableMessage(context: WaRetryDecryptFailureContext): boolean;
104
+ private getPlaceholderResendMaxAgeSeconds;
105
+ /**
106
+ * Builds the message key the primary indexes the message under: the chat is
107
+ * the `from`, or the `recipient` for a self-sent 1:1, always without the
108
+ * `:device` segment. The participant rides along only for an incoming
109
+ * group/broadcast message. Asking with the raw device-addressed `from`
110
+ * matches nothing and the resend comes back empty.
111
+ */
112
+ private buildPlaceholderResendKey;
91
113
  private enqueuePlaceholderResend;
92
114
  private flushPlaceholderBatch;
93
115
  }
@@ -14,7 +14,9 @@ const replay_1 = require("../../retry/replay");
14
14
  const keygen_1 = require("../../signal/registration/keygen");
15
15
  const global_1 = require("../../transport/node/builders/global");
16
16
  const retry_1 = require("../../transport/node/builders/retry");
17
+ const helpers_1 = require("../../transport/node/helpers");
17
18
  const bytes_1 = require("../../util/bytes");
19
+ const coercion_1 = require("../../util/coercion");
18
20
  const collections_1 = require("../../util/collections");
19
21
  const primitives_1 = require("../../util/primitives");
20
22
  const RETRY_CLEANUP_INTERVAL_MS = 30000;
@@ -26,8 +28,10 @@ const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
26
28
  const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
27
29
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
28
30
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
29
- const PLACEHOLDER_RESEND_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
31
+ const PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS = 14;
30
32
  const PLACEHOLDER_RESEND_IN_FLIGHT_MAX = 256;
33
+ const PLACEHOLDER_RESEND_TIMEOUT_MS = 120000;
34
+ const DAY_SECONDS = 24 * 60 * 60;
31
35
  const PLACEHOLDER_RESEND_SKIP_SUBTYPES = new Set([
32
36
  'bot_unavailable_fanout',
33
37
  'hosted_unavailable_fanout',
@@ -50,6 +54,30 @@ function getRemoteRetryReasonLogFields(reason) {
50
54
  remoteRetryReasonName: getRetryReasonName(reason) ?? 'missing_in_retry_receipt'
51
55
  };
52
56
  }
57
+ /**
58
+ * Returns the placeholder subtype the primary never resends (bot fanout, hosted
59
+ * account, consumed view-once), or `null` when the message is recoverable.
60
+ */
61
+ function resolvePlaceholderResendSkipSubtype(node) {
62
+ const subtype = node.attrs.subtype;
63
+ if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
64
+ return subtype;
65
+ }
66
+ const unavailable = (0, helpers_1.findNodeChild)(node, 'unavailable');
67
+ if (!unavailable) {
68
+ return null;
69
+ }
70
+ if ((0, helpers_1.findNodeChild)(node, 'bot')) {
71
+ return 'bot_unavailable_fanout';
72
+ }
73
+ if (unavailable.attrs.hosted === 'true') {
74
+ return 'hosted_unavailable_fanout';
75
+ }
76
+ if (unavailable.attrs.type === 'view_once') {
77
+ return 'view_once_unavailable_fanout';
78
+ }
79
+ return null;
80
+ }
53
81
  class WaRetryCoordinator {
54
82
  constructor(options) {
55
83
  this.nextRetryCleanupAtMs = 0;
@@ -768,6 +796,42 @@ class WaRetryCoordinator {
768
796
  const meJid = credentials?.meJid;
769
797
  return !!meJid && (0, jid_1.parseSignalAddressFromJid)(meJid).user === senderUser;
770
798
  }
799
+ /**
800
+ * Queues a `PLACEHOLDER_MESSAGE_RESEND` peer request for a message the server
801
+ * delivered as `<unavailable/>` (no `<enc>` to decrypt, so no retry receipt).
802
+ * Returns `false` when the placeholder is unrecoverable, too old, or this
803
+ * session is the primary. The payload surfaces later as a `message` event.
804
+ */
805
+ onUnavailableMessage(context) {
806
+ return this.enqueuePlaceholderResend(context);
807
+ }
808
+ getPlaceholderResendMaxAgeSeconds() {
809
+ const days = (0, coercion_1.tryAsNumber)(this.deps.getAbPropNumber?.('placeholder_message_resend_maximum_days_limit'));
810
+ return ((days !== null && days > 0 ? days : PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS) *
811
+ DAY_SECONDS);
812
+ }
813
+ /**
814
+ * Builds the message key the primary indexes the message under: the chat is
815
+ * the `from`, or the `recipient` for a self-sent 1:1, always without the
816
+ * `:device` segment. The participant rides along only for an incoming
817
+ * group/broadcast message. Asking with the raw device-addressed `from`
818
+ * matches nothing and the resend comes back empty.
819
+ */
820
+ buildPlaceholderResendKey(context) {
821
+ const fromMe = this.isSenderFromOwnAccount(context);
822
+ const isGroupOrBroadcast = (0, jid_1.isGroupOrBroadcastJid)(context.from);
823
+ const chatJid = fromMe && !isGroupOrBroadcast
824
+ ? (context.messageNode.attrs.recipient ?? context.from)
825
+ : context.from;
826
+ return {
827
+ remoteJid: (0, jid_1.toUserJid)(chatJid),
828
+ id: context.stanzaId,
829
+ fromMe,
830
+ ...(!fromMe && isGroupOrBroadcast && context.participant
831
+ ? { participant: (0, jid_1.toUserJid)(context.participant) }
832
+ : {})
833
+ };
834
+ }
771
835
  enqueuePlaceholderResend(context) {
772
836
  if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
773
837
  return false;
@@ -775,14 +839,18 @@ class WaRetryCoordinator {
775
839
  if (this.deps.isMobilePrimary?.()) {
776
840
  return false;
777
841
  }
778
- const subtype = context.messageNode.attrs.subtype;
779
- if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
842
+ const skipSubtype = resolvePlaceholderResendSkipSubtype(context.messageNode);
843
+ if (skipSubtype !== null) {
844
+ this.deps.logger.trace('placeholder resend skipped: unrecoverable placeholder', {
845
+ id: context.stanzaId,
846
+ subtype: skipSubtype
847
+ });
780
848
  return false;
781
849
  }
782
850
  const timestampSeconds = context.t ? Number(context.t) : Date.now() / 1000;
783
851
  if (Number.isFinite(timestampSeconds)) {
784
852
  const ageSeconds = Date.now() / 1000 - timestampSeconds;
785
- if (ageSeconds > PLACEHOLDER_RESEND_MAX_AGE_SECONDS) {
853
+ if (ageSeconds > this.getPlaceholderResendMaxAgeSeconds()) {
786
854
  return false;
787
855
  }
788
856
  }
@@ -797,12 +865,7 @@ class WaRetryCoordinator {
797
865
  return false;
798
866
  }
799
867
  this.placeholderInFlight.add(context.stanzaId);
800
- this.placeholderQueue.push({
801
- remoteJid: context.from,
802
- id: context.stanzaId,
803
- fromMe: this.isSenderFromOwnAccount(context),
804
- participant: context.participant
805
- });
868
+ this.placeholderQueue.push(this.buildPlaceholderResendKey(context));
806
869
  if (this.placeholderTimer === null) {
807
870
  this.placeholderTimer = setTimeout(() => {
808
871
  this.placeholderTimer = null;
@@ -831,7 +894,7 @@ class WaRetryCoordinator {
831
894
  participant: item.participant
832
895
  }
833
896
  }))
834
- });
897
+ }, { timeoutMs: PLACEHOLDER_RESEND_TIMEOUT_MS });
835
898
  const meJid = this.deps.getCurrentCredentials()?.meJid;
836
899
  for (const result of results) {
837
900
  const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
@@ -1,4 +1,5 @@
1
1
  import type { WaAuthCredentials } from '../../auth/types';
2
+ import { type WaBlocklistResult } from '../events/privacy';
2
3
  import type { Logger } from '../../infra/log/types';
3
4
  import type { BinaryNode } from '../../transport/types';
4
5
  export interface WaDirtyBit {
@@ -14,6 +15,8 @@ interface WaDirtySyncRuntime {
14
15
  readonly getCurrentCredentials: () => WaAuthCredentials | null;
15
16
  readonly syncAppState: () => Promise<void>;
16
17
  readonly generateUsyncSid: () => Promise<string>;
18
+ readonly syncAccountPrivacy: () => Promise<void>;
19
+ readonly emitBlocklist: (blocklist: WaBlocklistResult) => void;
17
20
  readonly newsletterListSubscribed?: () => Promise<unknown>;
18
21
  }
19
22
  export declare function parseDirtyBits(nodes: readonly BinaryNode[], logger: Logger): readonly WaDirtyBit[];