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
@@ -6,17 +6,69 @@ import { applyContextInfo, resolveSendContextInfo } from '../../message/context-
6
6
  import { injectDeviceListMetadata, resolveIcdcMeta } from '../../message/crypto/icdc.js';
7
7
  import { computePhashV2 } from '../../message/crypto/phash.js';
8
8
  import { buildReportingTokenArtifacts } from '../../message/crypto/reporting-token.js';
9
- import { isSendAddonCryptoMessage, isSendMediaMessage, isSendTextMessage, needsSecretPersistence, resolveButtonAddonKind, resolveDecryptFailAttr, resolveEditAttr, resolveEncMediaType, resolveMessageTypeAttr, resolveMetaAttrs, wrapAsViewOnce } from '../../message/encode/content.js';
9
+ import { isSendAddonCryptoMessage, isSendMediaMessage, isSendTextMessage, needsSecretPersistence, resolveButtonAddonKind, resolveDecryptFailAttr, resolveEditAttr, resolveEncMediaType, resolveMessageTypeAttr, resolveMetaAttrs, unwrapMessage, wrapAsViewOnce } from '../../message/encode/content.js';
10
10
  import { wrapDeviceSentMessage } from '../../message/encode/device-sent.js';
11
11
  import { writeRandomPadMax16 } from '../../message/encode/padding.js';
12
12
  import { buildBotInvokeProtoCopy, extractInvokedBotJid, genBotMsgSecret } from '../../message/kinds/bot.js';
13
13
  import { proto } from '../../proto.js';
14
- import { WA_ADDRESSING_MODES, WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
14
+ import { normalizeEphemeralSettingSeconds, WA_ADDRESSING_MODES, WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
15
15
  import { canonicalizeOwnAccountJid, isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, isUserJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
16
16
  import { encodeBinaryNode } from '../../transport/binary/index.js';
17
17
  import { buildButtonAddonNode, buildDirectMessageFanoutNode, buildGroupSenderKeyMessageNode, buildMetaNode } from '../../transport/node/builders/message.js';
18
18
  import { bytesToHex, TEXT_ENCODER } from '../../util/bytes.js';
19
19
  import { toError } from '../../util/primitives.js';
20
+ /**
21
+ * Members a group-history bundle is addressed to. Empty for every other
22
+ * message, which is what keeps the restricted fanout scoped to bundles.
23
+ */
24
+ function resolveGroupHistoryReceivers(message) {
25
+ const metadata = unwrapMessage(message).messageHistoryBundle?.messageHistoryMetadata;
26
+ return metadata?.historyReceivers ?? [];
27
+ }
28
+ /**
29
+ * Narrows a group's participant list to the members a group-history bundle is
30
+ * addressed to, plus the sender so its own devices keep a copy. Receivers are
31
+ * intersected with the live participant list, so a stale receiver entry cannot
32
+ * pull a non-member into the fanout. Any other message keeps the full list.
33
+ *
34
+ * Receivers must be written in the group's addressing mode - a PN entry for a
35
+ * LID-addressed group does not match and is reported through `onDropped`.
36
+ *
37
+ * @throws when no receiver matches a current member. Sending to the sender's
38
+ * own devices alone counts as no match: it would look like a delivered share
39
+ * while nobody received anything.
40
+ */
41
+ export function restrictGroupHistoryTargets(message, participantUserJids, senderJid, onDropped) {
42
+ const receivers = resolveGroupHistoryReceivers(message);
43
+ if (receivers.length === 0) {
44
+ return participantUserJids;
45
+ }
46
+ const pending = new Set();
47
+ for (let index = 0; index < receivers.length; index += 1) {
48
+ pending.add(toUserJid(receivers[index]));
49
+ }
50
+ const senderUserJid = toUserJid(senderJid);
51
+ const targets = [];
52
+ let matchedReceivers = 0;
53
+ for (let index = 0; index < participantUserJids.length; index += 1) {
54
+ const participant = participantUserJids[index];
55
+ const participantUserJid = toUserJid(participant);
56
+ if (pending.delete(participantUserJid)) {
57
+ matchedReceivers += 1;
58
+ targets[targets.length] = participant;
59
+ }
60
+ else if (participantUserJid === senderUserJid) {
61
+ targets[targets.length] = participant;
62
+ }
63
+ }
64
+ if (pending.size > 0 && onDropped) {
65
+ onDropped([...pending], receivers.length);
66
+ }
67
+ if (matchedReceivers === 0) {
68
+ throw new Error('group history bundle resolved no receivers in the group');
69
+ }
70
+ return targets;
71
+ }
20
72
  export class WaMessageDispatchCoordinator {
21
73
  constructor(options) {
22
74
  this.icdcDedup = new PromiseDedup();
@@ -26,6 +78,82 @@ export class WaMessageDispatchCoordinator {
26
78
  this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? (() => false);
27
79
  this.serverClock = options.serverClock;
28
80
  }
81
+ /**
82
+ * Per-chat disappearing settings from the cache, falling back to the
83
+ * durable thread record on a cold miss and warming the cache with it. A
84
+ * chat with no disappearing mode is cached as expiration `0` rather than
85
+ * left absent, so the archive is not re-read on every send to it; the
86
+ * incoming writers overwrite that entry the moment the chat turns it on.
87
+ * A failing archive degrades the send to "no ephemeral fields" instead of
88
+ * rejecting it - the store is opt-in persistence, not a send dependency.
89
+ */
90
+ async resolveChatMetadata(chatJid) {
91
+ try {
92
+ const cached = await this.deps.chatMetadataStore.getChatMetadata(chatJid);
93
+ if (cached) {
94
+ return cached;
95
+ }
96
+ const thread = await this.deps.threadStore.getByJid(chatJid);
97
+ const expiration = thread?.ephemeralExpiration ?? 0;
98
+ const settingTimestamp = expiration > 0 ? thread?.ephemeralSettingTimestamp : undefined;
99
+ const snapshot = {
100
+ chatJid,
101
+ ephemeralExpiration: expiration,
102
+ ...(settingTimestamp !== undefined
103
+ ? { ephemeralSettingTimestamp: settingTimestamp }
104
+ : {}),
105
+ updatedAtMs: Date.now()
106
+ };
107
+ await this.deps.chatMetadataStore.upsertChatMetadata(snapshot);
108
+ return snapshot;
109
+ }
110
+ catch (error) {
111
+ this.deps.logger.debug('chat metadata lookup failed, sending without ephemeral fields', {
112
+ jid: chatJid,
113
+ message: toError(error).message
114
+ });
115
+ return null;
116
+ }
117
+ }
118
+ /**
119
+ * Disappearing-message fields for a send into a chat with the mode on, or
120
+ * `{}` when nothing resolves. Both chat kinds report initiator
121
+ * `CHANGED_IN_CHAT` when nothing more specific is stored; the trigger comes
122
+ * from the group metadata for a group and defaults to `CHAT_SETTING` for
123
+ * 1:1, and is omitted when a group reports none.
124
+ */
125
+ async resolveChatEphemeral(recipientJid) {
126
+ if (isGroupJid(recipientJid)) {
127
+ const settings = await this.deps.groupMetadataCache.resolveEphemeralSettings(recipientJid);
128
+ if (!settings) {
129
+ return {};
130
+ }
131
+ return {
132
+ expirationSeconds: settings.expirationSeconds,
133
+ disappearingModeInitiator: proto.DisappearingMode.Initiator.CHANGED_IN_CHAT,
134
+ ...(settings.trigger !== undefined
135
+ ? {
136
+ disappearingModeTrigger: settings.trigger
137
+ }
138
+ : {})
139
+ };
140
+ }
141
+ const chat = await this.resolveChatMetadata(recipientJid);
142
+ const expiration = chat?.ephemeralExpiration;
143
+ if (expiration === undefined || expiration <= 0) {
144
+ return {};
145
+ }
146
+ const stored = chat?.ephemeralSettingTimestamp;
147
+ const settingTimestamp = stored !== undefined ? normalizeEphemeralSettingSeconds(stored) : undefined;
148
+ return {
149
+ expirationSeconds: expiration,
150
+ disappearingModeInitiator: proto.DisappearingMode.Initiator.CHANGED_IN_CHAT,
151
+ disappearingModeTrigger: proto.DisappearingMode.Trigger.CHAT_SETTING,
152
+ ...(settingTimestamp !== undefined
153
+ ? { ephemeralSettingTimestamp: settingTimestamp }
154
+ : {})
155
+ };
156
+ }
29
157
  async publishMessageNode(node, options = {}) {
30
158
  this.deps.logger.trace('wa client publish message node', {
31
159
  tag: node.tag,
@@ -146,17 +274,34 @@ export class WaMessageDispatchCoordinator {
146
274
  this.withResolvedMessageId(options)
147
275
  ]);
148
276
  }
277
+ const isGroupRecipient = isGroupJid(recipientJid);
278
+ const directRecipientJid = isGroupRecipient
279
+ ? recipientJid
280
+ : await this.resolveDirectRecipientLid(toUserJid(recipientJid));
149
281
  let optionsCtx = options.contextInfo;
150
282
  if (options.expirationSeconds !== undefined) {
151
283
  optionsCtx = { ...optionsCtx, expirationSeconds: options.expirationSeconds };
152
284
  }
153
- if (isGroupJid(recipientJid) &&
154
- optionsCtx?.expirationSeconds === undefined &&
155
- !options.disableGroupEphemeralAutoInject) {
156
- const cachedEphemeral = await this.deps.groupMetadataCache.resolveEphemeral(recipientJid);
157
- if (cachedEphemeral !== null && cachedEphemeral > 0) {
158
- optionsCtx = { ...optionsCtx, expirationSeconds: cachedEphemeral };
159
- }
285
+ if (options.ephemeralSettingTimestamp !== undefined) {
286
+ optionsCtx = {
287
+ ...optionsCtx,
288
+ ephemeralSettingTimestamp: options.ephemeralSettingTimestamp
289
+ };
290
+ }
291
+ if (options.disappearingModeTrigger !== undefined) {
292
+ optionsCtx = {
293
+ ...optionsCtx,
294
+ disappearingModeTrigger: options.disappearingModeTrigger
295
+ };
296
+ }
297
+ const autoInjectDisabled = isGroupRecipient
298
+ ? options.disableGroupEphemeralAutoInject
299
+ : options.disableDirectEphemeralAutoInject;
300
+ if (optionsCtx?.expirationSeconds === undefined && !autoInjectDisabled) {
301
+ optionsCtx = {
302
+ ...(await this.resolveChatEphemeral(directRecipientJid)),
303
+ ...optionsCtx
304
+ };
160
305
  }
161
306
  const ctx = resolveSendContextInfo({
162
307
  contentLevel: pickContentContextInfo(content),
@@ -282,9 +427,6 @@ export class WaMessageDispatchCoordinator {
282
427
  customNodes: customNodes.length > 0 ? customNodes : undefined,
283
428
  sendOptions
284
429
  };
285
- const directRecipientJid = isGroup
286
- ? recipientJid
287
- : await this.resolveDirectRecipientLid(toUserJid(recipientJid));
288
430
  const peerRecipientPn = isGroup
289
431
  ? undefined
290
432
  : await this.resolvePeerRecipientPn(toUserJid(recipientJid), directRecipientJid);
@@ -547,9 +689,59 @@ export class WaMessageDispatchCoordinator {
547
689
  async mutateGroupMetadataCacheFromGroupEvent(event) {
548
690
  await this.deps.groupMetadataCache.mutateFromGroupEvent(event);
549
691
  }
550
- // noop for now
692
+ /**
693
+ * Splits a group's members into the ones a history bundle is being shared
694
+ * with and the rest, normalizing everything to the group's addressing mode
695
+ * so the receiver list written into the proto matches what the fanout can
696
+ * actually resolve. This account is excluded from both lists.
697
+ *
698
+ * Requested JIDs that are not current members come back in `unknownJids`
699
+ * instead of being silently dropped - sharing history with a non-member is
700
+ * a caller mistake worth surfacing.
701
+ */
702
+ async resolveGroupHistoryAudience(groupJid, requestedJids) {
703
+ const participants = await this.deps.groupMetadataCache.resolveParticipantUsers(groupJid);
704
+ const meJid = this.requireCurrentMeJid('shareGroupHistory');
705
+ const addressingMode = this.resolveGroupAddressingMode(participants, groupJid);
706
+ const meUserJid = toUserJid(this.resolveSenderForAddressingMode(addressingMode, meJid));
707
+ const pending = new Set();
708
+ for (let index = 0; index < requestedJids.length; index += 1) {
709
+ pending.add(toUserJid(normalizeRecipientJid(requestedJids[index])));
710
+ }
711
+ const historyReceivers = [];
712
+ const nonHistoryReceivers = [];
713
+ for (let index = 0; index < participants.length; index += 1) {
714
+ const participantUserJid = toUserJid(participants[index]);
715
+ if (participantUserJid === meUserJid) {
716
+ continue;
717
+ }
718
+ if (pending.delete(participantUserJid)) {
719
+ historyReceivers[historyReceivers.length] = participantUserJid;
720
+ }
721
+ else {
722
+ nonHistoryReceivers[nonHistoryReceivers.length] = participantUserJid;
723
+ }
724
+ }
725
+ const requestedSelf = pending.delete(meUserJid);
726
+ const unknownJids = [];
727
+ for (const jid of pending) {
728
+ unknownJids[unknownJids.length] = jid;
729
+ }
730
+ return {
731
+ historyReceivers,
732
+ nonHistoryReceivers,
733
+ unknownJids,
734
+ requestedSelf,
735
+ addressingMode
736
+ };
737
+ }
738
+ /**
739
+ * A group-history bundle goes to the members named in `historyReceivers`
740
+ * rather than to the whole group, so it takes the per-device direct fanout
741
+ * instead of the group sender key.
742
+ */
551
743
  shouldUseGroupDirectPath(message) {
552
- return false;
744
+ return resolveGroupHistoryReceivers(message).length > 0;
553
745
  }
554
746
  async publishGroupDirectMessage(groupJid, envelope, retryContext = {}) {
555
747
  const { message, plaintext, type, edit, mediatype, sendOptions } = envelope;
@@ -559,8 +751,16 @@ export class WaMessageDispatchCoordinator {
559
751
  : await this.deps.groupMetadataCache.resolveParticipantUsers(groupJid);
560
752
  const addressingMode = retryContext.forceAddressingMode ??
561
753
  this.resolveGroupAddressingMode(participantUserJids, groupJid);
562
- const senderForPhash = this.resolveSenderForAddressingMode(addressingMode, meJid);
563
- const fanoutDeviceJids = await this.deps.fanoutResolver.resolveGroupParticipantDeviceJids(participantUserJids);
754
+ const senderJid = this.resolveSenderForAddressingMode(addressingMode, meJid);
755
+ const targetUserJids = restrictGroupHistoryTargets(message, participantUserJids, senderJid, (droppedJids, totalExpected) => {
756
+ this.deps.logger.warn('group history receivers missing from the participant list', {
757
+ groupJid,
758
+ droppedCount: droppedJids.length,
759
+ totalExpected,
760
+ sample: droppedJids.slice(0, 3)
761
+ });
762
+ });
763
+ const fanoutDeviceJids = await this.deps.fanoutResolver.resolveGroupParticipantDeviceJids(targetUserJids);
564
764
  if (fanoutDeviceJids.length === 0) {
565
765
  throw new Error('group direct send resolved no target devices');
566
766
  }
@@ -615,22 +815,10 @@ export class WaMessageDispatchCoordinator {
615
815
  break;
616
816
  }
617
817
  }
618
- const phashTargets = new Array(resolvedFanoutTargets.length + 1);
619
- let phashTargetCount = 0;
620
- for (let index = 0; index < resolvedFanoutTargets.length; index += 1) {
621
- const candidate = resolvedFanoutTargets[index].jid;
622
- if (isHostedDeviceJid(candidate))
623
- continue;
624
- phashTargets[phashTargetCount] = candidate;
625
- phashTargetCount += 1;
626
- }
627
- phashTargets[phashTargetCount] = senderForPhash;
628
- phashTargets.length = phashTargetCount + 1;
629
- const localPhash = computePhashV2(phashTargets);
630
818
  const reportingArtifacts = await this.tryBuildReportingTokenArtifacts({
631
819
  message,
632
820
  stanzaId: sendOptions.id,
633
- senderUserJid: toUserJid(senderForPhash),
821
+ senderUserJid: toUserJid(senderJid),
634
822
  remoteJid: groupJid,
635
823
  context: 'group_direct'
636
824
  });
@@ -642,7 +830,6 @@ export class WaMessageDispatchCoordinator {
642
830
  type,
643
831
  id: sendOptions.id,
644
832
  edit,
645
- phash: localPhash,
646
833
  addressingMode,
647
834
  participants,
648
835
  deviceIdentity: shouldAttachDeviceIdentity
@@ -668,7 +855,7 @@ export class WaMessageDispatchCoordinator {
668
855
  const ackError = result.ack.error;
669
856
  const serverPhash = result.ack.phash;
670
857
  const serverAddressingMode = result.ack.addressingMode;
671
- const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
858
+ const hasPhashMismatch = !!serverPhash;
672
859
  const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
673
860
  const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
674
861
  if (!retryContext.retried &&
@@ -676,7 +863,6 @@ export class WaMessageDispatchCoordinator {
676
863
  this.deps.logger.warn('group direct publish acknowledged with mismatch metadata', {
677
864
  id: result.id,
678
865
  groupJid,
679
- localPhash,
680
866
  serverPhash,
681
867
  localAddressingMode: addressingMode,
682
868
  serverAddressingMode,
@@ -1,91 +1,12 @@
1
+ import { parseBlocklist, parseDisallowedList, parseDisallowedListUpdate, parsePrivacyCategoryDhash, parsePrivacySettings } from '../events/privacy.js';
2
+ import { PromiseDedup } from '../../infra/perf/PromiseDedup.js';
3
+ import { WA_DEFAULTS } from '../../protocol/defaults.js';
1
4
  import { isLidJid, isUserJid, normalizeRecipientJid } from '../../protocol/jid.js';
2
- import { WA_NODE_TAGS } from '../../protocol/nodes.js';
3
- import { WA_PRIVACY_CATEGORY_TO_SETTING, WA_PRIVACY_SETTING_TO_CATEGORY, WA_PRIVACY_TAGS, WA_PRIVACY_VALUES } from '../../protocol/privacy.js';
4
- import { buildBlocklistBlockIq, buildBlocklistUnblockIq, buildGetBlocklistIq, buildGetPrivacyDisallowedListIq, buildGetPrivacySettingsIq, buildSetPrivacyCategoryIq } from '../../transport/node/builders/privacy.js';
5
- import { findNodeChild, getNodeChildren, getNodeChildrenByTag } from '../../transport/node/helpers.js';
6
- import { assertIqResult } from '../../transport/node/query.js';
7
- const IGNORED_SERVER_CATEGORIES = new Set([
8
- 'pix',
9
- 'linked_profiles',
10
- 'stickers',
11
- 'dependentaccountmessages',
12
- 'cover_photo',
13
- 'dependent_account_calling',
14
- 'groupcreation'
15
- ]);
16
- const VALID_PRIVACY_VALUES = new Set(Object.values(WA_PRIVACY_VALUES));
17
- function isValidPrivacyValue(value) {
18
- return value !== WA_PRIVACY_VALUES.ERROR && VALID_PRIVACY_VALUES.has(value);
19
- }
20
- function parsePrivacySettings(result) {
21
- const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
22
- if (!privacyNode) {
23
- return {};
24
- }
25
- const settings = {};
26
- const categories = getNodeChildrenByTag(privacyNode, WA_PRIVACY_TAGS.CATEGORY);
27
- for (let i = 0; i < categories.length; i += 1) {
28
- const node = categories[i];
29
- const name = node.attrs.name;
30
- const value = node.attrs.value;
31
- if (!name || !value) {
32
- continue;
33
- }
34
- if (IGNORED_SERVER_CATEGORIES.has(name)) {
35
- continue;
36
- }
37
- if (!isValidPrivacyValue(value)) {
38
- continue;
39
- }
40
- const settingName = WA_PRIVACY_CATEGORY_TO_SETTING[name];
41
- if (settingName) {
42
- settings[settingName] = value;
43
- }
44
- }
45
- return settings;
46
- }
47
- function parseDisallowedList(result) {
48
- const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
49
- if (!privacyNode) {
50
- return { jids: [] };
51
- }
52
- const listNode = findNodeChild(privacyNode, WA_PRIVACY_TAGS.LIST);
53
- if (!listNode) {
54
- return { jids: [] };
55
- }
56
- const dhash = listNode.attrs.dhash;
57
- const userNodes = getNodeChildrenByTag(listNode, WA_PRIVACY_TAGS.USER);
58
- const jids = new Array(userNodes.length);
59
- let jidsCount = 0;
60
- for (let i = 0; i < userNodes.length; i += 1) {
61
- const jid = userNodes[i].attrs.jid;
62
- if (jid) {
63
- jids[jidsCount] = jid;
64
- jidsCount += 1;
65
- }
66
- }
67
- jids.length = jidsCount;
68
- return { jids, dhash };
69
- }
70
- function parseBlocklist(result) {
71
- const listNode = findNodeChild(result, WA_NODE_TAGS.LIST);
72
- if (!listNode) {
73
- return { jids: [] };
74
- }
75
- const dhash = listNode.attrs.dhash;
76
- const itemNodes = getNodeChildren(listNode);
77
- const jids = new Array(itemNodes.length);
78
- let jidsCount = 0;
79
- for (let i = 0; i < itemNodes.length; i += 1) {
80
- const jid = itemNodes[i].attrs.jid;
81
- if (jid) {
82
- jids[jidsCount] = jid;
83
- jidsCount += 1;
84
- }
85
- }
86
- jids.length = jidsCount;
87
- return { jids, dhash };
88
- }
5
+ import { WA_IQ_TYPES, WA_NODE_TAGS } from '../../protocol/nodes.js';
6
+ import { WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS, WA_PRIVACY_LIST_ACTIONS, WA_PRIVACY_SETTING_TO_CATEGORY } from '../../protocol/privacy.js';
7
+ import { buildBlocklistBlockIq, buildBlocklistUnblockIq, buildGetBlocklistIq, buildGetPrivacyDisallowedListIq, buildGetPrivacySettingsIq, buildSetPrivacyCategoryIq, buildSetPrivacyDisallowedListIq } from '../../transport/node/builders/privacy.js';
8
+ import { assertIqResult, parseIqError } from '../../transport/node/query.js';
9
+ import { toError } from '../../util/primitives.js';
89
10
  /**
90
11
  * Resolves a blocklist input into both addressing forms via
91
12
  * `resolveUserJidPair` (device-list cache first, usync fallback for phone
@@ -103,9 +24,111 @@ async function resolveBlocklistTarget(options, jid) {
103
24
  }
104
25
  return { lidJid: null, pnJid: pair.pnJid ?? normalized };
105
26
  }
27
+ const ACCOUNT_SYNC_REFRESH_KEY = 'account-sync';
28
+ /**
29
+ * A `409` on a disallowed-list write means the `dhash` we sent no longer
30
+ * matches the server's: another device changed the list in between.
31
+ */
32
+ function isStaleDhashResult(node) {
33
+ if (node.tag !== WA_NODE_TAGS.IQ || node.attrs.type === WA_IQ_TYPES.RESULT) {
34
+ return false;
35
+ }
36
+ const error = parseIqError(node);
37
+ return error.numericCode === 409 || error.code === '409';
38
+ }
39
+ /**
40
+ * Resolves a disallowed-list input into `<user>` entries. Unlike the blocklist
41
+ * the server takes both actions in one stanza, so add/remove are resolved
42
+ * together and keep their relative order.
43
+ */
44
+ async function resolveDisallowedListEntries(options, input) {
45
+ const entries = [];
46
+ const actions = [
47
+ { action: WA_PRIVACY_LIST_ACTIONS.ADD, jids: input.add ?? [] },
48
+ { action: WA_PRIVACY_LIST_ACTIONS.REMOVE, jids: input.remove ?? [] }
49
+ ];
50
+ for (const { action, jids } of actions) {
51
+ for (const jid of jids) {
52
+ const target = await resolveBlocklistTarget(options, jid);
53
+ entries.push({ action, lidJid: target.lidJid, pnJid: target.pnJid });
54
+ }
55
+ }
56
+ if (entries.length === 0) {
57
+ throw new Error('setDisallowedList requires at least one add/remove entry');
58
+ }
59
+ return entries;
60
+ }
106
61
  /** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
107
62
  export function createPrivacyCoordinator(options) {
108
63
  const { queryWithContext } = options;
64
+ const usesLidAddressing = () => options.getSelfLid() !== null;
65
+ let refreshTimer = null;
66
+ let refreshStopped = false;
67
+ let activeRefresh = null;
68
+ const refreshDedup = new PromiseDedup();
69
+ const queryDisallowedList = async (category) => {
70
+ const node = buildGetPrivacyDisallowedListIq(category, usesLidAddressing());
71
+ const result = await queryWithContext('privacy.getDisallowedList', node, undefined, {
72
+ category
73
+ });
74
+ assertIqResult(result, 'privacy.getDisallowedList');
75
+ return result;
76
+ };
77
+ const readDisallowedList = async (category) => parseDisallowedList(await queryDisallowedList(category));
78
+ const runAccountSyncRefresh = async () => {
79
+ const settingsNode = await queryWithContext('privacy.getSettings', buildGetPrivacySettingsIq());
80
+ assertIqResult(settingsNode, 'privacy.getSettings');
81
+ const listNodes = await Promise.all(WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS.map(async (setting) => ({
82
+ setting,
83
+ node: await queryDisallowedList(WA_PRIVACY_SETTING_TO_CATEGORY[setting])
84
+ })));
85
+ const disallowedLists = [];
86
+ for (const { setting, node } of listNodes) {
87
+ const update = parseDisallowedListUpdate(node);
88
+ if (update) {
89
+ disallowedLists.push({ setting, ...update });
90
+ }
91
+ }
92
+ const result = {
93
+ settings: parsePrivacySettings(settingsNode),
94
+ disallowedLists
95
+ };
96
+ options.emitPrivacy(result);
97
+ return result;
98
+ };
99
+ /**
100
+ * Joins a refresh already in flight instead of racing it, so concurrent
101
+ * callers share one round of queries and one emit.
102
+ */
103
+ const dedupedRefresh = () => {
104
+ const running = refreshDedup.run(ACCOUNT_SYNC_REFRESH_KEY, runAccountSyncRefresh);
105
+ activeRefresh = running;
106
+ return running;
107
+ };
108
+ /**
109
+ * Refresh for a change the server just announced. It must not join a
110
+ * refresh already in flight: that one's queries went out before the
111
+ * change landed, so joining it would emit a snapshot without the new
112
+ * value and nothing would refetch. Waiting for it first guarantees a read
113
+ * issued after the notification.
114
+ */
115
+ const followUpRefresh = async () => {
116
+ if (refreshStopped) {
117
+ return;
118
+ }
119
+ await activeRefresh?.catch(() => undefined);
120
+ if (refreshStopped) {
121
+ return;
122
+ }
123
+ await dedupedRefresh();
124
+ };
125
+ const writeDisallowedList = async (category, entries, dhash) => {
126
+ const node = buildSetPrivacyDisallowedListIq(category, entries, dhash, usesLidAddressing());
127
+ return queryWithContext('privacy.setDisallowedList', node, undefined, {
128
+ category,
129
+ entries: entries.length
130
+ });
131
+ };
109
132
  return {
110
133
  getPrivacySettings: async () => {
111
134
  const node = buildGetPrivacySettingsIq();
@@ -121,15 +144,43 @@ export function createPrivacyCoordinator(options) {
121
144
  value
122
145
  });
123
146
  assertIqResult(result, 'privacy.setSetting');
147
+ return parsePrivacyCategoryDhash(result, category);
124
148
  },
125
- getDisallowedList: async (setting) => {
149
+ getDisallowedList: async (setting) => readDisallowedList(WA_PRIVACY_SETTING_TO_CATEGORY[setting]),
150
+ setDisallowedList: async (setting, input) => {
126
151
  const category = WA_PRIVACY_SETTING_TO_CATEGORY[setting];
127
- const node = buildGetPrivacyDisallowedListIq(category);
128
- const result = await queryWithContext('privacy.getDisallowedList', node, undefined, {
129
- category
130
- });
131
- assertIqResult(result, 'privacy.getDisallowedList');
132
- return parseDisallowedList(result);
152
+ const entries = await resolveDisallowedListEntries(options, input);
153
+ const current = await readDisallowedList(category);
154
+ let result = await writeDisallowedList(category, entries, current.dhash ?? null);
155
+ if (isStaleDhashResult(result)) {
156
+ const refreshed = await readDisallowedList(category);
157
+ result = await writeDisallowedList(category, entries, refreshed.dhash ?? null);
158
+ }
159
+ assertIqResult(result, 'privacy.setDisallowedList');
160
+ return parsePrivacyCategoryDhash(result, category);
161
+ },
162
+ refreshFromAccountSync: () => dedupedRefresh(),
163
+ scheduleAccountSyncRefresh: () => {
164
+ refreshStopped = false;
165
+ if (refreshTimer !== null) {
166
+ clearTimeout(refreshTimer);
167
+ }
168
+ refreshTimer = setTimeout(() => {
169
+ refreshTimer = null;
170
+ void followUpRefresh().catch((error) => {
171
+ options.logger.warn('account_sync privacy refresh failed', {
172
+ message: toError(error).message
173
+ });
174
+ });
175
+ }, WA_DEFAULTS.PRIVACY_ACCOUNT_SYNC_DEBOUNCE_MS);
176
+ refreshTimer.unref?.();
177
+ },
178
+ stopAccountSyncRefresh: () => {
179
+ refreshStopped = true;
180
+ if (refreshTimer !== null) {
181
+ clearTimeout(refreshTimer);
182
+ refreshTimer = null;
183
+ }
133
184
  },
134
185
  getBlocklist: async () => {
135
186
  const node = buildGetBlocklistIq();