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
@@ -0,0 +1,116 @@
1
+ import { PromiseDedup } from '../../infra/perf/PromiseDedup.js';
2
+ import { pickIncomingEphemeralSettingTimestamp } from '../../message/context-info.js';
3
+ import { isGroupJid } from '../../protocol/jid.js';
4
+ import { normalizeEphemeralSettingSeconds } from '../../protocol/message.js';
5
+ import { longToNumber, toError } from '../../util/primitives.js';
6
+ /**
7
+ * Persists a 1:1 `EPHEMERAL_SETTING` protocol message so outgoing sends pick up
8
+ * the peer's change without waiting for history sync. Groups are skipped –
9
+ * their timer comes from group notifications, not protocol messages.
10
+ */
11
+ export function persistIncomingEphemeralSetting(options) {
12
+ const { logger, writeBehind, event, protocolMessage, chatMetadataStore } = options;
13
+ const chatJid = event.key.remoteJid;
14
+ if (!chatJid || event.key.isGroup || isGroupJid(chatJid)) {
15
+ return;
16
+ }
17
+ const expiration = protocolMessage.ephemeralExpiration ?? 0;
18
+ const rawTimestamp = protocolMessage.ephemeralSettingTimestamp !== undefined &&
19
+ protocolMessage.ephemeralSettingTimestamp !== null
20
+ ? longToNumber(protocolMessage.ephemeralSettingTimestamp)
21
+ : event.timestampSeconds;
22
+ const settingTimestamp = rawTimestamp !== undefined ? normalizeEphemeralSettingSeconds(rawTimestamp) : undefined;
23
+ void chatMetadataStore
24
+ .upsertChatMetadata({
25
+ chatJid,
26
+ ephemeralExpiration: expiration,
27
+ ...(settingTimestamp !== undefined
28
+ ? { ephemeralSettingTimestamp: settingTimestamp }
29
+ : {}),
30
+ updatedAtMs: Date.now()
31
+ })
32
+ .catch((error) => {
33
+ logger.debug('failed to cache incoming ephemeral setting', {
34
+ jid: chatJid,
35
+ message: toError(error).message
36
+ });
37
+ });
38
+ try {
39
+ writeBehind.persistThread({
40
+ jid: chatJid,
41
+ ephemeralExpiration: expiration,
42
+ ...(settingTimestamp !== undefined
43
+ ? { ephemeralSettingTimestamp: settingTimestamp }
44
+ : {})
45
+ });
46
+ }
47
+ catch (error) {
48
+ logger.warn('failed to persist incoming ephemeral setting', {
49
+ jid: chatJid,
50
+ expiration,
51
+ message: toError(error).message
52
+ });
53
+ }
54
+ }
55
+ /**
56
+ * Builds the inbound observer that refreshes the chat-metadata cache from
57
+ * ordinary traffic. A peer stamps `contextInfo.ephemeralSettingTimestamp` on
58
+ * every message in a disappearing chat, making it the only continuously
59
+ * refreshed source - history sync and `EPHEMERAL_SETTING` are point-in-time.
60
+ *
61
+ * Never blocks and never rejects: the store round-trip is fire-and-forget.
62
+ * Messages repeating a value share one round-trip, so the batch an offline
63
+ * resume delivers costs a single read instead of one per message, while a
64
+ * message carrying a *different* value still gets its own round-trip rather
65
+ * than being swallowed by the one in flight.
66
+ *
67
+ * A write skips a setting older than the cached one, mirroring how the peer
68
+ * resolves it: highest timestamp wins. That check is not atomic with the
69
+ * write, so two round-trips that both read a cold cache can still land out of
70
+ * order and leave the older value behind. The next message from the chat
71
+ * corrects it, and a stale-but-older stamp loses to the peer's own value
72
+ * anyway, so this is not worth serializing the receive path over.
73
+ */
74
+ export function createEphemeralObserver(options) {
75
+ const { logger, chatMetadataStore } = options;
76
+ const dedup = new PromiseDedup();
77
+ return (event) => {
78
+ const expiration = event.expirationSeconds;
79
+ if (expiration === undefined || expiration <= 0) {
80
+ return;
81
+ }
82
+ const chatJid = event.key.remoteJid;
83
+ if (!chatJid || event.key.isGroup || event.key.isNewsletter || isGroupJid(chatJid)) {
84
+ return;
85
+ }
86
+ const raw = pickIncomingEphemeralSettingTimestamp(event.message);
87
+ if (raw === undefined) {
88
+ return;
89
+ }
90
+ const settingTimestamp = normalizeEphemeralSettingSeconds(raw);
91
+ void dedup
92
+ .run(`observe:${chatJid}:${expiration}:${settingTimestamp}`, async () => {
93
+ const cached = await chatMetadataStore.getChatMetadata(chatJid);
94
+ if (cached?.ephemeralSettingTimestamp === settingTimestamp &&
95
+ cached.ephemeralExpiration === expiration) {
96
+ return;
97
+ }
98
+ if (cached?.ephemeralSettingTimestamp !== undefined &&
99
+ cached.ephemeralSettingTimestamp > settingTimestamp) {
100
+ return;
101
+ }
102
+ await chatMetadataStore.upsertChatMetadata({
103
+ chatJid,
104
+ ephemeralExpiration: expiration,
105
+ ephemeralSettingTimestamp: settingTimestamp,
106
+ updatedAtMs: Date.now()
107
+ });
108
+ })
109
+ .catch((error) => {
110
+ logger.debug('failed to cache observed ephemeral setting', {
111
+ jid: chatJid,
112
+ message: toError(error).message
113
+ });
114
+ });
115
+ };
116
+ }
@@ -0,0 +1,145 @@
1
+ import { downloadHistoryBlob, flushPendingWrites } from '../persistence/history-blob.js';
2
+ import { decodeGroupHistoryBundle } from '../../message/kinds/group-history.js';
3
+ import { proto } from '../../proto.js';
4
+ import { isGroupJid, toUserJid } from '../../protocol/jid.js';
5
+ import { longToNumber, toError } from '../../util/primitives.js';
6
+ const GROUP_HISTORY_MAX_PENDING_WRITES = 1024;
7
+ export async function runGroupHistoryBundle(deps, input) {
8
+ try {
9
+ await processGroupHistoryBundle(deps, input);
10
+ }
11
+ catch (error) {
12
+ deps.logger.warn('failed to process group history bundle', {
13
+ groupJid: input.groupJid,
14
+ id: input.bundleMessageId,
15
+ message: toError(error).message
16
+ });
17
+ }
18
+ }
19
+ export async function processGroupHistoryBundle(deps, input) {
20
+ const logger = deps.logger.child({
21
+ groupJid: input.groupJid,
22
+ id: input.bundleMessageId
23
+ });
24
+ if (!isGroupJid(input.groupJid)) {
25
+ logger.debug('skipping group history bundle outside a group');
26
+ return;
27
+ }
28
+ if (!isHistoryReceiver(deps, input.bundle)) {
29
+ logger.debug('skipping group history bundle not addressed to this account');
30
+ return;
31
+ }
32
+ const nowMs = Date.now();
33
+ const bundleTtlSeconds = deps.getAbPropNumber('group_history_bundle_time_limit_receiver_enforcement_secs');
34
+ if (input.sentAtSeconds !== undefined &&
35
+ nowMs > (input.sentAtSeconds + bundleTtlSeconds) * 1000) {
36
+ logger.debug('skipping expired group history bundle', {
37
+ sentAtSeconds: input.sentAtSeconds,
38
+ bundleTtlSeconds
39
+ });
40
+ return;
41
+ }
42
+ const blob = await downloadHistoryBlob(deps.mediaTransfer, input.bundle, 'group-history', 'group history bundle');
43
+ const history = await decodeGroupHistoryBundle(blob);
44
+ const messageTtlSeconds = deps.getAbPropNumber('group_history_messages_time_limit_receiver_enforcement_secs');
45
+ const pendingWrites = [];
46
+ const dropped = { foreignChat: 0, ephemeralExpired: 0, tooOld: 0, stub: 0 };
47
+ let messagesCount = 0;
48
+ let oldestTimestampMs;
49
+ for (const source of [
50
+ { messages: history.messages, skipAgeCheck: false },
51
+ { messages: history.outOfWindowPinnedMessages, skipAgeCheck: true }
52
+ ]) {
53
+ for (const webMsg of source.messages) {
54
+ if (!webMsg.key?.id || !webMsg.message) {
55
+ dropped.stub += 1;
56
+ continue;
57
+ }
58
+ if (webMsg.key.remoteJid && webMsg.key.remoteJid !== input.groupJid) {
59
+ dropped.foreignChat += 1;
60
+ continue;
61
+ }
62
+ const timestampSeconds = longToNumber(webMsg.messageTimestamp);
63
+ if (isEphemeralExpired(webMsg, timestampSeconds, nowMs)) {
64
+ dropped.ephemeralExpired += 1;
65
+ continue;
66
+ }
67
+ if (!source.skipAgeCheck &&
68
+ timestampSeconds > 0 &&
69
+ (timestampSeconds + 2 * messageTtlSeconds) * 1000 < nowMs) {
70
+ dropped.tooOld += 1;
71
+ continue;
72
+ }
73
+ const timestampMs = timestampSeconds * 1000;
74
+ if (timestampMs > 0 &&
75
+ (oldestTimestampMs === undefined || timestampMs < oldestTimestampMs)) {
76
+ oldestTimestampMs = timestampMs;
77
+ }
78
+ pendingWrites[pendingWrites.length] = deps.writeBehind.persistMessageAsync({
79
+ id: webMsg.key.id,
80
+ threadJid: input.groupJid,
81
+ senderJid: webMsg.key.participant ?? undefined,
82
+ fromMe: webMsg.key.fromMe === true,
83
+ timestampMs: timestampMs || undefined,
84
+ messageBytes: proto.Message.encode(webMsg.message).finish()
85
+ });
86
+ if (pendingWrites.length >= GROUP_HISTORY_MAX_PENDING_WRITES) {
87
+ await flushPendingWrites(pendingWrites);
88
+ }
89
+ messagesCount += 1;
90
+ }
91
+ }
92
+ await flushPendingWrites(pendingWrites);
93
+ const droppedCount = dropped.foreignChat + dropped.ephemeralExpired + dropped.tooOld + dropped.stub;
94
+ logger.debug('processed group history bundle', {
95
+ messagesCount,
96
+ outOfWindowPinsCount: history.outOfWindowPinnedMessages.length,
97
+ droppedCount,
98
+ dropped
99
+ });
100
+ const event = {
101
+ groupJid: input.groupJid,
102
+ senderJid: input.senderJid,
103
+ bundleMessageId: input.bundleMessageId,
104
+ messagesCount,
105
+ outOfWindowPinsCount: history.outOfWindowPinnedMessages.length,
106
+ droppedCount,
107
+ oldestTimestampMs,
108
+ historyReceivers: [...(input.bundle.messageHistoryMetadata?.historyReceivers ?? [])]
109
+ };
110
+ deps.emitEvent('group_history_bundle', event);
111
+ }
112
+ /**
113
+ * A bundle is only ours to process when this account is listed in
114
+ * `historyReceivers`. The list may address us by either PN or LID, so both
115
+ * forms of the local identity are compared.
116
+ */
117
+ function isHistoryReceiver(deps, bundle) {
118
+ const receivers = bundle.messageHistoryMetadata?.historyReceivers;
119
+ if (!receivers || receivers.length === 0) {
120
+ return false;
121
+ }
122
+ const meUserJid = deps.meJid ? toUserJid(deps.meJid) : null;
123
+ const meUserLid = deps.meLid ? toUserJid(deps.meLid) : null;
124
+ if (!meUserJid && !meUserLid) {
125
+ return false;
126
+ }
127
+ for (let index = 0; index < receivers.length; index += 1) {
128
+ const receiver = toUserJid(receivers[index]);
129
+ if (receiver === meUserJid || receiver === meUserLid) {
130
+ return true;
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+ function isEphemeralExpired(webMsg, timestampSeconds, nowMs) {
136
+ const duration = webMsg.ephemeralDuration;
137
+ if (!duration) {
138
+ return false;
139
+ }
140
+ const startSeconds = longToNumber(webMsg.ephemeralStartTimestamp) || timestampSeconds;
141
+ if (startSeconds <= 0) {
142
+ return false;
143
+ }
144
+ return (startSeconds + duration) * 1000 <= nowMs;
145
+ }
@@ -0,0 +1,34 @@
1
+ import { decodeProtoBytes } from '../../util/bytes.js';
2
+ /**
3
+ * Fetches and decrypts a history blob. `label` prefixes the field errors so a
4
+ * failure names which payload was malformed.
5
+ *
6
+ * @throws when `directPath` is absent or a key/hash field is missing.
7
+ */
8
+ export async function downloadHistoryBlob(mediaTransfer, source, mediaType, label) {
9
+ if (!source.directPath) {
10
+ throw new Error(`${label} missing directPath`);
11
+ }
12
+ const mediaKey = decodeProtoBytes(source.mediaKey, `${label} mediaKey`);
13
+ const fileSha256 = decodeProtoBytes(source.fileSha256, `${label} fileSha256`);
14
+ const fileEncSha256 = decodeProtoBytes(source.fileEncSha256, `${label} fileEncSha256`);
15
+ return mediaTransfer.downloadAndDecrypt({
16
+ directPath: source.directPath,
17
+ mediaType,
18
+ mediaKey,
19
+ fileSha256,
20
+ fileEncSha256
21
+ });
22
+ }
23
+ /**
24
+ * Awaits an accumulating write batch and empties it in place, so the caller can
25
+ * keep filling the same array instead of allocating a new one per chunk.
26
+ */
27
+ export async function flushPendingWrites(pendingWrites) {
28
+ if (pendingWrites.length === 0) {
29
+ return;
30
+ }
31
+ const settled = Promise.all(pendingWrites);
32
+ pendingWrites.length = 0;
33
+ await settled;
34
+ }
@@ -1,7 +1,9 @@
1
1
  import { promisify } from 'node:util';
2
2
  import { unzip } from 'node:zlib';
3
+ import { downloadHistoryBlob, flushPendingWrites } from '../persistence/history-blob.js';
3
4
  import { proto } from '../../proto.js';
4
5
  import { isUserJid } from '../../protocol/jid.js';
6
+ import { normalizeEphemeralSettingSeconds } from '../../protocol/message.js';
5
7
  import { decodeProtoBytes, toBytesView } from '../../util/bytes.js';
6
8
  import { longToNumber, toError } from '../../util/primitives.js';
7
9
  const unzipAsync = promisify(unzip);
@@ -123,6 +125,25 @@ export async function processHistorySyncNotification(deps, notification) {
123
125
  deps.logger.debug('skipping history sync conversation without thread jid');
124
126
  continue;
125
127
  }
128
+ const ephemeralExpiration = conversation.ephemeralExpiration ?? undefined;
129
+ const ephemeralSettingTimestamp = normalizeEphemeralSettingSeconds(longToNumber(conversation.ephemeralSettingTimestamp)) || undefined;
130
+ if (deps.chatMetadataStore && ephemeralExpiration !== undefined) {
131
+ pendingWrites[pendingWrites.length] = deps.chatMetadataStore
132
+ .upsertChatMetadata({
133
+ chatJid: threadJid,
134
+ ephemeralExpiration,
135
+ ...(ephemeralSettingTimestamp !== undefined
136
+ ? { ephemeralSettingTimestamp }
137
+ : {}),
138
+ updatedAtMs: nowMs
139
+ })
140
+ .catch((error) => {
141
+ deps.logger.debug('failed to cache history sync chat metadata', {
142
+ jid: threadJid,
143
+ message: toError(error).message
144
+ });
145
+ });
146
+ }
126
147
  pendingWrites[pendingWrites.length] = deps.writeBehind.persistThreadAsync({
127
148
  jid: threadJid,
128
149
  name: conversation.name ?? undefined,
@@ -131,7 +152,8 @@ export async function processHistorySyncNotification(deps, notification) {
131
152
  pinned: conversation.pinned ?? undefined,
132
153
  muteEndMs: longToNumber(conversation.muteEndTime) || undefined,
133
154
  markedAsUnread: conversation.markedAsUnread ?? undefined,
134
- ephemeralExpiration: conversation.ephemeralExpiration ?? undefined
155
+ ephemeralExpiration,
156
+ ephemeralSettingTimestamp
135
157
  });
136
158
  if (pendingWrites.length >= HISTORY_SYNC_MAX_PENDING_WRITES) {
137
159
  await flushPendingWrites(pendingWrites);
@@ -228,29 +250,9 @@ export async function processHistorySyncNotification(deps, notification) {
228
250
  await deps.onProcessed(syncType);
229
251
  }
230
252
  }
231
- async function flushPendingWrites(pendingWrites) {
232
- if (pendingWrites.length === 0) {
233
- return;
234
- }
235
- const settled = Promise.all(pendingWrites);
236
- pendingWrites.length = 0;
237
- await settled;
238
- }
239
253
  async function downloadHistorySyncBlob(deps, notification) {
240
254
  if (notification.initialHistBootstrapInlinePayload) {
241
255
  return decodeProtoBytes(notification.initialHistBootstrapInlinePayload, 'initialHistBootstrapInlinePayload');
242
256
  }
243
- if (!notification.directPath) {
244
- throw new Error('history sync notification missing directPath');
245
- }
246
- const mediaKey = decodeProtoBytes(notification.mediaKey, 'history sync mediaKey');
247
- const fileSha256 = decodeProtoBytes(notification.fileSha256, 'history sync fileSha256');
248
- const fileEncSha256 = decodeProtoBytes(notification.fileEncSha256, 'history sync fileEncSha256');
249
- return deps.mediaTransfer.downloadAndDecrypt({
250
- directPath: notification.directPath,
251
- mediaType: 'history',
252
- mediaKey,
253
- fileSha256,
254
- fileEncSha256
255
- });
257
+ return downloadHistoryBlob(deps.mediaTransfer, notification, 'history', 'history sync');
256
258
  }
@@ -5,17 +5,30 @@ import { clampCurvePrivateKeyInPlace, montgomeryToEdwardsPublic } from '../curve
5
5
  import { encodeExtendedPoint, scalarMultBase } from '../math/edwards.js';
6
6
  import { bigIntToBytesLE, bytesToBigIntLE } from '../math/le.js';
7
7
  import { modGroup } from '../math/mod.js';
8
- import { assertByteLength, concatBytes } from '../../util/bytes.js';
8
+ import { resolveNativeCryptoBackend } from '../nativeBackend.js';
9
+ import { assertByteLength, concatBytes, toBytesView } from '../../util/bytes.js';
9
10
  const PREFIX_SIGNATURE_RANDOM = new Uint8Array([
10
11
  0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
11
12
  0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff
12
13
  ]);
14
+ const nativeBinding = (() => {
15
+ if (process.env.ZAPO_XEDDSA_FORCE_JS)
16
+ return null;
17
+ const mod = resolveNativeCryptoBackend();
18
+ if (mod && typeof mod.xeddsaSign === 'function' && typeof mod.xeddsaVerify === 'function') {
19
+ return { xeddsaSign: mod.xeddsaSign, xeddsaVerify: mod.xeddsaVerify };
20
+ }
21
+ return null;
22
+ })();
13
23
  /**
14
24
  * Verifies an XEdDSA signature over `message` against an X25519 (Montgomery)
15
25
  * public key, converting to the Edwards form internally. Returns `false`
16
26
  * on shape mismatch instead of throwing.
17
27
  */
18
28
  export async function xeddsaVerify(curvePublicKey, message, signature) {
29
+ if (nativeBinding) {
30
+ return nativeBinding.xeddsaVerify(curvePublicKey, message, signature);
31
+ }
19
32
  if (signature.length !== 64) {
20
33
  return false;
21
34
  }
@@ -40,6 +53,9 @@ export async function xeddsaVerify(curvePublicKey, message, signature) {
40
53
  */
41
54
  export async function xeddsaSign(privateKey, message) {
42
55
  assertByteLength(privateKey, 32, `invalid curve25519 private key length ${privateKey.length}`);
56
+ if (nativeBinding) {
57
+ return toBytesView(nativeBinding.xeddsaSign(privateKey, message));
58
+ }
43
59
  const clampedPrivateKey = clampCurvePrivateKeyInPlace(privateKey);
44
60
  const privateScalar = bytesToBigIntLE(clampedPrivateKey);
45
61
  const encodedPublic = encodeExtendedPoint(scalarMultBase(privateScalar));
@@ -5,7 +5,17 @@ import { X25519_PKCS8_PREFIX, X25519_SPKI_PREFIX } from '../curves/constants.js'
5
5
  import { pkcs8FromRawPrivate } from '../curves/types.js';
6
6
  import { FE_ONE } from '../math/constants.js';
7
7
  import { fe, feAdd, feFromBytes, feInv, feMul, fePack, feSub } from '../math/fe.js';
8
+ import { resolveNativeCryptoBackend } from '../nativeBackend.js';
8
9
  import { assertByteLength, concatBytes, decodeBase64Url, toBytesView } from '../../util/bytes.js';
10
+ const nativeX25519ScalarMult = (() => {
11
+ if (process.env.ZAPO_X25519_FORCE_JS)
12
+ return null;
13
+ const mod = resolveNativeCryptoBackend();
14
+ if (mod && typeof mod.x25519ScalarMult === 'function') {
15
+ return mod.x25519ScalarMult;
16
+ }
17
+ return null;
18
+ })();
9
19
  const diffieHellmanWithCallback = diffieHellman;
10
20
  let diffieHellmanAsync = null;
11
21
  let diffieHellmanAsyncProbed = false;
@@ -115,6 +125,9 @@ export class X25519 {
115
125
  static async scalarMult(privKey, pubKey) {
116
126
  assertByteLength(privKey, 32, 'x25519 private key must be 32 bytes');
117
127
  assertByteLength(pubKey, 32, 'x25519 public key must be 32 bytes');
128
+ if (nativeX25519ScalarMult) {
129
+ return toBytesView(nativeX25519ScalarMult(privKey, pubKey));
130
+ }
118
131
  const opts = {
119
132
  privateKey: x25519PrivateKeyObject(privKey),
120
133
  publicKey: x25519PublicKeyObject(pubKey)
@@ -0,0 +1,93 @@
1
+ import { createRequire } from 'node:module'
2
+ const require = createRequire(import.meta.url)
3
+ /**
4
+ * Resolves the optional native crypto accelerator backend shared by the
5
+ * X25519 and XEdDSA paths. Selected via `ZAPO_NATIVE_BACKEND`:
6
+ *
7
+ * - `auto` (default) – try the NAPI addon first, then the WASM build,
8
+ * then no accelerator.
9
+ * - `napi` – only the compiled Rust NAPI addon (`@zapo-js/native`).
10
+ * - `wasm` – only the wasm-bindgen build of the same Rust core.
11
+ * The shipped artifact targets the web (ESM glue +
12
+ * explicit init) so one build serves browsers and
13
+ * Node; loading it here relies on `require(esm)`,
14
+ * available unflagged on Node 20.19+ / 22.12+.
15
+ * - `js` / `none` – no accelerator; callers fall back to the pure-JS
16
+ * (and `node:crypto`) implementations.
17
+ *
18
+ * The module is resolved once and memoised. A failed backend resolves to
19
+ * `null` (or, under `auto`, falls through to the next one) so callers
20
+ * transparently fall back to JS. An absent backend stays silent - that is
21
+ * the expected shape of an optional accelerator - but a present-yet-broken
22
+ * one (corrupt binary, ABI mismatch, unsupported runtime) emits a process
23
+ * warning with code `ZAPO_NATIVE_LOAD_FAILED` so the degradation is
24
+ * visible. The per-primitive `ZAPO_X25519_FORCE_JS` / `ZAPO_XEDDSA_FORCE_JS`
25
+ * escape hatches are honoured by the callers, not here.
26
+ */
27
+ import { readFileSync } from 'node:fs';
28
+ import { toError } from '../util/primitives.js';
29
+ const WASM_MODULE_SPECIFIER = '@zapo-js/native/wasm/pkg/zapo_native_wasm.js';
30
+ const WASM_BINARY_SPECIFIER = '@zapo-js/native/wasm/pkg/zapo_native_wasm_bg.wasm';
31
+ function loadWasmBackend() {
32
+ const mod = require(WASM_MODULE_SPECIFIER);
33
+ const wasmBytes = readFileSync(require.resolve(WASM_BINARY_SPECIFIER));
34
+ mod.initSync({ module: wasmBytes });
35
+ return mod;
36
+ }
37
+ let cached;
38
+ const ABSENCE_TOKENS = {
39
+ napi: ["'@zapo-js/native'", 'binding.js', 'Cannot find native binding'],
40
+ wasm: ["'@zapo-js/native'", 'zapo_native_wasm.js']
41
+ };
42
+ /**
43
+ * Classifies a backend load failure: `true` means the backend is simply not
44
+ * available on this install (missing optional package, entry file absent,
45
+ * no prebuilt binary, ESM build without `require`) and the fallback should
46
+ * stay silent; `false` means the backend is present but broken and the
47
+ * degradation deserves a process warning.
48
+ */
49
+ export function isBackendAbsence(backend, error) {
50
+ if (error instanceof ReferenceError) {
51
+ return error.message.includes('require is not defined');
52
+ }
53
+ const { code, message } = (error ?? {});
54
+ if (typeof message !== 'string')
55
+ return false;
56
+ const notFound = code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND';
57
+ if (!notFound && !message.includes('Cannot find native binding'))
58
+ return false;
59
+ return ABSENCE_TOKENS[backend].some((token) => message.includes(token));
60
+ }
61
+ function warnLoadFailure(backend, error) {
62
+ process.emitWarning('failed to load native crypto accelerator, falling back', {
63
+ code: 'ZAPO_NATIVE_LOAD_FAILED',
64
+ detail: `${backend}: ${toError(error).message}`
65
+ });
66
+ }
67
+ export function resolveNativeCryptoBackend() {
68
+ if (cached !== undefined)
69
+ return cached;
70
+ const backend = (process.env.ZAPO_NATIVE_BACKEND ?? 'auto').toLowerCase();
71
+ cached = null;
72
+ if (backend === 'js' || backend === 'none')
73
+ return cached;
74
+ if (backend === 'napi' || backend === 'auto') {
75
+ try {
76
+ cached = require('@zapo-js/native');
77
+ }
78
+ catch (error) {
79
+ if (!isBackendAbsence('napi', error))
80
+ warnLoadFailure('napi', error);
81
+ }
82
+ }
83
+ if (cached === null && (backend === 'wasm' || backend === 'auto')) {
84
+ try {
85
+ cached = loadWasmBackend();
86
+ }
87
+ catch (error) {
88
+ if (!isBackendAbsence('wasm', error))
89
+ warnLoadFailure('wasm', error);
90
+ }
91
+ }
92
+ return cached;
93
+ }
package/dist/esm/index.js CHANGED
@@ -7,6 +7,7 @@ export { createFileCompanionHostPersistence } from './client/persistence/compani
7
7
  export { parseUsyncResultEnvelope } from './transport/node/builders/usync.js';
8
8
  export { getContentType, resolveEncMediaType, resolveMessageTarget, unwrapMessage } from './message/encode/content.js';
9
9
  export { getContextInfo } from './message/context-info.js';
10
+ export { decodeGroupHistoryBundle, encodeGroupHistoryBundle } from './message/kinds/group-history.js';
10
11
  export { resolveMediaPayload } from './message/encode/media-payload.js';
11
12
  export { unpadPkcs7, writeRandomPadMax16 } from './message/encode/padding.js';
12
13
  export { fetchLatestWaWebVersion, fetchLatestWaMobileVersion } from './transport/wa-version-fetcher.js';
@@ -14,7 +15,8 @@ export { ConsoleLogger } from './infra/log/ConsoleLogger.js';
14
15
  export { PinoLogger, createPinoLogger } from './infra/log/PinoLogger.js';
15
16
  export { createNoopLogger } from './infra/log/types.js';
16
17
  export { WA_VERSION } from './version-spec.js';
18
+ export { resolveAbPropNameByCode, WA_ABPROPS, WA_ABPROPS_BY_CODE, WA_ABPROPS_SPECIAL_EARLY, WA_ABPROPS_USED_BEFORE_INIT, WA_GROUP_ABPROPS, WA_GROUP_ABPROPS_BY_CODE } from './abprops-spec.js';
17
19
  export { createStore, WaAuthMemoryStore } from './store/index.js';
18
20
  export { delay } from './util/async.js';
19
- export { buildDeviceJid, canonicalizeSignalJid, canonicalizeSignalServer, getLoginIdentity, getWaCompanionPlatformId, getWaMediaHkdfInfo, isBotJid, isBroadcastJid, isGroupJid, isGroupOrBroadcastJid, isHostedDeviceId, isHostedDeviceJid, isHostedServer, isLidJid, isNewsletterJid, isStatusBroadcastJid, isUserJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parsePhoneJid, parseSignalAddressFromJid, signalAddressKey, splitJid, toUserJid, WA_ACCOUNT_SYNC_PROTOCOLS, WA_APP_STATE_COLLECTIONS, WA_APP_STATE_COLLECTION_STATES, WA_APP_STATE_ERROR_CODES, WA_APP_STATE_KDF_INFO, WA_APP_STATE_KEY_TYPES, WA_APP_STATE_SYNC_DATA_TYPE, WA_BROWSERS, WA_BUSINESS_HOURS_DAYS, WA_BUSINESS_HOURS_MODES, WA_COMPANION_PLATFORM_IDS, WA_DEFAULTS, WA_DIRTY_PROTOCOLS, WA_DIRTY_TYPES, WA_DISCONNECT_REASONS, WA_IQ_TYPES, WA_LOGOUT_REASONS, WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_MEDIA_HKDF_INFO, WA_NODE_TAGS, WA_PAIRING_KDF_INFO, WA_PREVIEW_MEDIA_HKDF_INFO, WA_READY_STATES, WA_RETRYABLE_ACK_CODES, WA_SIGNALING, WA_STREAM_SIGNALING, WA_SUPPORTED_DIRTY_TYPES, WA_PRIVACY_CATEGORIES, WA_PRIVACY_CATEGORY_TO_SETTING, WA_PRIVACY_DISALLOWED_LIST_CATEGORIES, WA_PRIVACY_SETTING_TO_CATEGORY, WA_PRIVACY_TAGS, WA_PRIVACY_VALUES, WA_XMLNS } from './protocol/index.js';
21
+ export { buildDeviceJid, canonicalizeSignalJid, canonicalizeSignalServer, getLoginIdentity, getWaCompanionPlatformId, getWaMediaHkdfInfo, isBotJid, isBroadcastJid, isGroupJid, isGroupOrBroadcastJid, isHostedDeviceId, isHostedDeviceJid, isHostedServer, isLidJid, isNewsletterJid, isStatusBroadcastJid, isUserJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parsePhoneJid, parseSignalAddressFromJid, signalAddressKey, splitJid, toUserJid, WA_ACCOUNT_SYNC_PROTOCOLS, WA_APP_STATE_COLLECTIONS, WA_APP_STATE_COLLECTION_STATES, WA_APP_STATE_ERROR_CODES, WA_APP_STATE_KDF_INFO, WA_APP_STATE_KEY_TYPES, WA_APP_STATE_SYNC_DATA_TYPE, WA_BROWSERS, WA_BUSINESS_HOURS_DAYS, WA_BUSINESS_HOURS_MODES, WA_COMPANION_PLATFORM_IDS, WA_DEFAULTS, WA_DIRTY_PROTOCOLS, WA_DIRTY_TYPES, WA_DISCONNECT_REASONS, WA_IQ_TYPES, WA_LOGOUT_REASONS, WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_MEDIA_HKDF_INFO, WA_NODE_TAGS, WA_PAIRING_KDF_INFO, WA_PREVIEW_MEDIA_HKDF_INFO, WA_READY_STATES, WA_RETRYABLE_ACK_CODES, WA_SIGNALING, WA_STREAM_SIGNALING, WA_SUPPORTED_DIRTY_TYPES, WA_PRIVACY_ACCOUNT_SYNC_DISALLOWED_LISTS, WA_PRIVACY_CATEGORIES, WA_PRIVACY_CATEGORY_TO_SETTING, WA_PRIVACY_DISALLOWED_LIST_CATEGORIES, WA_PRIVACY_SETTING_TO_CATEGORY, WA_PRIVACY_SETTING_VALUES, WA_PRIVACY_TAGS, WA_PRIVACY_VALUES, WA_XMLNS } from './protocol/index.js';
20
22
  export { proto } from './proto.js';
@@ -1,4 +1,5 @@
1
1
  import { isGroupOrBroadcastJid, toUserJid } from '../protocol/jid.js';
2
+ import { longToNumber } from '../util/primitives.js';
2
3
  export function buildContextInfoProto(input) {
3
4
  const ctx = {};
4
5
  if (input.quotedMessageId !== undefined)
@@ -20,6 +21,20 @@ export function buildContextInfoProto(input) {
20
21
  ctx.isSpoiler = input.isSpoiler;
21
22
  if (input.expirationSeconds !== undefined)
22
23
  ctx.expiration = input.expirationSeconds;
24
+ if (input.ephemeralSettingTimestamp !== undefined) {
25
+ ctx.ephemeralSettingTimestamp = input.ephemeralSettingTimestamp;
26
+ }
27
+ if (input.disappearingModeInitiator !== undefined ||
28
+ input.disappearingModeTrigger !== undefined) {
29
+ ctx.disappearingMode = {
30
+ ...(input.disappearingModeInitiator !== undefined
31
+ ? { initiator: input.disappearingModeInitiator }
32
+ : {}),
33
+ ...(input.disappearingModeTrigger !== undefined
34
+ ? { trigger: input.disappearingModeTrigger }
35
+ : {})
36
+ };
37
+ }
23
38
  if (input.groupSubject !== undefined)
24
39
  ctx.groupSubject = input.groupSubject;
25
40
  if (input.parentGroupJid !== undefined)
@@ -73,6 +88,30 @@ export function pickIncomingExpirationSeconds(message) {
73
88
  }
74
89
  return undefined;
75
90
  }
91
+ /**
92
+ * Reads `contextInfo.ephemeralSettingTimestamp` (Unix seconds) from the first
93
+ * submessage that carries it. Peers stamp it on every message in a disappearing
94
+ * chat, which makes it the only continuously-refreshed source for the setting -
95
+ * history sync and `EPHEMERAL_SETTING` are point-in-time.
96
+ */
97
+ export function pickIncomingEphemeralSettingTimestamp(message) {
98
+ if (!message)
99
+ return undefined;
100
+ const inner = message.ephemeralMessage?.message ?? message;
101
+ for (const key of Object.keys(inner)) {
102
+ const value = inner[key];
103
+ if (value &&
104
+ typeof value === 'object' &&
105
+ !Array.isArray(value) &&
106
+ !(value instanceof Uint8Array)) {
107
+ const stamp = value.contextInfo?.ephemeralSettingTimestamp;
108
+ if (stamp !== undefined && stamp !== null) {
109
+ return longToNumber(stamp);
110
+ }
111
+ }
112
+ }
113
+ return undefined;
114
+ }
76
115
  function pickContextInfoTarget(message) {
77
116
  for (const key of Object.keys(message)) {
78
117
  const value = message[key];
@@ -1,15 +1,31 @@
1
1
  import { proto } from '../../proto.js';
2
2
  import { WA_EDIT_ATTRS, WA_ENC_MEDIA_TYPES, WA_EVENT_META_TYPES, WA_POLL_META_TYPES, WA_STANZA_MSG_TYPES } from '../../protocol/constants.js';
3
+ /**
4
+ * Content keys that carry a payload but do not end in `Message`, so the
5
+ * substring match below would miss them.
6
+ */
7
+ const UNSUFFIXED_CONTENT_KEYS = new Set([
8
+ 'conversation',
9
+ 'messageHistoryBundle',
10
+ 'messageHistoryNotice'
11
+ ]);
3
12
  /**
4
13
  * Returns the content-type key of a message - `'conversation'`,
5
- * `'imageMessage'`, `'extendedTextMessage'`, etc. - or `undefined` for an empty
6
- * message. `senderKeyDistributionMessage` is skipped so group messages report
7
- * their real payload type rather than the piggy-backed sender-key.
14
+ * `'imageMessage'`, `'extendedTextMessage'`, `'messageHistoryBundle'`, etc. -
15
+ * or `undefined` for an empty message. `senderKeyDistributionMessage` is
16
+ * skipped so group messages report their real payload type rather than the
17
+ * piggy-backed sender-key.
18
+ *
19
+ * A key that is present but holds `null`/`undefined` carries no payload and is
20
+ * skipped, so an explicitly-cleared field cannot mask the real content.
8
21
  */
9
22
  export function getContentType(content) {
10
23
  if (!content)
11
24
  return undefined;
12
- const key = Object.keys(content).find((k) => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage');
25
+ const key = Object.keys(content).find((k) => content[k] !== null &&
26
+ content[k] !== undefined &&
27
+ (UNSUFFIXED_CONTENT_KEYS.has(k) || k.includes('Message')) &&
28
+ k !== 'senderKeyDistributionMessage');
13
29
  return key;
14
30
  }
15
31
  /**
@@ -28,7 +28,7 @@ function buildPayload(mediaType, fields) {
28
28
  * lacks the `directPath` / `mediaKey` needed to fetch and decrypt the blob (a
29
29
  * non-`Uint8Array` `mediaKey` also yields `null`). Supported kinds: image,
30
30
  * video (gif when `gifPlayback`), audio (ptt when `ptt`), document, sticker,
31
- * ptv.
31
+ * ptv, and the group-history bundle shared with a member after they join.
32
32
  *
33
33
  * @example
34
34
  * ```ts
@@ -56,5 +56,7 @@ export function resolveMediaPayload(message) {
56
56
  return buildPayload('sticker', msg.stickerMessage);
57
57
  if (msg.ptvMessage)
58
58
  return buildPayload('ptv', msg.ptvMessage);
59
+ if (msg.messageHistoryBundle)
60
+ return buildPayload('group-history', msg.messageHistoryBundle);
59
61
  return null;
60
62
  }