polygram 0.18.2 → 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.2",
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';
@@ -95,6 +95,10 @@ const TABLES_AND_HEADERS_SECTION_RICH = [
95
95
  '',
96
96
  '**Collapsible detail:** wrap verbose logs/diffs/output you want available but not front-and-center in `<details><summary>Label</summary>...</details>`.',
97
97
  '',
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
+ '',
98
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.',
99
103
  '',
100
104
  '### Other Telegram quirks',
@@ -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,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 / 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
+
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,227 @@ 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} (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
+
329
670
  /**
330
671
  * toTelegramRichBlocks(markdown, opts) -> { blocks: RichBlock[], usedRich: boolean }
331
672
  *
@@ -338,6 +679,17 @@ function blocksFromTokens(tokens) {
338
679
  * top-level block — a single in-progress block is emitted as-is rather
339
680
  * than producing an empty array (better to show a resizing paragraph
340
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.
341
693
  */
342
694
  function toTelegramRichBlocks(markdown, opts = {}) {
343
695
  const usedRich = needsRichRendering(markdown);
@@ -346,10 +698,38 @@ function toTelegramRichBlocks(markdown, opts = {}) {
346
698
  const tokens = _lexer.lexer(markdown);
347
699
  let blocks = blocksFromTokens(tokens);
348
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
+
349
719
  if (opts.partial && blocks.length > 1) {
350
720
  blocks = blocks.slice(0, -1);
351
721
  }
352
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
+
353
733
  return { blocks, usedRich: true };
354
734
  }
355
735
 
@@ -365,7 +745,14 @@ function toTelegramRichBlocks(markdown, opts = {}) {
365
745
  // editMessageText endpoint can instead reject its `rich_message`
366
746
  // parameter with an unknown/unsupported-field response, so both shapes
367
747
  // 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;
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;
369
756
 
370
757
  function errorMessage(err) {
371
758
  if (!err) return '';
@@ -395,8 +782,11 @@ module.exports = {
395
782
  isRichCapabilityError,
396
783
  isRichContentError,
397
784
  errorMessage,
785
+ stripMediaMarkdown,
786
+ countMediaBlocks,
398
787
  // exported for tests / introspection, not part of the public contract
399
788
  _plainTextOf: plainTextOf,
400
789
  _blocksFromTokens: blocksFromTokens,
401
790
  _stripFencedCodeBlocks: stripFencedCodeBlocks,
791
+ _collectMediaDescriptors: collectMediaDescriptors,
402
792
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.18.2",
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);