polygram 0.17.11 → 0.18.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/error/classify.js +14 -0
- package/lib/error/net.js +8 -0
- package/lib/handlers/config-callback.js +44 -18
- package/lib/handlers/config-ui.js +29 -2
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/build-options.js +10 -2
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/session-key.js +7 -16
- package/lib/telegram/api.js +12 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/display-hint.js +47 -12
- package/lib/telegram/format.js +10 -1
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/rich-edit.js +94 -0
- package/lib/telegram/rich.js +397 -0
- package/lib/telegram/streamer.js +157 -11
- package/package.json +2 -1
- package/polygram.js +260 -44
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → Telegram Bot API Rich Message blocks.
|
|
3
|
+
*
|
|
4
|
+
* Companion to format.js's markdown→HTML pipeline, not a replacement:
|
|
5
|
+
* this only runs when needsRichRendering() finds a construct (task list,
|
|
6
|
+
* table, <details>, heading) that the plain HTML pipeline can't express
|
|
7
|
+
* natively, AND the chat has opted in (config.richText). Everything else
|
|
8
|
+
* stays on the existing toTelegramHtml() path.
|
|
9
|
+
*
|
|
10
|
+
* Block text fields carry plain strings only. Inline bold, italic, and
|
|
11
|
+
* links are flattened rather than mapped to an unverified nested schema.
|
|
12
|
+
* This keeps formatting safe while still supporting headings, tables,
|
|
13
|
+
* checklists, and collapsible details.
|
|
14
|
+
*
|
|
15
|
+
* Bot API limits are enforced by Telegram itself:
|
|
16
|
+
* ≤32,768 chars, ≤500 blocks, ≤16 nesting levels, ≤50 media, ≤20 table
|
|
17
|
+
* columns.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
'use strict';
|
|
21
|
+
|
|
22
|
+
const { Marked } = require('marked');
|
|
23
|
+
const { getTopicConfig } = require('../session-key');
|
|
24
|
+
|
|
25
|
+
// ─── Per-chat opt-in resolution ──────────────────────────────────────
|
|
26
|
+
//
|
|
27
|
+
// Match the standard per-chat override precedence: topic, chat, active
|
|
28
|
+
// bot, then defaults. `config.bot` is already filtered to the active bot
|
|
29
|
+
// during startup.
|
|
30
|
+
function resolveRichTextEnabled(config, chatId, threadId = null) {
|
|
31
|
+
if (!config) return false;
|
|
32
|
+
const chat = config.chats?.[String(chatId)] || null;
|
|
33
|
+
const topicCfg = (chat && threadId != null) ? getTopicConfig(chat, String(threadId)) : null;
|
|
34
|
+
const pick = (v) => (typeof v === 'boolean' ? v : undefined);
|
|
35
|
+
const resolved = pick(topicCfg?.richText)
|
|
36
|
+
?? pick(chat?.richText)
|
|
37
|
+
?? pick(config.bot?.richText)
|
|
38
|
+
?? pick(config.defaults?.richText);
|
|
39
|
+
return resolved === true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Content-adaptive gate ───────────────────────────────────────────
|
|
43
|
+
//
|
|
44
|
+
// Rich rendering uses different typography than plain prose (Telegram's
|
|
45
|
+
// own client renders it as a document, not a chat bubble) — sending
|
|
46
|
+
// ordinary prose through it makes it look oversized with no Bot API knob
|
|
47
|
+
// to correct it. So rich is
|
|
48
|
+
// reserved for content that actually contains a structural construct the
|
|
49
|
+
// plain pipeline can't express: task lists, tables, <details>, headings.
|
|
50
|
+
// False negatives are always safe (falls through to the existing,
|
|
51
|
+
// well-tested toTelegramHtml path); false positives risk the oversized-
|
|
52
|
+
// prose problem, so keep these patterns specific, not broad.
|
|
53
|
+
//
|
|
54
|
+
// Regexes below are deliberately simple/anchored (single quantified
|
|
55
|
+
// groups, no nested-quantifier ambiguity) to avoid catastrophic
|
|
56
|
+
// backtracking on adversarial input — the content they scan is agent
|
|
57
|
+
// output that can be influenced by a user asking the agent to echo a
|
|
58
|
+
// crafted string. See tests/rich.test.js for the ReDoS regression guard.
|
|
59
|
+
|
|
60
|
+
const TASK_ITEM_RE = /^[ \t]{0,3}[-*+][ \t]+\[[ xX]\][ \t]+/m;
|
|
61
|
+
const TABLE_ROW_RE = /^[ \t]{0,3}\|?[ \t]*:?-{2,}:?[ \t]*\|/m;
|
|
62
|
+
const DETAILS_RE = /<details[ \t>]/i;
|
|
63
|
+
const HEADING_RE = /^#{1,6}[ \t]+\S/m;
|
|
64
|
+
const FENCE_LINE_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
|
|
65
|
+
|
|
66
|
+
// Code-fence content renders literally, so heading, checklist, and table
|
|
67
|
+
// syntax inside a fence must not influence the rich-rendering decision.
|
|
68
|
+
// Track fences with a line scan to keep the gate easy to reason about on
|
|
69
|
+
// adversarial input.
|
|
70
|
+
function stripFencedCodeBlocks(markdown) {
|
|
71
|
+
if (markdown.indexOf('`') === -1 && markdown.indexOf('~') === -1) return markdown;
|
|
72
|
+
const lines = markdown.split('\n');
|
|
73
|
+
const out = [];
|
|
74
|
+
// A CommonMark closing fence uses the opener's character, is at least
|
|
75
|
+
// as long, and has only trailing whitespace. Preserve the opener so a
|
|
76
|
+
// marker with trailing content cannot expose fenced syntax to the gate.
|
|
77
|
+
let fence = null; // { char, len } while inside a fence, else null
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const m = FENCE_LINE_RE.exec(line);
|
|
80
|
+
if (m) {
|
|
81
|
+
const marker = m[1];
|
|
82
|
+
const char = marker[0];
|
|
83
|
+
const len = marker.length;
|
|
84
|
+
const trailing = line.slice(m[0].length);
|
|
85
|
+
if (!fence) {
|
|
86
|
+
fence = { char, len };
|
|
87
|
+
out.push('');
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === fence.char && len >= fence.len && trailing.trim() === '') {
|
|
91
|
+
fence = null;
|
|
92
|
+
out.push('');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Same-or-different marker mid-fence with trailing content (or a
|
|
96
|
+
// shorter/mismatched marker) — still inside the block; scope it
|
|
97
|
+
// out like any other fenced line, don't treat as a toggle.
|
|
98
|
+
out.push(fence ? '' : line);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out.push(fence ? '' : line);
|
|
102
|
+
}
|
|
103
|
+
return out.join('\n');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function needsRichRendering(markdown) {
|
|
107
|
+
if (typeof markdown !== 'string' || markdown.length === 0) return false;
|
|
108
|
+
const scoped = stripFencedCodeBlocks(markdown);
|
|
109
|
+
return TASK_ITEM_RE.test(scoped)
|
|
110
|
+
|| TABLE_ROW_RE.test(scoped)
|
|
111
|
+
|| DETAILS_RE.test(scoped)
|
|
112
|
+
|| HEADING_RE.test(scoped);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ─── marked instance for block-level tokenization ────────────────────
|
|
116
|
+
//
|
|
117
|
+
// Use a dedicated lexer because format.js's instance has an HTML-string
|
|
118
|
+
// renderer and does not expose a reusable token tree. GFM is required for
|
|
119
|
+
// task-list items and tables.
|
|
120
|
+
const _lexer = new Marked({ gfm: true, breaks: false });
|
|
121
|
+
|
|
122
|
+
function plainTextOf(tokens) {
|
|
123
|
+
// Flatten inline tokens (bold/italic/link/code-span/text) to their
|
|
124
|
+
// plain text content — see module doc for why. marked's Tokens carry
|
|
125
|
+
// `.text` on leaf nodes; walk recursively for nested inline tokens
|
|
126
|
+
// (e.g. bold-wrapping-a-link) so nothing is silently dropped.
|
|
127
|
+
if (!Array.isArray(tokens)) return '';
|
|
128
|
+
let out = '';
|
|
129
|
+
for (const t of tokens) {
|
|
130
|
+
if (t.type === 'text' || t.type === 'codespan' || t.type === 'escape') {
|
|
131
|
+
out += t.text ?? '';
|
|
132
|
+
} else if (t.type === 'link' || t.type === 'image') {
|
|
133
|
+
out += plainTextOf(t.tokens) || t.text || t.href || '';
|
|
134
|
+
} else if (t.type === 'br') {
|
|
135
|
+
out += '\n';
|
|
136
|
+
} else if (Array.isArray(t.tokens)) {
|
|
137
|
+
out += plainTextOf(t.tokens);
|
|
138
|
+
} else if (typeof t.text === 'string') {
|
|
139
|
+
out += t.text;
|
|
140
|
+
} else if (typeof t.raw === 'string') {
|
|
141
|
+
out += t.raw;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const DETAILS_BLOCK_RE = /<details[^>]*>(?:\s*<summary[^>]*>([\s\S]*?)<\/summary>)?([\s\S]*?)<\/details>/i;
|
|
148
|
+
const DETAILS_OPEN_RE = /<details[^>]*>/i;
|
|
149
|
+
const DETAILS_CLOSE_RE = /<\/details>/i;
|
|
150
|
+
const SUMMARY_TAG_RE = /<summary[^>]*>([\s\S]*?)<\/summary>/i;
|
|
151
|
+
const HTML_TAG_STRIP_RE = /<[^>]+>/g;
|
|
152
|
+
const DEFAULT_DETAILS_SUMMARY = 'Details';
|
|
153
|
+
|
|
154
|
+
// Convert one marked block-level token into zero or more RichBlock
|
|
155
|
+
// objects. Returns an array (a single markdown token can occasionally
|
|
156
|
+
// need zero blocks — e.g. a bare 'space' token — or, in principle,
|
|
157
|
+
// more than one).
|
|
158
|
+
function blockFromToken(token) {
|
|
159
|
+
switch (token.type) {
|
|
160
|
+
case 'paragraph': {
|
|
161
|
+
const text = plainTextOf(token.tokens);
|
|
162
|
+
if (!text.trim()) return [];
|
|
163
|
+
return [{ type: 'paragraph', text }];
|
|
164
|
+
}
|
|
165
|
+
case 'heading': {
|
|
166
|
+
const size = Math.min(6, Math.max(1, token.depth || 1));
|
|
167
|
+
return [{ type: 'heading', text: plainTextOf(token.tokens), size }];
|
|
168
|
+
}
|
|
169
|
+
case 'code': {
|
|
170
|
+
return [{ type: 'pre', text: token.text || '' }];
|
|
171
|
+
}
|
|
172
|
+
case 'blockquote': {
|
|
173
|
+
const blocks = blocksFromTokens(token.tokens);
|
|
174
|
+
if (!blocks.length) return [];
|
|
175
|
+
return [{ type: 'blockquote', blocks }];
|
|
176
|
+
}
|
|
177
|
+
case 'list': {
|
|
178
|
+
const items = (token.items || []).map((item, i) => listItemFromToken(item, token, i)).filter(Boolean);
|
|
179
|
+
if (!items.length) return [];
|
|
180
|
+
return [{ type: 'list', items }];
|
|
181
|
+
}
|
|
182
|
+
case 'table': {
|
|
183
|
+
const cellAlignment = (column) => {
|
|
184
|
+
const align = token.align?.[column];
|
|
185
|
+
return align === 'center' || align === 'right' ? align : 'left';
|
|
186
|
+
};
|
|
187
|
+
const headerRow = (token.header || []).map((cell, column) => ({
|
|
188
|
+
text: plainTextOf(cell.tokens),
|
|
189
|
+
is_header: true,
|
|
190
|
+
align: cellAlignment(column),
|
|
191
|
+
valign: 'top',
|
|
192
|
+
}));
|
|
193
|
+
const rows = (token.rows || []).map((row) =>
|
|
194
|
+
row.map((cell, column) => ({
|
|
195
|
+
text: plainTextOf(cell.tokens),
|
|
196
|
+
align: cellAlignment(column),
|
|
197
|
+
valign: 'top',
|
|
198
|
+
})));
|
|
199
|
+
const cells = [headerRow, ...rows];
|
|
200
|
+
return [{ type: 'table', cells }];
|
|
201
|
+
}
|
|
202
|
+
case 'hr': {
|
|
203
|
+
return [{ type: 'divider' }];
|
|
204
|
+
}
|
|
205
|
+
case 'html': {
|
|
206
|
+
// Only <details> is handled specially (it's an explicit gate
|
|
207
|
+
// trigger, DETAILS_RE above); any other raw HTML block is NOT
|
|
208
|
+
// passed through verbatim (that would reopen exactly the
|
|
209
|
+
// injection surface escapeHtml closes in format.js) — it's
|
|
210
|
+
// rendered as a plain paragraph with tags stripped, matching
|
|
211
|
+
// the plain-text-only invariant this module holds throughout.
|
|
212
|
+
const m = DETAILS_BLOCK_RE.exec(token.raw || token.text || '');
|
|
213
|
+
if (m) {
|
|
214
|
+
const summary = (m[1] || '').replace(HTML_TAG_STRIP_RE, '').trim();
|
|
215
|
+
const innerMarkdown = (m[2] || '').trim();
|
|
216
|
+
const blocks = innerMarkdown ? blocksFromTokens(_lexer.lexer(innerMarkdown)) : [];
|
|
217
|
+
return [{
|
|
218
|
+
type: 'details',
|
|
219
|
+
summary: summary || DEFAULT_DETAILS_SUMMARY,
|
|
220
|
+
blocks: blocks.length ? blocks : [{ type: 'paragraph', text: '' }],
|
|
221
|
+
is_open: false,
|
|
222
|
+
}];
|
|
223
|
+
}
|
|
224
|
+
const stripped = (token.text || token.raw || '').replace(HTML_TAG_STRIP_RE, '').trim();
|
|
225
|
+
if (!stripped) return [];
|
|
226
|
+
return [{ type: 'paragraph', text: stripped }];
|
|
227
|
+
}
|
|
228
|
+
case 'space':
|
|
229
|
+
return [];
|
|
230
|
+
default: {
|
|
231
|
+
// Unknown token type — fall back to its flattened text rather
|
|
232
|
+
// than dropping content silently.
|
|
233
|
+
const text = plainTextOf(token.tokens) || token.text || '';
|
|
234
|
+
if (!text.trim()) return [];
|
|
235
|
+
return [{ type: 'paragraph', text }];
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function listItemFromToken(item, listToken, index) {
|
|
241
|
+
const { inline, blockTokens } = splitListItemTokens(item.tokens);
|
|
242
|
+
const leaderText = plainTextOf(inline);
|
|
243
|
+
const nestedBlocks = blocksFromTokens(blockTokens);
|
|
244
|
+
const blocks = [];
|
|
245
|
+
if (leaderText.trim() || !nestedBlocks.length) blocks.push({ type: 'paragraph', text: leaderText });
|
|
246
|
+
blocks.push(...nestedBlocks);
|
|
247
|
+
|
|
248
|
+
const out = { blocks };
|
|
249
|
+
if (item.task) {
|
|
250
|
+
out.has_checkbox = true;
|
|
251
|
+
if (item.checked) out.is_checked = true;
|
|
252
|
+
} else if (listToken.ordered) {
|
|
253
|
+
out.value = (typeof listToken.start === 'number' ? listToken.start : 1) + index;
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Mirrors format.js's splitItemTokens (BLOCK_TYPES boundary) — a list
|
|
259
|
+
// item's tokens are a leading inline run followed by any block-level
|
|
260
|
+
// tokens (nested list, code block, etc). Kept separate from format.js's
|
|
261
|
+
// copy because rich blocks need nested block arrays rather than HTML.
|
|
262
|
+
const LIST_ITEM_BLOCK_TYPES = new Set(['list', 'blockquote', 'code', 'table', 'paragraph', 'space', 'html', 'hr']);
|
|
263
|
+
|
|
264
|
+
function splitListItemTokens(tokens) {
|
|
265
|
+
const inline = [];
|
|
266
|
+
const blockTokens = [];
|
|
267
|
+
let crossed = false;
|
|
268
|
+
for (const tok of tokens || []) {
|
|
269
|
+
if (LIST_ITEM_BLOCK_TYPES.has(tok.type)) {
|
|
270
|
+
crossed = true;
|
|
271
|
+
blockTokens.push(tok);
|
|
272
|
+
} else if (crossed) {
|
|
273
|
+
blockTokens.push({ type: 'paragraph', tokens: [tok] });
|
|
274
|
+
} else {
|
|
275
|
+
inline.push(tok);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { inline, blockTokens };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// GFM tokenizes <details>...</details> as a SINGLE 'html' token only when
|
|
282
|
+
// there's no blank line inside it; a blank line (very common in
|
|
283
|
+
// agent-authored content — a summary line, then a blank line, then the
|
|
284
|
+
// body) splits it into separate sibling tokens: an 'html' open token, the
|
|
285
|
+
// nested content as normal block tokens, and an 'html' close token. This
|
|
286
|
+
// scans the token STREAM (not a single token) for that open/close span so
|
|
287
|
+
// details blocks with real paragraph content inside render correctly
|
|
288
|
+
// rather than getting silently split into a stray open-tag paragraph +
|
|
289
|
+
// unrelated nested blocks + a stray close-tag paragraph.
|
|
290
|
+
function blocksFromTokens(tokens) {
|
|
291
|
+
const out = [];
|
|
292
|
+
const list = tokens || [];
|
|
293
|
+
let i = 0;
|
|
294
|
+
while (i < list.length) {
|
|
295
|
+
const t = list[i];
|
|
296
|
+
const raw = t.raw || t.text || '';
|
|
297
|
+
if (t.type === 'html' && DETAILS_OPEN_RE.test(raw) && !DETAILS_CLOSE_RE.test(raw)) {
|
|
298
|
+
const summaryMatch = SUMMARY_TAG_RE.exec(raw);
|
|
299
|
+
const summary = summaryMatch ? summaryMatch[1].replace(HTML_TAG_STRIP_RE, '').trim() : '';
|
|
300
|
+
const inner = [];
|
|
301
|
+
let j = i + 1;
|
|
302
|
+
for (; j < list.length; j++) {
|
|
303
|
+
const tj = list[j];
|
|
304
|
+
const rawj = tj.raw || tj.text || '';
|
|
305
|
+
if (tj.type === 'html' && DETAILS_CLOSE_RE.test(rawj)) break;
|
|
306
|
+
inner.push(tj);
|
|
307
|
+
}
|
|
308
|
+
const nestedBlocks = blocksFromTokens(inner);
|
|
309
|
+
out.push({
|
|
310
|
+
type: 'details',
|
|
311
|
+
summary: summary || DEFAULT_DETAILS_SUMMARY,
|
|
312
|
+
blocks: nestedBlocks.length ? nestedBlocks : [{ type: 'paragraph', text: '' }],
|
|
313
|
+
is_open: false,
|
|
314
|
+
});
|
|
315
|
+
i = j + 1; // skip past the close token (or to the end if never closed)
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
out.push(...blockFromToken(t));
|
|
319
|
+
i++;
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* toTelegramRichBlocks(markdown, opts) -> { blocks: RichBlock[], usedRich: boolean }
|
|
326
|
+
*
|
|
327
|
+
* opts.partial (boolean, default false) — streaming mode: when true, the
|
|
328
|
+
* last top-level block is held back rather than emitted. Growing text
|
|
329
|
+
* may have cut it off mid-structure (an unclosed table,
|
|
330
|
+
* a dangling list item). The caller re-calls this on every chunk, so the
|
|
331
|
+
* held-back tail simply appears (complete) on a later call once the
|
|
332
|
+
* source text has moved past it. Only applies when there's more than one
|
|
333
|
+
* top-level block — a single in-progress block is emitted as-is rather
|
|
334
|
+
* than producing an empty array (better to show a resizing paragraph
|
|
335
|
+
* than nothing).
|
|
336
|
+
*/
|
|
337
|
+
function toTelegramRichBlocks(markdown, opts = {}) {
|
|
338
|
+
const usedRich = needsRichRendering(markdown);
|
|
339
|
+
if (!usedRich) return { blocks: [], usedRich: false };
|
|
340
|
+
|
|
341
|
+
const tokens = _lexer.lexer(markdown);
|
|
342
|
+
let blocks = blocksFromTokens(tokens);
|
|
343
|
+
|
|
344
|
+
if (opts.partial && blocks.length > 1) {
|
|
345
|
+
blocks = blocks.slice(0, -1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { blocks, usedRich: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ─── Error classification ────────────────────────────────────────────
|
|
352
|
+
//
|
|
353
|
+
// Capability errors (endpoint missing) latch
|
|
354
|
+
// rich off permanently for this process; content errors (this payload
|
|
355
|
+
// specifically) fall back once, never latch. An error matching NEITHER
|
|
356
|
+
// must be treated as transient and fall through to the existing retry
|
|
357
|
+
// path in api.js.
|
|
358
|
+
|
|
359
|
+
// Unsupported new endpoints normally return 404. The existing
|
|
360
|
+
// editMessageText endpoint can instead reject its `rich_message`
|
|
361
|
+
// parameter with an unknown/unsupported-field response, so both shapes
|
|
362
|
+
// identify a missing rich-message capability.
|
|
363
|
+
const RICH_CAPABILITY_ERR_RE = /method\s*["']?(?:sendRichMessage|sendRichMessageDraft|editMessageText)["']?\s*not found|no such method|unknown method|method not found|(?:unknown|unsupported|unrecognized)\s+(?:parameter|field)s?\s*["']?rich_message["']?|rich_message\s*["']?\s*(?:is\s+)?(?:unknown|unsupported|unrecognized|not\s+supported)/i;
|
|
364
|
+
|
|
365
|
+
function errorMessage(err) {
|
|
366
|
+
if (!err) return '';
|
|
367
|
+
return String(err.description || err.message || err.error_message || err);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isRichCapabilityError(err) {
|
|
371
|
+
if (!err) return false;
|
|
372
|
+
const msg = errorMessage(err);
|
|
373
|
+
if (RICH_CAPABILITY_ERR_RE.test(msg)) return true;
|
|
374
|
+
// A 404 is sufficient because these checks only wrap rich-message calls.
|
|
375
|
+
const status = err.error_code ?? err.status ?? err.statusCode;
|
|
376
|
+
return status === 404;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const RICH_CONTENT_ERR_RE = /RICH_MESSAGE_[A-Z_]+|can'?t parse (?:input)?rich\s*block|rich message.*(?:too (?:many|long)|invalid)/i;
|
|
380
|
+
|
|
381
|
+
function isRichContentError(err) {
|
|
382
|
+
if (!err) return false;
|
|
383
|
+
return RICH_CONTENT_ERR_RE.test(errorMessage(err));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = {
|
|
387
|
+
needsRichRendering,
|
|
388
|
+
toTelegramRichBlocks,
|
|
389
|
+
resolveRichTextEnabled,
|
|
390
|
+
isRichCapabilityError,
|
|
391
|
+
isRichContentError,
|
|
392
|
+
errorMessage,
|
|
393
|
+
// exported for tests / introspection, not part of the public contract
|
|
394
|
+
_plainTextOf: plainTextOf,
|
|
395
|
+
_blocksFromTokens: blocksFromTokens,
|
|
396
|
+
_stripFencedCodeBlocks: stripFencedCodeBlocks,
|
|
397
|
+
};
|
package/lib/telegram/streamer.js
CHANGED
|
@@ -84,15 +84,37 @@ function createStreamer({
|
|
|
84
84
|
// (only final bubble visible) for partner-facing chats that
|
|
85
85
|
// prefer terse output.
|
|
86
86
|
preserveIntermediateBubbles = true,
|
|
87
|
+
// Optional progressive rich-message rendering. A null callback keeps
|
|
88
|
+
// the streamer on its plain-text path.
|
|
89
|
+
//
|
|
90
|
+
// toRichPayload(text, { partial }) -> { blocks, usedRich } | null
|
|
91
|
+
// Called on every flush/finalize with the CURRENT latestText/finalText
|
|
92
|
+
// (untruncated — rich content is never char-truncated with a "..."
|
|
93
|
+
// the way plain live-preview text is; partial:true tells it to hold
|
|
94
|
+
// back a possibly-incomplete trailing block, see lib/telegram/rich.js).
|
|
95
|
+
// usedRich:false (or a null return) means "not rich this tick" — the
|
|
96
|
+
// streamer falls through to the existing plain-text path unchanged.
|
|
97
|
+
// The callback may return a different result on each call when live
|
|
98
|
+
// configuration or capability state changes.
|
|
99
|
+
toRichPayload = null,
|
|
100
|
+
// Rich messages have a larger Bot API character cap than plain
|
|
101
|
+
// messages, so rich edits and finalization must use this limit.
|
|
102
|
+
richMaxLen = 32768,
|
|
103
|
+
// Fires for each successful plain-to-rich transition. Caller wires this
|
|
104
|
+
// to instrumentation for the visible bubble-shape change.
|
|
105
|
+
onRichUpgrade = () => {},
|
|
87
106
|
} = {}) {
|
|
88
107
|
throttleMs = Math.max(THROTTLE_FLOOR_MS, throttleMs);
|
|
89
108
|
let state = 'idle'; // 'idle' | 'live' | 'finalized'
|
|
90
109
|
let msgId = null;
|
|
91
|
-
let currentText = ''; // what's on screen right now (truncated to maxLen)
|
|
110
|
+
let currentText = ''; // what's on screen right now (truncated to maxLen) — plain-path only
|
|
111
|
+
let currentRichJson = null; // JSON.stringify of the last-sent rich blocks, or null when not in rich mode
|
|
92
112
|
let latestText = ''; // latest we've been told about
|
|
93
113
|
let lastEditTs = 0;
|
|
94
114
|
let pendingEdit = null; // timer id
|
|
95
|
-
let flushPromise = null; //
|
|
115
|
+
let flushPromise = null; // serialized flush chain (for back-pressure)
|
|
116
|
+
let flushQueued = false; // latest text changed while a flush was active
|
|
117
|
+
let bubbleGeneration = 0; // invalidates state commits from superseded bubbles
|
|
96
118
|
// 0.7.2: msg_ids of bubbles that have been superseded by
|
|
97
119
|
// forceNewMessage(). The caller (polygram.js handleMessage at
|
|
98
120
|
// end-of-turn) reads getArchived() and issues deleteMessage on
|
|
@@ -121,7 +143,12 @@ function createStreamer({
|
|
|
121
143
|
// which preserves all content without any byte-cut.
|
|
122
144
|
function truncateForLive(s) {
|
|
123
145
|
if (s.length <= maxLen) return s;
|
|
124
|
-
|
|
146
|
+
// Back off one unit if the cut would split a surrogate pair (emoji /
|
|
147
|
+
// astral chars) — a lone high surrogate renders as � in the live bubble.
|
|
148
|
+
let cut = maxLen - 3;
|
|
149
|
+
const cc = s.charCodeAt(cut - 1);
|
|
150
|
+
if (cc >= 0xD800 && cc <= 0xDBFF) cut -= 1;
|
|
151
|
+
return s.slice(0, cut) + '...';
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
// rc.67: scrub recognised inline tags BEFORE the streamer commits text
|
|
@@ -180,24 +207,103 @@ function createStreamer({
|
|
|
180
207
|
pendingEdit = schedule(flush, delay);
|
|
181
208
|
}
|
|
182
209
|
|
|
210
|
+
// Decide the rich-vs-plain payload for the CURRENT latestText/finalText.
|
|
211
|
+
// Shared by flush() and finalize() so the "only attempt rich under the
|
|
212
|
+
// rich char cap" guard and the upgrade-event firing live in one place.
|
|
213
|
+
function resolveRichPayload(text, { partial }) {
|
|
214
|
+
if (!toRichPayload) return null;
|
|
215
|
+
if (text.length > richMaxLen) {
|
|
216
|
+
// Edge case, not fully engineered (documented limitation): a
|
|
217
|
+
// mid-stream reply that's ALREADY past the rich cap while still
|
|
218
|
+
// growing. Rather than flap into a truncated/ellipsis rich render
|
|
219
|
+
// (which has no real analog — rich has no "..." convention), skip
|
|
220
|
+
// rich for this tick; finalize()'s own overflow check (against
|
|
221
|
+
// richMaxLen) is what actually decides discard-and-redeliver-plain
|
|
222
|
+
// once streaming ends.
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
let payload;
|
|
226
|
+
try {
|
|
227
|
+
payload = toRichPayload(text, { partial });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
logger.error?.(`[stream] toRichPayload threw, falling back to plain: ${err.message}`);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
return payload && payload.usedRich ? payload : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
183
235
|
async function flush() {
|
|
184
236
|
pendingEdit = null;
|
|
185
237
|
if (state !== 'live' || msgId == null) return;
|
|
186
|
-
|
|
187
|
-
if (
|
|
238
|
+
|
|
239
|
+
if (flushPromise) {
|
|
240
|
+
flushQueued = true;
|
|
241
|
+
return flushPromise;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const run = (async () => {
|
|
245
|
+
do {
|
|
246
|
+
flushQueued = false;
|
|
247
|
+
await flushOnce();
|
|
248
|
+
} while (flushQueued && state === 'live' && msgId != null);
|
|
249
|
+
})();
|
|
250
|
+
flushPromise = run;
|
|
251
|
+
try {
|
|
252
|
+
await run;
|
|
253
|
+
} finally {
|
|
254
|
+
if (flushPromise === run) flushPromise = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function flushOnce() {
|
|
259
|
+
if (state !== 'live' || msgId == null) return;
|
|
260
|
+
const editMsgId = msgId;
|
|
261
|
+
const generation = bubbleGeneration;
|
|
262
|
+
const sourceText = latestText;
|
|
263
|
+
|
|
264
|
+
const richPayload = resolveRichPayload(sourceText, { partial: true });
|
|
265
|
+
if (richPayload) {
|
|
266
|
+
const json = JSON.stringify(richPayload.blocks);
|
|
267
|
+
if (json === currentRichJson) return; // no structural change since last edit
|
|
268
|
+
const wasRich = currentRichJson !== null;
|
|
269
|
+
lastEditTs = clock();
|
|
270
|
+
try {
|
|
271
|
+
// Keep the source beside its blocks so a content-error fallback
|
|
272
|
+
// can render the same Markdown through the plain-text path.
|
|
273
|
+
const editResult = await edit(editMsgId, {
|
|
274
|
+
rich: true, blocks: richPayload.blocks, sourceText,
|
|
275
|
+
});
|
|
276
|
+
if (generation !== bubbleGeneration || msgId !== editMsgId) return;
|
|
277
|
+
// A rich editor can resolve successfully after sending the same
|
|
278
|
+
// source as plain fallback. Track what actually landed.
|
|
279
|
+
const wentRich = editResult == null || editResult.wentRich !== false;
|
|
280
|
+
if (wentRich) {
|
|
281
|
+
currentRichJson = json;
|
|
282
|
+
if (!wasRich) onRichUpgrade();
|
|
283
|
+
} else {
|
|
284
|
+
currentText = sourceText;
|
|
285
|
+
currentRichJson = null;
|
|
286
|
+
}
|
|
287
|
+
} catch (err) {
|
|
288
|
+
logger.error(`[stream] rich edit failed: ${err.message}`);
|
|
289
|
+
}
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const next = truncateForLive(sourceText);
|
|
294
|
+
if (currentRichJson === null && next === currentText) return;
|
|
188
295
|
lastEditTs = clock();
|
|
189
|
-
currentText = next;
|
|
190
296
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
297
|
+
await edit(editMsgId, next);
|
|
298
|
+
if (generation !== bubbleGeneration || msgId !== editMsgId) return;
|
|
299
|
+
currentText = next;
|
|
300
|
+
currentRichJson = null;
|
|
193
301
|
} catch (err) {
|
|
194
302
|
// Non-fatal — maybe 429 or transient. Log and keep going; next
|
|
195
303
|
// chunk will retry. The HTML→plain fallback in lib/telegram.js
|
|
196
304
|
// already handles the most common cause (parse error from
|
|
197
305
|
// truncate cutting mid-tag).
|
|
198
306
|
logger.error(`[stream] edit failed: ${err.message}`);
|
|
199
|
-
} finally {
|
|
200
|
-
flushPromise = null;
|
|
201
307
|
}
|
|
202
308
|
}
|
|
203
309
|
|
|
@@ -237,9 +343,12 @@ function createStreamer({
|
|
|
237
343
|
}
|
|
238
344
|
msgId = null;
|
|
239
345
|
currentText = '';
|
|
346
|
+
currentRichJson = null; // new bubble starts plain — see toRichPayload doc
|
|
240
347
|
latestText = '';
|
|
241
348
|
state = 'idle';
|
|
242
349
|
lastEditTs = 0;
|
|
350
|
+
flushQueued = false;
|
|
351
|
+
bubbleGeneration += 1;
|
|
243
352
|
}
|
|
244
353
|
|
|
245
354
|
// 0.7.0: delete the current bubble via the injected deleteMessage
|
|
@@ -306,6 +415,40 @@ function createStreamer({
|
|
|
306
415
|
let body = applyTransform(finalText ?? latestText);
|
|
307
416
|
if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
|
|
308
417
|
|
|
418
|
+
// Finalization evaluates the complete body, so no trailing block is
|
|
419
|
+
// held back as potentially incomplete.
|
|
420
|
+
const richPayload = resolveRichPayload(body, { partial: false });
|
|
421
|
+
if (richPayload) {
|
|
422
|
+
// Rich finalization uses the rich-message cap; applying the smaller
|
|
423
|
+
// plain-message cap would unnecessarily redeliver valid rich bodies.
|
|
424
|
+
if (body.length > richMaxLen) {
|
|
425
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: true };
|
|
426
|
+
}
|
|
427
|
+
const json = JSON.stringify(richPayload.blocks);
|
|
428
|
+
if (json === currentRichJson) {
|
|
429
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
|
|
430
|
+
}
|
|
431
|
+
const wasRich = currentRichJson !== null;
|
|
432
|
+
try {
|
|
433
|
+
const editResult = await edit(msgId, { rich: true, blocks: richPayload.blocks, sourceText: body });
|
|
434
|
+
// A fallback still delivered the final content, but as plain text.
|
|
435
|
+
const wentRich = editResult == null || editResult.wentRich !== false;
|
|
436
|
+
if (wentRich) {
|
|
437
|
+
currentRichJson = json;
|
|
438
|
+
if (!wasRich) onRichUpgrade();
|
|
439
|
+
} else {
|
|
440
|
+
currentText = body;
|
|
441
|
+
currentRichJson = null;
|
|
442
|
+
}
|
|
443
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
|
|
444
|
+
} catch (err) {
|
|
445
|
+
logger.error(`[stream] final rich edit failed: ${err.message}`);
|
|
446
|
+
return { streamed: true, msgId, finalText: body, finalEditOk: false, overflow: false };
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Plain finalization uses the regular message cap.
|
|
451
|
+
//
|
|
309
452
|
// If body overflows the single-message cap, the caller needs to
|
|
310
453
|
// discard this bubble and redeliver via chunks. Don't try to edit.
|
|
311
454
|
if (body.length > maxLen) {
|
|
@@ -317,13 +460,14 @@ function createStreamer({
|
|
|
317
460
|
// without redelivering. If it fails (e.g. parse error after our
|
|
318
461
|
// wrapper exhausts its retry, or a 5xx), caller should discard
|
|
319
462
|
// and redeliver — the bubble's content is unreliable.
|
|
320
|
-
if (body === currentText) {
|
|
463
|
+
if (currentRichJson === null && body === currentText) {
|
|
321
464
|
// Already correct — no edit needed.
|
|
322
465
|
return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
|
|
323
466
|
}
|
|
324
467
|
try {
|
|
325
468
|
await edit(msgId, body);
|
|
326
469
|
currentText = body;
|
|
470
|
+
currentRichJson = null;
|
|
327
471
|
return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
|
|
328
472
|
} catch (err) {
|
|
329
473
|
logger.error(`[stream] final edit failed: ${err.message}`);
|
|
@@ -348,6 +492,8 @@ function createStreamer({
|
|
|
348
492
|
get state() { return state; },
|
|
349
493
|
get msgId() { return msgId; },
|
|
350
494
|
get currentText() { return currentText; },
|
|
495
|
+
get isRichMode() { return currentRichJson !== null; },
|
|
496
|
+
get currentRichBlocks() { return currentRichJson ? JSON.parse(currentRichJson) : null; },
|
|
351
497
|
};
|
|
352
498
|
}
|
|
353
499
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "polygram",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@anthropic-ai/claude-agent-sdk": "0.2.123",
|
|
65
65
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
66
|
+
"@shumkov/orchestra": "^0.4.0",
|
|
66
67
|
"better-sqlite3": "^12.9.0",
|
|
67
68
|
"grammy": "^1.42.0",
|
|
68
69
|
"markdown-it": "^14.1.1",
|