polygram 0.17.12 → 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/net.js +8 -0
- package/lib/handlers/config-callback.js +44 -18
- package/lib/handlers/config-ui.js +29 -2
- package/lib/sdk/build-options.js +10 -2
- package/lib/session-key.js +7 -16
- package/lib/telegram/api.js +12 -1
- package/lib/telegram/display-hint.js +47 -12
- package/lib/telegram/format.js +10 -1
- package/lib/telegram/rich-edit.js +94 -0
- package/lib/telegram/rich.js +397 -0
- package/lib/telegram/streamer.js +151 -10
- package/package.json +1 -1
- package/polygram.js +72 -3
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
|
|
3
3
|
"name": "polygram",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.18.0",
|
|
5
5
|
"description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"telegram",
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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[
|
|
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:
|
|
85
|
-
old_value: oldValue, new_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 ${
|
|
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
|
|
115
|
-
const
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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}`;
|
package/lib/sdk/build-options.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/lib/session-key.js
CHANGED
|
@@ -107,26 +107,17 @@ function getTopicConfig(chatConfig, threadId) {
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
/**
|
|
110
|
-
* Resolve the config object a per-chat/topic setting
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
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
|
package/lib/telegram/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
};
|
package/lib/telegram/format.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
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
|
|
@@ -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
|
-
|
|
192
|
-
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;
|
|
193
295
|
lastEditTs = clock();
|
|
194
|
-
currentText = next;
|
|
195
296
|
try {
|
|
196
|
-
|
|
197
|
-
|
|
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.
|
|
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": {
|
package/polygram.js
CHANGED
|
@@ -96,6 +96,11 @@ 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 { redactBotToken, stripUrlCredentials } = require('./lib/error/net');
|
|
99
104
|
// F#23: shared agent-reply helper. parseResponse + sanitizer + chunked
|
|
100
105
|
// delivery + inline sticker/react in one place. Wired into both the
|
|
101
106
|
// channels dispatcher (F#1) and the autonomous-wakeup handler (F#23).
|
|
@@ -299,6 +304,23 @@ function logEvent(kind, detail) {
|
|
|
299
304
|
dbWrite(() => db.logEvent(kind, detail), `log ${kind}`);
|
|
300
305
|
}
|
|
301
306
|
|
|
307
|
+
// One bot runs per process, so rich-message capability can be represented
|
|
308
|
+
// by one process-wide boolean rather than a per-connection map. Set once by
|
|
309
|
+
// isRichCapabilityError (endpoint missing/404) and never cleared for the
|
|
310
|
+
// process lifetime — retrying a permanently-missing endpoint on every
|
|
311
|
+
// future send is wasteful. Transient errors must not set the latch.
|
|
312
|
+
let richKnownUnsupported = false;
|
|
313
|
+
|
|
314
|
+
// Extracted to lib/telegram/rich-edit.js so the capability/content/
|
|
315
|
+
// transient error-classification + fallback wiring is directly unit-
|
|
316
|
+
// testable. Constructed in main() (like `tg` itself, just below),
|
|
317
|
+
// NOT here at module load time — BOT_NAME/config are `let`s still
|
|
318
|
+
// `null`/`undefined` at this point; capturing them by destructuring now
|
|
319
|
+
// would bake in stale values permanently, unlike `logEvent`, which is
|
|
320
|
+
// safe to reference early because it's a stable function that does its
|
|
321
|
+
// own live lookups internally when called.
|
|
322
|
+
let richEditMessageText;
|
|
323
|
+
|
|
302
324
|
// 0.15 secret redaction (agent-flagged path): the agent marks a secret it saw
|
|
303
325
|
// in the user's message with `[redact:<secret>]`. parseResponse / stripInlineTags
|
|
304
326
|
// strip the marker so nothing leaks to the user; here we wipe the literal from
|
|
@@ -819,8 +841,9 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
819
841
|
// showed "Agent: shumabit" instead of music-curation:music-curator
|
|
820
842
|
// (2026-06-03). getTopicConfig returns {} when there's no active topic.
|
|
821
843
|
const _cardTopicCfg = getTopicConfig(chatConfig, threadIdStr || null);
|
|
822
|
-
const
|
|
823
|
-
const
|
|
844
|
+
const effectiveRichText = resolveRichTextEnabled(config, chatId, threadIdStr || null);
|
|
845
|
+
const info = formatConfigInfoText(chatConfig, show, sessionKey, _cardTopicCfg, effectiveRichText);
|
|
846
|
+
const reply_markup = buildConfigKeyboard(chatConfig, show, _cardTopicCfg, effectiveRichText);
|
|
824
847
|
await sendReply(info, { params: { reply_markup } });
|
|
825
848
|
return;
|
|
826
849
|
}
|
|
@@ -981,11 +1004,35 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
981
1004
|
// eliminates the "stuck at 15min typing" complaint from the non-streaming
|
|
982
1005
|
// code path. For short responses the streamer stays idle and we fall
|
|
983
1006
|
// through to the normal send path via finalize() returning streamed=false.
|
|
1007
|
+
// Gate rich delivery on the per-chat/topic opt-in and the process-wide
|
|
1008
|
+
// capability latch. Returning null forces the streamer's plain-text
|
|
1009
|
+
// path — same contract for "not opted in", "capability known
|
|
1010
|
+
// unsupported", and "content doesn't need it" (needsRichRendering
|
|
1011
|
+
// inside toTelegramRichBlocks handles the last one).
|
|
1012
|
+
// Rich delivery is limited to this interactive streamer instance.
|
|
1013
|
+
// A reply that never crosses minChars (stays idle → delivered via
|
|
1014
|
+
// deliverReplies) and any non-streaming send path — autonomous-wakeup/
|
|
1015
|
+
// auto-resume/autosteer via lib/telegram/process-agent-reply.js, cron/
|
|
1016
|
+
// IPC sends — never render rich even when richText is on and the
|
|
1017
|
+
// content qualifies. Those delivery paths intentionally remain plain.
|
|
1018
|
+
// Re-read both controls for every flush so a config change or a
|
|
1019
|
+
// capability result applies to the turn already in progress.
|
|
1020
|
+
const toRichPayload = (text, opts) => {
|
|
1021
|
+
if (richKnownUnsupported || !resolveRichTextEnabled(config, chatId, threadId)) return null;
|
|
1022
|
+
return toTelegramRichBlocks(text, opts);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
984
1025
|
const streamer = createStreamer({
|
|
985
1026
|
// rc.67: pre-process every chunk to strip recognised
|
|
986
1027
|
// [sticker:NAME] / [react:EMOJI] tags BEFORE the bubble or DB row
|
|
987
1028
|
// captures them. See stripInlineTagsForStreamer above.
|
|
988
1029
|
transformText: stripInlineTagsForStreamer,
|
|
1030
|
+
toRichPayload,
|
|
1031
|
+
onRichUpgrade: () => {
|
|
1032
|
+
// Record the visible transition when an existing plain bubble
|
|
1033
|
+
// becomes rich during streaming.
|
|
1034
|
+
logEvent('rich-streaming-upgrade', { chat_id: chatId, thread_id: threadId, bot: BOT_NAME });
|
|
1035
|
+
},
|
|
989
1036
|
send: async (text) => {
|
|
990
1037
|
const params = {
|
|
991
1038
|
chat_id: chatId, text,
|
|
@@ -1001,7 +1048,17 @@ async function handleMessage(sessionKey, chatId, msg, bot) {
|
|
|
1001
1048
|
}
|
|
1002
1049
|
return tg(bot, 'sendMessage', params, outMetaBase);
|
|
1003
1050
|
},
|
|
1004
|
-
edit: async (messageId,
|
|
1051
|
+
edit: async (messageId, payload) => {
|
|
1052
|
+
// The streamer emits either a plain string or a rich payload with
|
|
1053
|
+
// blocks and their source Markdown. Rich error classification and
|
|
1054
|
+
// fallback live in rich-edit.js so this wiring stays focused.
|
|
1055
|
+
if (payload && typeof payload === 'object' && payload.rich) {
|
|
1056
|
+
return richEditMessageText({
|
|
1057
|
+
bot, chatId, threadId, messageId,
|
|
1058
|
+
blocks: payload.blocks, sourceText: payload.sourceText,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
const text = payload;
|
|
1005
1062
|
try {
|
|
1006
1063
|
// Route edits through tg() so applyFormatting runs (MarkdownV2
|
|
1007
1064
|
// + escape). Going direct to bot.api.editMessageText would
|
|
@@ -2279,6 +2336,18 @@ async function main() {
|
|
|
2279
2336
|
db = dbClient.open(DB_PATH);
|
|
2280
2337
|
console.log(`[db] opened ${DB_PATH}`);
|
|
2281
2338
|
tg = createSender(db, console, config);
|
|
2339
|
+
richEditMessageText = createRichEditor({
|
|
2340
|
+
tg,
|
|
2341
|
+
botName: BOT_NAME,
|
|
2342
|
+
logEvent,
|
|
2343
|
+
redactBotToken,
|
|
2344
|
+
isRichCapabilityError,
|
|
2345
|
+
isRichContentError,
|
|
2346
|
+
getRichKnownUnsupported: () => richKnownUnsupported,
|
|
2347
|
+
setRichKnownUnsupported: () => { richKnownUnsupported = true; },
|
|
2348
|
+
getApiRoot: () => config.bot?.apiRoot || null,
|
|
2349
|
+
stripUrlCreds: stripUrlCredentials,
|
|
2350
|
+
});
|
|
2282
2351
|
pairings = createPairingsStore(db.raw);
|
|
2283
2352
|
approvals = createApprovalsStore(db.raw);
|
|
2284
2353
|
const migration = migrateJsonToDb(db, SESSIONS_JSON_PATH, config.chats);
|