polygram 0.18.2 → 0.20.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.
@@ -0,0 +1,616 @@
1
+ /**
2
+ * Rich-message media resolver — turns agent-authored media srcs into
3
+ * uploadable values for typed blocks, enforcing the same trust
4
+ * boundary as reply(files).
5
+ *
6
+ * The renderer (rich.js) stays filesystem-free; every impure decision
7
+ * lives here: src classification, path allowlisting, extension and
8
+ * size gating, and the per-message media budget. Returned local-file
9
+ * media are JSON-safe `{ source: '<realpath>', fingerprint: '…' }`
10
+ * envelopes — the
11
+ * grammy InputFile is materialized later, in rich-edit.js, because an
12
+ * InputFile inside the block tree would break the streamer's
13
+ * JSON.stringify payload dedup (InputFile.toJSON throws by design).
14
+ *
15
+ * src classification is a trichotomy decided by a literal prefix test,
16
+ * never a URL parser (a colon in a local path must not read as a
17
+ * scheme):
18
+ * 1. http(s):// URL → passed through; Telegram fetches it
19
+ * server-side. Rejected when the bot runs
20
+ * through a self-hosted Bot API server
21
+ * (allowUrlMedia=false) — whether that server
22
+ * fetches the URL from inside the operator's
23
+ * network (an SSRF surface) is unverified.
24
+ * 2. absolute path → realpath + allowlist via the same
25
+ * validateAttachmentPath used by reply(files);
26
+ * the RESOLVED path is what gets uploaded, so
27
+ * symlink games can't dodge the check.
28
+ * 3. anything else → rejected (data:, file:, tg:, attach:,
29
+ * relative paths). A literal agent-authored
30
+ * attach:// string must never reach grammy —
31
+ * it would dangle against a multipart part
32
+ * that doesn't exist.
33
+ *
34
+ * Rejections degrade to placeholder paragraphs upstream; the reply
35
+ * always delivers. Events log reason classes only — never the raw src
36
+ * (URLs can embed credentials, paths leak workspace structure).
37
+ */
38
+
39
+ 'use strict';
40
+
41
+ const fs = require('node:fs');
42
+ const path = require('node:path');
43
+ const { validateAttachmentPath } = require('../process/channels-tool-dispatcher');
44
+ const { TELEGRAM_MAX_CAPTION_LENGTH } = require('./format');
45
+
46
+ // Portable multipart ceilings. Photos are capped tighter than video and
47
+ // animation by the Bot API.
48
+ const PHOTO_UPLOAD_CEILING = 10 * 1024 * 1024;
49
+ const OTHER_MEDIA_UPLOAD_CEILING = 50 * 1024 * 1024;
50
+
51
+ // Bound on the SINGLE multipart request that carries every local media
52
+ // item of a reply (uploads happen in one finalize edit). Matches
53
+ // attachments.js's MAX_TOTAL_BYTES posture.
54
+ const MAX_TOTAL_MEDIA_BYTES = 50 * 1024 * 1024;
55
+
56
+ // Rich messages allow ≤50 media attachments in total (Bot API "Rich
57
+ // Message Limits"), wrapper children included. Enforced here, on the
58
+ // flattened descriptor list, so Telegram never rejects the whole reply
59
+ // over attachment #51.
60
+ const MAX_MEDIA_PER_MESSAGE = 50;
61
+
62
+ // Source selection is extension-based, not a security control; the path
63
+ // allowlist is, and Telegram validates actual bytes server-side.
64
+ const PHOTO_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
65
+ const LOCAL_MEDIA_KIND_BY_EXTENSION = new Map([
66
+ ...[...PHOTO_EXTENSIONS].map((extension) => [extension, 'photo']),
67
+ ['.mp4', 'video'],
68
+ ['.gif', 'animation'],
69
+ ]);
70
+
71
+ const HTTP_URL_RE = /^https?:\/\//i;
72
+ const MAX_RESCUE_CAPTION_LENGTH = TELEGRAM_MAX_CAPTION_LENGTH;
73
+ const FILE_ID_CACHE_MAX_ENTRIES = 256;
74
+ const MEDIA_KINDS = new Set(['photo', 'video', 'animation']);
75
+ const RESCUE_METHODS = Object.freeze({
76
+ photo: { method: 'sendPhoto', field: 'photo' },
77
+ video: { method: 'sendVideo', field: 'video' },
78
+ animation: { method: 'sendAnimation', field: 'animation' },
79
+ });
80
+
81
+ function classifyUrlMediaKind(src) {
82
+ let extension = '';
83
+ try {
84
+ extension = path.posix.extname(new URL(src).pathname).toLowerCase();
85
+ } catch {}
86
+ return LOCAL_MEDIA_KIND_BY_EXTENSION.get(extension) || 'photo';
87
+ }
88
+
89
+ function classifyLocalMediaKind(source) {
90
+ return LOCAL_MEDIA_KIND_BY_EXTENSION.get(path.extname(source).toLowerCase()) || null;
91
+ }
92
+
93
+ function statFingerprint(stat) {
94
+ const value = (name, fallback) => {
95
+ const v = stat?.[name] ?? stat?.[fallback];
96
+ return v == null ? '' : String(v);
97
+ };
98
+ return [
99
+ value('dev'),
100
+ value('ino'),
101
+ value('size'),
102
+ value('mtimeNs', 'mtimeMs'),
103
+ value('ctimeNs', 'ctimeMs'),
104
+ ].join(':');
105
+ }
106
+
107
+ function statMediaFile(source) {
108
+ return fs.statSync(source, { bigint: true });
109
+ }
110
+
111
+ function createMediaFileIdCache({ maxEntries = FILE_ID_CACHE_MAX_ENTRIES } = {}) {
112
+ const entries = new Map();
113
+ const limit = Number.isInteger(maxEntries) && maxEntries > 0
114
+ ? maxEntries
115
+ : FILE_ID_CACHE_MAX_ENTRIES;
116
+ const keyFor = (kind, source) => `${kind}\0${source}`;
117
+
118
+ function get(kind, source, fingerprint) {
119
+ if (!MEDIA_KINDS.has(kind) || typeof source !== 'string' || typeof fingerprint !== 'string') {
120
+ return null;
121
+ }
122
+ const key = keyFor(kind, source);
123
+ const entry = entries.get(key);
124
+ if (!entry) return null;
125
+ if (entry.fingerprint !== fingerprint) {
126
+ entries.delete(key);
127
+ return null;
128
+ }
129
+ entries.delete(key);
130
+ entries.set(key, entry);
131
+ return entry.fileId;
132
+ }
133
+
134
+ function set(kind, source, fingerprint, fileId) {
135
+ if (!MEDIA_KINDS.has(kind)
136
+ || typeof source !== 'string'
137
+ || typeof fingerprint !== 'string'
138
+ || typeof fileId !== 'string'
139
+ || !fileId) {
140
+ return false;
141
+ }
142
+ const key = keyFor(kind, source);
143
+ entries.delete(key);
144
+ entries.set(key, { fingerprint, fileId });
145
+ while (entries.size > limit) {
146
+ entries.delete(entries.keys().next().value);
147
+ }
148
+ return true;
149
+ }
150
+
151
+ function remove(kind, source) {
152
+ if (!MEDIA_KINDS.has(kind) || typeof source !== 'string') return false;
153
+ return entries.delete(keyFor(kind, source));
154
+ }
155
+
156
+ return Object.freeze({
157
+ get,
158
+ set,
159
+ delete: remove,
160
+ get size() { return entries.size; },
161
+ });
162
+ }
163
+
164
+ function visitNestedBlocks(blocks, visit) {
165
+ for (const block of blocks || []) {
166
+ visit(block);
167
+ if (Array.isArray(block?.blocks)) visitNestedBlocks(block.blocks, visit);
168
+ if (Array.isArray(block?.items)) {
169
+ for (const item of block.items) {
170
+ if (Array.isArray(item?.blocks)) visitNestedBlocks(item.blocks, visit);
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ function collectOutgoingMedia(blocks) {
177
+ const entries = [];
178
+ visitNestedBlocks(blocks, (block) => {
179
+ const kind = block?.type;
180
+ if (!MEDIA_KINDS.has(kind)) return;
181
+ const inputMedia = block[kind];
182
+ if (!inputMedia || Array.isArray(inputMedia) || inputMedia.media == null) return;
183
+ entries.push({ kind, media: inputMedia.media });
184
+ });
185
+ return entries;
186
+ }
187
+
188
+ function largestPhotoFileId(sizes) {
189
+ if (!Array.isArray(sizes)) return null;
190
+ let best = null;
191
+ let bestArea = -1;
192
+ for (const size of sizes) {
193
+ if (typeof size?.file_id !== 'string' || !size.file_id) continue;
194
+ const area = Math.max(0, Number(size.width) || 0) * Math.max(0, Number(size.height) || 0);
195
+ if (area > bestArea) {
196
+ best = size.file_id;
197
+ bestArea = area;
198
+ }
199
+ }
200
+ return best;
201
+ }
202
+
203
+ function returnedMediaFileId(kind, media) {
204
+ if (kind === 'photo') return largestPhotoFileId(media);
205
+ if (!MEDIA_KINDS.has(kind)) return null;
206
+ return typeof media?.file_id === 'string' ? media.file_id : null;
207
+ }
208
+
209
+ function collectReturnedMedia(blocks) {
210
+ const entries = [];
211
+ visitNestedBlocks(blocks, (block) => {
212
+ const kind = block?.type;
213
+ if (!MEDIA_KINDS.has(kind)) return;
214
+ entries.push({ kind, fileId: returnedMediaFileId(kind, block[kind]) });
215
+ });
216
+ return entries;
217
+ }
218
+
219
+ function collectMediaRescueEntries(blocks) {
220
+ const entries = [];
221
+ visitNestedBlocks(blocks, (block) => {
222
+ const kind = block?.type;
223
+ if (!MEDIA_KINDS.has(kind) || block[kind]?.media == null) return;
224
+ entries.push({
225
+ kind,
226
+ media: block[kind].media,
227
+ caption: typeof block.caption?.text === 'string' ? block.caption.text : '',
228
+ });
229
+ });
230
+ return entries;
231
+ }
232
+
233
+ /**
234
+ * @param {object} deps
235
+ * @param {string[]} deps.allowedRoots — absolute roots media paths must
236
+ * resolve under (same builder as reply(files): buildAllowedRoots)
237
+ * @param {number} [deps.maxPhotoBytes] — per-file cap, already clamped
238
+ * to PHOTO_UPLOAD_CEILING by the caller
239
+ * @param {number} [deps.maxOtherMediaBytes] — video/animation per-file cap,
240
+ * already clamped to OTHER_MEDIA_UPLOAD_CEILING by the caller
241
+ * @param {number} [deps.maxTotalMediaBytes] — per-reply budget
242
+ * @param {boolean} [deps.allowUrlMedia] — false when a self-hosted Bot
243
+ * API server is configured (see module doc)
244
+ * @param {(kind: string, detail: object) => void} [deps.logEvent]
245
+ * @param {(p: string, roots: string[]) => {ok: boolean, resolved?: string, error?: string}} [deps.validatePath]
246
+ * @param {(p: string) => object} [deps.fileStat]
247
+ * @param {(p: string) => number} [deps.fileSize]
248
+ * @returns {(descriptors: Array<{src: string, caption: string}>) => Array<
249
+ * {kind: ('photo'|'video'|'animation'), media: (string|{
250
+ * source: string, fingerprint: string, fileId?: string
251
+ * })}|{rejected: string}
252
+ * >}
253
+ */
254
+ function createRichMediaResolver({
255
+ allowedRoots = [],
256
+ maxPhotoBytes = PHOTO_UPLOAD_CEILING,
257
+ maxOtherMediaBytes = OTHER_MEDIA_UPLOAD_CEILING,
258
+ maxTotalMediaBytes = MAX_TOTAL_MEDIA_BYTES,
259
+ allowUrlMedia = true,
260
+ logEvent = null,
261
+ validatePath = validateAttachmentPath,
262
+ fileStat = statMediaFile,
263
+ fileSize = null,
264
+ fileIdCache = null,
265
+ } = {}) {
266
+ return function resolveMedia(descriptors) {
267
+ const results = [];
268
+ const reasons = [];
269
+ let rejected = 0;
270
+ let accepted = 0;
271
+ let totalBytes = 0;
272
+
273
+ const reject = (reason) => {
274
+ rejected += 1;
275
+ if (reasons.length < 10) reasons.push(reason);
276
+ results.push({ rejected: reason });
277
+ };
278
+
279
+ for (const d of descriptors || []) {
280
+ const src = typeof d?.src === 'string' ? d.src : '';
281
+ if (!src) { reject('empty-src'); continue; }
282
+ if (accepted >= MAX_MEDIA_PER_MESSAGE) { reject('media-cap'); continue; }
283
+
284
+ if (HTTP_URL_RE.test(src)) {
285
+ if (!allowUrlMedia) { reject('url-local-api'); continue; }
286
+ accepted += 1;
287
+ results.push({ kind: classifyUrlMediaKind(src), media: src });
288
+ continue;
289
+ }
290
+
291
+ if (!path.isAbsolute(src)) {
292
+ // Covers relative paths AND every non-http scheme (data:,
293
+ // file:, tg:, attach:, …) — none of them start with "/".
294
+ reject('not-absolute');
295
+ continue;
296
+ }
297
+
298
+ const check = validatePath(src, allowedRoots);
299
+ if (!check.ok) { reject('path'); continue; }
300
+
301
+ const kind = classifyLocalMediaKind(check.resolved);
302
+ if (!kind) {
303
+ reject('extension');
304
+ continue;
305
+ }
306
+
307
+ let stat;
308
+ let bytes;
309
+ try {
310
+ stat = fileStat(check.resolved);
311
+ bytes = fileSize ? fileSize(check.resolved) : Number(stat.size);
312
+ }
313
+ catch { reject('unreadable'); continue; }
314
+ const maxBytes = kind === 'photo' ? maxPhotoBytes : maxOtherMediaBytes;
315
+ if (bytes > maxBytes) { reject('too-large'); continue; }
316
+ if (totalBytes + bytes > maxTotalMediaBytes) { reject('total-budget'); continue; }
317
+
318
+ totalBytes += bytes;
319
+ accepted += 1;
320
+ const fingerprint = statFingerprint(stat);
321
+ const fileId = fileIdCache?.get?.(kind, check.resolved, fingerprint) || null;
322
+ results.push({
323
+ kind,
324
+ media: {
325
+ source: check.resolved,
326
+ fingerprint,
327
+ ...(fileId ? { fileId } : {}),
328
+ },
329
+ });
330
+ }
331
+
332
+ if (rejected > 0 && typeof logEvent === 'function') {
333
+ try {
334
+ logEvent('rich-media-rejected', {
335
+ rejected_count: rejected,
336
+ accepted_count: accepted,
337
+ reasons,
338
+ });
339
+ } catch { /* instrumentation must never break delivery */ }
340
+ }
341
+
342
+ return results;
343
+ };
344
+ }
345
+
346
+ function createMediaDeliveryContext({
347
+ allowedRoots = [],
348
+ tg,
349
+ bot,
350
+ chatId,
351
+ threadId = null,
352
+ replyToMessageId = null,
353
+ botName = null,
354
+ logEvent = null,
355
+ setDeliveryError = () => {},
356
+ logger = console,
357
+ validatePath = validateAttachmentPath,
358
+ fileStat = statMediaFile,
359
+ fileIdCache = null,
360
+ } = {}) {
361
+ const roots = Object.freeze([...(allowedRoots || [])]);
362
+ const failures = { text: 0, media: 0, deletion: 0 };
363
+ let warningFlushed = false;
364
+
365
+ function recordFailure(kind, count = 1) {
366
+ if (Object.hasOwn(failures, kind)) failures[kind] += Math.max(0, Number(count) || 0);
367
+ }
368
+
369
+ function evict(kind, media) {
370
+ if (media && typeof media.source === 'string') {
371
+ fileIdCache?.delete?.(kind, media.source);
372
+ }
373
+ }
374
+
375
+ function preflight(media, kind = 'photo', { preferFileId = true } = {}) {
376
+ if (!MEDIA_KINDS.has(kind)) return { ok: false };
377
+ if (typeof media === 'string') return { ok: HTTP_URL_RE.test(media), value: media };
378
+ if (!media || typeof media.source !== 'string' || typeof media.fingerprint !== 'string') {
379
+ return { ok: false };
380
+ }
381
+ const check = validatePath(media.source, roots);
382
+ if (!check.ok || check.resolved !== media.source) {
383
+ evict(kind, media);
384
+ return { ok: false };
385
+ }
386
+ let current;
387
+ try { current = statFingerprint(fileStat(check.resolved)); }
388
+ catch {
389
+ evict(kind, media);
390
+ return { ok: false };
391
+ }
392
+ if (current !== media.fingerprint) {
393
+ evict(kind, media);
394
+ return { ok: false };
395
+ }
396
+ let fileId = typeof media.fileId === 'string' && media.fileId ? media.fileId : null;
397
+ if (fileId && fileIdCache) {
398
+ const currentId = fileIdCache.get?.(kind, media.source, media.fingerprint) || null;
399
+ if (currentId !== fileId) fileId = null;
400
+ }
401
+ return {
402
+ ok: true,
403
+ value: preferFileId && fileId ? fileId : { source: media.source },
404
+ };
405
+ }
406
+
407
+ function rememberLocal(kind, media, fileId) {
408
+ if (!fileIdCache || typeof fileId !== 'string' || !fileId) return false;
409
+ const checked = preflight(media, kind, { preferFileId: false });
410
+ if (!checked.ok || typeof media === 'string') return false;
411
+ return fileIdCache.set?.(kind, media.source, media.fingerprint, fileId) === true;
412
+ }
413
+
414
+ function learnDirectResult(kind, media, result) {
415
+ try {
416
+ const fileId = returnedMediaFileId(kind, result?.[kind]);
417
+ return rememberLocal(kind, media, fileId);
418
+ } catch (err) {
419
+ logger.warn?.(`[rich-media] cache learning failed (${err?.name || 'Error'})`);
420
+ return false;
421
+ }
422
+ }
423
+
424
+ function learnRichResult(blocks, result) {
425
+ try {
426
+ if (result?._notModified || !Array.isArray(result?.rich_message?.blocks)) return false;
427
+ const outgoing = collectOutgoingMedia(blocks);
428
+ const returned = collectReturnedMedia(result.rich_message.blocks);
429
+ if (outgoing.length !== 1 || returned.length !== 1) return false;
430
+ if (outgoing[0].kind !== returned[0].kind) return false;
431
+ if (!outgoing[0].media || typeof outgoing[0].media !== 'object') return false;
432
+ return rememberLocal(outgoing[0].kind, outgoing[0].media, returned[0].fileId);
433
+ } catch (err) {
434
+ logger.warn?.(`[rich-media] cache learning failed (${err?.name || 'Error'})`);
435
+ return false;
436
+ }
437
+ }
438
+
439
+ function evictCachedBlocks(blocks) {
440
+ let evicted = 0;
441
+ for (const entry of collectOutgoingMedia(blocks)) {
442
+ if (entry.media
443
+ && typeof entry.media === 'object'
444
+ && typeof entry.media.fileId === 'string'
445
+ && fileIdCache?.delete?.(entry.kind, entry.media.source)) {
446
+ evicted += 1;
447
+ }
448
+ }
449
+ return evicted;
450
+ }
451
+
452
+ async function deletePlaceholder(messageId, meta = {}) {
453
+ try {
454
+ await tg(bot, 'deleteMessage', {
455
+ chat_id: chatId,
456
+ message_id: messageId,
457
+ }, { ...meta, source: 'bot-reply-rich-media-placeholder-delete', botName });
458
+ return true;
459
+ } catch (err) {
460
+ recordFailure('deletion');
461
+ logger.warn?.('[rich-media] placeholder delete failed');
462
+ return false;
463
+ }
464
+ }
465
+
466
+ async function rescueEntries(entries, {
467
+ trigger,
468
+ anchorFirst = false,
469
+ meta = {},
470
+ } = {}) {
471
+ const list = Array.isArray(entries) ? entries : [];
472
+ let attempted = 0;
473
+ let sent = 0;
474
+ let failed = 0;
475
+ let needsAnchor = anchorFirst && replyToMessageId != null;
476
+ const kindCounts = { photo: 0, video: 0, animation: 0 };
477
+
478
+ for (const entry of list) {
479
+ const rescue = RESCUE_METHODS[entry?.kind];
480
+ if (!rescue) continue;
481
+ attempted += 1;
482
+ kindCounts[entry.kind] += 1;
483
+ const checked = preflight(entry.media, entry.kind, { preferFileId: false });
484
+ if (!checked.ok) {
485
+ failed += 1;
486
+ recordFailure('media');
487
+ continue;
488
+ }
489
+ const params = {
490
+ chat_id: chatId,
491
+ [rescue.field]: checked.value,
492
+ ...(threadId != null && { message_thread_id: threadId }),
493
+ };
494
+ if (entry.caption && entry.caption.length <= MAX_RESCUE_CAPTION_LENGTH) {
495
+ params.caption = entry.caption;
496
+ }
497
+ if (needsAnchor) {
498
+ params.reply_parameters = {
499
+ message_id: replyToMessageId,
500
+ allow_sending_without_reply: true,
501
+ };
502
+ }
503
+ try {
504
+ const result = await tg(bot, rescue.method, params, {
505
+ ...meta,
506
+ source: 'bot-reply-rich-media-rescue',
507
+ botName,
508
+ plainText: true,
509
+ });
510
+ learnDirectResult(entry.kind, entry.media, result);
511
+ sent += 1;
512
+ needsAnchor = false;
513
+ } catch (err) {
514
+ failed += 1;
515
+ recordFailure('media');
516
+ logger.warn?.(`[rich-media] ${rescue.method} rescue failed`);
517
+ }
518
+ }
519
+
520
+ if (attempted > 0 && typeof logEvent === 'function') {
521
+ try {
522
+ logEvent('rich-media-rescue', {
523
+ trigger: trigger || 'unknown',
524
+ attempted,
525
+ sent,
526
+ failed,
527
+ photo_count: kindCounts.photo,
528
+ video_count: kindCounts.video,
529
+ animation_count: kindCounts.animation,
530
+ });
531
+ } catch { /* instrumentation must never break delivery */ }
532
+ }
533
+ return { attempted, sent, failed };
534
+ }
535
+
536
+ async function rescueBlocks(blocks, opts = {}) {
537
+ return rescueEntries(collectMediaRescueEntries(blocks), opts);
538
+ }
539
+
540
+ function recordTextFailures(count) {
541
+ recordFailure('text', count);
542
+ }
543
+
544
+ function recordDeletionFailures(count) {
545
+ recordFailure('deletion', count);
546
+ }
547
+
548
+ function recordUnexpectedMediaFailure() {
549
+ recordFailure('media');
550
+ }
551
+
552
+ async function flushPartialDeliveryWarning(meta = {}) {
553
+ if (warningFlushed) return false;
554
+ const total = failures.text + failures.media + failures.deletion;
555
+ if (total === 0) return false;
556
+ warningFlushed = true;
557
+ try { setDeliveryError(); } catch {}
558
+ if (typeof logEvent === 'function') {
559
+ try {
560
+ logEvent('rich-media-delivery-summary', {
561
+ text_failed: failures.text,
562
+ media_failed: failures.media,
563
+ deletion_failed: failures.deletion,
564
+ });
565
+ } catch { /* instrumentation must never break delivery */ }
566
+ }
567
+
568
+ const parts = [];
569
+ if (failures.text) parts.push(`${failures.text} message part${failures.text === 1 ? '' : 's'}`);
570
+ if (failures.media) parts.push(`${failures.media} media item${failures.media === 1 ? '' : 's'}`);
571
+ if (failures.deletion) parts.push('the media placeholder cleanup');
572
+ const risks = [];
573
+ if (failures.text || failures.media) risks.push('incomplete');
574
+ if (failures.deletion) risks.push('duplicated');
575
+ try {
576
+ await tg(bot, 'sendMessage', {
577
+ chat_id: chatId,
578
+ text: `⚠️ ${parts.join(' and ')} failed. The reply may be ${risks.join(' or ')} — please retry.`,
579
+ ...(threadId != null && { message_thread_id: threadId }),
580
+ }, { ...meta, source: 'partial-delivery-warning', botName });
581
+ } catch (err) {
582
+ logger.warn?.('[rich-media] partial-delivery warning failed');
583
+ }
584
+ return true;
585
+ }
586
+
587
+ return Object.freeze({
588
+ preflightMedia: preflight,
589
+ learnRichResult,
590
+ evictCachedBlocks,
591
+ rescueEntries,
592
+ rescueBlocks,
593
+ deletePlaceholder,
594
+ recordTextFailures,
595
+ recordDeletionFailures,
596
+ recordUnexpectedMediaFailure,
597
+ flushPartialDeliveryWarning,
598
+ get deliveryIncomplete() {
599
+ return failures.text + failures.media + failures.deletion > 0;
600
+ },
601
+ });
602
+ }
603
+
604
+ module.exports = {
605
+ createRichMediaResolver,
606
+ createMediaDeliveryContext,
607
+ createMediaFileIdCache,
608
+ collectMediaRescueEntries,
609
+ largestPhotoFileId,
610
+ PHOTO_UPLOAD_CEILING,
611
+ OTHER_MEDIA_UPLOAD_CEILING,
612
+ MAX_TOTAL_MEDIA_BYTES,
613
+ MAX_MEDIA_PER_MESSAGE,
614
+ PHOTO_EXTENSIONS,
615
+ MAX_RESCUE_CAPTION_LENGTH,
616
+ };