zapo-js 1.8.0 → 1.8.1

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.
@@ -278,7 +278,8 @@ class WaClientImpl extends node_events_1.EventEmitter {
278
278
  emitEvent: this.emit.bind(this),
279
279
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
280
280
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
281
- onProcessed: sendHistSyncReceipt
281
+ onProcessed: sendHistSyncReceipt,
282
+ meJid: this.deps.authClient.getCurrentCredentials()?.meJid
282
283
  }, protocolMessage.historySyncNotification);
283
284
  }
284
285
  else if (sendHistSyncReceipt) {
@@ -25,7 +25,7 @@ const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
25
25
  // serialize on the prekey lock and hit the store); excess under flood is dropped.
26
26
  const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
27
27
  const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
28
- const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
28
+ const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 4;
29
29
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
30
30
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
31
31
  const PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS = 14;
@@ -4,6 +4,7 @@ exports.runGroupHistoryBundle = runGroupHistoryBundle;
4
4
  exports.processGroupHistoryBundle = processGroupHistoryBundle;
5
5
  const history_blob_1 = require("../persistence/history-blob");
6
6
  const group_history_1 = require("../../message/kinds/group-history");
7
+ const incoming_1 = require("../../message/primitives/incoming");
7
8
  const _proto_1 = require("../../proto");
8
9
  const jid_1 = require("../../protocol/jid");
9
10
  const primitives_1 = require("../../util/primitives");
@@ -79,10 +80,12 @@ async function processGroupHistoryBundle(deps, input) {
79
80
  (oldestTimestampMs === undefined || timestampMs < oldestTimestampMs)) {
80
81
  oldestTimestampMs = timestampMs;
81
82
  }
83
+ const authorJid = (0, incoming_1.resolveWebMessageInfoAuthor)(webMsg, deps.meJid, input.groupJid);
82
84
  const write = deps.writeBehind.persistMessageAsync({
83
85
  id: webMsg.key.id,
84
86
  threadJid: input.groupJid,
85
- senderJid: webMsg.key.participant ?? undefined,
87
+ senderJid: authorJid,
88
+ participantJid: authorJid,
86
89
  fromMe: webMsg.key.fromMe === true,
87
90
  timestampMs: timestampMs || undefined,
88
91
  messageBytes: _proto_1.proto.Message.encode(webMsg.message).finish()
@@ -33,6 +33,8 @@ interface WaHistorySyncDeps {
33
33
  readonly onNctSalt?: (salt: Uint8Array) => Promise<void>;
34
34
  /** Acks the chunk via the `hist_sync` receipt so the primary stops resending it. */
35
35
  readonly onProcessed?: (syncType: Proto.Message.HistorySyncType) => Promise<void>;
36
+ /** Author fallback for self-sent group messages that carry no participant. */
37
+ readonly meJid?: string | null;
36
38
  }
37
39
  export declare function runHistorySyncNotification(deps: WaHistorySyncDeps, notification: Proto.Message.IHistorySyncNotification): Promise<void>;
38
40
  /**
@@ -4,6 +4,7 @@ exports.CONVERSATION_FIELDS = exports.HISTORY_SYNC_FIELDS = void 0;
4
4
  exports.runHistorySyncNotification = runHistorySyncNotification;
5
5
  exports.processHistorySyncNotification = processHistorySyncNotification;
6
6
  const history_blob_1 = require("../persistence/history-blob");
7
+ const incoming_1 = require("../../message/primitives/incoming");
7
8
  const _proto_1 = require("../../proto");
8
9
  const jid_1 = require("../../protocol/jid");
9
10
  const message_1 = require("../../protocol/message");
@@ -158,15 +159,7 @@ async function consumeHistorySyncStream(deps, inflated, state) {
158
159
  parked[parked.length] = message;
159
160
  return undefined;
160
161
  }
161
- state.messagesCount += 1;
162
- pushWrite(state, deps.writeBehind.persistMessageAsync({
163
- id: message.id,
164
- threadJid,
165
- senderJid: message.senderJid,
166
- fromMe: message.fromMe,
167
- timestampMs: message.timestampMs,
168
- messageBytes: message.messageBytes
169
- }));
162
+ persistHistoryMessage(deps, state, message, threadJid);
170
163
  return maybeFlush(state);
171
164
  }
172
165
  if (headerParts) {
@@ -292,18 +285,35 @@ async function closeConversation(deps, state, headerParts, threadJid, parked) {
292
285
  }
293
286
  }
294
287
  for (const message of parked ?? []) {
295
- state.messagesCount += 1;
296
- pushWrite(state, deps.writeBehind.persistMessageAsync({
297
- id: message.id,
298
- threadJid: resolvedJid,
299
- senderJid: message.senderJid,
300
- fromMe: message.fromMe,
301
- timestampMs: message.timestampMs,
302
- messageBytes: message.messageBytes
303
- }));
288
+ persistHistoryMessage(deps, state, message, resolvedJid);
304
289
  }
305
290
  await maybeFlush(state);
306
291
  }
292
+ /**
293
+ * Resolves the author here rather than at decode time because a message may be
294
+ * read before its `Conversation.id`, and the thread type decides both the
295
+ * self-sent fallback and the shape of the record.
296
+ *
297
+ * `participantJid` carries the group/broadcast author and stays absent in 1:1
298
+ * threads, where `senderJid` falls back to the thread JID - the same shape the
299
+ * live message path persists. A group author that stays unresolved leaves both
300
+ * fields empty: writing the group JID there would read downstream as if the
301
+ * group itself had sent the message.
302
+ */
303
+ function persistHistoryMessage(deps, state, message, threadJid) {
304
+ state.messagesCount += 1;
305
+ const authorJid = (0, incoming_1.resolveWebMessageInfoAuthor)(message.authorInfo, deps.meJid, threadJid);
306
+ const isGroupOrBroadcast = (0, jid_1.isGroupJid)(threadJid) || (0, jid_1.isBroadcastJid)(threadJid);
307
+ pushWrite(state, deps.writeBehind.persistMessageAsync({
308
+ id: message.id,
309
+ threadJid,
310
+ senderJid: isGroupOrBroadcast ? authorJid : (authorJid ?? threadJid),
311
+ participantJid: isGroupOrBroadcast ? authorJid : undefined,
312
+ fromMe: message.fromMe,
313
+ timestampMs: message.timestampMs,
314
+ messageBytes: message.messageBytes
315
+ }));
316
+ }
307
317
  async function settleHistorySyncChunk(deps, state) {
308
318
  for (const pushname of state.pushnames) {
309
319
  if (!pushname.id) {
@@ -365,7 +375,11 @@ function readHistoryMessage(record) {
365
375
  const timestampMs = (0, primitives_1.longToNumber)(webMsg.messageTimestamp) * 1000;
366
376
  return {
367
377
  id: webMsg.key.id,
368
- senderJid: webMsg.key.participant ?? undefined,
378
+ authorInfo: {
379
+ key: { fromMe: webMsg.key.fromMe, participant: webMsg.key.participant },
380
+ participant: webMsg.participant,
381
+ originalSelfAuthorUserJidString: webMsg.originalSelfAuthorUserJidString
382
+ },
369
383
  fromMe: webMsg.key.fromMe === true,
370
384
  timestampMs: timestampMs || undefined,
371
385
  messageBytes: _proto_1.proto.Message.encode(webMsg.message).finish()
@@ -275,7 +275,8 @@ class WaClientImpl extends EventEmitter {
275
275
  emitEvent: this.emit.bind(this),
276
276
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
277
277
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
278
- onProcessed: sendHistSyncReceipt
278
+ onProcessed: sendHistSyncReceipt,
279
+ meJid: this.deps.authClient.getCurrentCredentials()?.meJid
279
280
  }, protocolMessage.historySyncNotification);
280
281
  }
281
282
  else if (sendHistSyncReceipt) {
@@ -22,7 +22,7 @@ const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
22
22
  // serialize on the prekey lock and hit the store); excess under flood is dropped.
23
23
  const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
24
24
  const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
25
- const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
25
+ const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 4;
26
26
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
27
27
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
28
28
  const PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS = 14;
@@ -1,5 +1,6 @@
1
1
  import { flushPendingWrites, openHistoryBlobStream } from '../persistence/history-blob.js';
2
2
  import { streamGroupHistoryBundle } from '../../message/kinds/group-history.js';
3
+ import { resolveWebMessageInfoAuthor } from '../../message/primitives/incoming.js';
3
4
  import { proto } from '../../proto.js';
4
5
  import { isGroupJid, toUserJid } from '../../protocol/jid.js';
5
6
  import { longToNumber, toError } from '../../util/primitives.js';
@@ -75,10 +76,12 @@ export async function processGroupHistoryBundle(deps, input) {
75
76
  (oldestTimestampMs === undefined || timestampMs < oldestTimestampMs)) {
76
77
  oldestTimestampMs = timestampMs;
77
78
  }
79
+ const authorJid = resolveWebMessageInfoAuthor(webMsg, deps.meJid, input.groupJid);
78
80
  const write = deps.writeBehind.persistMessageAsync({
79
81
  id: webMsg.key.id,
80
82
  threadJid: input.groupJid,
81
- senderJid: webMsg.key.participant ?? undefined,
83
+ senderJid: authorJid,
84
+ participantJid: authorJid,
82
85
  fromMe: webMsg.key.fromMe === true,
83
86
  timestampMs: timestampMs || undefined,
84
87
  messageBytes: proto.Message.encode(webMsg.message).finish()
@@ -1,6 +1,7 @@
1
1
  import { flushPendingWrites, openHistoryBlobStream } from '../persistence/history-blob.js';
2
+ import { resolveWebMessageInfoAuthor } from '../../message/primitives/incoming.js';
2
3
  import { proto } from '../../proto.js';
3
- import { isUserJid } from '../../protocol/jid.js';
4
+ import { isBroadcastJid, isGroupJid, isUserJid } from '../../protocol/jid.js';
4
5
  import { normalizeEphemeralSettingSeconds } from '../../protocol/message.js';
5
6
  import { normalizeUsername } from '../../protocol/username.js';
6
7
  import { concatBytes, TEXT_DECODER } from '../../util/bytes.js';
@@ -153,15 +154,7 @@ async function consumeHistorySyncStream(deps, inflated, state) {
153
154
  parked[parked.length] = message;
154
155
  return undefined;
155
156
  }
156
- state.messagesCount += 1;
157
- pushWrite(state, deps.writeBehind.persistMessageAsync({
158
- id: message.id,
159
- threadJid,
160
- senderJid: message.senderJid,
161
- fromMe: message.fromMe,
162
- timestampMs: message.timestampMs,
163
- messageBytes: message.messageBytes
164
- }));
157
+ persistHistoryMessage(deps, state, message, threadJid);
165
158
  return maybeFlush(state);
166
159
  }
167
160
  if (headerParts) {
@@ -287,18 +280,35 @@ async function closeConversation(deps, state, headerParts, threadJid, parked) {
287
280
  }
288
281
  }
289
282
  for (const message of parked ?? []) {
290
- state.messagesCount += 1;
291
- pushWrite(state, deps.writeBehind.persistMessageAsync({
292
- id: message.id,
293
- threadJid: resolvedJid,
294
- senderJid: message.senderJid,
295
- fromMe: message.fromMe,
296
- timestampMs: message.timestampMs,
297
- messageBytes: message.messageBytes
298
- }));
283
+ persistHistoryMessage(deps, state, message, resolvedJid);
299
284
  }
300
285
  await maybeFlush(state);
301
286
  }
287
+ /**
288
+ * Resolves the author here rather than at decode time because a message may be
289
+ * read before its `Conversation.id`, and the thread type decides both the
290
+ * self-sent fallback and the shape of the record.
291
+ *
292
+ * `participantJid` carries the group/broadcast author and stays absent in 1:1
293
+ * threads, where `senderJid` falls back to the thread JID - the same shape the
294
+ * live message path persists. A group author that stays unresolved leaves both
295
+ * fields empty: writing the group JID there would read downstream as if the
296
+ * group itself had sent the message.
297
+ */
298
+ function persistHistoryMessage(deps, state, message, threadJid) {
299
+ state.messagesCount += 1;
300
+ const authorJid = resolveWebMessageInfoAuthor(message.authorInfo, deps.meJid, threadJid);
301
+ const isGroupOrBroadcast = isGroupJid(threadJid) || isBroadcastJid(threadJid);
302
+ pushWrite(state, deps.writeBehind.persistMessageAsync({
303
+ id: message.id,
304
+ threadJid,
305
+ senderJid: isGroupOrBroadcast ? authorJid : (authorJid ?? threadJid),
306
+ participantJid: isGroupOrBroadcast ? authorJid : undefined,
307
+ fromMe: message.fromMe,
308
+ timestampMs: message.timestampMs,
309
+ messageBytes: message.messageBytes
310
+ }));
311
+ }
302
312
  async function settleHistorySyncChunk(deps, state) {
303
313
  for (const pushname of state.pushnames) {
304
314
  if (!pushname.id) {
@@ -360,7 +370,11 @@ function readHistoryMessage(record) {
360
370
  const timestampMs = longToNumber(webMsg.messageTimestamp) * 1000;
361
371
  return {
362
372
  id: webMsg.key.id,
363
- senderJid: webMsg.key.participant ?? undefined,
373
+ authorInfo: {
374
+ key: { fromMe: webMsg.key.fromMe, participant: webMsg.key.participant },
375
+ participant: webMsg.participant,
376
+ originalSelfAuthorUserJidString: webMsg.originalSelfAuthorUserJidString
377
+ },
364
378
  fromMe: webMsg.key.fromMe === true,
365
379
  timestampMs: timestampMs || undefined,
366
380
  messageBytes: proto.Message.encode(webMsg.message).finish()
@@ -12,6 +12,12 @@ export const MAC_KEY_END = 80;
12
12
  export const HMAC_TRUNCATED_SIZE = 10;
13
13
  export const SIDECAR_CHUNK_SIZE = 65536;
14
14
  export const SIDECAR_HMAC_SIZE = 10;
15
+ /**
16
+ * Leading bytes buffered when deciding whether a payload supports progressive
17
+ * playback. The top-level boxes that settle it sit at the very front of the
18
+ * file, so this only has to survive a few small boxes before `moov`/`mdat`.
19
+ */
20
+ export const CONTAINER_SCAN_MAX_BYTES = 1024;
15
21
  export const MEDIA_UPLOAD_PATHS = Object.freeze({
16
22
  image: '/mms/image',
17
23
  video: '/mms/video',
@@ -0,0 +1,105 @@
1
+ import { CONTAINER_SCAN_MAX_BYTES } from './constants.js';
2
+ const BOX_HEADER_SIZE = 8;
3
+ const LARGE_BOX_HEADER_SIZE = 16;
4
+ const LARGE_BOX_SIZE_MARKER = 1;
5
+ const BOX_TYPE_FTYP = 0x66747970;
6
+ const BOX_TYPE_MOOV = 0x6d6f6f76;
7
+ const BOX_TYPE_MDAT = 0x6d646174;
8
+ function readU32(bytes, offset) {
9
+ return (((bytes[offset] << 24) |
10
+ (bytes[offset + 1] << 16) |
11
+ (bytes[offset + 2] << 8) |
12
+ bytes[offset + 3]) >>>
13
+ 0);
14
+ }
15
+ /**
16
+ * Decides whether a media payload supports progressive playback, by walking
17
+ * the top-level ISO base media file format (mp4/mov/3gp/m4a) boxes over the
18
+ * leading bytes.
19
+ *
20
+ * An ISO-BMFF file is streamable only when its `moov` index box precedes the
21
+ * `mdat` payload box. With `mdat` first, a player has to reach the end of the
22
+ * download before it can start decoding, so advertising the file as seekable
23
+ * (by attaching a streaming sidecar) makes the recipient fail to open it
24
+ * instead of falling back to a plain download.
25
+ *
26
+ * Anything that is not ISO-BMFF - Ogg/Opus voice notes, for instance - is
27
+ * reported as streamable: those containers carry their index inline and have
28
+ * no equivalent failure mode.
29
+ *
30
+ * @param head - Leading bytes of the payload, starting at offset 0.
31
+ * @returns `true` when streamable, `false` when not, `null` when `head` is
32
+ * too short to tell and the caller should retry with more bytes.
33
+ */
34
+ export function scanStreamable(head) {
35
+ if (head.byteLength < BOX_HEADER_SIZE) {
36
+ return null;
37
+ }
38
+ if (readU32(head, 4) !== BOX_TYPE_FTYP) {
39
+ return true;
40
+ }
41
+ let offset = 0;
42
+ while (offset + BOX_HEADER_SIZE <= head.byteLength) {
43
+ const type = readU32(head, offset + 4);
44
+ if (type === BOX_TYPE_MOOV)
45
+ return true;
46
+ if (type === BOX_TYPE_MDAT)
47
+ return false;
48
+ let size = readU32(head, offset);
49
+ let headerSize = BOX_HEADER_SIZE;
50
+ if (size === LARGE_BOX_SIZE_MARKER) {
51
+ if (offset + LARGE_BOX_HEADER_SIZE > head.byteLength)
52
+ return null;
53
+ if (readU32(head, offset + BOX_HEADER_SIZE) !== 0)
54
+ return true;
55
+ size = readU32(head, offset + BOX_HEADER_SIZE + 4);
56
+ headerSize = LARGE_BOX_HEADER_SIZE;
57
+ }
58
+ else if (size === 0) {
59
+ return true;
60
+ }
61
+ if (size < headerSize)
62
+ return true;
63
+ offset += size;
64
+ if (offset > CONTAINER_SCAN_MAX_BYTES)
65
+ return true;
66
+ }
67
+ return offset > CONTAINER_SCAN_MAX_BYTES ? true : null;
68
+ }
69
+ /**
70
+ * Incremental front-end for {@link scanStreamable}, for callers that see the
71
+ * payload as a sequence of chunks. Buffers at most
72
+ * {@link CONTAINER_SCAN_MAX_BYTES} leading bytes and settles on `true` once
73
+ * that budget is spent without a verdict.
74
+ */
75
+ export class WaStreamableScanner {
76
+ constructor() {
77
+ this.head = new Uint8Array(CONTAINER_SCAN_MAX_BYTES);
78
+ this.headLength = 0;
79
+ this.settled = null;
80
+ }
81
+ /** Verdict so far; `null` until enough leading bytes have been seen. */
82
+ get verdict() {
83
+ return this.settled;
84
+ }
85
+ /** Feeds the next payload chunk. Ignored once the verdict has settled. */
86
+ push(chunk) {
87
+ if (this.settled !== null || chunk.byteLength === 0) {
88
+ return;
89
+ }
90
+ const room = this.head.byteLength - this.headLength;
91
+ if (room > 0) {
92
+ const copied = Math.min(room, chunk.byteLength);
93
+ this.head.set(chunk.subarray(0, copied), this.headLength);
94
+ this.headLength += copied;
95
+ }
96
+ this.settled = scanStreamable(this.head.subarray(0, this.headLength));
97
+ if (this.settled === null && this.headLength === this.head.byteLength) {
98
+ this.settled = true;
99
+ }
100
+ }
101
+ /** Verdict to use once the payload has ended, defaulting to streamable. */
102
+ finish() {
103
+ return this.settled ?? true;
104
+ }
105
+ }
@@ -8,6 +8,7 @@ import { hkdf } from '../../crypto/core/hkdf.js';
8
8
  import { aesCbcDecrypt, aesCbcEncryptWithTrailer, hmacSha256Sign, sha256 } from '../../crypto/core/primitives.js';
9
9
  import { randomBytesAsync } from '../../crypto/core/random.js';
10
10
  import { ENC_KEY_END, ENC_KEY_START, HMAC_TRUNCATED_SIZE, IV_SIZE, MAC_KEY_END, MAC_KEY_START, MEDIA_HKDF_SIZE, SIDECAR_CHUNK_SIZE, SIDECAR_HMAC_SIZE } from '../constants.js';
11
+ import { scanStreamable, WaStreamableScanner } from '../container.js';
11
12
  import { getWaMediaHkdfInfo, WA_APP_STATE_KEY_TYPES } from '../../protocol/constants.js';
12
13
  import { assertByteLength, toBytesView, toChunkBytes, uint8TimingSafeEqual } from '../../util/bytes.js';
13
14
  import { toError } from '../../util/primitives.js';
@@ -105,7 +106,7 @@ export class WaMediaCrypto {
105
106
  const signature = mac.subarray(0, HMAC_TRUNCATED_SIZE);
106
107
  ciphertextHmac.set(signature, cipherLength);
107
108
  let streamingSidecar;
108
- if (options?.sidecar !== false) {
109
+ if (options?.sidecar !== false && scanStreamable(plaintext) !== false) {
109
110
  const acc = new SidecarAccumulator(keys.macKey, plaintext.byteLength);
110
111
  acc.push(keys.iv);
111
112
  acc.push(ciphertext);
@@ -219,7 +220,8 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
219
220
  const plainHash = createHash('sha256');
220
221
  const encHash = createHash('sha256');
221
222
  const hmac = createHmac('sha256', keys.macKey);
222
- const sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
223
+ let sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
224
+ const scanner = computeSidecar ? new WaStreamableScanner() : null;
223
225
  const ffTarget = firstFrameLength !== undefined
224
226
  ? IV_SIZE + Math.ceil(firstFrameLength / AES_BLOCK_SIZE) * AES_BLOCK_SIZE
225
227
  : 0;
@@ -249,6 +251,11 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
249
251
  continue;
250
252
  plaintextLength += plainChunk.byteLength;
251
253
  plainHash.update(plainChunk);
254
+ if (scanner !== null && scanner.verdict === null) {
255
+ scanner.push(plainChunk);
256
+ if (scanner.verdict === false)
257
+ sidecar = null;
258
+ }
252
259
  await consumeCiphertext(cipher.update(plainChunk));
253
260
  }
254
261
  await consumeCiphertext(cipher.final());
@@ -125,6 +125,32 @@ function buildIncomingEventRawNode(node) {
125
125
  content: children
126
126
  };
127
127
  }
128
+ /**
129
+ * Resolves who authored a {@link proto.IWebMessageInfo}. The top-level
130
+ * `participant` wins over `key.participant` - history sync carries the group
131
+ * sender in the former and leaves the latter empty on a large share of records.
132
+ * A self-sent group/broadcast message with neither falls back to this account,
133
+ * preferring `originalSelfAuthorUserJidString` over `meJid` (the current account
134
+ * user JID) so a message authored under a previous identity keeps its author.
135
+ *
136
+ * The participant fields are read whatever the thread type is, matching how
137
+ * WhatsApp Web builds the author: only the self-sent fallback is gated on
138
+ * group/broadcast. A 1:1 record normally carries neither, so this returns
139
+ * `undefined` there and the caller supplies the thread JID instead.
140
+ *
141
+ * `threadJid` overrides `key.remoteJid` for carriers that know the thread out
142
+ * of band, such as a group history bundle whose records may omit it.
143
+ */
144
+ export function resolveWebMessageInfoAuthor(webMessageInfo, meJid, threadJid) {
145
+ const key = webMessageInfo.key ?? {};
146
+ const chatJid = threadJid ?? key.remoteJid ?? undefined;
147
+ const isGroupOrBroadcast = chatJid ? isGroupJid(chatJid) || isBroadcastJid(chatJid) : false;
148
+ const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
149
+ const selfAuthor = key.fromMe === true && isGroupOrBroadcast && rawSelfAuthor
150
+ ? toUserJid(rawSelfAuthor)
151
+ : undefined;
152
+ return webMessageInfo.participant ?? key.participant ?? selfAuthor ?? undefined;
153
+ }
128
154
  /**
129
155
  * Rebuilds a `message` event from a recovered {@link proto.IWebMessageInfo}
130
156
  * (placeholder resend). `meJid` is the current account user JID, used as the
@@ -137,9 +163,7 @@ export function buildRecoveredIncomingEvent(webMessageInfo, meJid) {
137
163
  const fromMe = key.fromMe === true;
138
164
  const isGroup = chatJid ? isGroupJid(chatJid) : false;
139
165
  const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
140
- const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
141
- const selfAuthor = fromMe && (isGroup || isBroadcast) && rawSelfAuthor ? toUserJid(rawSelfAuthor) : undefined;
142
- const participant = webMessageInfo.participant ?? key.participant ?? selfAuthor;
166
+ const participant = resolveWebMessageInfoAuthor(webMessageInfo, meJid);
143
167
  const pushName = webMessageInfo.pushName ?? undefined;
144
168
  const rawSender = fromMe ? undefined : isGroup || isBroadcast ? participant : chatJid;
145
169
  const sender = rawSender ? parseJidFull(rawSender) : null;
@@ -9,6 +9,12 @@ export declare const MAC_KEY_END = 80;
9
9
  export declare const HMAC_TRUNCATED_SIZE = 10;
10
10
  export declare const SIDECAR_CHUNK_SIZE = 65536;
11
11
  export declare const SIDECAR_HMAC_SIZE = 10;
12
+ /**
13
+ * Leading bytes buffered when deciding whether a payload supports progressive
14
+ * playback. The top-level boxes that settle it sit at the very front of the
15
+ * file, so this only has to survive a few small boxes before `moov`/`mdat`.
16
+ */
17
+ export declare const CONTAINER_SCAN_MAX_BYTES = 1024;
12
18
  export type MediaUploadKind = 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'gif' | 'ptt' | 'ptv' | 'ads-image' | 'ads-video' | 'group-history' | 'md-app-state' | 'md-msg-hist' | 'music-artwork' | 'newsletter-music-artwork' | 'sticker-pack' | 'thumbnail-document' | 'thumbnail-image' | 'thumbnail-link' | 'thumbnail-sticker-pack' | 'thumbnail-video' | 'waffle-image' | 'waffle-video';
13
19
  export declare const MEDIA_UPLOAD_PATHS: Readonly<Record<MediaUploadKind, string>>;
14
20
  export type NewsletterMediaKind = 'image' | 'video' | 'audio' | 'document' | 'sticker' | 'sticker-pack' | 'gif' | 'ptt' | 'ptv' | 'thumbnail-link';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.NEWSLETTER_MEDIA_UPLOAD_PATHS = exports.PPS_UPLOAD_PATHS = exports.MEDIA_UPLOAD_PATHS = exports.SIDECAR_HMAC_SIZE = exports.SIDECAR_CHUNK_SIZE = exports.HMAC_TRUNCATED_SIZE = exports.MAC_KEY_END = exports.MAC_KEY_START = exports.ENC_KEY_END = exports.ENC_KEY_START = exports.IV_SIZE = exports.MEDIA_HKDF_SIZE = exports.MEDIA_CONN_CACHE_GRACE_MS = exports.DEFAULT_MEDIA_HOSTS = void 0;
3
+ exports.NEWSLETTER_MEDIA_UPLOAD_PATHS = exports.PPS_UPLOAD_PATHS = exports.MEDIA_UPLOAD_PATHS = exports.CONTAINER_SCAN_MAX_BYTES = exports.SIDECAR_HMAC_SIZE = exports.SIDECAR_CHUNK_SIZE = exports.HMAC_TRUNCATED_SIZE = exports.MAC_KEY_END = exports.MAC_KEY_START = exports.ENC_KEY_END = exports.ENC_KEY_START = exports.IV_SIZE = exports.MEDIA_HKDF_SIZE = exports.MEDIA_CONN_CACHE_GRACE_MS = exports.DEFAULT_MEDIA_HOSTS = void 0;
4
4
  exports.DEFAULT_MEDIA_HOSTS = Object.freeze([
5
5
  'mmg.whatsapp.net',
6
6
  'mmg-fallback.whatsapp.net'
@@ -15,6 +15,12 @@ exports.MAC_KEY_END = 80;
15
15
  exports.HMAC_TRUNCATED_SIZE = 10;
16
16
  exports.SIDECAR_CHUNK_SIZE = 65536;
17
17
  exports.SIDECAR_HMAC_SIZE = 10;
18
+ /**
19
+ * Leading bytes buffered when deciding whether a payload supports progressive
20
+ * playback. The top-level boxes that settle it sit at the very front of the
21
+ * file, so this only has to survive a few small boxes before `moov`/`mdat`.
22
+ */
23
+ exports.CONTAINER_SCAN_MAX_BYTES = 1024;
18
24
  exports.MEDIA_UPLOAD_PATHS = Object.freeze({
19
25
  image: '/mms/image',
20
26
  video: '/mms/video',
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Verdict of {@link scanStreamable}: `true` when the payload can be played
3
+ * back while it downloads, `false` when it cannot, `null` while the leading
4
+ * bytes seen so far are not enough to decide.
5
+ */
6
+ export type WaStreamableVerdict = boolean | null;
7
+ /**
8
+ * Decides whether a media payload supports progressive playback, by walking
9
+ * the top-level ISO base media file format (mp4/mov/3gp/m4a) boxes over the
10
+ * leading bytes.
11
+ *
12
+ * An ISO-BMFF file is streamable only when its `moov` index box precedes the
13
+ * `mdat` payload box. With `mdat` first, a player has to reach the end of the
14
+ * download before it can start decoding, so advertising the file as seekable
15
+ * (by attaching a streaming sidecar) makes the recipient fail to open it
16
+ * instead of falling back to a plain download.
17
+ *
18
+ * Anything that is not ISO-BMFF - Ogg/Opus voice notes, for instance - is
19
+ * reported as streamable: those containers carry their index inline and have
20
+ * no equivalent failure mode.
21
+ *
22
+ * @param head - Leading bytes of the payload, starting at offset 0.
23
+ * @returns `true` when streamable, `false` when not, `null` when `head` is
24
+ * too short to tell and the caller should retry with more bytes.
25
+ */
26
+ export declare function scanStreamable(head: Uint8Array): WaStreamableVerdict;
27
+ /**
28
+ * Incremental front-end for {@link scanStreamable}, for callers that see the
29
+ * payload as a sequence of chunks. Buffers at most
30
+ * {@link CONTAINER_SCAN_MAX_BYTES} leading bytes and settles on `true` once
31
+ * that budget is spent without a verdict.
32
+ */
33
+ export declare class WaStreamableScanner {
34
+ private readonly head;
35
+ private headLength;
36
+ private settled;
37
+ /** Verdict so far; `null` until enough leading bytes have been seen. */
38
+ get verdict(): WaStreamableVerdict;
39
+ /** Feeds the next payload chunk. Ignored once the verdict has settled. */
40
+ push(chunk: Uint8Array): void;
41
+ /** Verdict to use once the payload has ended, defaulting to streamable. */
42
+ finish(): boolean;
43
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.WaStreamableScanner = void 0;
4
+ exports.scanStreamable = scanStreamable;
5
+ const constants_1 = require("./constants");
6
+ const BOX_HEADER_SIZE = 8;
7
+ const LARGE_BOX_HEADER_SIZE = 16;
8
+ const LARGE_BOX_SIZE_MARKER = 1;
9
+ const BOX_TYPE_FTYP = 0x66747970;
10
+ const BOX_TYPE_MOOV = 0x6d6f6f76;
11
+ const BOX_TYPE_MDAT = 0x6d646174;
12
+ function readU32(bytes, offset) {
13
+ return (((bytes[offset] << 24) |
14
+ (bytes[offset + 1] << 16) |
15
+ (bytes[offset + 2] << 8) |
16
+ bytes[offset + 3]) >>>
17
+ 0);
18
+ }
19
+ /**
20
+ * Decides whether a media payload supports progressive playback, by walking
21
+ * the top-level ISO base media file format (mp4/mov/3gp/m4a) boxes over the
22
+ * leading bytes.
23
+ *
24
+ * An ISO-BMFF file is streamable only when its `moov` index box precedes the
25
+ * `mdat` payload box. With `mdat` first, a player has to reach the end of the
26
+ * download before it can start decoding, so advertising the file as seekable
27
+ * (by attaching a streaming sidecar) makes the recipient fail to open it
28
+ * instead of falling back to a plain download.
29
+ *
30
+ * Anything that is not ISO-BMFF - Ogg/Opus voice notes, for instance - is
31
+ * reported as streamable: those containers carry their index inline and have
32
+ * no equivalent failure mode.
33
+ *
34
+ * @param head - Leading bytes of the payload, starting at offset 0.
35
+ * @returns `true` when streamable, `false` when not, `null` when `head` is
36
+ * too short to tell and the caller should retry with more bytes.
37
+ */
38
+ function scanStreamable(head) {
39
+ if (head.byteLength < BOX_HEADER_SIZE) {
40
+ return null;
41
+ }
42
+ if (readU32(head, 4) !== BOX_TYPE_FTYP) {
43
+ return true;
44
+ }
45
+ let offset = 0;
46
+ while (offset + BOX_HEADER_SIZE <= head.byteLength) {
47
+ const type = readU32(head, offset + 4);
48
+ if (type === BOX_TYPE_MOOV)
49
+ return true;
50
+ if (type === BOX_TYPE_MDAT)
51
+ return false;
52
+ let size = readU32(head, offset);
53
+ let headerSize = BOX_HEADER_SIZE;
54
+ if (size === LARGE_BOX_SIZE_MARKER) {
55
+ if (offset + LARGE_BOX_HEADER_SIZE > head.byteLength)
56
+ return null;
57
+ if (readU32(head, offset + BOX_HEADER_SIZE) !== 0)
58
+ return true;
59
+ size = readU32(head, offset + BOX_HEADER_SIZE + 4);
60
+ headerSize = LARGE_BOX_HEADER_SIZE;
61
+ }
62
+ else if (size === 0) {
63
+ return true;
64
+ }
65
+ if (size < headerSize)
66
+ return true;
67
+ offset += size;
68
+ if (offset > constants_1.CONTAINER_SCAN_MAX_BYTES)
69
+ return true;
70
+ }
71
+ return offset > constants_1.CONTAINER_SCAN_MAX_BYTES ? true : null;
72
+ }
73
+ /**
74
+ * Incremental front-end for {@link scanStreamable}, for callers that see the
75
+ * payload as a sequence of chunks. Buffers at most
76
+ * {@link CONTAINER_SCAN_MAX_BYTES} leading bytes and settles on `true` once
77
+ * that budget is spent without a verdict.
78
+ */
79
+ class WaStreamableScanner {
80
+ constructor() {
81
+ this.head = new Uint8Array(constants_1.CONTAINER_SCAN_MAX_BYTES);
82
+ this.headLength = 0;
83
+ this.settled = null;
84
+ }
85
+ /** Verdict so far; `null` until enough leading bytes have been seen. */
86
+ get verdict() {
87
+ return this.settled;
88
+ }
89
+ /** Feeds the next payload chunk. Ignored once the verdict has settled. */
90
+ push(chunk) {
91
+ if (this.settled !== null || chunk.byteLength === 0) {
92
+ return;
93
+ }
94
+ const room = this.head.byteLength - this.headLength;
95
+ if (room > 0) {
96
+ const copied = Math.min(room, chunk.byteLength);
97
+ this.head.set(chunk.subarray(0, copied), this.headLength);
98
+ this.headLength += copied;
99
+ }
100
+ this.settled = scanStreamable(this.head.subarray(0, this.headLength));
101
+ if (this.settled === null && this.headLength === this.head.byteLength) {
102
+ this.settled = true;
103
+ }
104
+ }
105
+ /** Verdict to use once the payload has ended, defaulting to streamable. */
106
+ finish() {
107
+ return this.settled ?? true;
108
+ }
109
+ }
110
+ exports.WaStreamableScanner = WaStreamableScanner;
@@ -11,6 +11,7 @@ const hkdf_1 = require("../../crypto/core/hkdf");
11
11
  const primitives_1 = require("../../crypto/core/primitives");
12
12
  const random_1 = require("../../crypto/core/random");
13
13
  const constants_1 = require("../constants");
14
+ const container_1 = require("../container");
14
15
  const constants_2 = require("../../protocol/constants");
15
16
  const bytes_1 = require("../../util/bytes");
16
17
  const primitives_2 = require("../../util/primitives");
@@ -108,7 +109,7 @@ class WaMediaCrypto {
108
109
  const signature = mac.subarray(0, constants_1.HMAC_TRUNCATED_SIZE);
109
110
  ciphertextHmac.set(signature, cipherLength);
110
111
  let streamingSidecar;
111
- if (options?.sidecar !== false) {
112
+ if (options?.sidecar !== false && (0, container_1.scanStreamable)(plaintext) !== false) {
112
113
  const acc = new SidecarAccumulator(keys.macKey, plaintext.byteLength);
113
114
  acc.push(keys.iv);
114
115
  acc.push(ciphertext);
@@ -223,7 +224,8 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
223
224
  const plainHash = (0, node_crypto_1.createHash)('sha256');
224
225
  const encHash = (0, node_crypto_1.createHash)('sha256');
225
226
  const hmac = (0, node_crypto_1.createHmac)('sha256', keys.macKey);
226
- const sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
227
+ let sidecar = computeSidecar ? new SidecarAccumulator(keys.macKey, expectedFileSize) : null;
228
+ const scanner = computeSidecar ? new container_1.WaStreamableScanner() : null;
227
229
  const ffTarget = firstFrameLength !== undefined
228
230
  ? constants_1.IV_SIZE + Math.ceil(firstFrameLength / AES_BLOCK_SIZE) * AES_BLOCK_SIZE
229
231
  : 0;
@@ -253,6 +255,11 @@ async function pumpEncryption(plaintext, output, keys, computeSidecar, firstFram
253
255
  continue;
254
256
  plaintextLength += plainChunk.byteLength;
255
257
  plainHash.update(plainChunk);
258
+ if (scanner !== null && scanner.verdict === null) {
259
+ scanner.push(plainChunk);
260
+ if (scanner.verdict === false)
261
+ sidecar = null;
262
+ }
256
263
  await consumeCiphertext(cipher.update(plainChunk));
257
264
  }
258
265
  await consumeCiphertext(cipher.final());
@@ -24,6 +24,23 @@ interface WaIncomingMessageAckHandlerOptions {
24
24
  readonly emitUnhandledStanza?: (event: WaIncomingUnhandledStanzaEvent) => void;
25
25
  readonly emitDecryptedPayload?: (build: () => WaIncomingDecryptedPayloadEvent) => void;
26
26
  }
27
+ /**
28
+ * Resolves who authored a {@link proto.IWebMessageInfo}. The top-level
29
+ * `participant` wins over `key.participant` - history sync carries the group
30
+ * sender in the former and leaves the latter empty on a large share of records.
31
+ * A self-sent group/broadcast message with neither falls back to this account,
32
+ * preferring `originalSelfAuthorUserJidString` over `meJid` (the current account
33
+ * user JID) so a message authored under a previous identity keeps its author.
34
+ *
35
+ * The participant fields are read whatever the thread type is, matching how
36
+ * WhatsApp Web builds the author: only the self-sent fallback is gated on
37
+ * group/broadcast. A 1:1 record normally carries neither, so this returns
38
+ * `undefined` there and the caller supplies the thread JID instead.
39
+ *
40
+ * `threadJid` overrides `key.remoteJid` for carriers that know the thread out
41
+ * of band, such as a group history bundle whose records may omit it.
42
+ */
43
+ export declare function resolveWebMessageInfoAuthor(webMessageInfo: proto.IWebMessageInfo, meJid?: string | null, threadJid?: string): string | undefined;
27
44
  /**
28
45
  * Rebuilds a `message` event from a recovered {@link proto.IWebMessageInfo}
29
46
  * (placeholder resend). `meJid` is the current account user JID, used as the
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveWebMessageInfoAuthor = resolveWebMessageInfoAuthor;
3
4
  exports.buildRecoveredIncomingEvent = buildRecoveredIncomingEvent;
4
5
  exports.handleIncomingMessageAck = handleIncomingMessageAck;
5
6
  const context_info_1 = require("../context-info");
@@ -129,6 +130,32 @@ function buildIncomingEventRawNode(node) {
129
130
  content: children
130
131
  };
131
132
  }
133
+ /**
134
+ * Resolves who authored a {@link proto.IWebMessageInfo}. The top-level
135
+ * `participant` wins over `key.participant` - history sync carries the group
136
+ * sender in the former and leaves the latter empty on a large share of records.
137
+ * A self-sent group/broadcast message with neither falls back to this account,
138
+ * preferring `originalSelfAuthorUserJidString` over `meJid` (the current account
139
+ * user JID) so a message authored under a previous identity keeps its author.
140
+ *
141
+ * The participant fields are read whatever the thread type is, matching how
142
+ * WhatsApp Web builds the author: only the self-sent fallback is gated on
143
+ * group/broadcast. A 1:1 record normally carries neither, so this returns
144
+ * `undefined` there and the caller supplies the thread JID instead.
145
+ *
146
+ * `threadJid` overrides `key.remoteJid` for carriers that know the thread out
147
+ * of band, such as a group history bundle whose records may omit it.
148
+ */
149
+ function resolveWebMessageInfoAuthor(webMessageInfo, meJid, threadJid) {
150
+ const key = webMessageInfo.key ?? {};
151
+ const chatJid = threadJid ?? key.remoteJid ?? undefined;
152
+ const isGroupOrBroadcast = chatJid ? (0, jid_1.isGroupJid)(chatJid) || (0, jid_1.isBroadcastJid)(chatJid) : false;
153
+ const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
154
+ const selfAuthor = key.fromMe === true && isGroupOrBroadcast && rawSelfAuthor
155
+ ? (0, jid_1.toUserJid)(rawSelfAuthor)
156
+ : undefined;
157
+ return webMessageInfo.participant ?? key.participant ?? selfAuthor ?? undefined;
158
+ }
132
159
  /**
133
160
  * Rebuilds a `message` event from a recovered {@link proto.IWebMessageInfo}
134
161
  * (placeholder resend). `meJid` is the current account user JID, used as the
@@ -141,9 +168,7 @@ function buildRecoveredIncomingEvent(webMessageInfo, meJid) {
141
168
  const fromMe = key.fromMe === true;
142
169
  const isGroup = chatJid ? (0, jid_1.isGroupJid)(chatJid) : false;
143
170
  const isBroadcast = chatJid ? (0, jid_1.isBroadcastJid)(chatJid) : false;
144
- const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
145
- const selfAuthor = fromMe && (isGroup || isBroadcast) && rawSelfAuthor ? (0, jid_1.toUserJid)(rawSelfAuthor) : undefined;
146
- const participant = webMessageInfo.participant ?? key.participant ?? selfAuthor;
171
+ const participant = resolveWebMessageInfoAuthor(webMessageInfo, meJid);
147
172
  const pushName = webMessageInfo.pushName ?? undefined;
148
173
  const rawSender = fromMe ? undefined : isGroup || isBroadcast ? participant : chatJid;
149
174
  const sender = rawSender ? (0, jid_1.parseJidFull)(rawSender) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapo-js",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "High-performance WhatsApp Web TypeScript library",
5
5
  "license": "MIT",
6
6
  "author": "vinikjkkj <contact@vinicius.email> (https://github.com/vinikjkkj)",