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
@@ -11,7 +11,9 @@ import { WaRetryReplayService } from '../../retry/replay.js';
11
11
  import { generatePreKeyPair } from '../../signal/registration/keygen.js';
12
12
  import { buildAckNode } from '../../transport/node/builders/global.js';
13
13
  import { buildRetryReceiptNode } from '../../transport/node/builders/retry.js';
14
+ import { findNodeChild } from '../../transport/node/helpers.js';
14
15
  import { uint8Equal } from '../../util/bytes.js';
16
+ import { tryAsNumber } from '../../util/coercion.js';
15
17
  import { setBoundedMapEntry } from '../../util/collections.js';
16
18
  import { toError } from '../../util/primitives.js';
17
19
  const RETRY_CLEANUP_INTERVAL_MS = 30000;
@@ -23,8 +25,10 @@ const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
23
25
  const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
24
26
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
25
27
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
26
- const PLACEHOLDER_RESEND_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
28
+ const PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS = 14;
27
29
  const PLACEHOLDER_RESEND_IN_FLIGHT_MAX = 256;
30
+ const PLACEHOLDER_RESEND_TIMEOUT_MS = 120000;
31
+ const DAY_SECONDS = 24 * 60 * 60;
28
32
  const PLACEHOLDER_RESEND_SKIP_SUBTYPES = new Set([
29
33
  'bot_unavailable_fanout',
30
34
  'hosted_unavailable_fanout',
@@ -47,6 +51,30 @@ function getRemoteRetryReasonLogFields(reason) {
47
51
  remoteRetryReasonName: getRetryReasonName(reason) ?? 'missing_in_retry_receipt'
48
52
  };
49
53
  }
54
+ /**
55
+ * Returns the placeholder subtype the primary never resends (bot fanout, hosted
56
+ * account, consumed view-once), or `null` when the message is recoverable.
57
+ */
58
+ function resolvePlaceholderResendSkipSubtype(node) {
59
+ const subtype = node.attrs.subtype;
60
+ if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
61
+ return subtype;
62
+ }
63
+ const unavailable = findNodeChild(node, 'unavailable');
64
+ if (!unavailable) {
65
+ return null;
66
+ }
67
+ if (findNodeChild(node, 'bot')) {
68
+ return 'bot_unavailable_fanout';
69
+ }
70
+ if (unavailable.attrs.hosted === 'true') {
71
+ return 'hosted_unavailable_fanout';
72
+ }
73
+ if (unavailable.attrs.type === 'view_once') {
74
+ return 'view_once_unavailable_fanout';
75
+ }
76
+ return null;
77
+ }
50
78
  export class WaRetryCoordinator {
51
79
  constructor(options) {
52
80
  this.nextRetryCleanupAtMs = 0;
@@ -765,6 +793,42 @@ export class WaRetryCoordinator {
765
793
  const meJid = credentials?.meJid;
766
794
  return !!meJid && parseSignalAddressFromJid(meJid).user === senderUser;
767
795
  }
796
+ /**
797
+ * Queues a `PLACEHOLDER_MESSAGE_RESEND` peer request for a message the server
798
+ * delivered as `<unavailable/>` (no `<enc>` to decrypt, so no retry receipt).
799
+ * Returns `false` when the placeholder is unrecoverable, too old, or this
800
+ * session is the primary. The payload surfaces later as a `message` event.
801
+ */
802
+ onUnavailableMessage(context) {
803
+ return this.enqueuePlaceholderResend(context);
804
+ }
805
+ getPlaceholderResendMaxAgeSeconds() {
806
+ const days = tryAsNumber(this.deps.getAbPropNumber?.('placeholder_message_resend_maximum_days_limit'));
807
+ return ((days !== null && days > 0 ? days : PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS) *
808
+ DAY_SECONDS);
809
+ }
810
+ /**
811
+ * Builds the message key the primary indexes the message under: the chat is
812
+ * the `from`, or the `recipient` for a self-sent 1:1, always without the
813
+ * `:device` segment. The participant rides along only for an incoming
814
+ * group/broadcast message. Asking with the raw device-addressed `from`
815
+ * matches nothing and the resend comes back empty.
816
+ */
817
+ buildPlaceholderResendKey(context) {
818
+ const fromMe = this.isSenderFromOwnAccount(context);
819
+ const isGroupOrBroadcast = isGroupOrBroadcastJid(context.from);
820
+ const chatJid = fromMe && !isGroupOrBroadcast
821
+ ? (context.messageNode.attrs.recipient ?? context.from)
822
+ : context.from;
823
+ return {
824
+ remoteJid: toUserJid(chatJid),
825
+ id: context.stanzaId,
826
+ fromMe,
827
+ ...(!fromMe && isGroupOrBroadcast && context.participant
828
+ ? { participant: toUserJid(context.participant) }
829
+ : {})
830
+ };
831
+ }
768
832
  enqueuePlaceholderResend(context) {
769
833
  if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
770
834
  return false;
@@ -772,14 +836,18 @@ export class WaRetryCoordinator {
772
836
  if (this.deps.isMobilePrimary?.()) {
773
837
  return false;
774
838
  }
775
- const subtype = context.messageNode.attrs.subtype;
776
- if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
839
+ const skipSubtype = resolvePlaceholderResendSkipSubtype(context.messageNode);
840
+ if (skipSubtype !== null) {
841
+ this.deps.logger.trace('placeholder resend skipped: unrecoverable placeholder', {
842
+ id: context.stanzaId,
843
+ subtype: skipSubtype
844
+ });
777
845
  return false;
778
846
  }
779
847
  const timestampSeconds = context.t ? Number(context.t) : Date.now() / 1000;
780
848
  if (Number.isFinite(timestampSeconds)) {
781
849
  const ageSeconds = Date.now() / 1000 - timestampSeconds;
782
- if (ageSeconds > PLACEHOLDER_RESEND_MAX_AGE_SECONDS) {
850
+ if (ageSeconds > this.getPlaceholderResendMaxAgeSeconds()) {
783
851
  return false;
784
852
  }
785
853
  }
@@ -794,12 +862,7 @@ export class WaRetryCoordinator {
794
862
  return false;
795
863
  }
796
864
  this.placeholderInFlight.add(context.stanzaId);
797
- this.placeholderQueue.push({
798
- remoteJid: context.from,
799
- id: context.stanzaId,
800
- fromMe: this.isSenderFromOwnAccount(context),
801
- participant: context.participant
802
- });
865
+ this.placeholderQueue.push(this.buildPlaceholderResendKey(context));
803
866
  if (this.placeholderTimer === null) {
804
867
  this.placeholderTimer = setTimeout(() => {
805
868
  this.placeholderTimer = null;
@@ -828,7 +891,7 @@ export class WaRetryCoordinator {
828
891
  participant: item.participant
829
892
  }
830
893
  }))
831
- });
894
+ }, { timeoutMs: PLACEHOLDER_RESEND_TIMEOUT_MS });
832
895
  const meJid = this.deps.getCurrentCredentials()?.meJid;
833
896
  for (const result of results) {
834
897
  const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
@@ -1,7 +1,7 @@
1
+ import { parseBlocklist } from '../events/privacy.js';
1
2
  import { WA_ACCOUNT_SYNC_PROTOCOLS, WA_DEFAULTS, WA_DIRTY_PROTOCOLS, WA_DIRTY_TYPES, WA_IQ_TYPES, WA_NODE_TAGS, WA_SUPPORTED_DIRTY_TYPES } from '../../protocol/constants.js';
2
3
  import { toUserJid } from '../../protocol/jid.js';
3
4
  import { buildAccountBlocklistSyncIq, buildAccountDevicesSyncIq, buildAccountPictureSyncIq, buildClearDirtyBitsIq, buildListParticipatingGroupsIq, buildNewsletterMetadataSyncIq } from '../../transport/node/builders/account-sync.js';
4
- import { buildGetPrivacySettingsIq } from '../../transport/node/builders/privacy.js';
5
5
  import { parseUsyncResultEnvelope } from '../../transport/node/builders/usync.js';
6
6
  import { getNodeChildrenTags } from '../../transport/node/helpers.js';
7
7
  import { assertIqResult, parseIqError } from '../../transport/node/query.js';
@@ -219,19 +219,23 @@ async function syncAccountPictureDirtyBit(runtime) {
219
219
  }
220
220
  throw new Error(`account_sync.picture iq failed (${iqError.code}: ${iqError.text})`);
221
221
  }
222
+ /**
223
+ * Catch-up path for a session that was offline while another device changed a
224
+ * setting. Runs the same refresh the live `account_sync` notification
225
+ * triggers: the whole category set plus the disallowed lists.
226
+ */
222
227
  async function syncAccountPrivacyDirtyBit(runtime) {
223
- await runSyncQuery(runtime, {
224
- queryContext: 'account_sync.privacy',
225
- node: buildGetPrivacySettingsIq(),
226
- logMessage: 'account_sync privacy synchronized'
227
- });
228
+ await runtime.syncAccountPrivacy();
229
+ runtime.logger.debug('account_sync privacy synchronized');
228
230
  }
231
+ /** The reply carries the full blocklist, not a delta. */
229
232
  async function syncAccountBlocklistDirtyBit(runtime) {
230
- await runSyncQuery(runtime, {
233
+ const response = await runSyncQuery(runtime, {
231
234
  queryContext: 'account_sync.blocklist',
232
235
  node: buildAccountBlocklistSyncIq(),
233
236
  logMessage: 'account_sync blocklist synchronized'
234
237
  });
238
+ runtime.emitBlocklist(parseBlocklist(response));
235
239
  }
236
240
  async function syncGroupsDirtyBit(runtime) {
237
241
  await runSyncQuery(runtime, {
@@ -271,6 +275,7 @@ async function runSyncQuery(runtime, args) {
271
275
  assertIqResult(response, args.assertContext ?? args.queryContext);
272
276
  logUsyncProtocolErrors(parseUsyncResultEnvelope(response), runtime.logger, args.queryContext);
273
277
  runtime.logger.debug(args.logMessage, args.contextData);
278
+ return response;
274
279
  }
275
280
  async function clearDirtyBits(runtime, dirtyBits) {
276
281
  try {
@@ -0,0 +1,92 @@
1
+ import { findNodeChild, getNodeChildrenByTag } from '../../transport/node/helpers.js';
2
+ import { parseOptionalInt } from '../../util/primitives.js';
3
+ const OFFLINE_THREAD_TAGS = Object.freeze({
4
+ ITEM: 'item',
5
+ WATERMARK: 'watermark',
6
+ STATUS_MSGS: 'status_msgs',
7
+ NOTIFICATIONS: 'notifications'
8
+ });
9
+ function parseThreadPreviews(node) {
10
+ const items = getNodeChildrenByTag(node, OFFLINE_THREAD_TAGS.ITEM);
11
+ const previews = [];
12
+ for (let index = 0; index < items.length; index += 1) {
13
+ const item = items[index];
14
+ const jid = item.attrs.from;
15
+ const timestampSeconds = parseOptionalInt(item.attrs.t);
16
+ if (!jid || timestampSeconds === undefined || timestampSeconds < 0) {
17
+ continue;
18
+ }
19
+ previews.push({ jid, timestampSeconds });
20
+ }
21
+ return previews;
22
+ }
23
+ /** Keeps the highest `sts` per jid; non-positive means "no watermark", not "read at epoch". */
24
+ function parseReadWatermarks(node) {
25
+ const watermarkNode = findNodeChild(node, OFFLINE_THREAD_TAGS.WATERMARK);
26
+ if (!watermarkNode) {
27
+ return undefined;
28
+ }
29
+ const highestByJid = new Map();
30
+ const items = getNodeChildrenByTag(watermarkNode, OFFLINE_THREAD_TAGS.ITEM);
31
+ for (let index = 0; index < items.length; index += 1) {
32
+ const item = items[index];
33
+ const jid = item.attrs.from;
34
+ const readTimestampSeconds = parseOptionalInt(item.attrs.sts);
35
+ if (!jid || readTimestampSeconds === undefined || readTimestampSeconds <= 0) {
36
+ continue;
37
+ }
38
+ const current = highestByJid.get(jid);
39
+ if (current === undefined || readTimestampSeconds > current) {
40
+ highestByJid.set(jid, readTimestampSeconds);
41
+ }
42
+ }
43
+ if (highestByJid.size === 0) {
44
+ return undefined;
45
+ }
46
+ const watermarks = new Array(highestByJid.size);
47
+ let index = 0;
48
+ for (const [jid, readTimestampSeconds] of highestByJid) {
49
+ watermarks[index] = { jid, readTimestampSeconds };
50
+ index += 1;
51
+ }
52
+ return watermarks;
53
+ }
54
+ function parsePendingStatusMessages(node) {
55
+ const statusNode = findNodeChild(node, OFFLINE_THREAD_TAGS.STATUS_MSGS);
56
+ if (!statusNode) {
57
+ return undefined;
58
+ }
59
+ const items = getNodeChildrenByTag(statusNode, OFFLINE_THREAD_TAGS.ITEM);
60
+ const jids = [];
61
+ for (let index = 0; index < items.length; index += 1) {
62
+ const jid = items[index].attrs.from;
63
+ if (jid) {
64
+ jids.push(jid);
65
+ }
66
+ }
67
+ return {
68
+ count: parseOptionalInt(statusNode.attrs.count) ?? 0,
69
+ jids
70
+ };
71
+ }
72
+ function parsePendingNotifications(node) {
73
+ const notificationsNode = findNodeChild(node, OFFLINE_THREAD_TAGS.NOTIFICATIONS);
74
+ if (!notificationsNode) {
75
+ return undefined;
76
+ }
77
+ return { count: parseOptionalInt(notificationsNode.attrs.count) ?? 0 };
78
+ }
79
+ /**
80
+ * Parses the offline preview manifest out of an `<ib>` bulletin. Every child
81
+ * is optional: a bare node yields an empty `threads` list.
82
+ *
83
+ * @param node - the `<thread_metadata>` node, not the enclosing `<ib>`
84
+ */
85
+ export function parseOfflineThreadMetadata(node) {
86
+ return {
87
+ threads: parseThreadPreviews(node),
88
+ readWatermarks: parseReadWatermarks(node),
89
+ pendingStatusMessages: parsePendingStatusMessages(node),
90
+ pendingNotifications: parsePendingNotifications(node)
91
+ };
92
+ }
@@ -0,0 +1,123 @@
1
+ import { WA_NODE_TAGS } from '../../protocol/nodes.js';
2
+ import { WA_PRIVACY_CATEGORY_TO_SETTING, WA_PRIVACY_SETTING_VALUES, WA_PRIVACY_TAGS } from '../../protocol/privacy.js';
3
+ import { findNodeChild, getNodeChildren, getNodeChildrenByTag } from '../../transport/node/helpers.js';
4
+ /**
5
+ * Server categories the library intentionally drops: they are either
6
+ * region/product specific or not part of the modeled privacy surface.
7
+ */
8
+ const IGNORED_SERVER_CATEGORIES = new Set([
9
+ 'stickers',
10
+ 'dependentaccountmessages',
11
+ 'cover_photo',
12
+ 'dependent_account_calling',
13
+ 'groupcreation'
14
+ ]);
15
+ const SETTING_VALUES = WA_PRIVACY_SETTING_VALUES;
16
+ /** Parses the `<privacy>` payload of a privacy `get` IQ result. */
17
+ export function parsePrivacySettings(result) {
18
+ const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
19
+ if (!privacyNode) {
20
+ return {};
21
+ }
22
+ const settings = {};
23
+ const categories = getNodeChildrenByTag(privacyNode, WA_PRIVACY_TAGS.CATEGORY);
24
+ for (let i = 0; i < categories.length; i += 1) {
25
+ const node = categories[i];
26
+ const name = node.attrs.name;
27
+ const value = node.attrs.value;
28
+ if (!name || !value) {
29
+ continue;
30
+ }
31
+ if (IGNORED_SERVER_CATEGORIES.has(name)) {
32
+ continue;
33
+ }
34
+ const settingName = WA_PRIVACY_CATEGORY_TO_SETTING[name];
35
+ if (!settingName) {
36
+ continue;
37
+ }
38
+ if (SETTING_VALUES[settingName]?.includes(value) !== true) {
39
+ continue;
40
+ }
41
+ settings[settingName] = value;
42
+ }
43
+ return settings;
44
+ }
45
+ /**
46
+ * Reads the `dhash` the server echoes back on a privacy `set` result. It is a
47
+ * millisecond timestamp acting as the version stamp of that category's
48
+ * disallowed list, and is only present while the category sits on
49
+ * `contact_blacklist`.
50
+ */
51
+ export function parsePrivacyCategoryDhash(result, category) {
52
+ const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
53
+ if (!privacyNode) {
54
+ return null;
55
+ }
56
+ const categories = getNodeChildrenByTag(privacyNode, WA_PRIVACY_TAGS.CATEGORY);
57
+ for (let i = 0; i < categories.length; i += 1) {
58
+ const node = categories[i];
59
+ if (node.attrs.name !== category) {
60
+ continue;
61
+ }
62
+ return node.attrs.dhash ?? null;
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * Parses the `<privacy><list>` payload of a disallowed-list `get` IQ result,
68
+ * returning `null` when the reply carries no `<list>` at all. The server omits
69
+ * it to mean "nothing to update" - either the category is not on
70
+ * `contact_blacklist`, or the `dhash` the client sent still matches - which is
71
+ * different from an empty list, so a caller reacting to changes can skip it.
72
+ */
73
+ export function parseDisallowedListUpdate(result) {
74
+ const privacyNode = findNodeChild(result, WA_NODE_TAGS.PRIVACY);
75
+ if (!privacyNode) {
76
+ return null;
77
+ }
78
+ const listNode = findNodeChild(privacyNode, WA_PRIVACY_TAGS.LIST);
79
+ if (!listNode) {
80
+ return null;
81
+ }
82
+ const dhash = listNode.attrs.dhash;
83
+ const userNodes = getNodeChildrenByTag(listNode, WA_PRIVACY_TAGS.USER);
84
+ const jids = new Array(userNodes.length);
85
+ let jidsCount = 0;
86
+ for (let i = 0; i < userNodes.length; i += 1) {
87
+ const jid = userNodes[i].attrs.jid;
88
+ if (jid) {
89
+ jids[jidsCount] = jid;
90
+ jidsCount += 1;
91
+ }
92
+ }
93
+ jids.length = jidsCount;
94
+ return { jids, dhash };
95
+ }
96
+ /**
97
+ * Same read as {@link parseDisallowedListUpdate}, flattening the "nothing to
98
+ * update" reply into an empty list for callers that just want the current
99
+ * membership.
100
+ */
101
+ export function parseDisallowedList(result) {
102
+ return parseDisallowedListUpdate(result) ?? { jids: [] };
103
+ }
104
+ /** Parses the `<list>` payload of a blocklist `get` IQ result. */
105
+ export function parseBlocklist(result) {
106
+ const listNode = findNodeChild(result, WA_NODE_TAGS.LIST);
107
+ if (!listNode) {
108
+ return { jids: [] };
109
+ }
110
+ const dhash = listNode.attrs.dhash;
111
+ const itemNodes = getNodeChildren(listNode);
112
+ const jids = new Array(itemNodes.length);
113
+ let jidsCount = 0;
114
+ for (let i = 0; i < itemNodes.length; i += 1) {
115
+ const jid = itemNodes[i].attrs.jid;
116
+ if (jid) {
117
+ jids[jidsCount] = jid;
118
+ jidsCount += 1;
119
+ }
120
+ }
121
+ jids.length = jidsCount;
122
+ return { jids, dhash };
123
+ }
@@ -39,6 +39,7 @@ export function createGroupMetadataCache(options) {
39
39
  groupJid,
40
40
  participants,
41
41
  ephemeral: cached?.ephemeral,
42
+ ephemeralTrigger: cached?.ephemeralTrigger,
42
43
  updatedAtMs: Date.now()
43
44
  });
44
45
  };
@@ -121,6 +122,7 @@ export function createGroupMetadataCache(options) {
121
122
  groupJid,
122
123
  participants,
123
124
  ephemeral: queried.ephemeral,
125
+ ephemeralTrigger: queried.ephemeralTrigger,
124
126
  updatedAtMs: Date.now()
125
127
  });
126
128
  return participants;
@@ -137,15 +139,22 @@ export function createGroupMetadataCache(options) {
137
139
  const cached = await groupMetadataStore.getGroupMetadata(groupJid);
138
140
  return cached?.ephemeral ?? null;
139
141
  };
140
- const resolveEphemeral = async (groupJid) => {
141
- const cached = await groupMetadataStore.getGroupMetadata(groupJid);
142
- if (cached) {
143
- return cached.ephemeral ?? null;
144
- }
145
- await refreshParticipantUsers(groupJid);
146
- const refreshed = await groupMetadataStore.getGroupMetadata(groupJid);
147
- return refreshed?.ephemeral ?? null;
142
+ const resolveEphemeralSettings = async (groupJid) => {
143
+ let cached = await groupMetadataStore.getGroupMetadata(groupJid);
144
+ if (!cached) {
145
+ await refreshParticipantUsers(groupJid);
146
+ cached = await groupMetadataStore.getGroupMetadata(groupJid);
147
+ }
148
+ const expirationSeconds = cached?.ephemeral;
149
+ if (expirationSeconds === undefined || expirationSeconds <= 0) {
150
+ return null;
151
+ }
152
+ return {
153
+ expirationSeconds,
154
+ ...(cached?.ephemeralTrigger !== undefined ? { trigger: cached.ephemeralTrigger } : {})
155
+ };
148
156
  };
157
+ const resolveEphemeral = async (groupJid) => (await resolveEphemeralSettings(groupJid))?.expirationSeconds ?? null;
149
158
  const applyGroupEvent = async (event) => {
150
159
  const groupJid = resolveGroupJidForGroupCacheEvent(event);
151
160
  if (!groupJid) {
@@ -161,13 +170,18 @@ export function createGroupMetadataCache(options) {
161
170
  return;
162
171
  }
163
172
  const nextEphemeral = event.expirationSeconds;
164
- if (cached.ephemeral === nextEphemeral) {
173
+ const parsedTrigger = event.mode === undefined ? undefined : Number(event.mode);
174
+ const nextTrigger = parsedTrigger !== undefined && Number.isFinite(parsedTrigger)
175
+ ? parsedTrigger
176
+ : cached.ephemeralTrigger;
177
+ if (cached.ephemeral === nextEphemeral && cached.ephemeralTrigger === nextTrigger) {
165
178
  return;
166
179
  }
167
180
  await groupMetadataStore.upsertGroupMetadata({
168
181
  groupJid,
169
182
  participants: cached.participants,
170
183
  ephemeral: nextEphemeral,
184
+ ephemeralTrigger: nextTrigger,
171
185
  updatedAtMs: Date.now()
172
186
  });
173
187
  return;
@@ -182,6 +196,7 @@ export function createGroupMetadataCache(options) {
182
196
  groupJid,
183
197
  participants: participantUsers,
184
198
  ephemeral: existing?.ephemeral,
199
+ ephemeralTrigger: existing?.ephemeralTrigger,
185
200
  updatedAtMs: Date.now()
186
201
  });
187
202
  return;
@@ -234,6 +249,7 @@ export function createGroupMetadataCache(options) {
234
249
  refreshParticipantUsers,
235
250
  getEphemeral,
236
251
  resolveEphemeral,
252
+ resolveEphemeralSettings,
237
253
  mutateFromGroupEvent
238
254
  };
239
255
  }
@@ -1,6 +1,6 @@
1
1
  import { createReadStream } from 'node:fs';
2
- import { assertMediaUploadStatus, buildMediaUploadUrl, parseMediaUploadJsonBody, selectMediaUploadHost } from '../media.js';
3
- import { MEDIA_UPLOAD_PATHS } from '../../media/constants.js';
2
+ import { assertMediaUploadStatus, buildMediaUploadUrl, parseMediaUploadJsonBody, performPlaintextMediaUpload, selectMediaUploadHost } from '../media.js';
3
+ import { MEDIA_UPLOAD_PATHS, NEWSLETTER_MEDIA_UPLOAD_PATHS } from '../../media/constants.js';
4
4
  import { WaMediaCrypto } from '../../media/crypto/WaMediaCrypto.js';
5
5
  import { findFirstLink } from '../../message/addons/link-preview/detect.js';
6
6
  import { proto } from '../../proto.js';
@@ -94,6 +94,9 @@ function buildInlineFields(thumbnail) {
94
94
  };
95
95
  }
96
96
  async function uploadHqFromBytes(deps, thumbnail) {
97
+ if (deps.surface === 'newsletter') {
98
+ return uploadPlaintextHq(deps, thumbnail.bytes, thumbnail);
99
+ }
97
100
  const mediaKey = await WaMediaCrypto.generateMediaKey();
98
101
  const encrypted = await WaMediaCrypto.encryptBytes('thumbnail-link', mediaKey, thumbnail.bytes, { sidecar: false });
99
102
  const directPath = await dispatchUpload(deps, encrypted.ciphertextHmac, encrypted.ciphertextHmac.byteLength, encrypted.fileEncSha256);
@@ -108,6 +111,9 @@ async function uploadHqFromBytes(deps, thumbnail) {
108
111
  };
109
112
  }
110
113
  async function uploadHqFromStream(deps, thumbnail) {
114
+ if (deps.surface === 'newsletter') {
115
+ return uploadPlaintextHq(deps, thumbnail.stream, thumbnail);
116
+ }
111
117
  const mediaKey = await WaMediaCrypto.generateMediaKey();
112
118
  const encrypted = await WaMediaCrypto.encryptToFile('thumbnail-link', mediaKey, thumbnail.stream, { sidecar: false, expectedFileSize: thumbnail.contentLength });
113
119
  let readStream;
@@ -134,6 +140,27 @@ async function uploadHqFromStream(deps, thumbnail) {
134
140
  await WaMediaCrypto.cleanupEncryptedFile(encrypted.filePath);
135
141
  }
136
142
  }
143
+ /** Uploads the HQ thumbnail unencrypted, so no `mediaKey` / `thumbnailEncSha256`. */
144
+ async function uploadPlaintextHq(deps, source, dimensions) {
145
+ const mediaConn = await deps.getMediaConn();
146
+ const upload = await performPlaintextMediaUpload({ mediaTransfer: deps.mediaTransfer, mediaConn, logger: deps.logger }, {
147
+ source,
148
+ path: NEWSLETTER_MEDIA_UPLOAD_PATHS['thumbnail-link'],
149
+ mimetype: 'image/jpeg',
150
+ logLabel: 'uploading newsletter link preview thumbnail'
151
+ });
152
+ assertMediaUploadStatus(upload.status, 'newsletter thumbnail-link upload');
153
+ const parsed = parseMediaUploadJsonBody(upload.responseBytes, 'newsletter thumbnail-link upload');
154
+ if (!parsed.direct_path) {
155
+ throw new Error('newsletter thumbnail-link upload response missing direct_path');
156
+ }
157
+ return {
158
+ thumbnailDirectPath: parsed.direct_path,
159
+ thumbnailSha256: upload.fileSha256,
160
+ ...(dimensions.width !== undefined ? { thumbnailWidth: dimensions.width } : {}),
161
+ ...(dimensions.height !== undefined ? { thumbnailHeight: dimensions.height } : {})
162
+ };
163
+ }
137
164
  async function dispatchUpload(deps, body, contentLength, fileEncSha256) {
138
165
  const mediaConn = await deps.getMediaConn();
139
166
  const host = selectMediaUploadHost(mediaConn);
@@ -1,12 +1,14 @@
1
1
  import { assertMediaUploadStatus, parseMediaUploadJsonBody, performPlaintextMediaUpload } from '../media.js';
2
2
  import { NEWSLETTER_MEDIA_UPLOAD_PATHS } from '../../media/constants.js';
3
3
  import { createStickerPackZipStream } from '../../media/sticker/sticker-pack.js';
4
+ import { buildExtendedTextWithPreview } from '../../message/addons/link-preview/builder.js';
4
5
  import { applyContextInfo } from '../../message/context-info.js';
5
- import { isSendEventMessage, isSendEventResponseMessage, isSendKeepMessage, isSendMediaMessage, isSendPinMessage, isSendPollMessage, isSendPollVoteMessage, isSendReactionMessage, isSendRevokeMessage, isSendTextMessage, resolveMessageTypeAttr, unwrapMessage } from '../../message/encode/content.js';
6
+ import { isSendEventMessage, isSendEventResponseMessage, isSendKeepMessage, isSendMediaMessage, isSendPinMessage, isSendPollMessage, isSendPollVoteMessage, isSendReactionMessage, isSendRevokeMessage, isSendTextMessage, resolveEncMediaType, resolveMessageTypeAttr, unwrapMessage } from '../../message/encode/content.js';
6
7
  import { toStickerPackProtoStickers, toStickerPackZipEntries, validateStickerPackInput } from '../../message/kinds/sticker-pack.js';
7
8
  import { proto } from '../../proto.js';
8
9
  import { WA_ENC_MEDIA_TYPES } from '../../protocol/message.js';
9
10
  import { base64ToBytes } from '../../util/bytes.js';
11
+ import { toError } from '../../util/primitives.js';
10
12
  export async function uploadNewsletterMedia(options, input) {
11
13
  const upload = await performPlaintextMediaUpload({
12
14
  mediaTransfer: options.mediaTransfer,
@@ -44,22 +46,41 @@ function resolveSendMediaKind(content) {
44
46
  return 'ptt';
45
47
  return content.type;
46
48
  }
49
+ /** Enc media types a channel publish accepts; anything else ships as text. */
50
+ const NEWSLETTER_MEDIA_TYPES = new Set([
51
+ WA_ENC_MEDIA_TYPES.AUDIO,
52
+ WA_ENC_MEDIA_TYPES.DOCUMENT,
53
+ WA_ENC_MEDIA_TYPES.GIF,
54
+ WA_ENC_MEDIA_TYPES.IMAGE,
55
+ WA_ENC_MEDIA_TYPES.PTT,
56
+ WA_ENC_MEDIA_TYPES.PTV,
57
+ WA_ENC_MEDIA_TYPES.STICKER,
58
+ WA_ENC_MEDIA_TYPES.STICKER_PACK,
59
+ WA_ENC_MEDIA_TYPES.URL,
60
+ WA_ENC_MEDIA_TYPES.VCARD,
61
+ WA_ENC_MEDIA_TYPES.VIDEO
62
+ ]);
63
+ /**
64
+ * Resolves the `plaintext` node `mediatype`. Shares the encrypted send path's
65
+ * resolver so media without an upload still gets it - a link preview publishes
66
+ * as `url`, a contact card as `vcard`; without it the channel drops the card.
67
+ */
47
68
  function pickMediaTypeFromMessage(message) {
48
- if (message.imageMessage)
49
- return WA_ENC_MEDIA_TYPES.IMAGE;
50
- if (message.videoMessage)
51
- return message.videoMessage.gifPlayback ? WA_ENC_MEDIA_TYPES.GIF : WA_ENC_MEDIA_TYPES.VIDEO;
52
- if (message.audioMessage)
53
- return message.audioMessage.ptt ? WA_ENC_MEDIA_TYPES.PTT : WA_ENC_MEDIA_TYPES.AUDIO;
54
- if (message.documentMessage)
55
- return WA_ENC_MEDIA_TYPES.DOCUMENT;
56
- if (message.stickerMessage)
57
- return WA_ENC_MEDIA_TYPES.STICKER;
58
- if (message.stickerPackMessage)
59
- return WA_ENC_MEDIA_TYPES.STICKER_PACK;
60
- if (message.ptvMessage)
61
- return WA_ENC_MEDIA_TYPES.PTV;
62
- return null;
69
+ const mediaType = resolveEncMediaType(message);
70
+ return mediaType !== null && NEWSLETTER_MEDIA_TYPES.has(mediaType) ? mediaType : null;
71
+ }
72
+ async function resolveTextLinkPreview(options, content) {
73
+ if (!options.linkPreviewResolver)
74
+ return null;
75
+ try {
76
+ return await options.linkPreviewResolver(content);
77
+ }
78
+ catch (error) {
79
+ options.logger.warn('link preview resolver failed, sending plain text', {
80
+ message: toError(error).message
81
+ });
82
+ return null;
83
+ }
63
84
  }
64
85
  function buildMediaProtoMessage(content, upload) {
65
86
  const common = {
@@ -145,11 +166,15 @@ export async function buildNewsletterMessageContent(options, content, ctx) {
145
166
  };
146
167
  }
147
168
  if (isSendTextMessage(content)) {
148
- const message = applyContextInfo({ extendedTextMessage: { text: content.text } }, ctx);
169
+ const preview = await resolveTextLinkPreview(options, content);
170
+ const message = applyContextInfo(preview !== null
171
+ ? buildExtendedTextWithPreview(content.text, preview.resolved, preview.thumbnailFields)
172
+ : { extendedTextMessage: { text: content.text } }, ctx);
173
+ const mediaType = pickMediaTypeFromMessage(message);
149
174
  return {
150
- kind: 'text',
175
+ kind: mediaType !== null ? 'media' : 'text',
151
176
  plaintext: proto.Message.encode(message).finish(),
152
- mediaType: null,
177
+ mediaType,
153
178
  upload: null
154
179
  };
155
180
  }
@@ -8,7 +8,8 @@ async function buildContent(deps, content, ctx) {
8
8
  return buildNewsletterMessageContent({
9
9
  logger: deps.logger,
10
10
  mediaTransfer: deps.mediaTransfer,
11
- getMediaConn: deps.getMediaConn
11
+ getMediaConn: deps.getMediaConn,
12
+ linkPreviewResolver: deps.linkPreviewResolver
12
13
  }, content, ctx);
13
14
  }
14
15
  function toUploadSummary(built) {
@@ -9,7 +9,8 @@ function mergeThread(previous, incoming) {
9
9
  pinned: incoming.pinned ?? previous.pinned,
10
10
  muteEndMs: incoming.muteEndMs ?? previous.muteEndMs,
11
11
  markedAsUnread: incoming.markedAsUnread ?? previous.markedAsUnread,
12
- ephemeralExpiration: incoming.ephemeralExpiration ?? previous.ephemeralExpiration
12
+ ephemeralExpiration: incoming.ephemeralExpiration ?? previous.ephemeralExpiration,
13
+ ephemeralSettingTimestamp: incoming.ephemeralSettingTimestamp ?? previous.ephemeralSettingTimestamp
13
14
  };
14
15
  }
15
16
  function mergeContact(previous, incoming) {