polygram 0.17.11 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/error/classify.js +14 -0
- package/lib/error/net.js +8 -0
- package/lib/handlers/config-callback.js +44 -18
- package/lib/handlers/config-ui.js +29 -2
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/sdk/build-options.js +10 -2
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/session-key.js +7 -16
- package/lib/telegram/api.js +12 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/display-hint.js +47 -12
- package/lib/telegram/format.js +10 -1
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/rich-edit.js +94 -0
- package/lib/telegram/rich.js +397 -0
- package/lib/telegram/streamer.js +157 -11
- package/package.json +2 -1
- package/polygram.js +260 -44
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- package/lib/tmux/tmux-runner.js +0 -412
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') {
|
package/lib/telegram/chunk.js
CHANGED
|
@@ -124,6 +124,20 @@ function resolveChunkEarlyReturn(text, limit) {
|
|
|
124
124
|
return undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// A hard cut at a fixed UTF-16 offset can land between the two code units
|
|
128
|
+
// of a surrogate pair (astral chars: emoji, rare CJK, math symbols) — the
|
|
129
|
+
// pair then renders as U+FFFD on both sides of the boundary. Back the cut
|
|
130
|
+
// off one unit when it would split a pair. Never returns 0 (a degenerate
|
|
131
|
+
// limit of 1 keeps the raw cut rather than looping forever on an empty
|
|
132
|
+
// chunk).
|
|
133
|
+
function surrogateSafeCut(text, idx) {
|
|
134
|
+
if (idx > 1 && idx < text.length) {
|
|
135
|
+
const c = text.charCodeAt(idx - 1);
|
|
136
|
+
if (c >= 0xD800 && c <= 0xDBFF) return idx - 1;
|
|
137
|
+
}
|
|
138
|
+
return idx;
|
|
139
|
+
}
|
|
140
|
+
|
|
127
141
|
// Generic break-resolver loop shared with markdown variant. The resolver
|
|
128
142
|
// receives a `window` (text.slice(0, limit)) and returns where to break.
|
|
129
143
|
// Negative / out-of-range break indices fall back to hard-cut at limit.
|
|
@@ -136,7 +150,7 @@ function chunkTextByBreakResolver(text, limit, resolveBreakIndex) {
|
|
|
136
150
|
const candidateBreak = resolveBreakIndex(remaining.slice(0, limit));
|
|
137
151
|
const breakIdx = Number.isFinite(candidateBreak) && candidateBreak > 0 && candidateBreak <= limit
|
|
138
152
|
? candidateBreak
|
|
139
|
-
: limit;
|
|
153
|
+
: surrogateSafeCut(remaining, limit);
|
|
140
154
|
const chunk = remaining.slice(0, breakIdx).trimEnd();
|
|
141
155
|
if (chunk.length > 0) chunks.push(chunk);
|
|
142
156
|
// If we broke on a separator (whitespace), consume it — don't carry it
|
|
@@ -202,7 +216,7 @@ function chunkMarkdownText(text, limit) {
|
|
|
202
216
|
while (remaining.length > limit) {
|
|
203
217
|
const spans = parseFenceSpans(remaining);
|
|
204
218
|
const softBreak = pickSafeBreakIndex(remaining.slice(0, limit), spans);
|
|
205
|
-
let breakIdx = softBreak > 0 ? softBreak : limit;
|
|
219
|
+
let breakIdx = softBreak > 0 ? softBreak : surrogateSafeCut(remaining, limit);
|
|
206
220
|
const initialFence = isSafeFenceBreak(spans, breakIdx) ? undefined : findFenceSpanAt(spans, breakIdx);
|
|
207
221
|
let fenceToSplit = initialFence;
|
|
208
222
|
if (initialFence) {
|
|
@@ -215,7 +229,7 @@ function chunkMarkdownText(text, limit) {
|
|
|
215
229
|
// Caller will see a malformed chunk, but that's a degenerate
|
|
216
230
|
// input case (limit smaller than the close marker).
|
|
217
231
|
fenceToSplit = undefined;
|
|
218
|
-
breakIdx = limit;
|
|
232
|
+
breakIdx = surrogateSafeCut(remaining, limit);
|
|
219
233
|
} else {
|
|
220
234
|
// Look for a newline inside the fence body that's late enough
|
|
221
235
|
// to make progress (past the open line + at least one body line)
|
|
@@ -243,11 +257,11 @@ function chunkMarkdownText(text, limit) {
|
|
|
243
257
|
// No safe in-fence newline found and no room to add one —
|
|
244
258
|
// give up on splitting this fence; hard-cut at limit.
|
|
245
259
|
fenceToSplit = undefined;
|
|
246
|
-
breakIdx = limit;
|
|
260
|
+
breakIdx = surrogateSafeCut(remaining, limit);
|
|
247
261
|
} else {
|
|
248
262
|
// Force the break; chunker will append a synthetic newline
|
|
249
263
|
// before the close line.
|
|
250
|
-
breakIdx = Math.max(minProgressIdx, maxIdxIfNeedNewline);
|
|
264
|
+
breakIdx = surrogateSafeCut(remaining, Math.max(minProgressIdx, maxIdxIfNeedNewline));
|
|
251
265
|
}
|
|
252
266
|
}
|
|
253
267
|
}
|
|
@@ -294,7 +308,7 @@ function chunkMarkdownText(text, limit) {
|
|
|
294
308
|
// limit (cheap heuristic — much better than mid-word).
|
|
295
309
|
const window = rest.slice(0, limit);
|
|
296
310
|
const lastNl = window.lastIndexOf('\n', limit - 1);
|
|
297
|
-
const cutAt = lastNl > limit - 200 ? lastNl + 1 : limit;
|
|
311
|
+
const cutAt = lastNl > limit - 200 ? lastNl + 1 : surrogateSafeCut(rest, limit);
|
|
298
312
|
safe.push(rest.slice(0, cutAt));
|
|
299
313
|
rest = rest.slice(cutAt);
|
|
300
314
|
}
|
|
@@ -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}`;
|
|
@@ -51,7 +51,9 @@
|
|
|
51
51
|
* @param {object} opts.bot — grammy bot instance
|
|
52
52
|
* @param {Function} opts.tg — (bot, method, params, meta) → Promise<res>
|
|
53
53
|
* @param {string|number} opts.chatId
|
|
54
|
-
* @param {?number} opts.threadId
|
|
54
|
+
* @param {?(number|string)} opts.threadId — Telegram message_thread_id (or null).
|
|
55
|
+
* Treated as opaque, deliver.js-style: the cli backend passes a STRING, so an
|
|
56
|
+
* integer-only guard would drop the id and send stickers to the General topic.
|
|
55
57
|
* @param {?number} opts.replyToMessageId — inbound msg_id this reply targets; null for unsolicited
|
|
56
58
|
* @param {boolean} [opts.applyReactions] — apply [react:EMOJI] tags to replyToMessageId. False for
|
|
57
59
|
* unsolicited (autonomous-wakeup) where there IS no target;
|
|
@@ -173,7 +175,7 @@ async function processAndDeliverAgentText({
|
|
|
173
175
|
await tg(bot, 'sendSticker', {
|
|
174
176
|
chat_id: chatId,
|
|
175
177
|
sticker: parsed.sticker,
|
|
176
|
-
...(
|
|
178
|
+
...(threadId != null && { message_thread_id: threadId }),
|
|
177
179
|
}, {
|
|
178
180
|
...meta,
|
|
179
181
|
source: `${source}-sticker`,
|
|
@@ -192,7 +194,7 @@ async function processAndDeliverAgentText({
|
|
|
192
194
|
await tg(bot, 'sendSticker', {
|
|
193
195
|
chat_id: chatId,
|
|
194
196
|
sticker: s.fileId,
|
|
195
|
-
...(
|
|
197
|
+
...(threadId != null && { message_thread_id: threadId }),
|
|
196
198
|
}, {
|
|
197
199
|
...meta,
|
|
198
200
|
source: `${source}-inline-sticker`,
|
|
@@ -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 };
|