@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 +1 -1
- package/src/gateway.ts +16 -4
- package/src/telegram-format.ts +256 -0
- package/src/telegram-html.ts +246 -0
package/package.json
CHANGED
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
|
|
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 =
|
|
994
|
-
|
|
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, "&")
|
|
26
|
+
.replace(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Escape HTML attribute value (quotes + entities) */
|
|
31
|
+
function escapeAttr(text: string): string {
|
|
32
|
+
return escapeHtml(text).replace(/"/g, """);
|
|
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 >)
|
|
242
|
+
processed = processed.replace(/^>\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
|
+
}
|