polygram 0.19.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/telegram/display-hint.js +2 -2
- package/lib/telegram/rich-edit.js +132 -26
- package/lib/telegram/rich-media.js +480 -26
- package/lib/telegram/rich.js +33 -25
- package/lib/telegram/streamer.js +156 -19
- package/package.json +1 -1
- package/polygram.js +84 -38
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Rich-message media resolver — turns agent-authored
|
|
3
|
-
* uploadable
|
|
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>' }`
|
|
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
|
|
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
|
-
//
|
|
46
|
-
//
|
|
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
|
|
51
|
-
// reply (
|
|
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
|
-
//
|
|
62
|
-
// allowlist 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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
309
|
+
try {
|
|
310
|
+
stat = fileStat(check.resolved);
|
|
311
|
+
bytes = fileSize ? fileSize(check.resolved) : Number(stat.size);
|
|
312
|
+
}
|
|
133
313
|
catch { reject('unreadable'); continue; }
|
|
134
|
-
|
|
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
|
-
|
|
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 (
|
|
332
|
+
if (rejected > 0 && typeof logEvent === 'function') {
|
|
143
333
|
try {
|
|
144
334
|
logEvent('rich-media-rejected', {
|
|
145
|
-
rejected_count:
|
|
335
|
+
rejected_count: rejected,
|
|
146
336
|
accepted_count: accepted,
|
|
147
|
-
reasons
|
|
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
|
};
|