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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/error/classify.js +14 -0
  3. package/lib/error/net.js +8 -0
  4. package/lib/handlers/config-callback.js +44 -18
  5. package/lib/handlers/config-ui.js +29 -2
  6. package/lib/handlers/dispatcher.js +54 -1
  7. package/lib/handlers/drop-redeliver.js +19 -0
  8. package/lib/handlers/should-handle.js +52 -0
  9. package/lib/media-group-buffer.js +29 -0
  10. package/lib/ops/auth-disabled-gate.js +43 -0
  11. package/lib/ops/heartbeat.js +56 -0
  12. package/lib/process/channels-tool-dispatcher.js +12 -1
  13. package/lib/sdk/build-options.js +10 -2
  14. package/lib/sdk/callbacks.js +2 -1
  15. package/lib/secret-detect.js +13 -1
  16. package/lib/session-key.js +7 -16
  17. package/lib/telegram/api.js +12 -1
  18. package/lib/telegram/chunk.js +20 -6
  19. package/lib/telegram/display-hint.js +47 -12
  20. package/lib/telegram/format.js +10 -1
  21. package/lib/telegram/process-agent-reply.js +5 -3
  22. package/lib/telegram/rich-edit.js +94 -0
  23. package/lib/telegram/rich.js +397 -0
  24. package/lib/telegram/streamer.js +157 -11
  25. package/package.json +2 -1
  26. package/polygram.js +260 -44
  27. package/lib/async-lock.js +0 -49
  28. package/lib/claude-bin.js +0 -246
  29. package/lib/compaction-warn.js +0 -59
  30. package/lib/context-usage.js +0 -93
  31. package/lib/process/channels-bridge-protocol.js +0 -199
  32. package/lib/process/channels-bridge-server.js +0 -274
  33. package/lib/process/channels-bridge.mjs +0 -477
  34. package/lib/process/cli-process.js +0 -4029
  35. package/lib/process/factory.js +0 -215
  36. package/lib/process/hook-event-tail.js +0 -162
  37. package/lib/process/hook-settings.js +0 -181
  38. package/lib/process/polygram-hook-append.js +0 -71
  39. package/lib/process/process.js +0 -215
  40. package/lib/process/sdk-process.js +0 -880
  41. package/lib/process-guard.js +0 -296
  42. package/lib/process-manager.js +0 -628
  43. package/lib/tmux/log-tail.js +0 -334
  44. package/lib/tmux/orphan-sweep.js +0 -79
  45. package/lib/tmux/poll-scheduler.js +0 -110
  46. package/lib/tmux/startup-gate.js +0 -250
  47. package/lib/tmux/tmux-runner.js +0 -412
@@ -107,26 +107,17 @@ function getTopicConfig(chatConfig, threadId) {
107
107
  }
108
108
 
109
109
  /**
110
- * Resolve the config object a per-chat/topic setting (model/effort) should be
111
- * WRITTEN to, given where the command/card was used. When in a topic, return
112
- * (creating if needed) that topic's override entry so a /model in the Music
113
- * topic targets Music alone and matches what the per-topic card displays,
114
- * instead of leaking to the chat root and every other topic that inherits it
115
- * (the 2026-06-12 "/model in Music does nothing" bug). At the chat level
116
- * (no thread), the writable scope is the chat config itself.
110
+ * Resolve the config object a per-chat/topic setting should be written to.
111
+ * Process-level settings use topic scope only for isolated sessions. A live
112
+ * delivery setting can opt into topic scope even when sessions are shared.
117
113
  *
118
114
  * @returns {{ scope: object, threadId: (string|null) }}
119
115
  */
120
- function getConfigWriteScope(chatConfig, threadId) {
116
+ function getConfigWriteScope(chatConfig, threadId, { allowNonIsolatedTopic = false } = {}) {
121
117
  const tid = (threadId == null || threadId === '') ? null : String(threadId);
122
- // Mirror getSessionKey's isolation rule: a per-topic override only takes
123
- // effect when isolateTopics === true (otherwise every topic shares the
124
- // chatId-keyed session and topics[tid].model is silently ignored — the
125
- // 2026-06-12 review found the topic-scope fix re-broke /model on the DEFAULT
126
- // non-isolated chats and made the card lie). So write the topic scope ONLY
127
- // when isolated; otherwise write the chat root (the session that actually
128
- // runs), and report threadId:null so the audit row reflects chat-level reach.
129
- if (tid && chatConfig?.isolateTopics === true) {
118
+ // Process-scoped settings follow session isolation. Live delivery settings
119
+ // may explicitly keep topic scope because they are resolved per message.
120
+ if (tid && (chatConfig?.isolateTopics === true || allowNonIsolatedTopic)) {
130
121
  chatConfig.topics = chatConfig.topics || {};
131
122
  // A topic can be stored in the legacy / hand-edited form where the entry
132
123
  // is the bare name string (topics["329"] = "Advertising") rather than the
@@ -100,9 +100,14 @@ const METHODS_WITHOUT_MSG = new Set([
100
100
  // Derive the row's `text` column. sendSticker has no text/caption, so we
101
101
  // synthesize `[sticker:<name>]` (or file_id as fallback) — without this the
102
102
  // transcript shows an empty outbound that's impossible to interpret later.
103
+ // Rich edits carry source Markdown in meta.richSourceText because the API
104
+ // payload has `rich_message` instead of `text`. Edits are excluded from
105
+ // transcript persistence by METHODS_WITHOUT_MSG, but deriving meaningful
106
+ // text here keeps the helper correct for any tracked rich-message caller.
103
107
  function deriveOutboundText(method, params, meta) {
104
108
  if (params.text) return params.text;
105
109
  if (params.caption) return params.caption;
110
+ if (params.rich_message) return meta.richSourceText || '[rich message]';
106
111
  if (method === 'sendSticker') {
107
112
  const label = meta.stickerName || params.sticker || 'unknown';
108
113
  return `[sticker:${label}]`;
@@ -153,11 +158,17 @@ async function send({ bot, method, params, db = null, meta = {}, logger = consol
153
158
  // as a user-facing "Hit a snag" — confusing because the bot didn't
154
159
  // really fail. Throw a typed error before the API call so callers
155
160
  // can detect + skip silently if appropriate.
156
- if (method === 'sendMessage' || method === 'editMessageText') {
161
+ // A rich edit carries `rich_message` instead of `text`, so validate
162
+ // its block collection rather than applying the plain-text guard.
163
+ if (method === 'sendMessage' || (method === 'editMessageText' && params.rich_message == null)) {
157
164
  const t = params.text;
158
165
  if (typeof t !== 'string' || t.length === 0) {
159
166
  throw new Error(`telegram ${method}: text is empty`);
160
167
  }
168
+ } else if (method === 'editMessageText' && params.rich_message != null) {
169
+ if (!Array.isArray(params.rich_message.blocks) || params.rich_message.blocks.length === 0) {
170
+ throw new Error(`telegram ${method}: rich_message.blocks is empty`);
171
+ }
161
172
  }
162
173
  if (method === 'sendPhoto' || method === 'sendVideo'
163
174
  || method === 'sendAudio' || method === 'sendDocument' || method === 'sendAnimation') {
@@ -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
- const POLYGRAM_DISPLAY_HINT = [
31
- '## Telegram display rules',
32
- '',
33
- 'Your replies render in the Telegram client. Phone is the design target.',
34
- '',
30
+ // Plain-mode guidance avoids structures that Telegram's HTML rendering
31
+ // cannot display well. The prompt and delivery capability must agree so
32
+ // the agent is not encouraged to author unsupported structures.
33
+ const TABLES_AND_HEADERS_SECTION_PLAIN = [
35
34
  '### Tables — HARD RULE',
36
35
  '',
37
36
  `Before emitting any markdown table, count the longest row in characters (including pipes \`|\`, padding, and separator dashes). If that row is longer than ${TELEGRAM_TABLE_WIDTH_BUDGET}, you MUST NOT emit a table. Use row blocks instead.`,
@@ -57,19 +56,54 @@ const POLYGRAM_DISPLAY_HINT = [
57
56
  '- Headers `#`, `##`, `###` render as plain text — use **bold** for emphasis.',
58
57
  '- Horizontal rules render as a thin divider line.',
59
58
  '- Long replies stream in chunks; prefer concise structure over walls of text.',
59
+ ].join('\n');
60
+
61
+ // Rich-mode guidance is limited to the constructs detected by
62
+ // needsRichRendering so ordinary prose stays on the compact plain path.
63
+ const TABLES_AND_HEADERS_SECTION_RICH = [
64
+ '### Rich formatting is available in this chat',
65
+ '',
66
+ 'This chat has rich-text rendering enabled. Real Telegram markdown tables, headings (`#`/`##`/etc.), task lists, and collapsible `<details>` blocks now render as an actual structured message, not flattened text — use them when they make an answer clearer or easier to scan (a comparison, a set of steps, a key/value summary, a checklist tracking progress). No character-width budget applies to tables here; write a normal markdown table.',
67
+ '',
68
+ '**Task lists for progress:** `- [ ] pending step` / `- [x] done step` renders as a real checkbox list — the natural way to show multi-step progress as it happens.',
60
69
  '',
61
- '### NEVER emit shell-context canned strings HARD RULE',
70
+ '**Collapsible detail:** wrap verbose logs/diffs/output you want available but not front-and-center in `<details><summary>Label</summary>...</details>`.',
62
71
  '',
63
- 'You are running as a Telegram chat bot, NOT as a script being piped into a shell. Certain phrases are CLI-context boilerplate from the underlying environment and MUST NEVER appear in a reply, because the user sees them as a literal message from you and they look like a system error:',
72
+ 'Not every reply needs this plain prose is still right for a normal conversational answer. Reach for structure when the CONTENT is structured, not by default.',
64
73
  '',
65
- '- `No response requested.`',
66
- '- `No response needed.`',
67
- '- `Continuing...` as a standalone reply',
68
- '- Any other shell-prompt-style filler that acknowledges silence',
74
+ '### Other Telegram quirks',
69
75
  '',
70
- 'If a user message is short, ambiguous, or feels like a no-op acknowledgement (e.g. `okay`, `ok`, `yes`, `got it`, `thanks`), reply with a brief substantive line — acknowledge what you understood and what (if anything) you will do next. If you genuinely have nothing useful to say, ask ONE specific clarifying question. NEVER emit a placeholder or a shell-style canned string — the chat surface has no silent-no-op state. Every reply must be intentional content.',
76
+ '- Horizontal rules render as a thin divider line.',
77
+ '- Long replies stream in chunks; prefer concise structure over walls of text.',
71
78
  ].join('\n');
72
79
 
80
+ function buildPolygramDisplayHint(richText = false) {
81
+ const tablesSection = richText ? TABLES_AND_HEADERS_SECTION_RICH : TABLES_AND_HEADERS_SECTION_PLAIN;
82
+ return [
83
+ '## Telegram display rules',
84
+ '',
85
+ 'Your replies render in the Telegram client. Phone is the design target.',
86
+ '',
87
+ tablesSection,
88
+ '',
89
+ '### NEVER emit shell-context canned strings — HARD RULE',
90
+ '',
91
+ 'You are running as a Telegram chat bot, NOT as a script being piped into a shell. Certain phrases are CLI-context boilerplate from the underlying environment and MUST NEVER appear in a reply, because the user sees them as a literal message from you and they look like a system error:',
92
+ '',
93
+ '- `No response requested.`',
94
+ '- `No response needed.`',
95
+ '- `Continuing...` as a standalone reply',
96
+ '- Any other shell-prompt-style filler that acknowledges silence',
97
+ '',
98
+ 'If a user message is short, ambiguous, or feels like a no-op acknowledgement (e.g. `okay`, `ok`, `yes`, `got it`, `thanks`), reply with a brief substantive line — acknowledge what you understood and what (if anything) you will do next. If you genuinely have nothing useful to say, ask ONE specific clarifying question. NEVER emit a placeholder or a shell-style canned string — the chat surface has no silent-no-op state. Every reply must be intentional content.',
99
+ ].join('\n');
100
+ }
101
+
102
+ // Pre-computed default (richText:false) — kept as a named export for
103
+ // backward compatibility with any caller/test that imported the old
104
+ // constant directly.
105
+ const POLYGRAM_DISPLAY_HINT = buildPolygramDisplayHint(false);
106
+
73
107
  /**
74
108
  * Append the polygram display hint to an existing systemPrompt option,
75
109
  * preserving the original shape (string / preset object / undefined).
@@ -111,5 +145,6 @@ function appendDisplayHint(systemPromptOpt, hint = POLYGRAM_DISPLAY_HINT) {
111
145
  module.exports = {
112
146
  POLYGRAM_DISPLAY_HINT,
113
147
  TELEGRAM_TABLE_WIDTH_BUDGET,
148
+ buildPolygramDisplayHint,
114
149
  appendDisplayHint,
115
150
  };
@@ -108,6 +108,12 @@ function buildRenderer() {
108
108
  image({ href, text }) {
109
109
  return `<a href="${escapeHtmlAttr(href)}">${escapeHtml(text || href)}</a>`;
110
110
  },
111
+ // Task-list items (marked type:'checkbox', gfm) render as a literal
112
+ // ☐/☑ glyph. Telegram's parse_mode=HTML has no support for <input> —
113
+ // marked's default checkbox() renderer emits one, which Telegram
114
+ // rejects outright (400: Unsupported start tag "input"), degrading
115
+ // the WHOLE message to raw markdown via api.js's HTML→plain fallback.
116
+ checkbox({ checked }) { return checked ? '☑ ' : '☐ '; },
111
117
  list({ items, ordered, start }) {
112
118
  const depth = listDepth;
113
119
  listDepth += 1;
@@ -115,7 +121,10 @@ function buildRenderer() {
115
121
  const bullet = bulletFor(depth);
116
122
  try {
117
123
  const lines = items.map((item, i) => {
118
- const marker = ordered ? `${(start || 1) + i}. ` : `${bullet} `;
124
+ // Task items carry their own checkbox() glyph as the visual
125
+ // marker (rendered inline below) — an extra bullet in front
126
+ // would double up ("• ☐ x").
127
+ const marker = item.task ? '' : (ordered ? `${(start || 1) + i}. ` : `${bullet} `);
119
128
  const { inline, blocks } = splitItemTokens(item.tokens);
120
129
  const leader = this.parser.parseInline(inline).trim();
121
130
  let line = `${indent}${marker}${leader}`;
@@ -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 — Telegram message_thread_id (or null)
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
- ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
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
- ...(Number.isInteger(threadId) && { message_thread_id: threadId }),
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 };