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.
@@ -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) that the plain HTML
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
- function needsRichRendering(markdown) {
110
- if (typeof markdown !== 'string' || markdown.length === 0) return false;
111
- const scoped = stripFencedCodeBlocks(markdown);
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 m = DETAILS_BLOCK_RE.exec(token.raw || token.text || '');
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
- return [{
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 (leaderText.trim() || !nestedBlocks.length) blocks.push({ type: 'paragraph', text: leaderText });
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 ("![shot](/User…" still being
629
+ // streamed, or malformed output missing its ")") 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]", "![alt](", "![alt](/partial/pa…". Single negated classes,
639
+ // optional groups anchored at $ — linear.
640
+ const TRAILING_IMAGE_FRAGMENT_RE = /!\[[^\]\n]*(?:\][ \t]*(?:\([^)\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
- 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)/i;
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
  };