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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/process/channels-tool-dispatcher.js +3 -0
- package/lib/telegram/display-hint.js +4 -0
- package/lib/telegram/rich-edit.js +58 -4
- package/lib/telegram/rich-media.js +162 -0
- package/lib/telegram/rich.js +401 -11
- package/package.json +1 -1
- package/polygram.js +65 -7
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
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 `` 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
|
+
};
|
package/lib/telegram/rich.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Companion to format.js's markdown→HTML pipeline, not a replacement:
|
|
5
5
|
* this only runs when needsRichRendering() finds a construct (task list,
|
|
6
|
-
* table, <details>, heading, blockquote, divider
|
|
6
|
+
* table, <details>, heading, blockquote, divider, image, or a
|
|
7
|
+
* <tg-collage>/<tg-slideshow> media wrapper) that the plain HTML
|
|
7
8
|
* pipeline can't express natively, AND the chat has opted in
|
|
8
9
|
* (config.richText). Everything else stays on the existing
|
|
9
10
|
* toTelegramHtml() path.
|
|
@@ -64,6 +65,10 @@ const DETAILS_RE = /<details[ \t>]/i;
|
|
|
64
65
|
const HEADING_RE = /^#{1,6}[ \t]+\S/m;
|
|
65
66
|
const BLOCKQUOTE_RE = /^[ \t]{0,3}>[ \t]?\S/m;
|
|
66
67
|
const DIVIDER_RE = /^[ \t]{0,3}(-{3,}|\*{3,}|_{3,})[ \t]*$/m;
|
|
68
|
+
// Markdown image syntax and Telegram's own collage/slideshow wrapper
|
|
69
|
+
// tags trigger rich so photos can render inline as media blocks.
|
|
70
|
+
const IMAGE_RE = /!\[[^\]\n]*\]\([^)\n]+\)/;
|
|
71
|
+
const MEDIA_WRAP_RE = /<tg-(?:collage|slideshow)[ \t>]/i;
|
|
67
72
|
const FENCE_LINE_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
68
73
|
|
|
69
74
|
// Code-fence content renders literally, so heading, checklist, and table
|
|
@@ -106,9 +111,11 @@ function stripFencedCodeBlocks(markdown) {
|
|
|
106
111
|
return out.join('\n');
|
|
107
112
|
}
|
|
108
113
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
114
|
+
// Non-media triggers, kept separate so the media-drop demotion in
|
|
115
|
+
// toTelegramRichBlocks can ask "would this content be rich WITHOUT its
|
|
116
|
+
// images?" — if not, and no image resolved, the reply goes plain rather
|
|
117
|
+
// than rendering prose-only rich blocks (the oversized-prose problem).
|
|
118
|
+
function hasNonMediaTrigger(scoped) {
|
|
112
119
|
return TASK_ITEM_RE.test(scoped)
|
|
113
120
|
|| TABLE_ROW_RE.test(scoped)
|
|
114
121
|
|| DETAILS_RE.test(scoped)
|
|
@@ -117,6 +124,14 @@ function needsRichRendering(markdown) {
|
|
|
117
124
|
|| DIVIDER_RE.test(scoped);
|
|
118
125
|
}
|
|
119
126
|
|
|
127
|
+
function needsRichRendering(markdown) {
|
|
128
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return false;
|
|
129
|
+
const scoped = stripFencedCodeBlocks(markdown);
|
|
130
|
+
return hasNonMediaTrigger(scoped)
|
|
131
|
+
|| IMAGE_RE.test(scoped)
|
|
132
|
+
|| MEDIA_WRAP_RE.test(scoped);
|
|
133
|
+
}
|
|
134
|
+
|
|
120
135
|
// ─── marked instance for block-level tokenization ────────────────────
|
|
121
136
|
//
|
|
122
137
|
// Use a dedicated lexer because format.js's instance has an HTML-string
|
|
@@ -156,6 +171,107 @@ const SUMMARY_TAG_RE = /<summary[^>]*>([\s\S]*?)<\/summary>/i;
|
|
|
156
171
|
const HTML_TAG_STRIP_RE = /<[^>]+>/g;
|
|
157
172
|
const DEFAULT_DETAILS_SUMMARY = 'Details';
|
|
158
173
|
|
|
174
|
+
// ─── Media (photo / 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
|
|
336
|
+
const src = token.raw || token.text || '';
|
|
337
|
+
const m = DETAILS_BLOCK_RE.exec(src);
|
|
218
338
|
if (m) {
|
|
339
|
+
// An html token can carry content around the <details> span (an
|
|
340
|
+
// HTML block runs until a blank line, so trailing prose or even a
|
|
341
|
+
// second <details> lands in the same token). Emit that content
|
|
342
|
+
// too rather than dropping it with the matched span.
|
|
343
|
+
const out = [];
|
|
344
|
+
const prefix = src.slice(0, m.index).replace(HTML_TAG_STRIP_RE, '').trim();
|
|
345
|
+
if (prefix) out.push({ type: 'paragraph', text: prefix });
|
|
219
346
|
const summary = (m[1] || '').replace(HTML_TAG_STRIP_RE, '').trim();
|
|
220
347
|
const innerMarkdown = (m[2] || '').trim();
|
|
221
348
|
const blocks = innerMarkdown ? blocksFromTokens(_lexer.lexer(innerMarkdown)) : [];
|
|
222
|
-
|
|
349
|
+
out.push({
|
|
223
350
|
type: 'details',
|
|
224
351
|
summary: summary || DEFAULT_DETAILS_SUMMARY,
|
|
225
352
|
blocks: blocks.length ? blocks : [{ type: 'paragraph', text: '' }],
|
|
226
353
|
is_open: false,
|
|
227
|
-
}
|
|
354
|
+
});
|
|
355
|
+
const suffix = src.slice(m.index + m[0].length);
|
|
356
|
+
if (suffix.trim()) out.push(...blocksFromTokens(_lexer.lexer(suffix)));
|
|
357
|
+
return out;
|
|
358
|
+
}
|
|
359
|
+
// A whole <tg-collage>/<tg-slideshow> span in one token (GFM
|
|
360
|
+
// produces this when the wrapper body has no blank lines).
|
|
361
|
+
const wrap = MEDIA_WRAP_OPEN_RE.exec(token.raw || token.text || '');
|
|
362
|
+
if (wrap && MEDIA_WRAP_CLOSE_RE.test(token.raw || token.text || '')) {
|
|
363
|
+
const items = extractMediaItems(token.raw || token.text || '');
|
|
364
|
+
if (!items.length) return [];
|
|
365
|
+
return [{ type: '_mediaGroup', kind: wrap[1].toLowerCase(), items }];
|
|
228
366
|
}
|
|
229
367
|
const stripped = (token.text || token.raw || '').replace(HTML_TAG_STRIP_RE, '').trim();
|
|
230
368
|
if (!stripped) return [];
|
|
@@ -244,10 +382,15 @@ function blockFromToken(token) {
|
|
|
244
382
|
|
|
245
383
|
function listItemFromToken(item, listToken, index) {
|
|
246
384
|
const { inline, blockTokens } = splitListItemTokens(item.tokens);
|
|
247
|
-
const leaderText = plainTextOf(inline);
|
|
248
385
|
const nestedBlocks = blocksFromTokens(blockTokens);
|
|
249
386
|
const blocks = [];
|
|
250
|
-
if (
|
|
387
|
+
if (inlineHasMedia(inline)) {
|
|
388
|
+
blocks.push(...blocksFromInlineTokens(inline));
|
|
389
|
+
if (!blocks.length && !nestedBlocks.length) blocks.push({ type: 'paragraph', text: '' });
|
|
390
|
+
} else {
|
|
391
|
+
const leaderText = plainTextOf(inline);
|
|
392
|
+
if (leaderText.trim() || !nestedBlocks.length) blocks.push({ type: 'paragraph', text: leaderText });
|
|
393
|
+
}
|
|
251
394
|
blocks.push(...nestedBlocks);
|
|
252
395
|
|
|
253
396
|
const out = { blocks };
|
|
@@ -303,29 +446,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 ("") 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]", "\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
|
-
|
|
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.
|
|
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 "'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
|
-
|
|
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
|
-
|
|
1743
|
+
// Rich-enabled chats may carry media markdown; the plain
|
|
1744
|
+
// redelivery can't upload it, and a raw ``
|
|
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
|
-
|
|
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);
|