polygram 0.18.1 → 0.19.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.18.1",
4
+ "version": "0.19.0",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
@@ -317,6 +317,9 @@ function createChannelsToolDispatcher({
317
317
  }
318
318
  try {
319
319
  const ext = path.extname(check.resolved).toLowerCase();
320
+ // Deliberately broader than rich-media.js's PHOTO_EXTENSIONS:
321
+ // sendPhoto accepts .gif as a photo message, while a rich
322
+ // photo BLOCK cannot carry an animation.
320
323
  const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
321
324
  const method = isImage ? 'sendPhoto' : 'sendDocument';
322
325
  const fieldName = isImage ? 'photo' : 'document';
@@ -63,13 +63,43 @@ const TABLES_AND_HEADERS_SECTION_PLAIN = [
63
63
  const TABLES_AND_HEADERS_SECTION_RICH = [
64
64
  '### Rich formatting is available in this chat',
65
65
  '',
66
- 'This chat has rich-text rendering enabled. Real Telegram markdown tables, headings (`#`/`##`/etc.), task lists, and collapsible `<details>` blocks now render as an actual structured message, not flattened text — use them when they make an answer clearer or easier to scan (a comparison, a set of steps, a key/value summary, a checklist tracking progress). No character-width budget applies to tables here; write a normal markdown table.',
66
+ 'This chat has rich-text rendering enabled. Real Telegram markdown tables, headings (`#`/`##`/etc.), task lists, collapsible `<details>` blocks, blockquotes (`>`), and dividers (`---`) now render as an actual structured message, not flattened text. No character-width budget applies to tables here; write a normal markdown table.',
67
67
  '',
68
- '**Task lists for progress:** `- [ ] pending step` / `- [x] done step` renders as a real checkbox list — the natural way to show multi-step progress as it happens.',
68
+ '**Use the real construct instead of faking it with bold text and emoji.** In plain-mode chats you had to fake structure with **bold** pseudo-headers, emoji bullets (✅ ❓ 🕓, •, etc.), and plain-text separator lines, because headings and checkboxes rendered as flattened text there. That workaround is unnecessary here and looks worse than the real thing. When you catch yourself about to write a bold pseudo-heading or an emoji-bulleted list, use the actual construct instead:',
69
+ '',
70
+ '- A named group of items (e.g. "ready to apply" / "needs your input" / "deferred") → a real heading (`## Ready to apply`), not `**Ready to apply:**`.',
71
+ '- A list of items each with a yes/no/pending state (approve this? confirm that?) → a real task list (`- [ ] item`), not `• item` with an emoji glued on.',
72
+ '- A comparison or key/value summary → a real markdown table.',
73
+ '- A quoted aside, caveat, or callout you want visually set apart → a real blockquote (`> like this`), not a bold prefix.',
74
+ '- A visual break between distinct sections of a long reply → a real divider (`---` on its own line), the same way you already reach for it, but now it renders as a divider instead of literal dashes.',
75
+ '- Verbose supporting detail (a diff, a log, raw output) you want available but not front-and-center → `<details><summary>Label</summary>...</details>`.',
76
+ '',
77
+ 'Worked example — triaging a batch of items into groups, each item with its own state:',
78
+ '',
79
+ '```',
80
+ '## Ready to apply',
81
+ '',
82
+ '- [ ] Merge the two lists into one (#18)',
83
+ '- [ ] Fix the delivery-time wording (#4)',
84
+ '',
85
+ '## Need your input',
86
+ '',
87
+ '- [ ] Hemming service — confirm we can offer it (#11)',
88
+ '',
89
+ '## Deferred to v2',
90
+ '',
91
+ '- [ ] Size-guide page (#16)',
92
+ '```',
93
+ '',
94
+ '**Task lists for progress:** `- [ ] pending step` / `- [x] done step` renders as a real checkbox list — the natural way to show multi-step progress, a batch of items awaiting confirmation, or anything else with a per-item done/pending state.',
69
95
  '',
70
96
  '**Collapsible detail:** wrap verbose logs/diffs/output you want available but not front-and-center in `<details><summary>Label</summary>...</details>`.',
71
97
  '',
72
- 'Not every reply needs this — plain prose is still right for a normal conversational answer. Reach for structure when the CONTENT is structured, not by default.',
98
+ '**Inline images:** write `![caption](/abs/path/to/shot.png)` on its own line to place a photo inline at that point in the reply — screenshots, plots, before/after comparisons. Absolute path only, inside your workspace or the attachments staging dir; png/jpg/webp only (no gif). A path you can\'t reach, or any other file type, degrades to a plain "(image unavailable)" line — it will not error. Always accompany an image with at least a sentence of text.',
99
+ '',
100
+ '**Grouping images:** wrap consecutive images in `<tg-collage>…</tg-collage>` (grid) or `<tg-slideshow>…</tg-slideshow>` (swipeable) to send them as one block instead of separate photos.',
101
+ '',
102
+ 'Not every reply needs this — plain prose is still right for a normal conversational answer with no distinct items or sections. Reach for structure when the CONTENT is structured, not by default.',
73
103
  '',
74
104
  '### Other Telegram quirks',
75
105
  '',
@@ -2,10 +2,56 @@
2
2
  * Rich-message edit-with-fallback. Capability and content errors preserve
3
3
  * the reply through a plain edit; transient errors remain visible to the
4
4
  * streamer's retry/finalization logic.
5
+ *
6
+ * Media handling: blocks arrive JSON-safe — local files as
7
+ * `{ source: '<realpath>' }` envelopes at photo.media (rich.js emits
8
+ * them that way because the streamer dedups payloads via JSON.stringify,
9
+ * which a grammy InputFile would break: its toJSON throws by design).
10
+ * The envelope→InputFile materialization happens HERE, on a cloned
11
+ * tree, immediately before each send attempt — so the caller's blocks
12
+ * stay serializable and every retry gets a fresh path-backed InputFile.
13
+ * Fallback edits go through sanitizeFallbackText so a media-bearing
14
+ * reply degrades without rendering absolute local paths into the chat.
5
15
  */
6
16
 
7
17
  'use strict';
8
18
 
19
+ const { countMediaBlocks } = require('./rich');
20
+
21
+ // Clone the block tree, replacing { source } envelopes at photo.media
22
+ // with uploadable files. Only the photo-block envelope shape is
23
+ // materialized — everything else passes through untouched.
24
+ function materializeMediaBlocks(blocks, makeInputFile) {
25
+ return (blocks || []).map((b) => {
26
+ if (b && b.type === 'photo'
27
+ && b.photo && typeof b.photo === 'object'
28
+ && b.photo.media && typeof b.photo.media === 'object'
29
+ && typeof b.photo.media.source === 'string') {
30
+ return { ...b, photo: { ...b.photo, media: makeInputFile(b.photo.media.source) } };
31
+ }
32
+ if (b && Array.isArray(b.blocks)) {
33
+ return { ...b, blocks: materializeMediaBlocks(b.blocks, makeInputFile) };
34
+ }
35
+ if (b && Array.isArray(b.items)) {
36
+ return {
37
+ ...b,
38
+ items: b.items.map((it) => (it && Array.isArray(it.blocks)
39
+ ? { ...it, blocks: materializeMediaBlocks(it.blocks, makeInputFile) }
40
+ : it)),
41
+ };
42
+ }
43
+ return b;
44
+ });
45
+ }
46
+
47
+ function defaultMakeInputFile(source) {
48
+ // Lazy require keeps unit tests free of the grammy dependency; the
49
+ // InputFile MUST be path-backed (never a stream/supplier) so api.js's
50
+ // 429/pre-connect retries can re-read it.
51
+ const { InputFile } = require('grammy');
52
+ return new InputFile(source);
53
+ }
54
+
9
55
  /**
10
56
  * @param {object} deps
11
57
  * @param {(bot, method, params, meta) => Promise<*>} deps.tg
@@ -19,6 +65,11 @@
19
65
  * @param {() => (string|null)} [deps.getApiRoot] — for the latch-trip log line only
20
66
  * @param {(url: string) => string} [deps.stripUrlCreds] — strips basic-auth
21
67
  * userinfo from apiRoot before logging
68
+ * @param {(source: string) => *} [deps.makeInputFile] — { source } envelope →
69
+ * uploadable file for grammy (test seam)
70
+ * @param {(text: string) => string} [deps.sanitizeFallbackText] — applied to
71
+ * sourceText on every plain-fallback edit (polygram wires
72
+ * stripMediaMarkdown; default identity preserves legacy callers)
22
73
  * @returns {(args: {bot, chatId: string, threadId: (string|null), messageId: number, blocks: Array, sourceText: string}) => Promise<{result: *, wentRich: boolean}>}
23
74
  */
24
75
  function createRichEditor({
@@ -32,11 +83,13 @@ function createRichEditor({
32
83
  setRichKnownUnsupported = () => {},
33
84
  getApiRoot = () => null,
34
85
  stripUrlCreds = (s) => s,
86
+ makeInputFile = defaultMakeInputFile,
87
+ sanitizeFallbackText = (s) => s,
35
88
  } = {}) {
36
89
  return async function richEditMessageText({ bot, chatId, threadId = null, messageId, blocks, sourceText }) {
37
90
  if (getRichKnownUnsupported()) {
38
91
  const res = await tg(bot, 'editMessageText', {
39
- chat_id: chatId, message_id: messageId, text: sourceText,
92
+ chat_id: chatId, message_id: messageId, text: sanitizeFallbackText(sourceText),
40
93
  }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
41
94
  return { result: res, wentRich: false };
42
95
  }
@@ -45,12 +98,13 @@ function createRichEditor({
45
98
  const res = await tg(bot, 'editMessageText', {
46
99
  chat_id: chatId,
47
100
  message_id: messageId,
48
- rich_message: { blocks },
101
+ rich_message: { blocks: materializeMediaBlocks(blocks, makeInputFile) },
49
102
  }, { source: 'bot-reply-stream-edit-rich', botName, richSourceText: sourceText });
50
103
  // Record successful rich deliveries without storing message content.
51
104
  logEvent('rich-message-sent', {
52
105
  chat_id: chatId, thread_id: threadId, bot: botName,
53
106
  streaming: true, block_count: blocks.length,
107
+ media_count: countMediaBlocks(blocks),
54
108
  char_count: sourceText?.length ?? null,
55
109
  });
56
110
  // The streamer commits rich state only when this marker is true.
@@ -84,11 +138,11 @@ function createRichEditor({
84
138
  // Preserve the same authored content and tell the streamer that the
85
139
  // accepted payload is plain, so its delivery trackers stay accurate.
86
140
  const fallbackRes = await tg(bot, 'editMessageText', {
87
- chat_id: chatId, message_id: messageId, text: sourceText,
141
+ chat_id: chatId, message_id: messageId, text: sanitizeFallbackText(sourceText),
88
142
  }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
89
143
  return { result: fallbackRes, wentRich: false };
90
144
  }
91
145
  };
92
146
  }
93
147
 
94
- module.exports = { createRichEditor };
148
+ module.exports = { createRichEditor, materializeMediaBlocks };
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Rich-message media resolver — turns agent-authored image srcs into
3
+ * uploadable media values for photo blocks, enforcing the same trust
4
+ * boundary as reply(files).
5
+ *
6
+ * The renderer (rich.js) stays filesystem-free; every impure decision
7
+ * lives here: src classification, path allowlisting, extension and
8
+ * size gating, and the per-message media budget. Returned local-file
9
+ * media are JSON-safe `{ source: '<realpath>' }` envelopes — the
10
+ * grammy InputFile is materialized later, in rich-edit.js, because an
11
+ * InputFile inside the block tree would break the streamer's
12
+ * JSON.stringify payload dedup (InputFile.toJSON throws by design).
13
+ *
14
+ * src classification is a trichotomy decided by a literal prefix test,
15
+ * never a URL parser (a colon in a local path must not read as a
16
+ * scheme):
17
+ * 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
20
+ * through a self-hosted Bot API server
21
+ * (allowUrlMedia=false) — whether that server
22
+ * fetches the URL from inside the operator's
23
+ * network (an SSRF surface) is unverified.
24
+ * 2. absolute path → realpath + allowlist via the same
25
+ * validateAttachmentPath used by reply(files);
26
+ * the RESOLVED path is what gets uploaded, so
27
+ * symlink games can't dodge the check.
28
+ * 3. anything else → rejected (data:, file:, tg:, attach:,
29
+ * relative paths). A literal agent-authored
30
+ * attach:// string must never reach grammy —
31
+ * it would dangle against a multipart part
32
+ * that doesn't exist.
33
+ *
34
+ * Rejections degrade to placeholder paragraphs upstream; the reply
35
+ * always delivers. Events log reason classes only — never the raw src
36
+ * (URLs can embed credentials, paths leak workspace structure).
37
+ */
38
+
39
+ 'use strict';
40
+
41
+ const fs = require('node:fs');
42
+ const path = require('node:path');
43
+ const { validateAttachmentPath } = require('../process/channels-tool-dispatcher');
44
+
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.
48
+ const PHOTO_UPLOAD_CEILING = 10 * 1024 * 1024;
49
+
50
+ // Bound on the SINGLE multipart request that carries every photo of a
51
+ // reply (media upload happens in one finalize edit). Matches
52
+ // attachments.js's MAX_TOTAL_BYTES posture.
53
+ const MAX_TOTAL_MEDIA_BYTES = 50 * 1024 * 1024;
54
+
55
+ // Rich messages allow ≤50 media attachments in total (Bot API "Rich
56
+ // Message Limits"), wrapper children included. Enforced here, on the
57
+ // flattened descriptor list, so Telegram never rejects the whole reply
58
+ // over attachment #51.
59
+ const MAX_MEDIA_PER_MESSAGE = 50;
60
+
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.
66
+ const PHOTO_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
67
+
68
+ const HTTP_URL_RE = /^https?:\/\//i;
69
+
70
+ /**
71
+ * @param {object} deps
72
+ * @param {string[]} deps.allowedRoots — absolute roots media paths must
73
+ * resolve under (same builder as reply(files): buildAllowedRoots)
74
+ * @param {number} [deps.maxPhotoBytes] — per-file cap, already clamped
75
+ * to PHOTO_UPLOAD_CEILING by the caller
76
+ * @param {number} [deps.maxTotalMediaBytes] — per-reply budget
77
+ * @param {boolean} [deps.allowUrlMedia] — false when a self-hosted Bot
78
+ * API server is configured (see module doc)
79
+ * @param {(kind: string, detail: object) => void} [deps.logEvent]
80
+ * @param {(p: string, roots: string[]) => {ok: boolean, resolved?: string, error?: string}} [deps.validatePath]
81
+ * @param {(p: string) => number} [deps.fileSize]
82
+ * @returns {(descriptors: Array<{src: string, caption: string}>) => Array<{media?: (string|{source: string}), rejected?: string}>}
83
+ */
84
+ function createRichMediaResolver({
85
+ allowedRoots = [],
86
+ maxPhotoBytes = PHOTO_UPLOAD_CEILING,
87
+ maxTotalMediaBytes = MAX_TOTAL_MEDIA_BYTES,
88
+ allowUrlMedia = true,
89
+ logEvent = null,
90
+ validatePath = validateAttachmentPath,
91
+ fileSize = (p) => fs.statSync(p).size,
92
+ } = {}) {
93
+ return function resolveMedia(descriptors) {
94
+ const results = [];
95
+ const reasons = [];
96
+ let accepted = 0;
97
+ let totalBytes = 0;
98
+
99
+ const reject = (reason) => {
100
+ reasons.push(reason);
101
+ results.push({ rejected: reason });
102
+ };
103
+
104
+ for (const d of descriptors || []) {
105
+ const src = typeof d?.src === 'string' ? d.src : '';
106
+ if (!src) { reject('empty-src'); continue; }
107
+ if (accepted >= MAX_MEDIA_PER_MESSAGE) { reject('media-cap'); continue; }
108
+
109
+ if (HTTP_URL_RE.test(src)) {
110
+ if (!allowUrlMedia) { reject('url-local-api'); continue; }
111
+ accepted += 1;
112
+ results.push({ media: src });
113
+ continue;
114
+ }
115
+
116
+ if (!path.isAbsolute(src)) {
117
+ // Covers relative paths AND every non-http scheme (data:,
118
+ // file:, tg:, attach:, …) — none of them start with "/".
119
+ reject('not-absolute');
120
+ continue;
121
+ }
122
+
123
+ const check = validatePath(src, allowedRoots);
124
+ if (!check.ok) { reject('path'); continue; }
125
+
126
+ if (!PHOTO_EXTENSIONS.has(path.extname(check.resolved).toLowerCase())) {
127
+ reject('extension');
128
+ continue;
129
+ }
130
+
131
+ let bytes;
132
+ try { bytes = fileSize(check.resolved); }
133
+ catch { reject('unreadable'); continue; }
134
+ if (bytes > maxPhotoBytes) { reject('too-large'); continue; }
135
+ if (totalBytes + bytes > maxTotalMediaBytes) { reject('total-budget'); continue; }
136
+
137
+ totalBytes += bytes;
138
+ accepted += 1;
139
+ results.push({ media: { source: check.resolved } });
140
+ }
141
+
142
+ if (reasons.length && typeof logEvent === 'function') {
143
+ try {
144
+ logEvent('rich-media-rejected', {
145
+ rejected_count: reasons.length,
146
+ accepted_count: accepted,
147
+ reasons: reasons.slice(0, 10),
148
+ });
149
+ } catch { /* instrumentation must never break delivery */ }
150
+ }
151
+
152
+ return results;
153
+ };
154
+ }
155
+
156
+ module.exports = {
157
+ createRichMediaResolver,
158
+ PHOTO_UPLOAD_CEILING,
159
+ MAX_TOTAL_MEDIA_BYTES,
160
+ MAX_MEDIA_PER_MESSAGE,
161
+ PHOTO_EXTENSIONS,
162
+ };
@@ -3,9 +3,11 @@
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) that the plain HTML pipeline can't express
7
- * natively, AND the chat has opted in (config.richText). Everything else
8
- * stays on the existing toTelegramHtml() path.
6
+ * table, <details>, heading, blockquote, divider, image, or a
7
+ * <tg-collage>/<tg-slideshow> media wrapper) that the plain HTML
8
+ * pipeline can't express natively, AND the chat has opted in
9
+ * (config.richText). Everything else stays on the existing
10
+ * toTelegramHtml() path.
9
11
  *
10
12
  * Block text fields carry plain strings only. Inline bold, italic, and
11
13
  * links are flattened rather than mapped to an unverified nested schema.
@@ -61,6 +63,12 @@ const TASK_ITEM_RE = /^[ \t]{0,3}[-*+][ \t]+\[[ xX]\][ \t]+/m;
61
63
  const TABLE_ROW_RE = /^[ \t]{0,3}\|?[ \t]*:?-{2,}:?[ \t]*\|/m;
62
64
  const DETAILS_RE = /<details[ \t>]/i;
63
65
  const HEADING_RE = /^#{1,6}[ \t]+\S/m;
66
+ const BLOCKQUOTE_RE = /^[ \t]{0,3}>[ \t]?\S/m;
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;
64
72
  const FENCE_LINE_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
65
73
 
66
74
  // Code-fence content renders literally, so heading, checklist, and table
@@ -103,13 +111,25 @@ function stripFencedCodeBlocks(markdown) {
103
111
  return out.join('\n');
104
112
  }
105
113
 
106
- function needsRichRendering(markdown) {
107
- if (typeof markdown !== 'string' || markdown.length === 0) return false;
108
- 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) {
109
119
  return TASK_ITEM_RE.test(scoped)
110
120
  || TABLE_ROW_RE.test(scoped)
111
121
  || DETAILS_RE.test(scoped)
112
- || HEADING_RE.test(scoped);
122
+ || HEADING_RE.test(scoped)
123
+ || BLOCKQUOTE_RE.test(scoped)
124
+ || DIVIDER_RE.test(scoped);
125
+ }
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);
113
133
  }
114
134
 
115
135
  // ─── marked instance for block-level tokenization ────────────────────
@@ -151,6 +171,107 @@ const SUMMARY_TAG_RE = /<summary[^>]*>([\s\S]*?)<\/summary>/i;
151
171
  const HTML_TAG_STRIP_RE = /<[^>]+>/g;
152
172
  const DEFAULT_DETAILS_SUMMARY = 'Details';
153
173
 
174
+ // ─── Media (photo / collage / slideshow) ─────────────────────────────
175
+ //
176
+ // Image markdown maps to Telegram photo blocks; <tg-collage>/<tg-slideshow>
177
+ // wrapper tags (Telegram's own rich-markdown dialect) group images into
178
+ // 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
+
154
275
  // Convert one marked block-level token into zero or more RichBlock
155
276
  // objects. Returns an array (a single markdown token can occasionally
156
277
  // need zero blocks — e.g. a bare 'space' token — or, in principle,
@@ -158,6 +279,9 @@ const DEFAULT_DETAILS_SUMMARY = 'Details';
158
279
  function blockFromToken(token) {
159
280
  switch (token.type) {
160
281
  case 'paragraph': {
282
+ if (inlineHasMedia(token.tokens)) {
283
+ return blocksFromInlineTokens(token.tokens);
284
+ }
161
285
  const text = plainTextOf(token.tokens);
162
286
  if (!text.trim()) return [];
163
287
  return [{ type: 'paragraph', text }];
@@ -209,17 +333,36 @@ function blockFromToken(token) {
209
333
  // injection surface escapeHtml closes in format.js) — it's
210
334
  // rendered as a plain paragraph with tags stripped, matching
211
335
  // the plain-text-only invariant this module holds throughout.
212
- 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);
213
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 });
214
346
  const summary = (m[1] || '').replace(HTML_TAG_STRIP_RE, '').trim();
215
347
  const innerMarkdown = (m[2] || '').trim();
216
348
  const blocks = innerMarkdown ? blocksFromTokens(_lexer.lexer(innerMarkdown)) : [];
217
- return [{
349
+ out.push({
218
350
  type: 'details',
219
351
  summary: summary || DEFAULT_DETAILS_SUMMARY,
220
352
  blocks: blocks.length ? blocks : [{ type: 'paragraph', text: '' }],
221
353
  is_open: false,
222
- }];
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 }];
223
366
  }
224
367
  const stripped = (token.text || token.raw || '').replace(HTML_TAG_STRIP_RE, '').trim();
225
368
  if (!stripped) return [];
@@ -239,10 +382,15 @@ function blockFromToken(token) {
239
382
 
240
383
  function listItemFromToken(item, listToken, index) {
241
384
  const { inline, blockTokens } = splitListItemTokens(item.tokens);
242
- const leaderText = plainTextOf(inline);
243
385
  const nestedBlocks = blocksFromTokens(blockTokens);
244
386
  const blocks = [];
245
- 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
+ }
246
394
  blocks.push(...nestedBlocks);
247
395
 
248
396
  const out = { blocks };
@@ -298,29 +446,227 @@ function blocksFromTokens(tokens) {
298
446
  const summaryMatch = SUMMARY_TAG_RE.exec(raw);
299
447
  const summary = summaryMatch ? summaryMatch[1].replace(HTML_TAG_STRIP_RE, '').trim() : '';
300
448
  const inner = [];
449
+ let closeRaw = null;
301
450
  let j = i + 1;
302
451
  for (; j < list.length; j++) {
303
452
  const tj = list[j];
304
453
  const rawj = tj.raw || tj.text || '';
305
- if (tj.type === 'html' && DETAILS_CLOSE_RE.test(rawj)) break;
454
+ if (tj.type === 'html' && DETAILS_CLOSE_RE.test(rawj)) { closeRaw = rawj; break; }
306
455
  inner.push(tj);
307
456
  }
308
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
+ }
309
469
  out.push({
310
470
  type: 'details',
311
471
  summary: summary || DEFAULT_DETAILS_SUMMARY,
312
472
  blocks: nestedBlocks.length ? nestedBlocks : [{ type: 'paragraph', text: '' }],
313
473
  is_open: false,
314
474
  });
475
+ if (after.trim()) out.push(...blocksFromTokens(_lexer.lexer(after)));
315
476
  i = j + 1; // skip past the close token (or to the end if never closed)
316
477
  continue;
317
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
+ }
318
512
  out.push(...blockFromToken(t));
319
513
  i++;
320
514
  }
321
515
  return out;
322
516
  }
323
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} (image unavailable)` : '(image unavailable)';
526
+ return d.caption ? `🖼 ${d.caption}` : '🖼 image';
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
+ function photoBlockFrom(result, caption) {
550
+ const block = { type: 'photo', photo: { type: 'photo', media: result.media } };
551
+ if (caption) block.caption = { text: caption };
552
+ return block;
553
+ }
554
+
555
+ // Replace descriptor blocks with real photo/collage/slideshow blocks
556
+ // (when `results` — index-aligned with collectMediaDescriptors order —
557
+ // is provided) or with placeholder paragraphs (streaming / no resolver).
558
+ // `cursor` threads the shared descriptor index through the recursion.
559
+ function substituteMediaBlocks(blocks, results, cursor = { i: 0 }) {
560
+ const out = [];
561
+ for (const b of blocks || []) {
562
+ if (b.type === '_media') {
563
+ const r = results ? results[cursor.i] : null;
564
+ cursor.i += 1;
565
+ if (r && r.media) out.push(photoBlockFrom(r, b.caption));
566
+ else if (results) out.push({ type: 'paragraph', text: mediaPlaceholderText(b, { unavailable: true }) });
567
+ else out.push({ type: 'paragraph', text: mediaPlaceholderText(b) });
568
+ } else if (b.type === '_mediaGroup') {
569
+ if (!results) {
570
+ cursor.i += b.items.length;
571
+ out.push({ type: 'paragraph', text: `🖼 ${b.kind} (${b.items.length} images)` });
572
+ continue;
573
+ }
574
+ const photos = [];
575
+ let unavailable = 0;
576
+ for (const it of b.items) {
577
+ const r = results[cursor.i];
578
+ cursor.i += 1;
579
+ if (r && r.media) photos.push(photoBlockFrom(r, it.caption));
580
+ else unavailable += 1;
581
+ }
582
+ if (photos.length === 1) out.push(photos[0]);
583
+ else if (photos.length > 1) out.push({ type: b.kind, blocks: photos });
584
+ if (unavailable > 0) {
585
+ out.push({ type: 'paragraph', text: `(${unavailable} image${unavailable === 1 ? '' : 's'} unavailable)` });
586
+ }
587
+ } else if (Array.isArray(b.blocks) || Array.isArray(b.items)) {
588
+ const clone = { ...b };
589
+ if (Array.isArray(b.blocks)) clone.blocks = substituteMediaBlocks(b.blocks, results, cursor);
590
+ if (Array.isArray(b.items)) {
591
+ clone.items = b.items.map((it) => (Array.isArray(it.blocks)
592
+ ? { ...it, blocks: substituteMediaBlocks(it.blocks, results, cursor) }
593
+ : it));
594
+ }
595
+ out.push(clone);
596
+ } else {
597
+ out.push(b);
598
+ }
599
+ }
600
+ return out;
601
+ }
602
+
603
+ // Count media attachments in an emitted block tree (photo blocks plus
604
+ // collage/slideshow children) — the media_count instrumentation field.
605
+ function countMediaBlocks(blocks) {
606
+ let n = 0;
607
+ for (const b of blocks || []) {
608
+ if (b.type === 'photo') n += 1;
609
+ else if (b.type === 'collage' || b.type === 'slideshow') n += countMediaBlocks(b.blocks);
610
+ else {
611
+ if (Array.isArray(b.blocks)) n += countMediaBlocks(b.blocks);
612
+ if (Array.isArray(b.items)) {
613
+ for (const it of b.items) if (Array.isArray(it.blocks)) n += countMediaBlocks(it.blocks);
614
+ }
615
+ }
616
+ }
617
+ return n;
618
+ }
619
+
620
+ // Project media markdown out of plain fallback text: image syntax
621
+ // degrades to its caption text, wrapper tags disappear, and a dangling
622
+ // UNTERMINATED trailing image fragment ("![shot](/User…" still being
623
+ // streamed, or malformed output missing its ")") is cut — the fragment
624
+ // is exactly the shape that carries a raw absolute path, and it's what
625
+ // live plain previews would otherwise render before the rich upgrade
626
+ // kicks in. Fenced code is left untouched (image syntax inside a fence
627
+ // is literal content). Used on every plain-text surface a rich-enabled
628
+ // chat's reply can reach: live preview edits, fallback edits, and
629
+ // chunked redelivery.
630
+ const MEDIA_WRAP_TAG_G_RE = /<\/?tg-(?:collage|slideshow)(?:[ \t][^>]*)?>/gi;
631
+ // An image opener at end-of-line that never completed: "![", "![alt",
632
+ // "![alt]", "![alt](", "![alt](/partial/pa…". Single negated classes,
633
+ // optional groups anchored at $ — linear.
634
+ const TRAILING_IMAGE_FRAGMENT_RE = /!\[[^\]\n]*(?:\][ \t]*(?:\([^)\n]*)?)?$/;
635
+
636
+ function stripMediaMarkdown(markdown) {
637
+ if (typeof markdown !== 'string' || markdown.length === 0) return markdown;
638
+ if (markdown.indexOf('![') === -1 && markdown.indexOf('<tg-') === -1) return markdown;
639
+ const lines = markdown.split('\n');
640
+ const out = [];
641
+ // Fence tracking mirrors stripFencedCodeBlocks (same open/close
642
+ // semantics) but keeps every line — only non-fenced lines are
643
+ // rewritten.
644
+ let fence = null;
645
+ for (const line of lines) {
646
+ const m = FENCE_LINE_RE.exec(line);
647
+ if (m) {
648
+ const marker = m[1];
649
+ const trailing = line.slice(m[0].length);
650
+ if (!fence) fence = { char: marker[0], len: marker.length };
651
+ else if (marker[0] === fence.char && marker.length >= fence.len && trailing.trim() === '') fence = null;
652
+ out.push(line);
653
+ continue;
654
+ }
655
+ if (fence) { out.push(line); continue; }
656
+ IMAGE_MD_G_RE.lastIndex = 0;
657
+ let clean = line
658
+ .replace(IMAGE_MD_G_RE, (_all, alt, _src, title) => (alt || title || '').trim())
659
+ .replace(MEDIA_WRAP_TAG_G_RE, '');
660
+ // Only a fragment on the FINAL line can still be completed by
661
+ // text that hasn't streamed in yet; earlier lines can't (image
662
+ // syntax never spans lines), so a fragment there is plain
663
+ // malformed output — cut it the same way.
664
+ clean = clean.replace(TRAILING_IMAGE_FRAGMENT_RE, '');
665
+ out.push(clean);
666
+ }
667
+ return out.join('\n');
668
+ }
669
+
324
670
  /**
325
671
  * toTelegramRichBlocks(markdown, opts) -> { blocks: RichBlock[], usedRich: boolean }
326
672
  *
@@ -333,6 +679,17 @@ function blocksFromTokens(tokens) {
333
679
  * top-level block — a single in-progress block is emitted as-is rather
334
680
  * than producing an empty array (better to show a resizing paragraph
335
681
  * than nothing).
682
+ *
683
+ * opts.resolveMedia (descriptors: [{src, caption}]) -> results[] —
684
+ * index-aligned with the descriptors; each result is `{ media }` (an
685
+ * https URL string or a JSON-safe `{ source: '<realpath>' }` envelope
686
+ * that rich-edit.js later materializes into a grammy InputFile) or a
687
+ * falsy/rejected marker. NEVER called in partial mode — streaming ticks
688
+ * must not touch the filesystem; images render as placeholder
689
+ * paragraphs until finalize. Absent resolver → placeholders too.
690
+ * When media was the ONLY gate trigger and none of it resolved, the
691
+ * whole render demotes to `usedRich: false` so prose doesn't ship as
692
+ * oversized rich paragraphs.
336
693
  */
337
694
  function toTelegramRichBlocks(markdown, opts = {}) {
338
695
  const usedRich = needsRichRendering(markdown);
@@ -341,10 +698,38 @@ function toTelegramRichBlocks(markdown, opts = {}) {
341
698
  const tokens = _lexer.lexer(markdown);
342
699
  let blocks = blocksFromTokens(tokens);
343
700
 
701
+ const descriptors = collectMediaDescriptors(blocks);
702
+ let results = null;
703
+ let resolvedCount = 0;
704
+ if (descriptors.length && !opts.partial && typeof opts.resolveMedia === 'function') {
705
+ try {
706
+ const r = opts.resolveMedia(descriptors);
707
+ if (Array.isArray(r)) {
708
+ results = r;
709
+ resolvedCount = r.filter((x) => x && x.media).length;
710
+ }
711
+ } catch {
712
+ results = null; // resolver failure degrades to placeholders, never crashes the reply
713
+ }
714
+ }
715
+ if (descriptors.length) {
716
+ blocks = substituteMediaBlocks(blocks, results);
717
+ }
718
+
344
719
  if (opts.partial && blocks.length > 1) {
345
720
  blocks = blocks.slice(0, -1);
346
721
  }
347
722
 
723
+ if (descriptors.length && !opts.partial && resolvedCount === 0
724
+ && !hasNonMediaTrigger(stripFencedCodeBlocks(markdown))) {
725
+ return { blocks: [], usedRich: false };
726
+ }
727
+
728
+ // An empty rich payload is never sendable (Telegram rejects
729
+ // rich_message with no blocks; api.js refuses it before the call) —
730
+ // whatever produced it, plain is the honest degrade.
731
+ if (!blocks.length) return { blocks: [], usedRich: false };
732
+
348
733
  return { blocks, usedRich: true };
349
734
  }
350
735
 
@@ -360,7 +745,14 @@ function toTelegramRichBlocks(markdown, opts = {}) {
360
745
  // editMessageText endpoint can instead reject its `rich_message`
361
746
  // parameter with an unknown/unsupported-field response, so both shapes
362
747
  // identify a missing rich-message capability.
363
- 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;
748
+ //
749
+ // "rich message must be non-empty" is a capability shape too, observed
750
+ // live on a Bot API 10.1 self-hosted server: it accepts sendRichMessage
751
+ // but predates the 10.2 `blocks` field, silently ignores it, and sees
752
+ // an "empty" InputRichMessage. api.js refuses genuinely empty payloads
753
+ // before any network call, so this response to a payload we sent can
754
+ // only mean the server cannot read typed blocks — latch, don't retry.
755
+ 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;
364
756
 
365
757
  function errorMessage(err) {
366
758
  if (!err) return '';
@@ -390,8 +782,11 @@ module.exports = {
390
782
  isRichCapabilityError,
391
783
  isRichContentError,
392
784
  errorMessage,
785
+ stripMediaMarkdown,
786
+ countMediaBlocks,
393
787
  // exported for tests / introspection, not part of the public contract
394
788
  _plainTextOf: plainTextOf,
395
789
  _blocksFromTokens: blocksFromTokens,
396
790
  _stripFencedCodeBlocks: stripFencedCodeBlocks,
791
+ _collectMediaDescriptors: collectMediaDescriptors,
397
792
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
package/polygram.js CHANGED
@@ -49,7 +49,7 @@ const { extractAssistantText } = require('@shumkov/orchestra');
49
49
  // and reused by createChannelsToolDispatcher inside main() — Claude replies
50
50
  // containing code blocks or HTML-style tags aren't split mid-element by the
51
51
  // size cap.
52
- const { createChannelsToolDispatcher } = require('./lib/process/channels-tool-dispatcher');
52
+ const { createChannelsToolDispatcher, buildAllowedRoots } = require('./lib/process/channels-tool-dispatcher');
53
53
  const { createTmuxRunner } = require('@shumkov/orchestra');
54
54
  const { sweepTmuxOrphans } = require('@shumkov/orchestra').orphanSweep;
55
55
  // rc.42: autosteer-buffer module deleted. Native SDK priority push
@@ -98,7 +98,9 @@ const { createStreamer } = require('./lib/telegram/streamer');
98
98
  const { chunkMarkdownText } = require('./lib/telegram/chunk');
99
99
  const {
100
100
  toTelegramRichBlocks, resolveRichTextEnabled, isRichCapabilityError, isRichContentError,
101
+ stripMediaMarkdown,
101
102
  } = require('./lib/telegram/rich');
103
+ const { createRichMediaResolver, PHOTO_UPLOAD_CEILING } = require('./lib/telegram/rich-media');
102
104
  const { createRichEditor } = require('./lib/telegram/rich-edit');
103
105
  const { buildPolygramDisplayHint } = require('./lib/telegram/display-hint');
104
106
  const { redactBotToken, stripUrlCredentials } = require('./lib/error/net');
@@ -1018,9 +1020,48 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1018
1020
  // content qualifies. Those delivery paths intentionally remain plain.
1019
1021
  // Re-read both controls for every flush so a config change or a
1020
1022
  // capability result applies to the turn already in progress.
1023
+ // Media (photo/collage/slideshow) resolution for rich replies. Same
1024
+ // trust boundary as reply(files): roots are the per-session staging
1025
+ // dir plus the session's spawn cwd (topic override wins, mirroring
1026
+ // getSessionSpawnIdentity) — path-set parity with the channels
1027
+ // dispatcher, which builds its roots from the same inputs. URL media
1028
+ // is disabled when a self-hosted Bot API server is configured (its
1029
+ // URL-fetch origin is unverified — see lib/telegram/rich-media.js).
1030
+ const _mediaTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
1031
+ const resolveRichMedia = createRichMediaResolver({
1032
+ allowedRoots: buildAllowedRoots({
1033
+ sessionKey,
1034
+ sessionCwd: _mediaTopicCfg.cwd || chatConfig.cwd || null,
1035
+ }),
1036
+ maxPhotoBytes: Math.min(
1037
+ PHOTO_UPLOAD_CEILING,
1038
+ resolveMaxFileOverride(config, chatId, threadId) ?? PHOTO_UPLOAD_CEILING,
1039
+ ),
1040
+ allowUrlMedia: !config.bot?.apiRoot,
1041
+ logEvent: (kind, detail) => logEvent(kind, {
1042
+ chat_id: chatId, thread_id: threadId, bot: BOT_NAME, ...detail,
1043
+ }),
1044
+ });
1021
1045
  const toRichPayload = (text, opts) => {
1022
1046
  if (richKnownUnsupported || !resolveRichTextEnabled(config, chatId, threadId)) return null;
1023
- return toTelegramRichBlocks(text, opts);
1047
+ return toTelegramRichBlocks(text, { ...opts, resolveMedia: resolveRichMedia });
1048
+ };
1049
+ // Plain-path text hygiene for rich-enabled chats: the live preview
1050
+ // streams PLAIN until the rich gate trips (and stays plain if the
1051
+ // capability latch fires), so a reply's image markdown — including a
1052
+ // still-incomplete trailing "![shot](/abs/pa…" fragment — would
1053
+ // otherwise render its raw local path into the visible bubble.
1054
+ // Degrade media markdown to caption text on every plain send/edit.
1055
+ // The streamer's internal dedup state keeps the UNsanitized text on
1056
+ // both sides of its comparisons, so this stays a display-only
1057
+ // transform (same class as tg()'s markdown→HTML conversion).
1058
+ const sanitizeLiveText = (text) => {
1059
+ if (!resolveRichTextEnabled(config, chatId, threadId)) return text;
1060
+ const clean = stripMediaMarkdown(text);
1061
+ // An image-only chunk can sanitize to nothing; keep the bubble
1062
+ // alive with a minimal placeholder rather than tripping tg()'s
1063
+ // empty-text guard.
1064
+ return clean.trim() ? clean : '…';
1024
1065
  };
1025
1066
 
1026
1067
  const streamer = createStreamer({
@@ -1036,7 +1077,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1036
1077
  },
1037
1078
  send: async (text) => {
1038
1079
  const params = {
1039
- chat_id: chatId, text,
1080
+ chat_id: chatId, text: sanitizeLiveText(text),
1040
1081
  ...(threadId && { message_thread_id: threadId }),
1041
1082
  };
1042
1083
  if (!firstBubbleSent) {
@@ -1070,7 +1111,7 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1070
1111
  return await tg(bot, 'editMessageText', {
1071
1112
  chat_id: chatId,
1072
1113
  message_id: messageId,
1073
- text,
1114
+ text: sanitizeLiveText(text),
1074
1115
  }, { source: 'bot-reply-stream-edit', botName: BOT_NAME });
1075
1116
  } catch (err) {
1076
1117
  // Stream-edit failures would otherwise be invisible — edits
@@ -1078,7 +1119,10 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1078
1119
  // want the failure path specifically surfaced). Log to events.
1079
1120
  logEvent('telegram-edit-failed', {
1080
1121
  chat_id: chatId, msg_id: messageId,
1081
- api_error: err.message?.slice(0, 200),
1122
+ // Network errors can embed the request URL, which carries the
1123
+ // bot token — redact before persisting, matching rich-edit.js
1124
+ // and api.js's handling of the same error class.
1125
+ api_error: redactBotToken(err.message)?.slice(0, 200),
1082
1126
  bot: BOT_NAME,
1083
1127
  });
1084
1128
  throw err;
@@ -1696,7 +1740,14 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1696
1740
  // send the body as proper chunks.
1697
1741
  try { await streamer.discard(); }
1698
1742
  catch (err) { console.error(`[${label}] discard failed: ${err.message}`); }
1699
- const chunks = chunkMarkdownText(parsed.text, TG_CHUNK_BUDGET);
1743
+ // Rich-enabled chats may carry media markdown; the plain
1744
+ // redelivery can't upload it, and a raw `![cap](/abs/path)`
1745
+ // would render the local path into the chat. Degrade images to
1746
+ // their captions (images are not delivered on this path).
1747
+ const redeliverText = resolveRichTextEnabled(config, chatId, threadId)
1748
+ ? stripMediaMarkdown(parsed.text)
1749
+ : parsed.text;
1750
+ const chunks = chunkMarkdownText(redeliverText, TG_CHUNK_BUDGET);
1700
1751
  const r = await deliverReplies({
1701
1752
  bot,
1702
1753
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -1774,7 +1825,13 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1774
1825
  // 0.7.0: use markdown-aware chunker + deliverReplies primitive.
1775
1826
  // The old chunkText was newline/byte-only; chunkMarkdownText also
1776
1827
  // respects code-fence boundaries (closes + reopens across chunks).
1777
- const chunks = chunkMarkdownText(parsed.text, TG_CHUNK_BUDGET);
1828
+ // Short replies never went live-rich, so image markdown from a
1829
+ // rich-enabled chat degrades to caption text here too — never a
1830
+ // raw local path in the bubble.
1831
+ const shortText = resolveRichTextEnabled(config, chatId, threadId)
1832
+ ? stripMediaMarkdown(parsed.text)
1833
+ : parsed.text;
1834
+ const chunks = chunkMarkdownText(shortText, TG_CHUNK_BUDGET);
1778
1835
  await deliverReplies({
1779
1836
  bot,
1780
1837
  send: (b, method, params, m) => tg(b, method, params, m),
@@ -2348,6 +2405,7 @@ async function main() {
2348
2405
  setRichKnownUnsupported: () => { richKnownUnsupported = true; },
2349
2406
  getApiRoot: () => config.bot?.apiRoot || null,
2350
2407
  stripUrlCreds: stripUrlCredentials,
2408
+ sanitizeFallbackText: stripMediaMarkdown,
2351
2409
  });
2352
2410
  pairings = createPairingsStore(db.raw);
2353
2411
  approvals = createApprovalsStore(db.raw);