@inceptionstack/roundhouse 0.3.20 → 0.3.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inceptionstack/roundhouse",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "type": "module",
5
5
  "description": "Multi-platform chat gateway that routes messages through a configured AI agent",
6
6
  "license": "MIT",
package/src/gateway.ts CHANGED
@@ -9,6 +9,7 @@ import { Chat } from "chat";
9
9
  import { createMemoryState } from "@chat-adapter/state-memory";
10
10
  import type { AgentAdapter, AgentMessage, AgentRouter, AgentStreamEvent, GatewayConfig, MessageAttachment } from "./types";
11
11
  import { splitMessage, isAllowed, startTypingLoop, threadIdToDir, generateAttachmentId, DEBUG_STREAM } from "./util";
12
+ import { isTelegramThread, postTelegramHtml, handleTelegramHtmlStream } from "./telegram-html";
12
13
  import { SttService, enrichAttachmentsWithTranscripts, DEFAULT_STT_CONFIG } from "./voice/stt-service";
13
14
  import { sendTelegramToMany } from "./notify/telegram";
14
15
  import { runDoctor, formatDoctorTelegram, createDoctorContext } from "./cli/doctor/runner";
@@ -602,7 +603,7 @@ export class Gateway {
602
603
  lines.push(`📝 Context: no usage data yet (${windowK}K window)`);
603
604
  }
604
605
 
605
- await thread.post({ markdown: lines.join("\n") });
606
+ await this.postWithFallback(thread, lines.join("\n"));
606
607
  console.log(`[roundhouse] /status for thread=${thread.id} agentThread=${agentThreadId}`);
607
608
  return;
608
609
  }
@@ -985,14 +986,20 @@ export class Gateway {
985
986
  currentPromise = null;
986
987
  };
987
988
 
989
+ const useTelegramHtml = isTelegramThread(thread);
990
+
988
991
  const ensureStream = () => {
989
992
  if (!currentPromise) {
990
993
  const ts = createTextStream();
991
994
  currentPush = ts.push;
992
995
  currentFinish = ts.finish;
993
- currentPromise = thread.handleStream(ts.iterable).catch((err: Error) => {
994
- console.warn(`[roundhouse] handleStream error:`, err.message);
995
- });
996
+ currentPromise = useTelegramHtml
997
+ ? handleTelegramHtmlStream(thread, ts.iterable).catch((err: Error) => {
998
+ console.warn(`[roundhouse] telegram html stream error:`, err.message);
999
+ })
1000
+ : thread.handleStream(ts.iterable).catch((err: Error) => {
1001
+ console.warn(`[roundhouse] handleStream error:`, err.message);
1002
+ });
996
1003
  }
997
1004
  };
998
1005
 
@@ -1098,6 +1105,11 @@ export class Gateway {
1098
1105
 
1099
1106
  /** Post text with markdown, falling back to plain text */
1100
1107
  private async postWithFallback(thread: any, text: string) {
1108
+ // Telegram: send as HTML for proper markdown rendering
1109
+ if (isTelegramThread(thread)) {
1110
+ await postTelegramHtml(thread, text);
1111
+ return;
1112
+ }
1101
1113
  for (const chunk of splitMessage(text, 4000)) {
1102
1114
  try {
1103
1115
  await thread.post({ markdown: chunk });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * telegram-format.ts — Convert markdown to Telegram-compatible HTML
3
+ *
4
+ * Telegram's Bot API supports a subset of HTML:
5
+ * <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">, <blockquote>
6
+ *
7
+ * This converter handles common agent output patterns:
8
+ * - Headers (#, ##, ###) → bold text
9
+ * - ***bold italic*** → <b><i>text</i></b>
10
+ * - **bold** / __bold__ → <b>
11
+ * - *italic* / _italic_ → <i>
12
+ * - ~~strikethrough~~ → <s>
13
+ * - `inline code` → <code>
14
+ * - ```code blocks``` → <pre>
15
+ * - [text](url) → <a href="url">text</a> (supports parens in URLs)
16
+ * - Bullet/numbered lists preserved as-is
17
+ * - HTML entities escaped (&, <, >)
18
+ */
19
+
20
+ import { randomBytes } from "node:crypto";
21
+
22
+ /** Escape HTML special characters */
23
+ function escapeHtml(text: string): string {
24
+ return text
25
+ .replace(/&/g, "&amp;")
26
+ .replace(/</g, "&lt;")
27
+ .replace(/>/g, "&gt;");
28
+ }
29
+
30
+ /** Escape HTML attribute value (quotes + entities) */
31
+ function escapeAttr(text: string): string {
32
+ return escapeHtml(text).replace(/"/g, "&quot;");
33
+ }
34
+
35
+ /**
36
+ * Match a markdown link with balanced parentheses in the URL.
37
+ * Returns [fullMatch, text, url, endIndex] or null.
38
+ */
39
+ function matchLink(str: string, startIdx: number): { full: string; text: string; url: string; end: number } | null {
40
+ if (str[startIdx] !== "[") return null;
41
+ // Find closing ]
42
+ const closeBracket = str.indexOf("]", startIdx + 1);
43
+ if (closeBracket === -1 || str[closeBracket + 1] !== "(") return null;
44
+ const text = str.slice(startIdx + 1, closeBracket);
45
+ // Match balanced parens for URL
46
+ let depth = 1;
47
+ let i = closeBracket + 2;
48
+ while (i < str.length && depth > 0) {
49
+ if (str[i] === "(") depth++;
50
+ else if (str[i] === ")") depth--;
51
+ if (depth > 0) i++;
52
+ }
53
+ if (depth !== 0) return null;
54
+ const url = str.slice(closeBracket + 2, i);
55
+ return { full: str.slice(startIdx, i + 1), text, url, end: i + 1 };
56
+ }
57
+
58
+ /** Extract all markdown links with balanced-paren URL support */
59
+ function extractLinks(str: string, cb: (text: string, url: string) => string): string {
60
+ let result = "";
61
+ let i = 0;
62
+ while (i < str.length) {
63
+ if (str[i] === "[") {
64
+ const link = matchLink(str, i);
65
+ if (link) {
66
+ result += cb(link.text, link.url);
67
+ i = link.end;
68
+ continue;
69
+ }
70
+ }
71
+ result += str[i];
72
+ i++;
73
+ }
74
+ return result;
75
+ }
76
+
77
+ /**
78
+ * Truncate HTML safely — avoids cutting inside tags.
79
+ * Finds the last '>' before the limit, or falls back to the limit itself.
80
+ */
81
+ export function truncateHtmlSafe(html: string, limit: number): string {
82
+ if (html.length <= limit) return html;
83
+ if (limit <= 3) return "...";
84
+ const cutoff = limit - 3; // room for "..."
85
+ // Find the last '>' at or before cutoff to avoid splitting a tag
86
+ let safeEnd = cutoff;
87
+ for (let i = cutoff - 1; i >= Math.max(0, cutoff - 200); i--) {
88
+ if (html[i] === ">") {
89
+ safeEnd = i + 1;
90
+ break;
91
+ }
92
+ }
93
+ return html.slice(0, safeEnd) + "...";
94
+ }
95
+
96
+ /**
97
+ * Convert a markdown table into a <pre>-wrapped, column-aligned monospace table.
98
+ * Parses the header row, skips the separator row, and pads all columns to uniform width.
99
+ */
100
+ function formatTable(tableMd: string): string {
101
+ const lines = tableMd.trim().split("\n");
102
+ if (lines.length < 2) return `<pre>${escapeHtml(tableMd)}</pre>`;
103
+
104
+ // Parse rows: split by | and trim each cell
105
+ const parseRow = (line: string): string[] =>
106
+ line.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
107
+
108
+ const headerCells = parseRow(lines[0]);
109
+ // lines[1] is the separator row (|---|---|) — skip it
110
+ const dataRows = lines.slice(2).map(parseRow);
111
+ const colCount = headerCells.length;
112
+
113
+ // Normalize rows to exactly colCount columns
114
+ const normalize = (cells: string[]): string[] =>
115
+ Array.from({ length: colCount }, (_, i) => cells[i] ?? "");
116
+
117
+ const rawHeader = normalize(headerCells);
118
+ const rawDataRows = dataRows.map(normalize);
119
+ const allRows = [rawHeader, ...rawDataRows];
120
+
121
+ // Visual length of a string (grapheme count via Intl.Segmenter)
122
+ const segmenter = new Intl.Segmenter();
123
+ const visualLen = (s: string): number => [...segmenter.segment(s)].length;
124
+
125
+ // Calculate max *visual* width for each column (on unescaped text,
126
+ // since Telegram renders entities back to their visual form in <pre>)
127
+ const colWidths: number[] = [];
128
+ for (let c = 0; c < colCount; c++) {
129
+ let max = 0;
130
+ for (const row of allRows) {
131
+ max = Math.max(max, visualLen(row[c]));
132
+ }
133
+ colWidths.push(max);
134
+ }
135
+
136
+ // Pad an escaped cell so it visually aligns to `width` rendered characters.
137
+ // We add (targetVisualWidth - actualVisualWidth) spaces, since spaces are
138
+ // 1 char both in HTML source and visually.
139
+ const padCell = (rawText: string, width: number): string => {
140
+ const escaped = escapeHtml(rawText);
141
+ const vLen = visualLen(rawText);
142
+ return escaped + " ".repeat(Math.max(0, width - vLen));
143
+ };
144
+
145
+ // Build formatted rows
146
+ const formatRow = (cells: string[]): string =>
147
+ "│ " + cells.map((cell, i) => padCell(cell, colWidths[i])).join(" │ ") + " │";
148
+
149
+ const separator = "├─" + colWidths.map(w => "─".repeat(w)).join("─┼─") + "─┤";
150
+ const topBorder = "┌─" + colWidths.map(w => "─".repeat(w)).join("─┬─") + "─┐";
151
+ const bottomBorder = "└─" + colWidths.map(w => "─".repeat(w)).join("─┴─") + "─┘";
152
+
153
+ // Cells are escaped inside padCell; box-drawing chars are HTML-safe.
154
+ const result = [
155
+ topBorder,
156
+ formatRow(rawHeader),
157
+ separator,
158
+ ...rawDataRows.map(formatRow),
159
+ bottomBorder,
160
+ ].join("\n");
161
+
162
+ return `<pre>${result}</pre>`;
163
+ }
164
+
165
+ /**
166
+ * Convert markdown text to Telegram-compatible HTML.
167
+ * Handles code blocks first (to avoid processing markdown inside them),
168
+ * then processes inline formatting.
169
+ */
170
+ export function markdownToTelegramHtml(md: string): string {
171
+ // Generate unique sentinel per call to prevent spoofing
172
+ const sentinel = randomBytes(8).toString("hex");
173
+ const S = (kind: string, idx: number) => `\x00${sentinel}_${kind}_${idx}\x00`;
174
+ const RE = (kind: string) => new RegExp(`\\x00${sentinel}_${kind}_(\\d+)\\x00`, "g");
175
+
176
+ // Extract fenced code blocks first to protect their contents
177
+ // (must happen before table extraction to avoid nested <pre> tags)
178
+ const codeBlocks: string[] = [];
179
+ let processed = md.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
180
+ const idx = codeBlocks.length;
181
+ codeBlocks.push(`<pre>${escapeHtml(code.replace(/\n$/, ""))}</pre>`);
182
+ return S("CB", idx);
183
+ });
184
+
185
+ // Extract markdown tables (now safe — code blocks are already sentinelled out)
186
+ const tables: string[] = [];
187
+ processed = processed.replace(
188
+ /(?:^|\n)(\|.+\|\n\|[-| :]+\|\n(?:\|.+\|(?:\n|$))+)/g,
189
+ (match) => {
190
+ const idx = tables.length;
191
+ const leadingNewline = match.startsWith("\n") ? "\n" : "";
192
+ const trailingNewline = match.endsWith("\n") ? "\n" : "";
193
+ const tableContent = match.replace(/^\n/, "").replace(/\n$/, "");
194
+ tables.push(formatTable(tableContent));
195
+ return leadingNewline + S("TB", idx) + trailingNewline;
196
+ },
197
+ );
198
+
199
+ // Extract inline code to protect contents
200
+ const inlineCodes: string[] = [];
201
+ processed = processed.replace(/`([^`\n]+)`/g, (_match, code) => {
202
+ const idx = inlineCodes.length;
203
+ inlineCodes.push(`<code>${escapeHtml(code)}</code>`);
204
+ return S("IC", idx);
205
+ });
206
+
207
+ // Extract links before HTML-escaping (URLs contain &, = etc. that must be escaped once)
208
+ const links: string[] = [];
209
+ processed = extractLinks(processed, (text, url) => {
210
+ const trimmedUrl = url.trim();
211
+ if (/^https?:\/\//i.test(trimmedUrl) || /^mailto:/i.test(trimmedUrl)) {
212
+ const idx = links.length;
213
+ links.push(`<a href="${escapeAttr(trimmedUrl)}">${escapeHtml(text)}</a>`);
214
+ return S("LK", idx);
215
+ }
216
+ // Unsafe or relative URL — render as text (will be escaped below)
217
+ return `${text} (${trimmedUrl})`;
218
+ });
219
+
220
+ // Now escape HTML in the rest
221
+ processed = escapeHtml(processed);
222
+
223
+ // Headers: # text → <b>text</b> (Telegram has no header tags)
224
+ processed = processed.replace(/^#{1,6}\s+(.+)$/gm, "<b>$1</b>");
225
+
226
+ // Bold+italic: ***text*** → <b><i>text</i></b> (must come before ** and *)
227
+ processed = processed.replace(/\*\*\*(.+?)\*\*\*/g, "<b><i>$1</i></b>");
228
+
229
+ // Bold: **text** or __text__
230
+ processed = processed.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>");
231
+ processed = processed.replace(/__(.+?)__/g, "<b>$1</b>");
232
+
233
+ // Italic: *text* (not part of **)
234
+ processed = processed.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "<i>$1</i>");
235
+ // _text_ only at word boundaries (avoid matching snake_case)
236
+ processed = processed.replace(/(?<!\w)_(?!_)(.+?)(?<!_)_(?!\w)/g, "<i>$1</i>");
237
+
238
+ // Strikethrough: ~~text~~
239
+ processed = processed.replace(/~~(.+?)~~/g, "<s>$1</s>");
240
+
241
+ // Blockquotes: > text (after HTML escaping, > becomes &gt;)
242
+ processed = processed.replace(/^&gt;\s?(.+)$/gm, "<blockquote>$1</blockquote>");
243
+ // Merge adjacent blockquotes
244
+ processed = processed.replace(/<\/blockquote>\n<blockquote>/g, "\n");
245
+
246
+ // Horizontal rules: --- or ***
247
+ processed = processed.replace(/^[-*]{3,}$/gm, "───────────────");
248
+
249
+ // Restore placeholders
250
+ processed = processed.replace(RE("LK"), (_match, idx) => links[parseInt(idx, 10)]);
251
+ processed = processed.replace(RE("IC"), (_match, idx) => inlineCodes[parseInt(idx, 10)]);
252
+ processed = processed.replace(RE("CB"), (_match, idx) => codeBlocks[parseInt(idx, 10)]);
253
+ processed = processed.replace(RE("TB"), (_match, idx) => tables[parseInt(idx, 10)]);
254
+
255
+ return processed;
256
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * telegram-html.ts — Direct Telegram HTML posting for rich agent responses
3
+ *
4
+ * Bypasses Chat SDK's legacy parse_mode:"Markdown" (v1) by calling the
5
+ * Telegram Bot API directly with parse_mode:"HTML" for agent content.
6
+ *
7
+ * Chat SDK remains responsible for: incoming messages, subscriptions,
8
+ * typing indicators, command handling, authorization, message history.
9
+ */
10
+
11
+ import { markdownToTelegramHtml, truncateHtmlSafe } from "./telegram-format";
12
+ import { splitMessage } from "./util";
13
+
14
+ /** Max Telegram message length */
15
+ const TELEGRAM_LIMIT = 4096;
16
+
17
+ /** Streaming edit interval (ms) */
18
+ const STREAM_EDIT_INTERVAL_MS = 600;
19
+
20
+ /** Check if a Chat SDK thread is backed by the Telegram adapter */
21
+ export function isTelegramThread(thread: any): boolean {
22
+ return (
23
+ typeof thread?.adapter?.telegramFetch === "function" &&
24
+ typeof thread?.id === "string" &&
25
+ thread.id.startsWith("telegram:")
26
+ );
27
+ }
28
+
29
+ /** Parse Telegram chat_id and optional message_thread_id from a Chat SDK thread ID */
30
+ function parseTelegramThreadId(threadId: string): { chatId: string; messageThreadId?: number } {
31
+ const parts = threadId.split(":");
32
+ const chatId = parts[1];
33
+ const topicPart = parts[2];
34
+ const result: { chatId: string; messageThreadId?: number } = { chatId };
35
+ if (topicPart) {
36
+ const parsed = parseInt(topicPart, 10);
37
+ if (Number.isFinite(parsed)) result.messageThreadId = parsed;
38
+ }
39
+ return result;
40
+ }
41
+
42
+ /** Common payload fields for Telegram API calls */
43
+ function basePayload(chatId: string, messageThreadId?: number) {
44
+ return {
45
+ chat_id: chatId,
46
+ ...(messageThreadId !== undefined && { message_thread_id: messageThreadId }),
47
+ disable_web_page_preview: true,
48
+ };
49
+ }
50
+
51
+ /** Send one HTML message, falling back to plain text on parse error */
52
+ async function sendHtmlOrPlain(
53
+ adapter: any,
54
+ chatId: string,
55
+ messageThreadId: number | undefined,
56
+ html: string,
57
+ plainFallback: string,
58
+ ): Promise<void> {
59
+ try {
60
+ await adapter.telegramFetch("sendMessage", {
61
+ ...basePayload(chatId, messageThreadId),
62
+ text: html,
63
+ parse_mode: "HTML",
64
+ });
65
+ } catch {
66
+ try {
67
+ await adapter.telegramFetch("sendMessage", {
68
+ ...basePayload(chatId, messageThreadId),
69
+ text: plainFallback,
70
+ });
71
+ } catch (err) {
72
+ console.error(`[roundhouse] Telegram sendMessage failed:`, (err as Error).message);
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Post markdown as Telegram HTML, with chunking and fallback.
79
+ * Splits markdown into chunks before conversion so each chunk's HTML stays within limits.
80
+ */
81
+ export async function postTelegramHtml(thread: any, markdown: string): Promise<void> {
82
+ const { chatId, messageThreadId } = parseTelegramThreadId(thread.id);
83
+
84
+ // Split before conversion — HTML expansion is usually modest
85
+ for (const chunk of splitMessage(markdown, 3800)) {
86
+ const html = markdownToTelegramHtml(chunk);
87
+ const safeHtml = truncateHtmlSafe(html, TELEGRAM_LIMIT);
88
+ await sendHtmlOrPlain(thread.adapter, chatId, messageThreadId, safeHtml, chunk);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Stream agent text as Telegram HTML using sendMessage + editMessageText.
94
+ *
95
+ * For responses under 4096 chars: single message with progressive edits.
96
+ * For longer responses: finalize current message and start new ones via postTelegramHtml.
97
+ */
98
+ export async function handleTelegramHtmlStream(
99
+ thread: any,
100
+ stream: AsyncIterable<string>,
101
+ ): Promise<void> {
102
+ const { chatId, messageThreadId } = parseTelegramThreadId(thread.id);
103
+
104
+ let accumulated = "";
105
+ let messageId: number | null = null;
106
+ let lastEditContent = "";
107
+ let lastEditTime = 0;
108
+ /** Content already committed to sent messages (for overflow handling) */
109
+ let committedLength = 0;
110
+
111
+ const sendInitial = async (html: string): Promise<number> => {
112
+ const result = await thread.adapter.telegramFetch("sendMessage", {
113
+ ...basePayload(chatId, messageThreadId),
114
+ text: html,
115
+ parse_mode: "HTML",
116
+ });
117
+ return result.message_id;
118
+ };
119
+
120
+ const editMessage = async (html: string): Promise<boolean> => {
121
+ if (!messageId || html === lastEditContent) return true;
122
+ try {
123
+ await thread.adapter.telegramFetch("editMessageText", {
124
+ ...basePayload(chatId, messageThreadId),
125
+ message_id: messageId,
126
+ text: html,
127
+ parse_mode: "HTML",
128
+ });
129
+ lastEditContent = html;
130
+ lastEditTime = Date.now();
131
+ return true;
132
+ } catch {
133
+ // Telegram may reject if content hasn't changed or HTML is temporarily invalid
134
+ return false;
135
+ }
136
+ };
137
+
138
+ /** Get the current uncommitted portion of accumulated text */
139
+ const currentText = () => accumulated.slice(committedLength);
140
+
141
+ const renderCurrent = (): string => {
142
+ return markdownToTelegramHtml(currentText());
143
+ };
144
+
145
+ /**
146
+ * Check if current content exceeds the Telegram limit.
147
+ * If so, finalize the current message and start overflow handling.
148
+ */
149
+ const handleOverflow = async (): Promise<void> => {
150
+ const html = renderCurrent();
151
+ if (html.length <= TELEGRAM_LIMIT) return;
152
+
153
+ // Finalize current streaming message with tag-safe truncation
154
+ if (messageId) {
155
+ const truncated = truncateHtmlSafe(html, TELEGRAM_LIMIT);
156
+ await editMessage(truncated);
157
+ // Estimate how many source chars were consumed using the expansion ratio
158
+ const sourceLen = currentText().length;
159
+ const ratio = sourceLen > 0 ? html.length / sourceLen : 1;
160
+ const consumed = Math.min(sourceLen, Math.floor((TELEGRAM_LIMIT - 10) / Math.max(ratio, 1)));
161
+ committedLength += consumed;
162
+ messageId = null;
163
+ lastEditContent = "";
164
+ }
165
+ };
166
+
167
+ for await (const chunk of stream) {
168
+ accumulated += chunk;
169
+
170
+ if (!messageId) {
171
+ // First chunk (or after overflow) — send initial message
172
+ const html = renderCurrent();
173
+ if (html.trim()) {
174
+ const safeHtml = truncateHtmlSafe(html, TELEGRAM_LIMIT);
175
+ try {
176
+ messageId = await sendInitial(safeHtml);
177
+ lastEditContent = safeHtml;
178
+ lastEditTime = Date.now();
179
+ } catch {
180
+ try {
181
+ const result = await thread.adapter.telegramFetch("sendMessage", {
182
+ ...basePayload(chatId, messageThreadId),
183
+ text: currentText(),
184
+ });
185
+ messageId = result.message_id;
186
+ lastEditContent = currentText();
187
+ lastEditTime = Date.now();
188
+ } catch (err) {
189
+ console.error(`[roundhouse] Telegram stream initial send failed:`, (err as Error).message);
190
+ }
191
+ }
192
+ }
193
+ continue;
194
+ }
195
+
196
+ // Check for overflow before editing
197
+ await handleOverflow();
198
+
199
+ // Throttled edit (only if we still have an active message)
200
+ if (messageId) {
201
+ const now = Date.now();
202
+ if (now - lastEditTime >= STREAM_EDIT_INTERVAL_MS) {
203
+ const html = renderCurrent();
204
+ const safeHtml = truncateHtmlSafe(html, TELEGRAM_LIMIT);
205
+ await editMessage(safeHtml);
206
+ }
207
+ }
208
+ }
209
+
210
+ // Final: handle any remaining content
211
+ const remaining = currentText();
212
+ if (!remaining.trim()) return;
213
+
214
+ if (messageId) {
215
+ // Try final edit
216
+ const finalHtml = renderCurrent();
217
+ if (finalHtml.length <= TELEGRAM_LIMIT) {
218
+ const ok = await editMessage(finalHtml);
219
+ if (!ok) {
220
+ // Fallback to plain text edit
221
+ try {
222
+ await thread.adapter.telegramFetch("editMessageText", {
223
+ ...basePayload(chatId, messageThreadId),
224
+ message_id: messageId,
225
+ text: remaining,
226
+ });
227
+ } catch { /* at least some content was sent */ }
228
+ }
229
+ } else {
230
+ // Content exceeds limit — finalize current message and post remainder
231
+ const truncated = truncateHtmlSafe(finalHtml, TELEGRAM_LIMIT);
232
+ await editMessage(truncated);
233
+ // Estimate consumed source chars using expansion ratio
234
+ const remLen = remaining.length;
235
+ const ratio = remLen > 0 ? finalHtml.length / remLen : 1;
236
+ const overflowStart = Math.min(remLen, Math.floor((TELEGRAM_LIMIT - 10) / Math.max(ratio, 1)));
237
+ const overflow = remaining.slice(overflowStart);
238
+ if (overflow.trim()) {
239
+ await postTelegramHtml(thread, overflow);
240
+ }
241
+ }
242
+ } else {
243
+ // No active message — post everything remaining
244
+ await postTelegramHtml(thread, remaining);
245
+ }
246
+ }