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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/channels-tool-dispatcher.js +3 -0
- package/lib/telegram/display-hint.js +4 -0
- package/lib/telegram/rich-edit.js +172 -12
- package/lib/telegram/rich-media.js +616 -0
- package/lib/telegram/rich.js +409 -11
- package/lib/telegram/streamer.js +156 -19
- package/package.json +1 -1
- package/polygram.js +137 -33
package/lib/telegram/rich.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Companion to format.js's markdown→HTML pipeline, not a replacement:
|
|
5
5
|
* this only runs when needsRichRendering() finds a construct (task list,
|
|
6
|
-
* table, <details>, heading, blockquote, divider
|
|
6
|
+
* table, <details>, heading, blockquote, divider, image, or a
|
|
7
|
+
* <tg-collage>/<tg-slideshow> media wrapper) that the plain HTML
|
|
7
8
|
* pipeline can't express natively, AND the chat has opted in
|
|
8
9
|
* (config.richText). Everything else stays on the existing
|
|
9
10
|
* toTelegramHtml() path.
|
|
@@ -64,6 +65,10 @@ const DETAILS_RE = /<details[ \t>]/i;
|
|
|
64
65
|
const HEADING_RE = /^#{1,6}[ \t]+\S/m;
|
|
65
66
|
const BLOCKQUOTE_RE = /^[ \t]{0,3}>[ \t]?\S/m;
|
|
66
67
|
const DIVIDER_RE = /^[ \t]{0,3}(-{3,}|\*{3,}|_{3,})[ \t]*$/m;
|
|
68
|
+
// Markdown image syntax and Telegram's own collage/slideshow wrapper
|
|
69
|
+
// tags trigger rich so photos can render inline as media blocks.
|
|
70
|
+
const IMAGE_RE = /!\[[^\]\n]*\]\([^)\n]+\)/;
|
|
71
|
+
const MEDIA_WRAP_RE = /<tg-(?:collage|slideshow)[ \t>]/i;
|
|
67
72
|
const FENCE_LINE_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
68
73
|
|
|
69
74
|
// Code-fence content renders literally, so heading, checklist, and table
|
|
@@ -106,9 +111,11 @@ function stripFencedCodeBlocks(markdown) {
|
|
|
106
111
|
return out.join('\n');
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
// Non-media triggers, kept separate so the media-drop demotion in
|
|
115
|
+
// toTelegramRichBlocks can ask "would this content be rich WITHOUT its
|
|
116
|
+
// images?" — if not, and no image resolved, the reply goes plain rather
|
|
117
|
+
// than rendering prose-only rich blocks (the oversized-prose problem).
|
|
118
|
+
function hasNonMediaTrigger(scoped) {
|
|
112
119
|
return TASK_ITEM_RE.test(scoped)
|
|
113
120
|
|| TABLE_ROW_RE.test(scoped)
|
|
114
121
|
|| DETAILS_RE.test(scoped)
|
|
@@ -117,6 +124,14 @@ function needsRichRendering(markdown) {
|
|
|
117
124
|
|| DIVIDER_RE.test(scoped);
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
function needsRichRendering(markdown) {
|
|
128
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return false;
|
|
129
|
+
const scoped = stripFencedCodeBlocks(markdown);
|
|
130
|
+
return hasNonMediaTrigger(scoped)
|
|
131
|
+
|| IMAGE_RE.test(scoped)
|
|
132
|
+
|| MEDIA_WRAP_RE.test(scoped);
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
// ─── marked instance for block-level tokenization ────────────────────
|
|
121
136
|
//
|
|
122
137
|
// Use a dedicated lexer because format.js's instance has an HTML-string
|
|
@@ -156,6 +171,107 @@ const SUMMARY_TAG_RE = /<summary[^>]*>([\s\S]*?)<\/summary>/i;
|
|
|
156
171
|
const HTML_TAG_STRIP_RE = /<[^>]+>/g;
|
|
157
172
|
const DEFAULT_DETAILS_SUMMARY = 'Details';
|
|
158
173
|
|
|
174
|
+
// ─── Media (photo / video / animation / wrappers) ────────────────────
|
|
175
|
+
//
|
|
176
|
+
// Image markdown maps to typed Telegram media blocks after resolution;
|
|
177
|
+
// <tg-collage>/<tg-slideshow> wrapper tags (Telegram's own rich-markdown
|
|
178
|
+
// dialect) group supported media into collage/slideshow blocks. The renderer emits internal DESCRIPTOR
|
|
179
|
+
// blocks (_media/_mediaGroup) carrying only { src, caption } strings;
|
|
180
|
+
// toTelegramRichBlocks substitutes them via the injected resolveMedia
|
|
181
|
+
// (final mode) or with placeholder paragraphs (streaming/partial mode,
|
|
182
|
+
// or when no resolver is wired). Emitted block trees therefore never
|
|
183
|
+
// contain a raw agent-authored path, and never contain anything that
|
|
184
|
+
// can't survive JSON.stringify (the streamer dedups payloads that way;
|
|
185
|
+
// grammy InputFile instances would throw — they're materialized later,
|
|
186
|
+
// in rich-edit.js).
|
|
187
|
+
//
|
|
188
|
+
// Wrapper-span extraction deliberately scans the token stream (like the
|
|
189
|
+
// <details> handling below) instead of using a lazy [\s\S]*? block
|
|
190
|
+
// regex — an unclosed wrapper over a large body would make such a regex
|
|
191
|
+
// backtrack, and this text is agent output a user can influence.
|
|
192
|
+
const MEDIA_WRAP_OPEN_RE = /<tg-(collage|slideshow)(?:[ \t][^>]*)?>/i;
|
|
193
|
+
const MEDIA_WRAP_CLOSE_RE = /<\/tg-(?:collage|slideshow)>/i;
|
|
194
|
+
// Extraction form of IMAGE_RE: alt, src, optional quoted title. Single
|
|
195
|
+
// negated character classes only — linear on adversarial input.
|
|
196
|
+
const IMAGE_MD_G_RE = /!\[([^\]\n]*)\]\(([^)\s]+)(?:[ \t]+"([^"\n]*)")?\)/g;
|
|
197
|
+
|
|
198
|
+
function mediaDescriptor(src, caption) {
|
|
199
|
+
return { type: '_media', src: src || '', caption: (caption || '').trim() };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Pull image markdown out of a raw text span (used for wrapper bodies
|
|
203
|
+
// that arrive as one opaque html token).
|
|
204
|
+
function extractMediaItems(text) {
|
|
205
|
+
const items = [];
|
|
206
|
+
IMAGE_MD_G_RE.lastIndex = 0;
|
|
207
|
+
let m;
|
|
208
|
+
while ((m = IMAGE_MD_G_RE.exec(text)) !== null) {
|
|
209
|
+
items.push(mediaDescriptor(m[2], m[1] || m[3]));
|
|
210
|
+
}
|
|
211
|
+
return items;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// True if any inline token in the subtree is an image or a media
|
|
215
|
+
// wrapper tag — the cheap pre-check that lets media-free paragraphs
|
|
216
|
+
// keep the existing single-plainTextOf path untouched.
|
|
217
|
+
function inlineHasMedia(tokens) {
|
|
218
|
+
for (const t of tokens || []) {
|
|
219
|
+
if (t.type === 'image') return true;
|
|
220
|
+
if (t.type === 'html' && MEDIA_WRAP_RE.test(t.raw || t.text || '')) return true;
|
|
221
|
+
if (Array.isArray(t.tokens) && inlineHasMedia(t.tokens)) return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Split a paragraph's inline tokens into paragraph text runs, _media
|
|
227
|
+
// descriptors, and _mediaGroup spans (inline <tg-collage>…</tg-collage>
|
|
228
|
+
// on one line). Recurses only into bare 'text' containers (marked nests
|
|
229
|
+
// list-item content that way); images inside links/bold stay flattened
|
|
230
|
+
// to their alt text by plainTextOf — splitting mid-link would produce
|
|
231
|
+
// stranger output than the flattening does.
|
|
232
|
+
function blocksFromInlineTokens(inlineTokens) {
|
|
233
|
+
const out = [];
|
|
234
|
+
let textRun = [];
|
|
235
|
+
let group = null;
|
|
236
|
+
const flushText = () => {
|
|
237
|
+
if (!textRun.length) return;
|
|
238
|
+
const text = plainTextOf(textRun);
|
|
239
|
+
textRun = [];
|
|
240
|
+
if (text.trim()) out.push({ type: 'paragraph', text });
|
|
241
|
+
};
|
|
242
|
+
const walk = (tokens) => {
|
|
243
|
+
for (const t of tokens || []) {
|
|
244
|
+
if (t.type === 'image') {
|
|
245
|
+
const d = mediaDescriptor(t.href, plainTextOf(t.tokens) || t.text || t.title);
|
|
246
|
+
if (group) group.items.push(d);
|
|
247
|
+
else { flushText(); out.push(d); }
|
|
248
|
+
} else if (t.type === 'html') {
|
|
249
|
+
const raw = t.raw || t.text || '';
|
|
250
|
+
const open = MEDIA_WRAP_OPEN_RE.exec(raw);
|
|
251
|
+
if (!group && open && !MEDIA_WRAP_CLOSE_RE.test(raw)) {
|
|
252
|
+
flushText();
|
|
253
|
+
group = { type: '_mediaGroup', kind: open[1].toLowerCase(), items: [] };
|
|
254
|
+
} else if (group && MEDIA_WRAP_CLOSE_RE.test(raw)) {
|
|
255
|
+
if (group.items.length) out.push(group);
|
|
256
|
+
group = null;
|
|
257
|
+
} else {
|
|
258
|
+
textRun.push(t);
|
|
259
|
+
}
|
|
260
|
+
} else if (t.type === 'text' && Array.isArray(t.tokens) && inlineHasMedia(t.tokens)) {
|
|
261
|
+
walk(t.tokens);
|
|
262
|
+
} else {
|
|
263
|
+
textRun.push(t);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
walk(inlineTokens);
|
|
268
|
+
// Unclosed inline wrapper: keep whatever images it collected rather
|
|
269
|
+
// than dropping content.
|
|
270
|
+
if (group && group.items.length) out.push(group);
|
|
271
|
+
flushText();
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
159
275
|
// Convert one marked block-level token into zero or more RichBlock
|
|
160
276
|
// objects. Returns an array (a single markdown token can occasionally
|
|
161
277
|
// need zero blocks — e.g. a bare 'space' token — or, in principle,
|
|
@@ -163,6 +279,9 @@ const DEFAULT_DETAILS_SUMMARY = 'Details';
|
|
|
163
279
|
function blockFromToken(token) {
|
|
164
280
|
switch (token.type) {
|
|
165
281
|
case 'paragraph': {
|
|
282
|
+
if (inlineHasMedia(token.tokens)) {
|
|
283
|
+
return blocksFromInlineTokens(token.tokens);
|
|
284
|
+
}
|
|
166
285
|
const text = plainTextOf(token.tokens);
|
|
167
286
|
if (!text.trim()) return [];
|
|
168
287
|
return [{ type: 'paragraph', text }];
|
|
@@ -214,17 +333,36 @@ function blockFromToken(token) {
|
|
|
214
333
|
// injection surface escapeHtml closes in format.js) — it's
|
|
215
334
|
// rendered as a plain paragraph with tags stripped, matching
|
|
216
335
|
// the plain-text-only invariant this module holds throughout.
|
|
217
|
-
const
|
|
336
|
+
const src = token.raw || token.text || '';
|
|
337
|
+
const m = DETAILS_BLOCK_RE.exec(src);
|
|
218
338
|
if (m) {
|
|
339
|
+
// An html token can carry content around the <details> span (an
|
|
340
|
+
// HTML block runs until a blank line, so trailing prose or even a
|
|
341
|
+
// second <details> lands in the same token). Emit that content
|
|
342
|
+
// too rather than dropping it with the matched span.
|
|
343
|
+
const out = [];
|
|
344
|
+
const prefix = src.slice(0, m.index).replace(HTML_TAG_STRIP_RE, '').trim();
|
|
345
|
+
if (prefix) out.push({ type: 'paragraph', text: prefix });
|
|
219
346
|
const summary = (m[1] || '').replace(HTML_TAG_STRIP_RE, '').trim();
|
|
220
347
|
const innerMarkdown = (m[2] || '').trim();
|
|
221
348
|
const blocks = innerMarkdown ? blocksFromTokens(_lexer.lexer(innerMarkdown)) : [];
|
|
222
|
-
|
|
349
|
+
out.push({
|
|
223
350
|
type: 'details',
|
|
224
351
|
summary: summary || DEFAULT_DETAILS_SUMMARY,
|
|
225
352
|
blocks: blocks.length ? blocks : [{ type: 'paragraph', text: '' }],
|
|
226
353
|
is_open: false,
|
|
227
|
-
}
|
|
354
|
+
});
|
|
355
|
+
const suffix = src.slice(m.index + m[0].length);
|
|
356
|
+
if (suffix.trim()) out.push(...blocksFromTokens(_lexer.lexer(suffix)));
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
// A whole <tg-collage>/<tg-slideshow> span in one token (GFM
|
|
360
|
+
// produces this when the wrapper body has no blank lines).
|
|
361
|
+
const wrap = MEDIA_WRAP_OPEN_RE.exec(token.raw || token.text || '');
|
|
362
|
+
if (wrap && MEDIA_WRAP_CLOSE_RE.test(token.raw || token.text || '')) {
|
|
363
|
+
const items = extractMediaItems(token.raw || token.text || '');
|
|
364
|
+
if (!items.length) return [];
|
|
365
|
+
return [{ type: '_mediaGroup', kind: wrap[1].toLowerCase(), items }];
|
|
228
366
|
}
|
|
229
367
|
const stripped = (token.text || token.raw || '').replace(HTML_TAG_STRIP_RE, '').trim();
|
|
230
368
|
if (!stripped) return [];
|
|
@@ -244,10 +382,15 @@ function blockFromToken(token) {
|
|
|
244
382
|
|
|
245
383
|
function listItemFromToken(item, listToken, index) {
|
|
246
384
|
const { inline, blockTokens } = splitListItemTokens(item.tokens);
|
|
247
|
-
const leaderText = plainTextOf(inline);
|
|
248
385
|
const nestedBlocks = blocksFromTokens(blockTokens);
|
|
249
386
|
const blocks = [];
|
|
250
|
-
if (
|
|
387
|
+
if (inlineHasMedia(inline)) {
|
|
388
|
+
blocks.push(...blocksFromInlineTokens(inline));
|
|
389
|
+
if (!blocks.length && !nestedBlocks.length) blocks.push({ type: 'paragraph', text: '' });
|
|
390
|
+
} else {
|
|
391
|
+
const leaderText = plainTextOf(inline);
|
|
392
|
+
if (leaderText.trim() || !nestedBlocks.length) blocks.push({ type: 'paragraph', text: leaderText });
|
|
393
|
+
}
|
|
251
394
|
blocks.push(...nestedBlocks);
|
|
252
395
|
|
|
253
396
|
const out = { blocks };
|
|
@@ -303,29 +446,234 @@ function blocksFromTokens(tokens) {
|
|
|
303
446
|
const summaryMatch = SUMMARY_TAG_RE.exec(raw);
|
|
304
447
|
const summary = summaryMatch ? summaryMatch[1].replace(HTML_TAG_STRIP_RE, '').trim() : '';
|
|
305
448
|
const inner = [];
|
|
449
|
+
let closeRaw = null;
|
|
306
450
|
let j = i + 1;
|
|
307
451
|
for (; j < list.length; j++) {
|
|
308
452
|
const tj = list[j];
|
|
309
453
|
const rawj = tj.raw || tj.text || '';
|
|
310
|
-
if (tj.type === 'html' && DETAILS_CLOSE_RE.test(rawj)) break;
|
|
454
|
+
if (tj.type === 'html' && DETAILS_CLOSE_RE.test(rawj)) { closeRaw = rawj; break; }
|
|
311
455
|
inner.push(tj);
|
|
312
456
|
}
|
|
313
457
|
const nestedBlocks = blocksFromTokens(inner);
|
|
458
|
+
// The close tag's html token can carry content on adjacent lines
|
|
459
|
+
// (an HTML block runs until a blank line): text before </details>
|
|
460
|
+
// belongs inside the collapsible body, text after it is ordinary
|
|
461
|
+
// top-level content following the details block.
|
|
462
|
+
let after = '';
|
|
463
|
+
if (closeRaw != null) {
|
|
464
|
+
const cm = DETAILS_CLOSE_RE.exec(closeRaw);
|
|
465
|
+
const before = closeRaw.slice(0, cm.index).replace(HTML_TAG_STRIP_RE, '').trim();
|
|
466
|
+
if (before) nestedBlocks.push({ type: 'paragraph', text: before });
|
|
467
|
+
after = closeRaw.slice(cm.index + cm[0].length);
|
|
468
|
+
}
|
|
314
469
|
out.push({
|
|
315
470
|
type: 'details',
|
|
316
471
|
summary: summary || DEFAULT_DETAILS_SUMMARY,
|
|
317
472
|
blocks: nestedBlocks.length ? nestedBlocks : [{ type: 'paragraph', text: '' }],
|
|
318
473
|
is_open: false,
|
|
319
474
|
});
|
|
475
|
+
if (after.trim()) out.push(...blocksFromTokens(_lexer.lexer(after)));
|
|
320
476
|
i = j + 1; // skip past the close token (or to the end if never closed)
|
|
321
477
|
continue;
|
|
322
478
|
}
|
|
479
|
+
// <tg-collage>/<tg-slideshow> split across tokens by blank lines —
|
|
480
|
+
// same open/close span scan as <details> above. Images among the
|
|
481
|
+
// inner tokens become the group's items; any non-media inner
|
|
482
|
+
// content is emitted after the group rather than dropped.
|
|
483
|
+
if (t.type === 'html' && MEDIA_WRAP_OPEN_RE.test(raw) && !MEDIA_WRAP_CLOSE_RE.test(raw)) {
|
|
484
|
+
const kind = MEDIA_WRAP_OPEN_RE.exec(raw)[1].toLowerCase();
|
|
485
|
+
// An UNCLOSED wrapper with no blank lines arrives as one html
|
|
486
|
+
// token whose raw already contains the image lines — they are
|
|
487
|
+
// not sibling tokens, so the span scan below can't see them.
|
|
488
|
+
// Extract them from the open token itself or they'd be silently
|
|
489
|
+
// dropped when the close tag never arrives (truncated stream,
|
|
490
|
+
// malformed agent output).
|
|
491
|
+
const items = extractMediaItems(raw);
|
|
492
|
+
const inner = [];
|
|
493
|
+
let j = i + 1;
|
|
494
|
+
for (; j < list.length; j++) {
|
|
495
|
+
const tj = list[j];
|
|
496
|
+
const rawj = tj.raw || tj.text || '';
|
|
497
|
+
if (tj.type === 'html' && MEDIA_WRAP_CLOSE_RE.test(rawj)) break;
|
|
498
|
+
inner.push(tj);
|
|
499
|
+
}
|
|
500
|
+
const innerBlocks = blocksFromTokens(inner);
|
|
501
|
+
const rest = [];
|
|
502
|
+
for (const b of innerBlocks) {
|
|
503
|
+
if (b.type === '_media') items.push({ type: '_media', src: b.src, caption: b.caption });
|
|
504
|
+
else if (b.type === '_mediaGroup') items.push(...b.items);
|
|
505
|
+
else rest.push(b);
|
|
506
|
+
}
|
|
507
|
+
if (items.length) out.push({ type: '_mediaGroup', kind, items });
|
|
508
|
+
out.push(...rest);
|
|
509
|
+
i = j + 1;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
323
512
|
out.push(...blockFromToken(t));
|
|
324
513
|
i++;
|
|
325
514
|
}
|
|
326
515
|
return out;
|
|
327
516
|
}
|
|
328
517
|
|
|
518
|
+
// ─── Media descriptor substitution ───────────────────────────────────
|
|
519
|
+
|
|
520
|
+
// Placeholder paragraph text for a media descriptor that isn't (or
|
|
521
|
+
// can't be) materialized. Uses the agent's own caption when present —
|
|
522
|
+
// never a filesystem-derived basename, which for a rejected path would
|
|
523
|
+
// echo the target filename into the visible chat.
|
|
524
|
+
function mediaPlaceholderText(d, { unavailable = false } = {}) {
|
|
525
|
+
if (unavailable) return d.caption ? `${d.caption} (media unavailable)` : '(media unavailable)';
|
|
526
|
+
return d.caption ? `📎 ${d.caption}` : '📎 media';
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Depth-first, document-order collection of media descriptors across
|
|
530
|
+
// the block tree (wrapper children flattened) — the single array
|
|
531
|
+
// resolveMedia sees, which is what keeps the message-scoped media cap
|
|
532
|
+
// honest.
|
|
533
|
+
function collectMediaDescriptors(blocks, list = []) {
|
|
534
|
+
for (const b of blocks || []) {
|
|
535
|
+
if (b.type === '_media') {
|
|
536
|
+
list.push({ src: b.src, caption: b.caption });
|
|
537
|
+
} else if (b.type === '_mediaGroup') {
|
|
538
|
+
for (const it of b.items) list.push({ src: it.src, caption: it.caption });
|
|
539
|
+
} else {
|
|
540
|
+
if (Array.isArray(b.blocks)) collectMediaDescriptors(b.blocks, list);
|
|
541
|
+
if (Array.isArray(b.items)) {
|
|
542
|
+
for (const it of b.items) if (Array.isArray(it.blocks)) collectMediaDescriptors(it.blocks, list);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return list;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const MEDIA_BLOCK_KINDS = new Set(['photo', 'video', 'animation']);
|
|
550
|
+
|
|
551
|
+
function mediaBlockFrom(result, caption) {
|
|
552
|
+
const kind = MEDIA_BLOCK_KINDS.has(result?.kind) ? result.kind : 'photo';
|
|
553
|
+
const block = { type: kind, [kind]: { type: kind, media: result.media } };
|
|
554
|
+
if (caption) block.caption = { text: caption };
|
|
555
|
+
return block;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Replace descriptor blocks with real media/wrapper blocks
|
|
559
|
+
// (when `results` — index-aligned with collectMediaDescriptors order —
|
|
560
|
+
// is provided) or with placeholder paragraphs (streaming / no resolver).
|
|
561
|
+
// `cursor` threads the shared descriptor index through the recursion.
|
|
562
|
+
function substituteMediaBlocks(blocks, results, cursor = { i: 0 }) {
|
|
563
|
+
const out = [];
|
|
564
|
+
for (const b of blocks || []) {
|
|
565
|
+
if (b.type === '_media') {
|
|
566
|
+
const r = results ? results[cursor.i] : null;
|
|
567
|
+
cursor.i += 1;
|
|
568
|
+
if (r && r.media) out.push(mediaBlockFrom(r, b.caption));
|
|
569
|
+
else if (results) out.push({ type: 'paragraph', text: mediaPlaceholderText(b, { unavailable: true }) });
|
|
570
|
+
else out.push({ type: 'paragraph', text: mediaPlaceholderText(b) });
|
|
571
|
+
} else if (b.type === '_mediaGroup') {
|
|
572
|
+
if (!results) {
|
|
573
|
+
cursor.i += b.items.length;
|
|
574
|
+
out.push({ type: 'paragraph', text: `📎 ${b.kind} (${b.items.length} media items)` });
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const mediaBlocks = [];
|
|
578
|
+
let unavailable = 0;
|
|
579
|
+
for (const it of b.items) {
|
|
580
|
+
const r = results[cursor.i];
|
|
581
|
+
cursor.i += 1;
|
|
582
|
+
if (r && r.media) mediaBlocks.push(mediaBlockFrom(r, it.caption));
|
|
583
|
+
else unavailable += 1;
|
|
584
|
+
}
|
|
585
|
+
if (mediaBlocks.length === 1) out.push(mediaBlocks[0]);
|
|
586
|
+
else if (mediaBlocks.length > 1) out.push({ type: b.kind, blocks: mediaBlocks });
|
|
587
|
+
if (unavailable > 0) {
|
|
588
|
+
out.push({
|
|
589
|
+
type: 'paragraph',
|
|
590
|
+
text: `(${unavailable} media item${unavailable === 1 ? '' : 's'} unavailable)`,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
} else if (Array.isArray(b.blocks) || Array.isArray(b.items)) {
|
|
594
|
+
const clone = { ...b };
|
|
595
|
+
if (Array.isArray(b.blocks)) clone.blocks = substituteMediaBlocks(b.blocks, results, cursor);
|
|
596
|
+
if (Array.isArray(b.items)) {
|
|
597
|
+
clone.items = b.items.map((it) => (Array.isArray(it.blocks)
|
|
598
|
+
? { ...it, blocks: substituteMediaBlocks(it.blocks, results, cursor) }
|
|
599
|
+
: it));
|
|
600
|
+
}
|
|
601
|
+
out.push(clone);
|
|
602
|
+
} else {
|
|
603
|
+
out.push(b);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Count media attachments in an emitted block tree, including wrapper
|
|
610
|
+
// and container children — the media_count instrumentation field.
|
|
611
|
+
function countMediaBlocks(blocks) {
|
|
612
|
+
let n = 0;
|
|
613
|
+
for (const b of blocks || []) {
|
|
614
|
+
if (MEDIA_BLOCK_KINDS.has(b.type)) n += 1;
|
|
615
|
+
else if (b.type === 'collage' || b.type === 'slideshow') n += countMediaBlocks(b.blocks);
|
|
616
|
+
else {
|
|
617
|
+
if (Array.isArray(b.blocks)) n += countMediaBlocks(b.blocks);
|
|
618
|
+
if (Array.isArray(b.items)) {
|
|
619
|
+
for (const it of b.items) if (Array.isArray(it.blocks)) n += countMediaBlocks(it.blocks);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return n;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Project media markdown out of plain fallback text: image syntax
|
|
627
|
+
// degrades to its caption text, wrapper tags disappear, and a dangling
|
|
628
|
+
// UNTERMINATED trailing image fragment ("") is cut — the fragment
|
|
630
|
+
// is exactly the shape that carries a raw absolute path, and it's what
|
|
631
|
+
// live plain previews would otherwise render before the rich upgrade
|
|
632
|
+
// kicks in. Fenced code is left untouched (image syntax inside a fence
|
|
633
|
+
// is literal content). Used on every plain-text surface a rich-enabled
|
|
634
|
+
// chat's reply can reach: live preview edits, fallback edits, and
|
|
635
|
+
// chunked redelivery.
|
|
636
|
+
const MEDIA_WRAP_TAG_G_RE = /<\/?tg-(?:collage|slideshow)(?:[ \t][^>]*)?>/gi;
|
|
637
|
+
// An image opener at end-of-line that never completed: "![", "![alt",
|
|
638
|
+
// "![alt]", "\n]*)?)?$/;
|
|
641
|
+
|
|
642
|
+
function stripMediaMarkdown(markdown) {
|
|
643
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return markdown;
|
|
644
|
+
if (markdown.indexOf('![') === -1 && markdown.indexOf('<tg-') === -1) return markdown;
|
|
645
|
+
const lines = markdown.split('\n');
|
|
646
|
+
const out = [];
|
|
647
|
+
// Fence tracking mirrors stripFencedCodeBlocks (same open/close
|
|
648
|
+
// semantics) but keeps every line — only non-fenced lines are
|
|
649
|
+
// rewritten.
|
|
650
|
+
let fence = null;
|
|
651
|
+
for (const line of lines) {
|
|
652
|
+
const m = FENCE_LINE_RE.exec(line);
|
|
653
|
+
if (m) {
|
|
654
|
+
const marker = m[1];
|
|
655
|
+
const trailing = line.slice(m[0].length);
|
|
656
|
+
if (!fence) fence = { char: marker[0], len: marker.length };
|
|
657
|
+
else if (marker[0] === fence.char && marker.length >= fence.len && trailing.trim() === '') fence = null;
|
|
658
|
+
out.push(line);
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (fence) { out.push(line); continue; }
|
|
662
|
+
IMAGE_MD_G_RE.lastIndex = 0;
|
|
663
|
+
let clean = line
|
|
664
|
+
.replace(IMAGE_MD_G_RE, (_all, alt, _src, title) => (alt || title || '').trim())
|
|
665
|
+
.replace(MEDIA_WRAP_TAG_G_RE, '');
|
|
666
|
+
// Only a fragment on the FINAL line can still be completed by
|
|
667
|
+
// text that hasn't streamed in yet; earlier lines can't (image
|
|
668
|
+
// syntax never spans lines), so a fragment there is plain
|
|
669
|
+
// malformed output — cut it the same way.
|
|
670
|
+
clean = clean.replace(TRAILING_IMAGE_FRAGMENT_RE, '');
|
|
671
|
+
out.push(clean);
|
|
672
|
+
}
|
|
673
|
+
const projected = out.join('\n');
|
|
674
|
+
return projected.trim() ? projected : '';
|
|
675
|
+
}
|
|
676
|
+
|
|
329
677
|
/**
|
|
330
678
|
* toTelegramRichBlocks(markdown, opts) -> { blocks: RichBlock[], usedRich: boolean }
|
|
331
679
|
*
|
|
@@ -338,6 +686,18 @@ function blocksFromTokens(tokens) {
|
|
|
338
686
|
* top-level block — a single in-progress block is emitted as-is rather
|
|
339
687
|
* than producing an empty array (better to show a resizing paragraph
|
|
340
688
|
* than nothing).
|
|
689
|
+
*
|
|
690
|
+
* opts.resolveMedia (descriptors: [{src, caption}]) -> results[] —
|
|
691
|
+
* index-aligned with the descriptors; each accepted result is
|
|
692
|
+
* `{ kind, media }`, where media is an https URL string or a JSON-safe
|
|
693
|
+
* `{ source, fingerprint, fileId? }` envelope that rich-edit.js validates
|
|
694
|
+
* before using a cached ID or materializing a grammy InputFile. Rejections
|
|
695
|
+
* carry `{ rejected }`. NEVER called in partial mode — streaming ticks
|
|
696
|
+
* must not touch the filesystem; media renders as placeholder
|
|
697
|
+
* paragraphs until finalize. Absent resolver → placeholders too.
|
|
698
|
+
* When media was the ONLY gate trigger and none of it resolved, the
|
|
699
|
+
* whole render demotes to `usedRich: false` so prose doesn't ship as
|
|
700
|
+
* oversized rich paragraphs.
|
|
341
701
|
*/
|
|
342
702
|
function toTelegramRichBlocks(markdown, opts = {}) {
|
|
343
703
|
const usedRich = needsRichRendering(markdown);
|
|
@@ -346,10 +706,38 @@ function toTelegramRichBlocks(markdown, opts = {}) {
|
|
|
346
706
|
const tokens = _lexer.lexer(markdown);
|
|
347
707
|
let blocks = blocksFromTokens(tokens);
|
|
348
708
|
|
|
709
|
+
const descriptors = collectMediaDescriptors(blocks);
|
|
710
|
+
let results = null;
|
|
711
|
+
let resolvedCount = 0;
|
|
712
|
+
if (descriptors.length && !opts.partial && typeof opts.resolveMedia === 'function') {
|
|
713
|
+
try {
|
|
714
|
+
const r = opts.resolveMedia(descriptors);
|
|
715
|
+
if (Array.isArray(r)) {
|
|
716
|
+
results = r;
|
|
717
|
+
resolvedCount = r.filter((x) => x && x.media).length;
|
|
718
|
+
}
|
|
719
|
+
} catch {
|
|
720
|
+
results = null; // resolver failure degrades to placeholders, never crashes the reply
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
if (descriptors.length) {
|
|
724
|
+
blocks = substituteMediaBlocks(blocks, results);
|
|
725
|
+
}
|
|
726
|
+
|
|
349
727
|
if (opts.partial && blocks.length > 1) {
|
|
350
728
|
blocks = blocks.slice(0, -1);
|
|
351
729
|
}
|
|
352
730
|
|
|
731
|
+
if (descriptors.length && !opts.partial && resolvedCount === 0
|
|
732
|
+
&& !hasNonMediaTrigger(stripFencedCodeBlocks(markdown))) {
|
|
733
|
+
return { blocks: [], usedRich: false };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// An empty rich payload is never sendable (Telegram rejects
|
|
737
|
+
// rich_message with no blocks; api.js refuses it before the call) —
|
|
738
|
+
// whatever produced it, plain is the honest degrade.
|
|
739
|
+
if (!blocks.length) return { blocks: [], usedRich: false };
|
|
740
|
+
|
|
353
741
|
return { blocks, usedRich: true };
|
|
354
742
|
}
|
|
355
743
|
|
|
@@ -365,7 +753,14 @@ function toTelegramRichBlocks(markdown, opts = {}) {
|
|
|
365
753
|
// editMessageText endpoint can instead reject its `rich_message`
|
|
366
754
|
// parameter with an unknown/unsupported-field response, so both shapes
|
|
367
755
|
// identify a missing rich-message capability.
|
|
368
|
-
|
|
756
|
+
//
|
|
757
|
+
// "rich message must be non-empty" is a capability shape too, observed
|
|
758
|
+
// live on a Bot API 10.1 self-hosted server: it accepts sendRichMessage
|
|
759
|
+
// but predates the 10.2 `blocks` field, silently ignores it, and sees
|
|
760
|
+
// an "empty" InputRichMessage. api.js refuses genuinely empty payloads
|
|
761
|
+
// before any network call, so this response to a payload we sent can
|
|
762
|
+
// only mean the server cannot read typed blocks — latch, don't retry.
|
|
763
|
+
const RICH_CAPABILITY_ERR_RE = /method\s*["']?(?:sendRichMessage|sendRichMessageDraft|editMessageText)["']?\s*not found|no such method|unknown method|method not found|(?:unknown|unsupported|unrecognized)\s+(?:parameter|field)s?\s*["']?rich_message["']?|rich_message\s*["']?\s*(?:is\s+)?(?:unknown|unsupported|unrecognized|not\s+supported)|rich message must be non-empty/i;
|
|
369
764
|
|
|
370
765
|
function errorMessage(err) {
|
|
371
766
|
if (!err) return '';
|
|
@@ -395,8 +790,11 @@ module.exports = {
|
|
|
395
790
|
isRichCapabilityError,
|
|
396
791
|
isRichContentError,
|
|
397
792
|
errorMessage,
|
|
793
|
+
stripMediaMarkdown,
|
|
794
|
+
countMediaBlocks,
|
|
398
795
|
// exported for tests / introspection, not part of the public contract
|
|
399
796
|
_plainTextOf: plainTextOf,
|
|
400
797
|
_blocksFromTokens: blocksFromTokens,
|
|
401
798
|
_stripFencedCodeBlocks: stripFencedCodeBlocks,
|
|
799
|
+
_collectMediaDescriptors: collectMediaDescriptors,
|
|
402
800
|
};
|