polygram 0.17.12 → 0.18.1

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.17.12",
4
+ "version": "0.18.1",
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",
package/lib/error/net.js CHANGED
@@ -156,6 +156,13 @@ function redactBotToken(s) {
156
156
  .replace(/(bot_token=)[^&\s]+/gi, '$1<redacted>');
157
157
  }
158
158
 
159
+ // redactBotToken() does not cover URL basic auth. Operator-configured API
160
+ // roots may contain userinfo, which must be stripped before logging.
161
+ function stripUrlCredentials(url) {
162
+ if (!url) return url;
163
+ return String(url).replace(/^(https?:\/\/)[^/@\s]+@/i, '$1');
164
+ }
165
+
159
166
  module.exports = {
160
167
  PRE_CONNECT_ERROR_CODES,
161
168
  RECOVERABLE_ERROR_CODES,
@@ -167,4 +174,5 @@ module.exports = {
167
174
  extractName,
168
175
  extractMessage,
169
176
  redactBotToken,
177
+ stripUrlCredentials,
170
178
  };
@@ -17,6 +17,7 @@
17
17
  'use strict';
18
18
 
19
19
  const { toTelegramHtml } = require('../telegram/format');
20
+ const { resolveRichTextEnabled } = require('../telegram/rich');
20
21
  const { getTopicConfig, getConfigWriteScope } = require('../session-key');
21
22
 
22
23
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
@@ -37,10 +38,14 @@ function createHandleConfigCallback({
37
38
 
38
39
  return async function handleConfigCallback(ctx) {
39
40
  const data = ctx.callbackQuery?.data || '';
40
- const m = String(data).match(/^cfg:(model|effort):(\S+)$/);
41
+ const m = String(data).match(/^cfg:(model|effort|richtext):(\S+)$/);
41
42
  if (!m) return;
42
43
  const setting = m[1];
43
44
  const value = m[2];
45
+ // richText is stored as a boolean so inherited configuration can
46
+ // distinguish an explicit false value from an unset value.
47
+ const isRichText = setting === 'richtext';
48
+ const parsedValue = isRichText ? value === 'on' : value;
44
49
 
45
50
  const chatId = String(ctx.callbackQuery.message?.chat?.id || '');
46
51
  const chatConfig = config.chats[chatId];
@@ -53,10 +58,17 @@ function createHandleConfigCallback({
53
58
  return;
54
59
  }
55
60
 
56
- const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
57
- if (!validValues.includes(value)) {
58
- await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
59
- return;
61
+ if (isRichText) {
62
+ if (value !== 'on' && value !== 'off') {
63
+ await ctx.answerCallbackQuery({ text: 'Invalid richtext value' }).catch(() => {});
64
+ return;
65
+ }
66
+ } else {
67
+ const validValues = setting === 'model' ? MODEL_OPTIONS : EFFORT_OPTIONS;
68
+ if (!validValues.includes(value)) {
69
+ await ctx.answerCallbackQuery({ text: `Invalid ${setting}` }).catch(() => {});
70
+ return;
71
+ }
60
72
  }
61
73
 
62
74
  // Write to the scope the card belongs to: a topic card targets THAT topic
@@ -65,14 +77,21 @@ function createHandleConfigCallback({
65
77
  // topic's effective value, not the chat root (2026-06-12 bug).
66
78
  const callbackThreadIdEarly = ctx.callbackQuery.message?.message_thread_id?.toString() || null;
67
79
  const { scope: writeScope, threadId: writeThreadId } =
68
- getConfigWriteScope(chatConfig, callbackThreadIdEarly);
69
- const oldValue = writeScope[setting] != null ? writeScope[setting] : chatConfig[setting];
70
- if (oldValue === value) {
80
+ getConfigWriteScope(chatConfig, callbackThreadIdEarly, {
81
+ allowNonIsolatedTopic: setting === 'richtext',
82
+ });
83
+ // The persisted camel-case key differs from the lowercase callback
84
+ // label used alongside model and effort.
85
+ const configField = isRichText ? 'richText' : setting;
86
+ const oldValue = isRichText
87
+ ? resolveRichTextEnabled(config, chatId, callbackThreadIdEarly)
88
+ : (writeScope[configField] != null ? writeScope[configField] : chatConfig[configField]);
89
+ if (oldValue === parsedValue) {
71
90
  await ctx.answerCallbackQuery({ text: `Already ${value}` }).catch(() => {});
72
91
  return;
73
92
  }
74
93
 
75
- writeScope[setting] = value;
94
+ writeScope[configField] = parsedValue;
76
95
  // Persist to config.json so the change survives a restart — without this,
77
96
  // every /model tap was lost on the next deploy (2026-06-12). Best-effort:
78
97
  // never let a disk hiccup swallow the in-memory change + live application.
@@ -81,15 +100,19 @@ function createHandleConfigCallback({
81
100
  const cmdUserId = ctx.callbackQuery.from?.id || null;
82
101
  const cmdUser = ctx.callbackQuery.from?.first_name || ctx.callbackQuery.from?.username || null;
83
102
  dbWrite(() => db.logConfigChange({
84
- chat_id: chatId, thread_id: writeThreadId, field: setting,
85
- old_value: oldValue, new_value: value,
103
+ chat_id: chatId, thread_id: writeThreadId, field: configField,
104
+ old_value: oldValue, new_value: parsedValue,
86
105
  user: cmdUser, user_id: cmdUserId, source: 'inline-button',
87
- }), `log ${setting} change`);
106
+ }), `log ${configField} change`);
88
107
 
89
108
  // Graceful application to the topic's session. SDK pm applies live
90
109
  // via setModel / applyFlagSettings; chatConfig is already updated
91
110
  // on disk above so a missing live session still picks up the new
92
- // value on its next cold spawn.
111
+ // value on its next cold spawn. richText is NOT a spawn-time flag —
112
+ // delivery reads it live at send time (lib/telegram/rich.js), so
113
+ // there is nothing to apply to a running session; only the system-
114
+ // prompt authoring hint lags to the next spawn, which the rich-text
115
+ // acknowledgement explains separately from model/effort changes.
93
116
  const callbackThreadId = callbackThreadIdEarly;
94
117
  const callbackSessionKey = getSessionKey(chatId, callbackThreadId, chatConfig);
95
118
  let applied = false;
@@ -111,8 +134,9 @@ function createHandleConfigCallback({
111
134
  // mirrors the /model command card (polygram.js). getTopicConfig returns {}
112
135
  // for the chat-level card.
113
136
  const _cbTopicCfg = getTopicConfig(chatConfig, callbackThreadId);
114
- const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg);
115
- const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg);
137
+ const effectiveRichText = resolveRichTextEnabled(config, chatId, callbackThreadId);
138
+ const newInfo = formatConfigInfoText(chatConfig, showRow, chatId, _cbTopicCfg, effectiveRichText);
139
+ const newKeyboard = buildConfigKeyboard(chatConfig, showRow, _cbTopicCfg, effectiveRichText);
116
140
  try {
117
141
  const { text: html, parseMode } = toTelegramHtml(newInfo);
118
142
  await ctx.editMessageText(html, {
@@ -123,9 +147,11 @@ function createHandleConfigCallback({
123
147
  logger.error?.(`[${botName}] config-card edit failed: ${err.message}`);
124
148
  }
125
149
 
126
- const ackText = anyActive
127
- ? `${setting} ${value} switching when finished`
128
- : `${setting} ${value}`;
150
+ // Rich delivery changes immediately, but its authoring hint changes
151
+ // on the next session spawn; surface both timings in the acknowledgement.
152
+ const ackText = isRichText
153
+ ? (parsedValue ? 'Rich text → on (delivery live; agent authors for it next session)' : 'Rich text → off')
154
+ : (anyActive ? `${setting} → ${value} — switching when finished` : `${setting} → ${value}`);
129
155
  await ctx.answerCallbackQuery({ text: ackText }).catch(() => {});
130
156
  };
131
157
  }
@@ -19,6 +19,16 @@
19
19
  const MODEL_OPTIONS = ['opus', 'sonnet', 'haiku'];
20
20
  const EFFORT_OPTIONS = ['low', 'medium', 'high', 'xhigh', 'max'];
21
21
 
22
+ // Runtime callers pass the value resolved from the full config hierarchy.
23
+ // Direct callers may omit it and retain the local topic/chat behavior.
24
+ function resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText) {
25
+ if (typeof effectiveRichText === 'boolean') return effectiveRichText;
26
+ const pick = (v) => (typeof v === 'boolean' ? v : undefined);
27
+ return pick(topicConfig?.richText)
28
+ ?? pick(chatConfig?.richText)
29
+ ?? false;
30
+ }
31
+
22
32
  // Mirrors what `claude --model <alias>` resolves to. Display only —
23
33
  // polygram passes the alias (opus / sonnet / haiku) and lets claude
24
34
  // resolve. Bump on Claude release.
@@ -35,7 +45,7 @@ const MODEL_VERSIONS_DESC = {
35
45
  * null for the chat-level card) wins over chatConfig so the ✓ matches what a
36
46
  * topic actually runs — mirrors the spawn-path precedence (topic > chat).
37
47
  */
38
- function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
48
+ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null, effectiveRichText) {
39
49
  const model = (topicConfig && topicConfig.model) || chatConfig.model;
40
50
  const effort = (topicConfig && topicConfig.effort) || chatConfig.effort;
41
51
  const rows = [];
@@ -51,6 +61,15 @@ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
51
61
  callback_data: `cfg:effort:${e}`,
52
62
  })));
53
63
  }
64
+ // Rich text is a boolean toggle on the full config card. Model-only
65
+ // and effort-only cards keep their focused layouts.
66
+ if (show === 'all') {
67
+ const on = resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText);
68
+ rows.push([{
69
+ text: on ? '✓ Rich text: on' : 'Rich text: off',
70
+ callback_data: `cfg:richtext:${on ? 'off' : 'on'}`,
71
+ }]);
72
+ }
54
73
  return { inline_keyboard: rows };
55
74
  }
56
75
 
@@ -64,7 +83,7 @@ function buildConfigKeyboard(chatConfig, show = 'all', topicConfig = null) {
64
83
  * @param {(db, sessionKey) => string|null} deps.getClaudeSessionId
65
84
  */
66
85
  function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
67
- return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null) {
86
+ return function formatConfigInfoText(chatConfig, show, sessionKey, topicConfig = null, effectiveRichText) {
68
87
  const alive = pm.has(sessionKey) && !pm.get(sessionKey).closed;
69
88
  // Per-topic overrides win over chat-level for the displayed values,
70
89
  // mirroring the spawn path (polygram.js: topicConfig.agent ||
@@ -93,9 +112,17 @@ function createFormatConfigInfoText({ pm, db, getClaudeSessionId } = {}) {
93
112
  const effortLine = (runEffort && runEffort !== effort)
94
113
  ? `Effort: ${runEffort} (running) → ${effort} (pending — applies on your next message)`
95
114
  : `Effort: ${effort}`;
115
+ // Delivery reads richText live, while the authoring hint is fixed at
116
+ // session spawn. Explain that lag separately from model/effort,
117
+ // which have concrete running and configured values to compare.
118
+ const richText = resolveDisplayRichText(chatConfig, topicConfig, effectiveRichText);
119
+ const richTextLine = richText
120
+ ? 'Rich text: on (headings/tables/checklists on qualifying replies; agent authors for it once this session next (re)spawns)'
121
+ : 'Rich text: off';
96
122
  const head =
97
123
  `${modelLine}\n` +
98
124
  `${effortLine}\n` +
125
+ `${richTextLine}\n` +
99
126
  `Agent: ${agent}\n` +
100
127
  `Process: ${alive ? 'warm' : 'cold'}\n` +
101
128
  `Session: ${sess}`;
@@ -25,7 +25,8 @@
25
25
 
26
26
  const agentLoader = require('../agents/loader');
27
27
  const { getTopicConfig } = require('../session-key');
28
- const { appendDisplayHint } = require('../telegram/display-hint');
28
+ const { appendDisplayHint, buildPolygramDisplayHint } = require('../telegram/display-hint');
29
+ const { resolveRichTextEnabled } = require('../telegram/rich');
29
30
 
30
31
  // Env: SHADOW semantics — must enumerate every var the spawned
31
32
  // worker is allowed to see. Anything else is dropped.
@@ -162,7 +163,14 @@ function createBuildSdkOptions({
162
163
 
163
164
  // Append polygram's display constraints to the systemPrompt —
164
165
  // infrastructure-layer hint, not agent business logic.
165
- composed.systemPrompt = appendDisplayHint(composed.systemPrompt);
166
+ //
167
+ // Resolve the authoring hint through the same topic→chat→bot→default
168
+ // precedence as delivery. The hint is fixed at spawn, while delivery
169
+ // reads the value live for each send.
170
+ composed.systemPrompt = appendDisplayHint(
171
+ composed.systemPrompt,
172
+ buildPolygramDisplayHint(resolveRichTextEnabled(config, chatId, threadId)),
173
+ );
166
174
  return composed;
167
175
  };
168
176
  }
@@ -107,26 +107,17 @@ function getTopicConfig(chatConfig, threadId) {
107
107
  }
108
108
 
109
109
  /**
110
- * Resolve the config object a per-chat/topic setting (model/effort) should be
111
- * WRITTEN to, given where the command/card was used. When in a topic, return
112
- * (creating if needed) that topic's override entry so a /model in the Music
113
- * topic targets Music alone and matches what the per-topic card displays,
114
- * instead of leaking to the chat root and every other topic that inherits it
115
- * (the 2026-06-12 "/model in Music does nothing" bug). At the chat level
116
- * (no thread), the writable scope is the chat config itself.
110
+ * Resolve the config object a per-chat/topic setting should be written to.
111
+ * Process-level settings use topic scope only for isolated sessions. A live
112
+ * delivery setting can opt into topic scope even when sessions are shared.
117
113
  *
118
114
  * @returns {{ scope: object, threadId: (string|null) }}
119
115
  */
120
- function getConfigWriteScope(chatConfig, threadId) {
116
+ function getConfigWriteScope(chatConfig, threadId, { allowNonIsolatedTopic = false } = {}) {
121
117
  const tid = (threadId == null || threadId === '') ? null : String(threadId);
122
- // Mirror getSessionKey's isolation rule: a per-topic override only takes
123
- // effect when isolateTopics === true (otherwise every topic shares the
124
- // chatId-keyed session and topics[tid].model is silently ignored — the
125
- // 2026-06-12 review found the topic-scope fix re-broke /model on the DEFAULT
126
- // non-isolated chats and made the card lie). So write the topic scope ONLY
127
- // when isolated; otherwise write the chat root (the session that actually
128
- // runs), and report threadId:null so the audit row reflects chat-level reach.
129
- if (tid && chatConfig?.isolateTopics === true) {
118
+ // Process-scoped settings follow session isolation. Live delivery settings
119
+ // may explicitly keep topic scope because they are resolved per message.
120
+ if (tid && (chatConfig?.isolateTopics === true || allowNonIsolatedTopic)) {
130
121
  chatConfig.topics = chatConfig.topics || {};
131
122
  // A topic can be stored in the legacy / hand-edited form where the entry
132
123
  // is the bare name string (topics["329"] = "Advertising") rather than the
@@ -100,9 +100,14 @@ const METHODS_WITHOUT_MSG = new Set([
100
100
  // Derive the row's `text` column. sendSticker has no text/caption, so we
101
101
  // synthesize `[sticker:<name>]` (or file_id as fallback) — without this the
102
102
  // transcript shows an empty outbound that's impossible to interpret later.
103
+ // Rich edits carry source Markdown in meta.richSourceText because the API
104
+ // payload has `rich_message` instead of `text`. Edits are excluded from
105
+ // transcript persistence by METHODS_WITHOUT_MSG, but deriving meaningful
106
+ // text here keeps the helper correct for any tracked rich-message caller.
103
107
  function deriveOutboundText(method, params, meta) {
104
108
  if (params.text) return params.text;
105
109
  if (params.caption) return params.caption;
110
+ if (params.rich_message) return meta.richSourceText || '[rich message]';
106
111
  if (method === 'sendSticker') {
107
112
  const label = meta.stickerName || params.sticker || 'unknown';
108
113
  return `[sticker:${label}]`;
@@ -153,11 +158,17 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
153
158
  // as a user-facing "Hit a snag" — confusing because the bot didn't
154
159
  // really fail. Throw a typed error before the API call so callers
155
160
  // can detect + skip silently if appropriate.
156
- if (method === 'sendMessage' || method === 'editMessageText') {
161
+ // A rich edit carries `rich_message` instead of `text`, so validate
162
+ // its block collection rather than applying the plain-text guard.
163
+ if (method === 'sendMessage' || (method === 'editMessageText' && params.rich_message == null)) {
157
164
  const t = params.text;
158
165
  if (typeof t !== 'string' || t.length === 0) {
159
166
  throw new Error(`telegram ${method}: text is empty`);
160
167
  }
168
+ } else if (method === 'editMessageText' && params.rich_message != null) {
169
+ if (!Array.isArray(params.rich_message.blocks) || params.rich_message.blocks.length === 0) {
170
+ throw new Error(`telegram ${method}: rich_message.blocks is empty`);
171
+ }
161
172
  }
162
173
  if (method === 'sendPhoto' || method === 'sendVideo'
163
174
  || method === 'sendAudio' || method === 'sendDocument' || method === 'sendAnimation') {
@@ -27,11 +27,10 @@
27
27
 
28
28
  const TELEGRAM_TABLE_WIDTH_BUDGET = 40;
29
29
 
30
- const POLYGRAM_DISPLAY_HINT = [
31
- '## Telegram display rules',
32
- '',
33
- 'Your replies render in the Telegram client. Phone is the design target.',
34
- '',
30
+ // Plain-mode guidance avoids structures that Telegram's HTML rendering
31
+ // cannot display well. The prompt and delivery capability must agree so
32
+ // the agent is not encouraged to author unsupported structures.
33
+ const TABLES_AND_HEADERS_SECTION_PLAIN = [
35
34
  '### Tables — HARD RULE',
36
35
  '',
37
36
  `Before emitting any markdown table, count the longest row in characters (including pipes \`|\`, padding, and separator dashes). If that row is longer than ${TELEGRAM_TABLE_WIDTH_BUDGET}, you MUST NOT emit a table. Use row blocks instead.`,
@@ -57,19 +56,54 @@ const POLYGRAM_DISPLAY_HINT = [
57
56
  '- Headers `#`, `##`, `###` render as plain text — use **bold** for emphasis.',
58
57
  '- Horizontal rules render as a thin divider line.',
59
58
  '- Long replies stream in chunks; prefer concise structure over walls of text.',
59
+ ].join('\n');
60
+
61
+ // Rich-mode guidance is limited to the constructs detected by
62
+ // needsRichRendering so ordinary prose stays on the compact plain path.
63
+ const TABLES_AND_HEADERS_SECTION_RICH = [
64
+ '### Rich formatting is available in this chat',
65
+ '',
66
+ 'This chat has rich-text rendering enabled. Real Telegram markdown tables, headings (`#`/`##`/etc.), task lists, and collapsible `<details>` blocks now render as an actual structured message, not flattened text — use them when they make an answer clearer or easier to scan (a comparison, a set of steps, a key/value summary, a checklist tracking progress). No character-width budget applies to tables here; write a normal markdown table.',
67
+ '',
68
+ '**Task lists for progress:** `- [ ] pending step` / `- [x] done step` renders as a real checkbox list — the natural way to show multi-step progress as it happens.',
60
69
  '',
61
- '### NEVER emit shell-context canned strings HARD RULE',
70
+ '**Collapsible detail:** wrap verbose logs/diffs/output you want available but not front-and-center in `<details><summary>Label</summary>...</details>`.',
62
71
  '',
63
- 'You are running as a Telegram chat bot, NOT as a script being piped into a shell. Certain phrases are CLI-context boilerplate from the underlying environment and MUST NEVER appear in a reply, because the user sees them as a literal message from you and they look like a system error:',
72
+ 'Not every reply needs this plain prose is still right for a normal conversational answer. Reach for structure when the CONTENT is structured, not by default.',
64
73
  '',
65
- '- `No response requested.`',
66
- '- `No response needed.`',
67
- '- `Continuing...` as a standalone reply',
68
- '- Any other shell-prompt-style filler that acknowledges silence',
74
+ '### Other Telegram quirks',
69
75
  '',
70
- 'If a user message is short, ambiguous, or feels like a no-op acknowledgement (e.g. `okay`, `ok`, `yes`, `got it`, `thanks`), reply with a brief substantive line — acknowledge what you understood and what (if anything) you will do next. If you genuinely have nothing useful to say, ask ONE specific clarifying question. NEVER emit a placeholder or a shell-style canned string — the chat surface has no silent-no-op state. Every reply must be intentional content.',
76
+ '- Horizontal rules render as a thin divider line.',
77
+ '- Long replies stream in chunks; prefer concise structure over walls of text.',
71
78
  ].join('\n');
72
79
 
80
+ function buildPolygramDisplayHint(richText = false) {
81
+ const tablesSection = richText ? TABLES_AND_HEADERS_SECTION_RICH : TABLES_AND_HEADERS_SECTION_PLAIN;
82
+ return [
83
+ '## Telegram display rules',
84
+ '',
85
+ 'Your replies render in the Telegram client. Phone is the design target.',
86
+ '',
87
+ tablesSection,
88
+ '',
89
+ '### NEVER emit shell-context canned strings — HARD RULE',
90
+ '',
91
+ 'You are running as a Telegram chat bot, NOT as a script being piped into a shell. Certain phrases are CLI-context boilerplate from the underlying environment and MUST NEVER appear in a reply, because the user sees them as a literal message from you and they look like a system error:',
92
+ '',
93
+ '- `No response requested.`',
94
+ '- `No response needed.`',
95
+ '- `Continuing...` as a standalone reply',
96
+ '- Any other shell-prompt-style filler that acknowledges silence',
97
+ '',
98
+ 'If a user message is short, ambiguous, or feels like a no-op acknowledgement (e.g. `okay`, `ok`, `yes`, `got it`, `thanks`), reply with a brief substantive line — acknowledge what you understood and what (if anything) you will do next. If you genuinely have nothing useful to say, ask ONE specific clarifying question. NEVER emit a placeholder or a shell-style canned string — the chat surface has no silent-no-op state. Every reply must be intentional content.',
99
+ ].join('\n');
100
+ }
101
+
102
+ // Pre-computed default (richText:false) — kept as a named export for
103
+ // backward compatibility with any caller/test that imported the old
104
+ // constant directly.
105
+ const POLYGRAM_DISPLAY_HINT = buildPolygramDisplayHint(false);
106
+
73
107
  /**
74
108
  * Append the polygram display hint to an existing systemPrompt option,
75
109
  * preserving the original shape (string / preset object / undefined).
@@ -111,5 +145,6 @@ function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
111
145
  module.exports = {
112
146
  POLYGRAM_DISPLAY_HINT,
113
147
  TELEGRAM_TABLE_WIDTH_BUDGET,
148
+ buildPolygramDisplayHint,
114
149
  appendDisplayHint,
115
150
  };
@@ -108,6 +108,12 @@ function buildRenderer() {
108
108
  image({ href, text }) {
109
109
  return `<a href="${escapeHtmlAttr(href)}">${escapeHtml(text || href)}</a>`;
110
110
  },
111
+ // Task-list items (marked type:'checkbox', gfm) render as a literal
112
+ // ☐/☑ glyph. Telegram's parse_mode=HTML has no support for <input> —
113
+ // marked's default checkbox() renderer emits one, which Telegram
114
+ // rejects outright (400: Unsupported start tag "input"), degrading
115
+ // the WHOLE message to raw markdown via api.js's HTML→plain fallback.
116
+ checkbox({ checked }) { return checked ? '☑ ' : '☐ '; },
111
117
  list({ items, ordered, start }) {
112
118
  const depth = listDepth;
113
119
  listDepth += 1;
@@ -115,7 +121,10 @@ function buildRenderer() {
115
121
  const bullet = bulletFor(depth);
116
122
  try {
117
123
  const lines = items.map((item, i) => {
118
- const marker = ordered ? `${(start || 1) + i}. ` : `${bullet} `;
124
+ // Task items carry their own checkbox() glyph as the visual
125
+ // marker (rendered inline below) — an extra bullet in front
126
+ // would double up ("• ☐ x").
127
+ const marker = item.task ? '' : (ordered ? `${(start || 1) + i}. ` : `${bullet} `);
119
128
  const { inline, blocks } = splitItemTokens(item.tokens);
120
129
  const leader = this.parser.parseInline(inline).trim();
121
130
  let line = `${indent}${marker}${leader}`;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Rich-message edit-with-fallback. Capability and content errors preserve
3
+ * the reply through a plain edit; transient errors remain visible to the
4
+ * streamer's retry/finalization logic.
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ /**
10
+ * @param {object} deps
11
+ * @param {(bot, method, params, meta) => Promise<*>} deps.tg
12
+ * @param {string} deps.botName
13
+ * @param {(kind: string, detail: object) => void} deps.logEvent
14
+ * @param {(s: string) => string} deps.redactBotToken
15
+ * @param {(err) => boolean} deps.isRichCapabilityError
16
+ * @param {(err) => boolean} deps.isRichContentError
17
+ * @param {() => boolean} deps.getRichKnownUnsupported
18
+ * @param {() => void} deps.setRichKnownUnsupported — marks the latch tripped
19
+ * @param {() => (string|null)} [deps.getApiRoot] — for the latch-trip log line only
20
+ * @param {(url: string) => string} [deps.stripUrlCreds] — strips basic-auth
21
+ * userinfo from apiRoot before logging
22
+ * @returns {(args: {bot, chatId: string, threadId: (string|null), messageId: number, blocks: Array, sourceText: string}) => Promise<{result: *, wentRich: boolean}>}
23
+ */
24
+ function createRichEditor({
25
+ tg,
26
+ botName,
27
+ logEvent,
28
+ redactBotToken,
29
+ isRichCapabilityError,
30
+ isRichContentError,
31
+ getRichKnownUnsupported = () => false,
32
+ setRichKnownUnsupported = () => {},
33
+ getApiRoot = () => null,
34
+ stripUrlCreds = (s) => s,
35
+ } = {}) {
36
+ return async function richEditMessageText({ bot, chatId, threadId = null, messageId, blocks, sourceText }) {
37
+ if (getRichKnownUnsupported()) {
38
+ const res = await tg(bot, 'editMessageText', {
39
+ chat_id: chatId, message_id: messageId, text: sourceText,
40
+ }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
41
+ return { result: res, wentRich: false };
42
+ }
43
+
44
+ try {
45
+ const res = await tg(bot, 'editMessageText', {
46
+ chat_id: chatId,
47
+ message_id: messageId,
48
+ rich_message: { blocks },
49
+ }, { source: 'bot-reply-stream-edit-rich', botName, richSourceText: sourceText });
50
+ // Record successful rich deliveries without storing message content.
51
+ logEvent('rich-message-sent', {
52
+ chat_id: chatId, thread_id: threadId, bot: botName,
53
+ streaming: true, block_count: blocks.length,
54
+ char_count: sourceText?.length ?? null,
55
+ });
56
+ // The streamer commits rich state only when this marker is true.
57
+ return { result: res, wentRich: true };
58
+ } catch (err) {
59
+ if (isRichCapabilityError(err)) {
60
+ setRichKnownUnsupported();
61
+ logEvent('rich-capability-latched', {
62
+ chat_id: chatId, bot: botName,
63
+ api_root: stripUrlCreds(getApiRoot() || 'cloud'),
64
+ error: redactBotToken(err.message)?.slice(0, 200),
65
+ });
66
+ } else if (isRichContentError(err)) {
67
+ logEvent('rich-content-fallback', {
68
+ chat_id: chatId, bot: botName,
69
+ error: redactBotToken(err.message)?.slice(0, 200),
70
+ });
71
+ } else {
72
+ // Transient (5xx/timeout/etc) — not ours to reclassify as a
73
+ // fallback; rethrow so the caller's existing retry/error
74
+ // handling deals with it exactly as a plain-path edit failure
75
+ // would (streamer.js's flush()/finalize() catch + log + move on).
76
+ logEvent('telegram-edit-failed', {
77
+ chat_id: chatId, msg_id: messageId,
78
+ // Network errors can include the request URL and bot token.
79
+ api_error: redactBotToken(err.message)?.slice(0, 200),
80
+ bot: botName,
81
+ });
82
+ throw err;
83
+ }
84
+ // Preserve the same authored content and tell the streamer that the
85
+ // accepted payload is plain, so its delivery trackers stay accurate.
86
+ const fallbackRes = await tg(bot, 'editMessageText', {
87
+ chat_id: chatId, message_id: messageId, text: sourceText,
88
+ }, { source: 'bot-reply-stream-edit-rich-fallback', botName });
89
+ return { result: fallbackRes, wentRich: false };
90
+ }
91
+ };
92
+ }
93
+
94
+ module.exports = { createRichEditor };
@@ -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
+ };
@@ -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; // ongoing edit promise (for back-pressure)
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
@@ -185,24 +207,103 @@ function createStreamer({
185
207
  pendingEdit = schedule(flush, delay);
186
208
  }
187
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
+
188
235
  async function flush() {
189
236
  pendingEdit = null;
190
237
  if (state !== 'live' || msgId == null) return;
191
- const next = truncateForLive(latestText);
192
- if (next === currentText) return;
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;
193
295
  lastEditTs = clock();
194
- currentText = next;
195
296
  try {
196
- flushPromise = edit(msgId, currentText);
197
- await flushPromise;
297
+ await edit(editMsgId, next);
298
+ if (generation !== bubbleGeneration || msgId !== editMsgId) return;
299
+ currentText = next;
300
+ currentRichJson = null;
198
301
  } catch (err) {
199
302
  // Non-fatal — maybe 429 or transient. Log and keep going; next
200
303
  // chunk will retry. The HTML→plain fallback in lib/telegram.js
201
304
  // already handles the most common cause (parse error from
202
305
  // truncate cutting mid-tag).
203
306
  logger.error(`[stream] edit failed: ${err.message}`);
204
- } finally {
205
- flushPromise = null;
206
307
  }
207
308
  }
208
309
 
@@ -242,9 +343,12 @@ function createStreamer({
242
343
  }
243
344
  msgId = null;
244
345
  currentText = '';
346
+ currentRichJson = null; // new bubble starts plain — see toRichPayload doc
245
347
  latestText = '';
246
348
  state = 'idle';
247
349
  lastEditTs = 0;
350
+ flushQueued = false;
351
+ bubbleGeneration += 1;
248
352
  }
249
353
 
250
354
  // 0.7.0: delete the current bubble via the injected deleteMessage
@@ -311,6 +415,40 @@ function createStreamer({
311
415
  let body = applyTransform(finalText ?? latestText);
312
416
  if (errorSuffix) body = `${body}\n\n⚠️ ${errorSuffix}`;
313
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
+ //
314
452
  // If body overflows the single-message cap, the caller needs to
315
453
  // discard this bubble and redeliver via chunks. Don't try to edit.
316
454
  if (body.length > maxLen) {
@@ -322,13 +460,14 @@ function createStreamer({
322
460
  // without redelivering. If it fails (e.g. parse error after our
323
461
  // wrapper exhausts its retry, or a 5xx), caller should discard
324
462
  // and redeliver — the bubble's content is unreliable.
325
- if (body === currentText) {
463
+ if (currentRichJson === null && body === currentText) {
326
464
  // Already correct — no edit needed.
327
465
  return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
328
466
  }
329
467
  try {
330
468
  await edit(msgId, body);
331
469
  currentText = body;
470
+ currentRichJson = null;
332
471
  return { streamed: true, msgId, finalText: body, finalEditOk: true, overflow: false };
333
472
  } catch (err) {
334
473
  logger.error(`[stream] final edit failed: ${err.message}`);
@@ -353,6 +492,8 @@ function createStreamer({
353
492
  get state() { return state; },
354
493
  get msgId() { return msgId; },
355
494
  get currentText() { return currentText; },
495
+ get isRichMode() { return currentRichJson !== null; },
496
+ get currentRichBlocks() { return currentRichJson ? JSON.parse(currentRichJson) : null; },
356
497
  };
357
498
  }
358
499
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.17.12",
3
+ "version": "0.18.1",
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,7 +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
+ "@shumkov/orchestra": "^0.4.2",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",
package/polygram.js CHANGED
@@ -96,6 +96,12 @@ const { createStore: createPairingsStore, parseTtl: parsePairingTtl } = require(
96
96
  const { transcribe: transcribeVoice, isVoiceAttachment } = require('./lib/telegram/voice');
97
97
  const { createStreamer } = require('./lib/telegram/streamer');
98
98
  const { chunkMarkdownText } = require('./lib/telegram/chunk');
99
+ const {
100
+ toTelegramRichBlocks, resolveRichTextEnabled, isRichCapabilityError, isRichContentError,
101
+ } = require('./lib/telegram/rich');
102
+ const { createRichEditor } = require('./lib/telegram/rich-edit');
103
+ const { buildPolygramDisplayHint } = require('./lib/telegram/display-hint');
104
+ const { redactBotToken, stripUrlCredentials } = require('./lib/error/net');
99
105
  // F#23: shared agent-reply helper. parseResponse + sanitizer + chunked
100
106
  // delivery + inline sticker/react in one place. Wired into both the
101
107
  // channels dispatcher (F#1) and the autonomous-wakeup handler (F#23).
@@ -299,6 +305,23 @@ function logEvent(kind, detail) {
299
305
  dbWrite(() => db.logEvent(kind, detail), `log ${kind}`);
300
306
  }
301
307
 
308
+ // One bot runs per process, so rich-message capability can be represented
309
+ // by one process-wide boolean rather than a per-connection map. Set once by
310
+ // isRichCapabilityError (endpoint missing/404) and never cleared for the
311
+ // process lifetime — retrying a permanently-missing endpoint on every
312
+ // future send is wasteful. Transient errors must not set the latch.
313
+ let richKnownUnsupported = false;
314
+
315
+ // Extracted to lib/telegram/rich-edit.js so the capability/content/
316
+ // transient error-classification + fallback wiring is directly unit-
317
+ // testable. Constructed in main() (like `tg` itself, just below),
318
+ // NOT here at module load time — BOT_NAME/config are `let`s still
319
+ // `null`/`undefined` at this point; capturing them by destructuring now
320
+ // would bake in stale values permanently, unlike `logEvent`, which is
321
+ // safe to reference early because it's a stable function that does its
322
+ // own live lookups internally when called.
323
+ let richEditMessageText;
324
+
302
325
  // 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
303
326
  // in the user's message with `[redact:<secret>]`. parseResponse / stripInlineTags
304
327
  // strip the marker so nothing leaks to the user; here we wipe the literal from
@@ -819,8 +842,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
819
842
  // showed "Agent: shumabit" instead of music-curation:music-curator
820
843
  // (2026-06-03). getTopicConfig returns {} when there's no active topic.
821
844
  const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
822
- const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg);
823
- const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg);
845
+ const effectiveRichText = resolveRichTextEnabled(config, chatId, threadIdStr || null);
846
+ const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg, effectiveRichText);
847
+ const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg, effectiveRichText);
824
848
  await sendReply(info, { params: { reply_markup } });
825
849
  return;
826
850
  }
@@ -981,11 +1005,35 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
981
1005
  // eliminates the "stuck at 15min typing" complaint from the non-streaming
982
1006
  // code path. For short responses the streamer stays idle and we fall
983
1007
  // through to the normal send path via finalize() returning streamed=false.
1008
+ // Gate rich delivery on the per-chat/topic opt-in and the process-wide
1009
+ // capability latch. Returning null forces the streamer's plain-text
1010
+ // path — same contract for "not opted in", "capability known
1011
+ // unsupported", and "content doesn't need it" (needsRichRendering
1012
+ // inside toTelegramRichBlocks handles the last one).
1013
+ // Rich delivery is limited to this interactive streamer instance.
1014
+ // A reply that never crosses minChars (stays idle → delivered via
1015
+ // deliverReplies) and any non-streaming send path — autonomous-wakeup/
1016
+ // auto-resume/autosteer via lib/telegram/process-agent-reply.js, cron/
1017
+ // IPC sends — never render rich even when richText is on and the
1018
+ // content qualifies. Those delivery paths intentionally remain plain.
1019
+ // Re-read both controls for every flush so a config change or a
1020
+ // capability result applies to the turn already in progress.
1021
+ const toRichPayload = (text, opts) => {
1022
+ if (richKnownUnsupported || !resolveRichTextEnabled(config, chatId, threadId)) return null;
1023
+ return toTelegramRichBlocks(text, opts);
1024
+ };
1025
+
984
1026
  const streamer = createStreamer({
985
1027
  // rc.67: pre-process every chunk to strip recognised
986
1028
  // [sticker:NAME] / [react:EMOJI] tags BEFORE the bubble or DB row
987
1029
  // captures them. See stripInlineTagsForStreamer above.
988
1030
  transformText: stripInlineTagsForStreamer,
1031
+ toRichPayload,
1032
+ onRichUpgrade: () => {
1033
+ // Record the visible transition when an existing plain bubble
1034
+ // becomes rich during streaming.
1035
+ logEvent('rich-streaming-upgrade', { chat_id: chatId, thread_id: threadId, bot: BOT_NAME });
1036
+ },
989
1037
  send: async (text) => {
990
1038
  const params = {
991
1039
  chat_id: chatId, text,
@@ -1001,7 +1049,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
1001
1049
  }
1002
1050
  return tg(bot, 'sendMessage', params, outMetaBase);
1003
1051
  },
1004
- edit: async (messageId, text) => {
1052
+ edit: async (messageId, payload) => {
1053
+ // The streamer emits either a plain string or a rich payload with
1054
+ // blocks and their source Markdown. Rich error classification and
1055
+ // fallback live in rich-edit.js so this wiring stays focused.
1056
+ if (payload && typeof payload === 'object' && payload.rich) {
1057
+ return richEditMessageText({
1058
+ bot, chatId, threadId, messageId,
1059
+ blocks: payload.blocks, sourceText: payload.sourceText,
1060
+ });
1061
+ }
1062
+ const text = payload;
1005
1063
  try {
1006
1064
  // Route edits through tg() so applyFormatting runs (MarkdownV2
1007
1065
  // + escape). Going direct to bot.api.editMessageText would
@@ -2279,6 +2337,18 @@ async function main() {
2279
2337
  db = dbClient.open(DB_PATH);
2280
2338
  console.log(`[db] opened ${DB_PATH}`);
2281
2339
  tg = createSender(db, console, config);
2340
+ richEditMessageText = createRichEditor({
2341
+ tg,
2342
+ botName: BOT_NAME,
2343
+ logEvent,
2344
+ redactBotToken,
2345
+ isRichCapabilityError,
2346
+ isRichContentError,
2347
+ getRichKnownUnsupported: () => richKnownUnsupported,
2348
+ setRichKnownUnsupported: () => { richKnownUnsupported = true; },
2349
+ getApiRoot: () => config.bot?.apiRoot || null,
2350
+ stripUrlCreds: stripUrlCredentials,
2351
+ });
2282
2352
  pairings = createPairingsStore(db.raw);
2283
2353
  approvals = createApprovalsStore(db.raw);
2284
2354
  const migration = migrateJsonToDb(db, SESSIONS_JSON_PATH, config.chats);
@@ -2635,7 +2705,9 @@ async function main() {
2635
2705
  pmDefault: 'sdk',
2636
2706
  // The CLI append-system-prompt's FIRST block — origin hard-required this into every
2637
2707
  // CliProcess. Without it, cli chats lose the Telegram table/markdown display rules.
2638
- displayHint: require('./lib/telegram/display-hint').POLYGRAM_DISPLAY_HINT,
2708
+ // A resolver (not a static string) so each cli-backed chat gets its own richText
2709
+ // state — orchestra's factory calls this per spawn with the spawning chat/topic.
2710
+ displayHint: (chatId, threadId) => buildPolygramDisplayHint(resolveRichTextEnabled(config, chatId, threadId)),
2639
2711
  // Backend-default outbound cap fallback (per-spawn buildSpawnContext override
2640
2712
  // normally supersedes this; kept so any context-less spawn still gets the backend
2641
2713
  // default rather than orchestra's neutral 100MB).