polygram 0.19.0 → 0.20.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.
@@ -1,12 +1,13 @@
1
1
  /**
2
- * Rich-message media resolver — turns agent-authored image srcs into
3
- * uploadable media values for photo blocks, enforcing the same trust
2
+ * Rich-message media resolver — turns agent-authored media srcs into
3
+ * uploadable values for typed blocks, enforcing the same trust
4
4
  * boundary as reply(files).
5
5
  *
6
6
  * The renderer (rich.js) stays filesystem-free; every impure decision
7
7
  * lives here: src classification, path allowlisting, extension and
8
8
  * size gating, and the per-message media budget. Returned local-file
9
- * media are JSON-safe `{ source: '<realpath>' }` envelopes — the
9
+ * media are JSON-safe `{ source: '<realpath>', fingerprint: '…' }`
10
+ * envelopes — the
10
11
  * grammy InputFile is materialized later, in rich-edit.js, because an
11
12
  * InputFile inside the block tree would break the streamer's
12
13
  * JSON.stringify payload dedup (InputFile.toJSON throws by design).
@@ -15,8 +16,7 @@
15
16
  * never a URL parser (a colon in a local path must not read as a
16
17
  * scheme):
17
18
  * 1. http(s):// URL → passed through; Telegram fetches it
18
- * server-side (its 5 MB URL-photo limit
19
- * applies). Rejected when the bot runs
19
+ * server-side. Rejected when the bot runs
20
20
  * through a self-hosted Bot API server
21
21
  * (allowUrlMedia=false) — whether that server
22
22
  * fetches the URL from inside the operator's
@@ -41,14 +41,15 @@
41
41
  const fs = require('node:fs');
42
42
  const path = require('node:path');
43
43
  const { validateAttachmentPath } = require('../process/channels-tool-dispatcher');
44
+ const { TELEGRAM_MAX_CAPTION_LENGTH } = require('./format');
44
45
 
45
- // Telegram's per-photo upload ceiling (multipart). Distinct from the
46
- // document caps in attachments.js — photos are capped tighter than
47
- // documents by the Bot API.
46
+ // Portable multipart ceilings. Photos are capped tighter than video and
47
+ // animation by the Bot API.
48
48
  const PHOTO_UPLOAD_CEILING = 10 * 1024 * 1024;
49
+ const OTHER_MEDIA_UPLOAD_CEILING = 50 * 1024 * 1024;
49
50
 
50
- // Bound on the SINGLE multipart request that carries every photo of a
51
- // reply (media upload happens in one finalize edit). Matches
51
+ // Bound on the SINGLE multipart request that carries every local media
52
+ // item of a reply (uploads happen in one finalize edit). Matches
52
53
  // attachments.js's MAX_TOTAL_BYTES posture.
53
54
  const MAX_TOTAL_MEDIA_BYTES = 50 * 1024 * 1024;
54
55
 
@@ -58,14 +59,176 @@ const MAX_TOTAL_MEDIA_BYTES = 50 * 1024 * 1024;
58
59
  // over attachment #51.
59
60
  const MAX_MEDIA_PER_MESSAGE = 50;
60
61
 
61
- // Photo-block source selection (NOT a security control — the path
62
- // allowlist is; Telegram validates actual bytes server-side). .gif is
63
- // deliberately absent: it maps to an animation block, which this
64
- // module doesn't emit — unlike channels-tool-dispatcher's image list,
65
- // which includes .gif because sendPhoto accepts it as a photo message.
62
+ // Source selection is extension-based, not a security control; the path
63
+ // allowlist is, and Telegram validates actual bytes server-side.
66
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
+ ]);
67
70
 
68
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
+ }
69
232
 
70
233
  /**
71
234
  * @param {object} deps
@@ -73,31 +236,43 @@ const HTTP_URL_RE = /^https?:\/\//i;
73
236
  * resolve under (same builder as reply(files): buildAllowedRoots)
74
237
  * @param {number} [deps.maxPhotoBytes] — per-file cap, already clamped
75
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
76
241
  * @param {number} [deps.maxTotalMediaBytes] — per-reply budget
77
242
  * @param {boolean} [deps.allowUrlMedia] — false when a self-hosted Bot
78
243
  * API server is configured (see module doc)
79
244
  * @param {(kind: string, detail: object) => void} [deps.logEvent]
80
245
  * @param {(p: string, roots: string[]) => {ok: boolean, resolved?: string, error?: string}} [deps.validatePath]
246
+ * @param {(p: string) => object} [deps.fileStat]
81
247
  * @param {(p: string) => number} [deps.fileSize]
82
- * @returns {(descriptors: Array<{src: string, caption: string}>) => Array<{media?: (string|{source: string}), rejected?: string}>}
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
+ * >}
83
253
  */
84
254
  function createRichMediaResolver({
85
255
  allowedRoots = [],
86
256
  maxPhotoBytes = PHOTO_UPLOAD_CEILING,
257
+ maxOtherMediaBytes = OTHER_MEDIA_UPLOAD_CEILING,
87
258
  maxTotalMediaBytes = MAX_TOTAL_MEDIA_BYTES,
88
259
  allowUrlMedia = true,
89
260
  logEvent = null,
90
261
  validatePath = validateAttachmentPath,
91
- fileSize = (p) => fs.statSync(p).size,
262
+ fileStat = statMediaFile,
263
+ fileSize = null,
264
+ fileIdCache = null,
92
265
  } = {}) {
93
266
  return function resolveMedia(descriptors) {
94
267
  const results = [];
95
268
  const reasons = [];
269
+ let rejected = 0;
96
270
  let accepted = 0;
97
271
  let totalBytes = 0;
98
272
 
99
273
  const reject = (reason) => {
100
- reasons.push(reason);
274
+ rejected += 1;
275
+ if (reasons.length < 10) reasons.push(reason);
101
276
  results.push({ rejected: reason });
102
277
  };
103
278
 
@@ -109,7 +284,7 @@ function createRichMediaResolver({
109
284
  if (HTTP_URL_RE.test(src)) {
110
285
  if (!allowUrlMedia) { reject('url-local-api'); continue; }
111
286
  accepted += 1;
112
- results.push({ media: src });
287
+ results.push({ kind: classifyUrlMediaKind(src), media: src });
113
288
  continue;
114
289
  }
115
290
 
@@ -123,28 +298,43 @@ function createRichMediaResolver({
123
298
  const check = validatePath(src, allowedRoots);
124
299
  if (!check.ok) { reject('path'); continue; }
125
300
 
126
- if (!PHOTO_EXTENSIONS.has(path.extname(check.resolved).toLowerCase())) {
301
+ const kind = classifyLocalMediaKind(check.resolved);
302
+ if (!kind) {
127
303
  reject('extension');
128
304
  continue;
129
305
  }
130
306
 
307
+ let stat;
131
308
  let bytes;
132
- try { bytes = fileSize(check.resolved); }
309
+ try {
310
+ stat = fileStat(check.resolved);
311
+ bytes = fileSize ? fileSize(check.resolved) : Number(stat.size);
312
+ }
133
313
  catch { reject('unreadable'); continue; }
134
- if (bytes > maxPhotoBytes) { reject('too-large'); continue; }
314
+ const maxBytes = kind === 'photo' ? maxPhotoBytes : maxOtherMediaBytes;
315
+ if (bytes > maxBytes) { reject('too-large'); continue; }
135
316
  if (totalBytes + bytes > maxTotalMediaBytes) { reject('total-budget'); continue; }
136
317
 
137
318
  totalBytes += bytes;
138
319
  accepted += 1;
139
- results.push({ media: { source: check.resolved } });
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
+ });
140
330
  }
141
331
 
142
- if (reasons.length && typeof logEvent === 'function') {
332
+ if (rejected > 0 && typeof logEvent === 'function') {
143
333
  try {
144
334
  logEvent('rich-media-rejected', {
145
- rejected_count: reasons.length,
335
+ rejected_count: rejected,
146
336
  accepted_count: accepted,
147
- reasons: reasons.slice(0, 10),
337
+ reasons,
148
338
  });
149
339
  } catch { /* instrumentation must never break delivery */ }
150
340
  }
@@ -153,10 +343,274 @@ function createRichMediaResolver({
153
343
  };
154
344
  }
155
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
+
156
604
  module.exports = {
157
605
  createRichMediaResolver,
606
+ createMediaDeliveryContext,
607
+ createMediaFileIdCache,
608
+ collectMediaRescueEntries,
609
+ largestPhotoFileId,
158
610
  PHOTO_UPLOAD_CEILING,
611
+ OTHER_MEDIA_UPLOAD_CEILING,
159
612
  MAX_TOTAL_MEDIA_BYTES,
160
613
  MAX_MEDIA_PER_MESSAGE,
161
614
  PHOTO_EXTENSIONS,
615
+ MAX_RESCUE_CAPTION_LENGTH,
162
616
  };