better-zap 25.0.2 → 25.0.3

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.
package/dist/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_client = require("./client.cjs");
3
+ const require_types = require("./types-CITvQMYt.cjs");
3
4
  //#region src/freeform-message-window.ts
4
5
  const FREEFORM_MESSAGE_WINDOW_MS = 1440 * 60 * 1e3;
5
6
  function toTimestamp(value) {
@@ -122,13 +123,184 @@ function formatPhone(phone) {
122
123
  throw new Error(`[formatPhone] Cannot normalize phone: "${phone}" (${digits.length} digits). Expected 10–13 digit Brazilian number.`);
123
124
  }
124
125
  //#endregion
125
- //#region src/utils/delay.ts
126
+ //#region src/utils/record.ts
127
+ /** True for a plain key-value object: not null, not an array. */
128
+ function isRecord(value) {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
131
+ /** The value when it is a plain object, otherwise an empty object. */
132
+ function asRecord(value) {
133
+ return isRecord(value) ? value : {};
134
+ }
135
+ /** Copies `patch` onto `base`, skipping `undefined` values so they never erase stored fields. */
136
+ function mergeDefined(base, patch) {
137
+ const merged = { ...base };
138
+ for (const [key, value] of Object.entries(patch ?? {})) {
139
+ if (value === void 0) continue;
140
+ merged[key] = value;
141
+ }
142
+ return merged;
143
+ }
144
+ //#endregion
145
+ //#region src/media/download.ts
146
+ /** Returned by one download pass when the 5-minute URL expired before use. */
147
+ const URL_EXPIRED = Symbol("url_expired");
148
+ var TooLargeError = class extends Error {
149
+ constructor(size) {
150
+ super("too_large");
151
+ this.size = size;
152
+ }
153
+ };
126
154
  /**
127
- * Utility function to pause execution for a given number of milliseconds.
128
- * Useful for exponential backoff or rate limiting.
155
+ * Downloads a WhatsApp media file:
156
+ * 1. `GET /{media-id}` returns a download URL that expires after 5 minutes.
157
+ * 2. The URL is fetched with the same bearer token. On 404 a new URL is
158
+ * requested once.
159
+ *
160
+ * Never throws. Files over `maxBytes` are not read.
129
161
  */
130
- function delay(ms) {
131
- return new Promise((resolve) => setTimeout(resolve, ms));
162
+ async function downloadMetaMedia(input) {
163
+ const [result, error] = await require_types.tryCatch(() => download(input));
164
+ if (error) return failed$1(`Media download failed: ${error.message}`);
165
+ return result;
166
+ }
167
+ async function download(input) {
168
+ const token = input.options?.accessToken ?? input.accessToken;
169
+ const context = {
170
+ lookupUrl: `${input.graphUrl}/${encodeURIComponent(input.mediaId)}`,
171
+ headers: { Authorization: `Bearer ${token}` },
172
+ maxBytes: input.options?.maxBytes ?? 26214400,
173
+ retries: input.options?.retries ?? 2,
174
+ expectedSha256: input.options?.expectedSha256,
175
+ signal: input.options?.signal,
176
+ fetch: input.fetch ?? fetch
177
+ };
178
+ const first = await downloadOnce(context);
179
+ if (first !== URL_EXPIRED) return first;
180
+ const second = await downloadOnce(context);
181
+ if (second !== URL_EXPIRED) return second;
182
+ return failed$1("Media download failed: HTTP 404");
183
+ }
184
+ async function downloadOnce(context) {
185
+ const { headers, maxBytes, retries, signal } = context;
186
+ const [lookup, lookupError] = await require_types.fetchWithRetry(() => context.fetch(context.lookupUrl, {
187
+ method: "GET",
188
+ headers,
189
+ signal
190
+ }), {
191
+ retries,
192
+ signal
193
+ });
194
+ if (lookupError) return failed$1(`Media lookup failed: ${lookupError.message}`);
195
+ if (!lookup.ok) return failed$1(`Media lookup failed: ${await describeError(lookup)}`);
196
+ const [json, jsonError] = await require_types.tryCatch(() => lookup.json());
197
+ if (jsonError) return failed$1(`Media lookup returned invalid JSON: ${jsonError.message}`);
198
+ const info = asRecord(json);
199
+ const fileUrl = info.url;
200
+ if (typeof fileUrl !== "string" || !fileUrl) return failed$1("Media lookup returned no url");
201
+ const reportedSize = require_types.toNumber(info.file_size);
202
+ if (reportedSize !== void 0 && reportedSize > maxBytes) return {
203
+ status: "too_large",
204
+ size: reportedSize,
205
+ maxBytes
206
+ };
207
+ const [response, downloadError] = await require_types.fetchWithRetry(() => context.fetch(fileUrl, {
208
+ method: "GET",
209
+ headers,
210
+ signal
211
+ }), {
212
+ retries,
213
+ signal
214
+ });
215
+ if (downloadError) return failed$1(`Media download failed: ${downloadError.message}`);
216
+ if (response.status === 404) {
217
+ await require_types.discardBody(response);
218
+ return URL_EXPIRED;
219
+ }
220
+ if (!response.ok) return failed$1(`Media download failed: ${await describeError(response)}`);
221
+ const [body, readError] = await readLimited(response, maxBytes);
222
+ if (readError instanceof TooLargeError) return {
223
+ status: "too_large",
224
+ size: readError.size ?? reportedSize,
225
+ maxBytes
226
+ };
227
+ if (readError) return failed$1(`Media download failed: ${readError.message}`);
228
+ const sha256 = await require_types.sha256Hex(body);
229
+ const expected = stringOrUndefined(info.sha256) ?? context.expectedSha256;
230
+ if (expected && !sha256Matches(sha256, expected)) return failed$1(`SHA-256 mismatch: expected ${expected}, got ${sha256}`);
231
+ return {
232
+ status: "downloaded",
233
+ body,
234
+ contentType: stringOrUndefined(info.mime_type) || response.headers.get("content-type") || "application/octet-stream",
235
+ size: body.byteLength,
236
+ sha256
237
+ };
238
+ }
239
+ function failed$1(error) {
240
+ return {
241
+ status: "failed",
242
+ error
243
+ };
244
+ }
245
+ function stringOrUndefined(value) {
246
+ return typeof value === "string" ? value : void 0;
247
+ }
248
+ async function describeError(response) {
249
+ const [data] = await require_types.tryCatch(() => response.json());
250
+ const message = data?.error?.message;
251
+ if (!message) return `HTTP ${response.status}`;
252
+ return `HTTP ${response.status}: ${message}`;
253
+ }
254
+ /** Reads the body, stopping with a `TooLargeError` once it passes `maxBytes`. */
255
+ async function readLimited(response, maxBytes) {
256
+ const declared = require_types.toNumber(response.headers.get("content-length"));
257
+ if (declared !== void 0 && declared > maxBytes) {
258
+ await require_types.discardBody(response);
259
+ return [null, new TooLargeError(declared)];
260
+ }
261
+ if (!response.body) return [/* @__PURE__ */ new ArrayBuffer(0), null];
262
+ const reader = response.body.getReader();
263
+ const chunks = [];
264
+ let total = 0;
265
+ for (;;) {
266
+ const [chunk, error] = await require_types.tryCatch(() => reader.read());
267
+ if (error) return [null, error];
268
+ if (chunk.done) break;
269
+ total += chunk.value.byteLength;
270
+ if (total > maxBytes) {
271
+ await require_types.tryCatch(() => reader.cancel());
272
+ return [null, new TooLargeError(declared)];
273
+ }
274
+ chunks.push(chunk.value);
275
+ }
276
+ const out = new Uint8Array(total);
277
+ let offset = 0;
278
+ for (const chunk of chunks) {
279
+ out.set(chunk, offset);
280
+ offset += chunk.byteLength;
281
+ }
282
+ return [out.buffer, null];
283
+ }
284
+ /**
285
+ * Meta's docs don't say whether the hash is hex or base64, so both are
286
+ * accepted. Values that are neither (e.g. test fixtures) are ignored rather
287
+ * than failing the download.
288
+ */
289
+ function sha256Matches(actualHex, expected) {
290
+ const trimmed = expected.trim();
291
+ if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase() === actualHex;
292
+ const bytes = decodeBase64(trimmed);
293
+ if (bytes?.length !== 32) return true;
294
+ return require_types.bytesToHex(bytes) === actualHex;
295
+ }
296
+ /** Decodes standard or URL-safe base64. Returns `undefined` for anything else. */
297
+ function decodeBase64(value) {
298
+ if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(value)) return void 0;
299
+ const standard = value.replace(/-/g, "+").replace(/_/g, "/");
300
+ if (standard.includes("=") && standard.length % 4 !== 0) return void 0;
301
+ if (standard.replace(/=+$/, "").length % 4 === 1) return void 0;
302
+ const binary = atob(standard);
303
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
132
304
  }
133
305
  //#endregion
134
306
  //#region src/services/whatsapp.service.ts
@@ -371,6 +543,24 @@ var WhatsAppService = class {
371
543
  };
372
544
  return this.send(payload);
373
545
  }
546
+ /**
547
+ * Download a media file received in a webhook (image, audio, video,
548
+ * document or sticker), using the configured access token.
549
+ *
550
+ * Meta deletes webhook media after 7 days. Never throws: failures and files
551
+ * over `maxBytes` are returned as results. Runs in development mode too,
552
+ * since it sends nothing.
553
+ *
554
+ * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/media
555
+ */
556
+ async downloadMedia(mediaId, options) {
557
+ return downloadMetaMedia({
558
+ mediaId,
559
+ accessToken: this.token,
560
+ graphUrl: `${META_BASE_URL$1}/${META_API_VERSION$1}`,
561
+ options
562
+ });
563
+ }
374
564
  /** Core send method with retry logic (2 retries, exponential backoff). */
375
565
  async send(payload, logging, retries = 2) {
376
566
  if (this.isDev) {
@@ -422,11 +612,11 @@ var WhatsAppService = class {
422
612
  errorData = null;
423
613
  }
424
614
  if (response.status === 429 && attempt < retries) {
425
- await delay(1e3 * (attempt + 1));
615
+ await require_types.delay(1e3 * (attempt + 1));
426
616
  continue;
427
617
  }
428
618
  if (response.status >= 500 && attempt < retries) {
429
- await delay(500 * (attempt + 1));
619
+ await require_types.delay(500 * (attempt + 1));
430
620
  continue;
431
621
  }
432
622
  return {
@@ -442,7 +632,7 @@ var WhatsAppService = class {
442
632
  };
443
633
  } catch (error) {
444
634
  if (attempt < retries) {
445
- await delay(500 * (attempt + 1));
635
+ await require_types.delay(500 * (attempt + 1));
446
636
  continue;
447
637
  }
448
638
  return {
@@ -744,9 +934,9 @@ const SESSION_IDENTIFIER_FIELDS = [
744
934
  ];
745
935
  function parseEmbeddedSignupMessage(data) {
746
936
  const payload = typeof data === "string" ? parseJson(data) : data;
747
- if (!isPlainObject(payload)) return null;
937
+ if (!isRecord(payload)) return null;
748
938
  if (payload.type !== "WA_EMBEDDED_SIGNUP" || !SUPPORTED_SESSION_VERSIONS.has(payload.version) || typeof payload.event !== "string" || payload.event.length === 0) return null;
749
- if (payload.data !== void 0 && !isPlainObject(payload.data)) return null;
939
+ if (payload.data !== void 0 && !isRecord(payload.data)) return null;
750
940
  const sessionData = payload.data;
751
941
  if (SESSION_IDENTIFIER_FIELDS.some((field) => sessionData?.[field] !== void 0 && typeof sessionData[field] !== "string")) return null;
752
942
  if (normalizeCoexistenceSessionEvent(payload.event) === "FINISH" && typeof sessionData?.waba_id !== "string") return null;
@@ -755,9 +945,6 @@ function parseEmbeddedSignupMessage(data) {
755
945
  data: sessionData
756
946
  };
757
947
  }
758
- function isPlainObject(value) {
759
- return typeof value === "object" && value !== null && !Array.isArray(value);
760
- }
761
948
  function parseJson(value) {
762
949
  try {
763
950
  return JSON.parse(value);
@@ -973,6 +1160,82 @@ var InMemoryCoexistenceStore = class {
973
1160
  }
974
1161
  };
975
1162
  //#endregion
1163
+ //#region src/utils/media-placeholder.ts
1164
+ /**
1165
+ * Coexistence history sync sends older media as `type: "media_placeholder"`
1166
+ * with no media object. For media sent within 14 days of onboarding, Meta later
1167
+ * sends another history webhook with the same message id and the real media.
1168
+ */
1169
+ const MEDIA_PLACEHOLDER_TYPE = "media_placeholder";
1170
+ /** True when a webhook message is a history media placeholder. */
1171
+ function isMediaPlaceholder(message) {
1172
+ return message?.type === MEDIA_PLACEHOLDER_TYPE;
1173
+ }
1174
+ /**
1175
+ * True when a stored log record still holds a media placeholder, so UIs can
1176
+ * show "media unavailable" instead of the text label.
1177
+ *
1178
+ * Checks the `mediaPending` flag set by the SDK, and falls back to the raw
1179
+ * message type for records stored before the flag existed.
1180
+ */
1181
+ function hasPendingMedia(record) {
1182
+ const metadata = asRecord(record?.metadata);
1183
+ if (metadata.mediaPending === true) return true;
1184
+ return isMediaPlaceholder(asRecord(metadata.raw));
1185
+ }
1186
+ //#endregion
1187
+ //#region src/media/message-media.ts
1188
+ /**
1189
+ * Returns the media object of an image, audio, video, document or sticker
1190
+ * message. Returns `undefined` for other types and for history media
1191
+ * placeholders, which carry no media.
1192
+ */
1193
+ function getMessageMediaPayload(message) {
1194
+ if (!message || isMediaPlaceholder(message)) return void 0;
1195
+ if (!isStorableMediaType(message.type)) return void 0;
1196
+ const type = message.type;
1197
+ const media = asRecord(message[type]);
1198
+ if (typeof media.id !== "string" || media.id.length === 0) return void 0;
1199
+ return {
1200
+ type,
1201
+ id: media.id,
1202
+ mimeType: typeof media.mime_type === "string" ? media.mime_type : void 0,
1203
+ sha256: typeof media.sha256 === "string" ? media.sha256 : void 0,
1204
+ filename: typeof media.filename === "string" ? media.filename : void 0
1205
+ };
1206
+ }
1207
+ /**
1208
+ * Stable storage key for a message's media:
1209
+ * `whatsapp/{phoneNumberId}/{waMessageId}`. Each segment is URI-encoded, so
1210
+ * ids with `/` or `=` stay one path segment.
1211
+ */
1212
+ function getMediaStorageKey(input) {
1213
+ return [
1214
+ "whatsapp",
1215
+ encodeURIComponent(input.phoneNumberId || "unknown"),
1216
+ encodeURIComponent(input.waMessageId)
1217
+ ].join("/");
1218
+ }
1219
+ /** Reads `metadata.media` from a stored log record. */
1220
+ function getMessageMedia(record) {
1221
+ const media = asRecord(asRecord(record?.metadata).media);
1222
+ if (!MEDIA_STATUSES.includes(media.status)) return void 0;
1223
+ return media;
1224
+ }
1225
+ /** Returns `metadata.media` only when the file is in the `MediaStore`. */
1226
+ function getStoredMessageMedia(record) {
1227
+ const media = getMessageMedia(record);
1228
+ return media?.status === "stored" && typeof media.key === "string" ? media : void 0;
1229
+ }
1230
+ const MEDIA_STATUSES = [
1231
+ "stored",
1232
+ "failed",
1233
+ "skipped"
1234
+ ];
1235
+ function isStorableMediaType(type) {
1236
+ return require_types.STORABLE_MEDIA_TYPES.includes(type);
1237
+ }
1238
+ //#endregion
976
1239
  //#region src/services/message-logger.service.ts
977
1240
  const WHATSAPP_MESSAGE_TYPES = [
978
1241
  "queue_position",
@@ -992,12 +1255,19 @@ var MessageLoggerService = class {
992
1255
  this.log = log;
993
1256
  }
994
1257
  async notify(event) {
995
- if (!this.notifier) return;
996
- try {
997
- await this.notifier.notify(event);
998
- } catch (err) {
999
- this.log.error("message_logger.sync_notify_failed", serializeError(err));
1000
- }
1258
+ const notifier = this.notifier;
1259
+ if (!notifier) return;
1260
+ const [, error] = await require_types.tryCatch(() => notifier.notify(event));
1261
+ if (error) this.log.error("message_logger.sync_notify_failed", serializeError(error));
1262
+ }
1263
+ async notifyNewMessage(message) {
1264
+ const conversation = await this.getConversationById(message.conversationId);
1265
+ if (!conversation) return;
1266
+ await this.notify({
1267
+ type: "NEW_MESSAGE",
1268
+ message,
1269
+ conversation
1270
+ });
1001
1271
  }
1002
1272
  async getConversationById(conversationId) {
1003
1273
  const conversation = await this.store.getConversationById(conversationId);
@@ -1040,12 +1310,7 @@ var MessageLoggerService = class {
1040
1310
  sentAt: (/* @__PURE__ */ new Date()).toISOString(),
1041
1311
  metadata: params.metadata
1042
1312
  });
1043
- const conversation = await this.getConversationById(inserted.conversationId);
1044
- if (conversation) await this.notify({
1045
- type: "NEW_MESSAGE",
1046
- message: inserted,
1047
- conversation
1048
- });
1313
+ await this.notifyNewMessage(inserted);
1049
1314
  return inserted.id;
1050
1315
  }
1051
1316
  /**
@@ -1095,21 +1360,34 @@ var MessageLoggerService = class {
1095
1360
  sentAt: params.sentAt
1096
1361
  });
1097
1362
  if (!created) return false;
1098
- const conversation = await this.getConversationById(inserted.conversationId);
1099
- if (conversation) await this.notify({
1100
- type: "NEW_MESSAGE",
1101
- message: inserted,
1102
- conversation
1103
- });
1363
+ await this.notifyNewMessage(inserted);
1104
1364
  return true;
1105
1365
  }
1106
1366
  /**
1107
1367
  * Log an imported WhatsApp message with an explicit direction and timestamp.
1108
1368
  * Used by coexistence history imports and app echo webhooks where the message
1109
1369
  * did not originate from the local send API call.
1370
+ *
1371
+ * Returns true only when a new row was created. See {@link importMessage}
1372
+ * for the detailed result, including placeholder upgrades.
1110
1373
  */
1111
1374
  async logImportedMessage(params) {
1112
- const { record: inserted, created } = await this.store.createWhatsAppLog({
1375
+ return await this.importMessage(params) === "created";
1376
+ }
1377
+ /**
1378
+ * Import a WhatsApp message, deduplicated by `waMessageId`.
1379
+ *
1380
+ * When a row with the same id already exists and still holds a history media
1381
+ * placeholder (see {@link hasPendingMedia}), and the new message has real
1382
+ * content, the stored content and metadata are replaced and a
1383
+ * `MESSAGE_UPDATE` sync event is sent. A placeholder never overwrites real
1384
+ * content.
1385
+ *
1386
+ * Relies on `createWhatsAppLog` returning the existing row with
1387
+ * `created: false` for a known `waMessageId`.
1388
+ */
1389
+ async importMessage(params) {
1390
+ const { record, created } = await this.store.createWhatsAppLog({
1113
1391
  phone: params.phone,
1114
1392
  contactName: params.senderName,
1115
1393
  waMessageId: params.waMessageId,
@@ -1120,12 +1398,50 @@ var MessageLoggerService = class {
1120
1398
  metadata: params.metadata,
1121
1399
  sentAt: params.sentAt
1122
1400
  });
1123
- if (!created) return false;
1124
- const conversation = await this.getConversationById(inserted.conversationId);
1125
- if (conversation) await this.notify({
1126
- type: "NEW_MESSAGE",
1127
- message: inserted,
1128
- conversation
1401
+ if (!created) return await this.replaceMediaPlaceholder(record, params) ? "upgraded" : "duplicate";
1402
+ await this.notifyNewMessage(record);
1403
+ return "created";
1404
+ }
1405
+ /**
1406
+ * Save the result of a media download as `metadata.media` on a stored
1407
+ * message and send a `MESSAGE_UPDATE` sync event.
1408
+ *
1409
+ * Only the `media` field is written; other metadata fields are kept. A
1410
+ * stored file is never replaced by a failed or skipped result.
1411
+ *
1412
+ * Returns the updated record, or `null` when the message isn't stored or
1413
+ * the update was skipped.
1414
+ */
1415
+ async setMessageMedia(waMessageId, media) {
1416
+ const existing = await this.store.getMessageByWaId(waMessageId);
1417
+ if (!existing) return null;
1418
+ if (getMessageMedia(existing)?.status === "stored" && media.status !== "stored") return null;
1419
+ return this.updateMessage(waMessageId, existing, { metadata: {
1420
+ ...asRecord(existing.metadata),
1421
+ media
1422
+ } });
1423
+ }
1424
+ /** Writes `updates` to a stored message and sends `MESSAGE_UPDATE`. */
1425
+ async updateMessage(waMessageId, existing, updates) {
1426
+ await this.store.updateWhatsAppLogByWaMessageId(waMessageId, updates);
1427
+ const message = {
1428
+ ...existing,
1429
+ ...updates
1430
+ };
1431
+ await this.notify({
1432
+ type: "MESSAGE_UPDATE",
1433
+ message
1434
+ });
1435
+ return message;
1436
+ }
1437
+ async replaceMediaPlaceholder(existing, params) {
1438
+ if (!existing || !hasPendingMedia(existing)) return false;
1439
+ if (hasPendingMedia({ metadata: params.metadata })) return false;
1440
+ const metadata = mergeDefined(asRecord(existing.metadata), params.metadata);
1441
+ delete metadata.mediaPending;
1442
+ await this.updateMessage(params.waMessageId, existing, {
1443
+ content: params.content,
1444
+ metadata
1129
1445
  });
1130
1446
  return true;
1131
1447
  }
@@ -1341,6 +1657,116 @@ function decodeEntities(value) {
1341
1657
  });
1342
1658
  }
1343
1659
  //#endregion
1660
+ //#region src/media/store-message-media.ts
1661
+ /**
1662
+ * Downloads a message's media, stores it, and records the result in
1663
+ * `metadata.media`. Does nothing for messages without media and for history
1664
+ * media placeholders. Never throws.
1665
+ *
1666
+ * The download and upload together must finish within `media.timeoutMs`.
1667
+ * The webhook processor runs this in the background through a media queue,
1668
+ * after `onMessage`.
1669
+ */
1670
+ async function storeMessageMedia(input) {
1671
+ if (!input.media) return void 0;
1672
+ const payload = getMessageMediaPayload(input.message);
1673
+ if (!payload) return void 0;
1674
+ const timeoutMs = input.media.timeoutMs ?? 3e4;
1675
+ const timeout = require_types.timeoutSignal(timeoutMs, `Media transfer timed out after ${timeoutMs}ms`);
1676
+ const result = await downloadAndStore(input.media, input, payload, timeout.signal);
1677
+ timeout.clear();
1678
+ const [, updateError] = await require_types.tryCatch(() => input.logger.setMessageMedia(input.message.id, result));
1679
+ if (updateError) input.log.error("webhook.media_metadata_update_failed", {
1680
+ waMessageId: input.message.id,
1681
+ ...serializeError(updateError)
1682
+ });
1683
+ return result;
1684
+ }
1685
+ async function downloadAndStore(media, input, payload, signal) {
1686
+ const { message, log } = input;
1687
+ const phoneNumberId = input.phoneNumberId ?? media.phoneNumberId;
1688
+ const logContext = {
1689
+ waMessageId: message.id,
1690
+ mediaId: payload.id,
1691
+ mediaType: payload.type
1692
+ };
1693
+ const [download, downloadError] = await require_types.tryCatch(() => media.download(payload.id, {
1694
+ maxBytes: media.maxBytes ?? 26214400,
1695
+ expectedSha256: payload.sha256,
1696
+ phoneNumberId,
1697
+ signal
1698
+ }), signal);
1699
+ if (downloadError) {
1700
+ log.error("webhook.media_download_failed", {
1701
+ ...logContext,
1702
+ ...serializeError(downloadError)
1703
+ });
1704
+ return failed(payload.id, downloadError.message);
1705
+ }
1706
+ if (download.status === "failed") {
1707
+ log.error("webhook.media_download_failed", {
1708
+ ...logContext,
1709
+ error: download.error
1710
+ });
1711
+ return failed(payload.id, download.error);
1712
+ }
1713
+ if (download.status === "too_large") {
1714
+ log.warn("webhook.media_skipped_too_large", {
1715
+ ...logContext,
1716
+ size: download.size,
1717
+ maxBytes: download.maxBytes
1718
+ });
1719
+ return {
1720
+ status: "skipped",
1721
+ reason: "too_large",
1722
+ ...download.size !== void 0 ? { size: download.size } : {},
1723
+ maxBytes: download.maxBytes,
1724
+ mediaId: payload.id,
1725
+ skippedAt: (/* @__PURE__ */ new Date()).toISOString()
1726
+ };
1727
+ }
1728
+ const key = getMediaStorageKey({
1729
+ phoneNumberId,
1730
+ waMessageId: message.id
1731
+ });
1732
+ const [, putError] = await require_types.tryCatch(() => media.store.put(key, download.body, {
1733
+ contentType: download.contentType,
1734
+ size: download.size,
1735
+ sha256: download.sha256,
1736
+ signal
1737
+ }), signal);
1738
+ if (putError) {
1739
+ log.error("webhook.media_store_failed", {
1740
+ ...logContext,
1741
+ ...serializeError(putError)
1742
+ });
1743
+ return failed(payload.id, putError.message);
1744
+ }
1745
+ log.info("webhook.media_stored", {
1746
+ ...logContext,
1747
+ key,
1748
+ size: download.size
1749
+ });
1750
+ return {
1751
+ status: "stored",
1752
+ key,
1753
+ contentType: download.contentType,
1754
+ size: download.size,
1755
+ sha256: download.sha256,
1756
+ ...payload.filename ? { filename: payload.filename } : {},
1757
+ mediaId: payload.id,
1758
+ storedAt: (/* @__PURE__ */ new Date()).toISOString()
1759
+ };
1760
+ }
1761
+ function failed(mediaId, error) {
1762
+ return {
1763
+ status: "failed",
1764
+ error,
1765
+ mediaId,
1766
+ failedAt: (/* @__PURE__ */ new Date()).toISOString()
1767
+ };
1768
+ }
1769
+ //#endregion
1344
1770
  //#region src/webhook/message-content.ts
1345
1771
  /**
1346
1772
  * Extract human-readable content from incoming messages for audit logs.
@@ -1360,17 +1786,85 @@ function getMessageContent(message) {
1360
1786
  return "[interativo]";
1361
1787
  case "sticker": return "[figurinha]";
1362
1788
  case "reaction": return "[reação]";
1789
+ case "media_placeholder": return "[mídia]";
1363
1790
  default: return `[${message.type}]`;
1364
1791
  }
1365
1792
  }
1366
1793
  //#endregion
1794
+ //#region src/utils/limit.ts
1795
+ /**
1796
+ * Returns a runner that runs at most `concurrency` tasks at a time. Extra
1797
+ * tasks wait their turn in order. Task errors come back as a {@link Result}.
1798
+ */
1799
+ function createLimiter(concurrency) {
1800
+ const max = Number.isNaN(concurrency) ? 1 : Math.max(1, Math.floor(concurrency));
1801
+ const waiting = [];
1802
+ let active = 0;
1803
+ const acquire = () => {
1804
+ if (active < max) {
1805
+ active++;
1806
+ return Promise.resolve();
1807
+ }
1808
+ return new Promise((resolve) => waiting.push(resolve));
1809
+ };
1810
+ const release = () => {
1811
+ const next = waiting.shift();
1812
+ if (next) return next();
1813
+ active--;
1814
+ };
1815
+ return async (task) => {
1816
+ await acquire();
1817
+ const result = await require_types.tryCatch(task);
1818
+ release();
1819
+ return result;
1820
+ };
1821
+ }
1822
+ //#endregion
1823
+ //#region src/media/media-queue.ts
1824
+ /** Runs media downloads in the background so they never delay webhook hooks. */
1825
+ function createMediaQueue(input) {
1826
+ const run = createLimiter(input.media.concurrency ?? 3);
1827
+ const queued = /* @__PURE__ */ new Map();
1828
+ return { add(message, phoneNumberId) {
1829
+ if (!getMessageMediaPayload(message)) return Promise.resolve();
1830
+ const existing = queued.get(message.id);
1831
+ if (existing) return existing;
1832
+ const job = run(() => storeMessageMedia({
1833
+ ...input,
1834
+ message,
1835
+ phoneNumberId
1836
+ })).then(() => {
1837
+ queued.delete(message.id);
1838
+ });
1839
+ queued.set(message.id, job);
1840
+ return job;
1841
+ } };
1842
+ }
1843
+ //#endregion
1367
1844
  //#region src/webhook/webhook-processor.ts
1368
1845
  function createWebhookProcessor(config) {
1369
- return { process: (payload) => processPayload(payload, config) };
1846
+ const mediaQueue = config.media ? createMediaQueue({
1847
+ media: config.media,
1848
+ logger: config.logger,
1849
+ log: config.log
1850
+ }) : void 0;
1851
+ return { process: (payload) => processPayload(payload, config, mediaQueue) };
1370
1852
  }
1371
1853
  /** Top-level dispatcher — iterates entries in the webhook payload. */
1372
- async function processPayload(payload, config) {
1854
+ async function processPayload(payload, processorConfig, mediaQueue) {
1855
+ const mediaJobs = [];
1856
+ const config = {
1857
+ ...processorConfig,
1858
+ queueMedia: (message, phoneNumberId) => {
1859
+ if (!mediaQueue) return;
1860
+ mediaJobs.push(mediaQueue.add(message, phoneNumberId));
1861
+ }
1862
+ };
1373
1863
  const log = config.log;
1864
+ await dispatchPayload(payload, config, log);
1865
+ await Promise.all(mediaJobs);
1866
+ }
1867
+ async function dispatchPayload(payload, config, log) {
1374
1868
  try {
1375
1869
  if (payload.object !== "whatsapp_business_account") {
1376
1870
  log.debug("webhook.ignored_payload", { object: payload.object });
@@ -1430,7 +1924,7 @@ async function processMessagesChange(change, config, log) {
1430
1924
  await processUnsupportedMessagesChange(change, config, log);
1431
1925
  return;
1432
1926
  }
1433
- if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), config, log);
1927
+ if (value.messages && value.messages.length > 0) for (const message of value.messages) await processIncomingMessage(message, resolveContact(value.contacts, message), value.metadata?.phone_number_id, config, log);
1434
1928
  if (value.statuses && value.statuses.length > 0) for (const status of value.statuses) await processStatusUpdate(status, config, log);
1435
1929
  if (value.errors && value.errors.length > 0) {
1436
1930
  const errorHandler = config.hooks.onError ?? ((err) => {
@@ -1455,6 +1949,7 @@ async function processHistoryChange(change, config, log) {
1455
1949
  const tally = {
1456
1950
  importedMessages: 0,
1457
1951
  duplicateMessages: 0,
1952
+ upgradedMessages: 0,
1458
1953
  failedMessages: 0
1459
1954
  };
1460
1955
  const threadMessageIds = /* @__PURE__ */ new Set();
@@ -1767,6 +2262,7 @@ async function processSmbMessageEchoesChange(change, config, log) {
1767
2262
  const tally = {
1768
2263
  importedMessages: 0,
1769
2264
  duplicateMessages: 0,
2265
+ upgradedMessages: 0,
1770
2266
  failedMessages: 0
1771
2267
  };
1772
2268
  const echoes = rawValue.message_echoes ?? [];
@@ -1937,10 +2433,15 @@ function readHistoryProgress(chunks) {
1937
2433
  return latest;
1938
2434
  }
1939
2435
  function countImportOutcome(tally, outcome) {
1940
- if (outcome === "created") tally.importedMessages += 1;
1941
- else if (outcome === "duplicate") tally.duplicateMessages += 1;
1942
- else if (outcome === "failed") tally.failedMessages += 1;
1943
- }
2436
+ if (outcome === "skipped") return;
2437
+ tally[TALLY_KEY[outcome]] += 1;
2438
+ }
2439
+ const TALLY_KEY = {
2440
+ created: "importedMessages",
2441
+ duplicate: "duplicateMessages",
2442
+ upgraded: "upgradedMessages",
2443
+ failed: "failedMessages"
2444
+ };
1944
2445
  async function importCoexistenceMessage(input) {
1945
2446
  if (!input.config.database?.coexistence) return "skipped";
1946
2447
  const { message, thread, log } = input;
@@ -1960,7 +2461,7 @@ async function importCoexistenceMessage(input) {
1960
2461
  message.to_user_id,
1961
2462
  thread?.context?.user_id
1962
2463
  ].find(isNonEmptyString);
1963
- return await input.config.logger.logImportedMessage({
2464
+ const result = await input.config.logger.importMessage({
1964
2465
  phone,
1965
2466
  waMessageId: message.id,
1966
2467
  direction,
@@ -1974,9 +2475,12 @@ async function importCoexistenceMessage(input) {
1974
2475
  ...userId ? { userId } : {},
1975
2476
  ...message.history_context?.status ? { historyStatus: message.history_context.status } : {},
1976
2477
  ...sentAt ? {} : { invalidTimestamp: true },
2478
+ ...isMediaPlaceholder(message) ? { mediaPending: true } : {},
1977
2479
  raw: message
1978
2480
  }
1979
- }) ? "created" : "duplicate";
2481
+ });
2482
+ if (result === "created" || result === "upgraded") input.config.queueMedia(message, input.metadata?.phone_number_id);
2483
+ return result;
1980
2484
  } catch (error) {
1981
2485
  log.error("webhook.coexistence_import_failed", {
1982
2486
  field: input.source,
@@ -2030,8 +2534,10 @@ async function runOptionalHook(run, logEvent, log) {
2030
2534
  * 1. Extracts human-readable content
2031
2535
  * 2. Atomically logs and deduplicates by waMessageId
2032
2536
  * 3. Calls {@link WebhookProcessorHooks.onMessage}
2537
+ * 4. Queues its media download when a `MediaStore` is configured. The hook
2538
+ * runs first so the bot's reply is never held up by a slow file.
2033
2539
  */
2034
- async function processIncomingMessage(message, contact, config, log) {
2540
+ async function processIncomingMessage(message, contact, phoneNumberId, config, log) {
2035
2541
  const phone = message.from;
2036
2542
  log.info("webhook.message_received", {
2037
2543
  waMessageId: message.id,
@@ -2071,6 +2577,7 @@ async function processIncomingMessage(message, contact, config, log) {
2071
2577
  ...serializeError(error)
2072
2578
  });
2073
2579
  }
2580
+ config.queueMedia(message, phoneNumberId);
2074
2581
  }
2075
2582
  /**
2076
2583
  * Processes a single delivery status update:
@@ -2146,26 +2653,39 @@ function toWebhookError(error) {
2146
2653
  //#endregion
2147
2654
  exports.BetterZapClientError = require_client.BetterZapClientError;
2148
2655
  exports.CoexistenceService = CoexistenceService;
2656
+ exports.DEFAULT_MEDIA_CONCURRENCY = require_types.DEFAULT_MEDIA_CONCURRENCY;
2657
+ exports.DEFAULT_MEDIA_MAX_BYTES = require_types.DEFAULT_MEDIA_MAX_BYTES;
2658
+ exports.DEFAULT_MEDIA_TIMEOUT_MS = require_types.DEFAULT_MEDIA_TIMEOUT_MS;
2149
2659
  exports.EMPTY_TEMPLATE_REGISTRY = EMPTY_TEMPLATE_REGISTRY;
2150
2660
  exports.FREEFORM_MESSAGE_WINDOW_MS = FREEFORM_MESSAGE_WINDOW_MS;
2151
2661
  exports.InMemoryCoexistenceStore = InMemoryCoexistenceStore;
2662
+ exports.MAX_SIGNED_URL_EXPIRES_IN = require_types.MAX_SIGNED_URL_EXPIRES_IN;
2663
+ exports.MEDIA_PLACEHOLDER_TYPE = MEDIA_PLACEHOLDER_TYPE;
2152
2664
  exports.MessageLoggerService = MessageLoggerService;
2665
+ exports.STORABLE_MEDIA_TYPES = require_types.STORABLE_MEDIA_TYPES;
2153
2666
  exports.WHATSAPP_MESSAGE_TYPES = WHATSAPP_MESSAGE_TYPES;
2154
2667
  exports.WhatsAppService = WhatsAppService;
2155
2668
  exports.apiKeyTransport = require_client.apiKeyTransport;
2669
+ exports.clampSignedUrlExpiry = require_types.clampSignedUrlExpiry;
2156
2670
  exports.createCoexistenceEmbeddedSignupConfig = createCoexistenceEmbeddedSignupConfig;
2157
2671
  exports.createFreeformMessageWindow = createFreeformMessageWindow;
2158
2672
  exports.createLogger = createLogger;
2159
2673
  exports.createWebhookProcessor = createWebhookProcessor;
2160
2674
  exports.createZapClient = require_client.createZapClient;
2161
2675
  exports.defineTemplates = defineTemplates;
2162
- exports.delay = delay;
2676
+ exports.delay = require_types.delay;
2163
2677
  exports.firstLinkIn = firstLinkIn;
2164
2678
  exports.formatPhone = formatPhone;
2165
2679
  exports.getLatestIncomingMessageAt = getLatestIncomingMessageAt;
2680
+ exports.getMediaStorageKey = getMediaStorageKey;
2166
2681
  exports.getMessageContent = getMessageContent;
2682
+ exports.getMessageMedia = getMessageMedia;
2683
+ exports.getMessageMediaPayload = getMessageMediaPayload;
2684
+ exports.getStoredMessageMedia = getStoredMessageMedia;
2167
2685
  exports.getTemplateNames = getTemplateNames;
2168
2686
  exports.hasConfiguredTemplates = hasConfiguredTemplates;
2687
+ exports.hasPendingMedia = hasPendingMedia;
2688
+ exports.isMediaPlaceholder = isMediaPlaceholder;
2169
2689
  exports.launchCoexistenceEmbeddedSignup = launchCoexistenceEmbeddedSignup;
2170
2690
  exports.noopLogger = noopLogger;
2171
2691
  exports.normalizeCoexistenceSessionEvent = normalizeCoexistenceSessionEvent;
@@ -2177,4 +2697,7 @@ exports.resolveConversationFreeformMessageWindow = resolveConversationFreeformMe
2177
2697
  exports.serializeError = serializeError;
2178
2698
  exports.serializeTemplateFromRegistry = serializeTemplateFromRegistry;
2179
2699
  exports.sessionTransport = require_client.sessionTransport;
2700
+ exports.storeMessageMedia = storeMessageMedia;
2701
+ exports.toError = require_types.toError;
2180
2702
  exports.toLegacyCoexistenceSessionEvent = toLegacyCoexistenceSessionEvent;
2703
+ exports.tryCatch = require_types.tryCatch;