pi-telegram-plus 0.0.1 → 0.0.3
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/README.md +102 -229
- package/index.ts +30 -11
- package/lib/attachments.ts +7 -3
- package/lib/commands/info.ts +109 -3
- package/lib/commands/register.ts +15 -3
- package/lib/commands/session.ts +4 -1
- package/lib/commands/telegram-commands.ts +140 -55
- package/lib/config.ts +19 -4
- package/lib/controller.ts +53 -14
- package/lib/custom-dialogs.ts +668 -0
- package/lib/heartbeat.ts +5 -1
- package/lib/logger.ts +304 -0
- package/lib/markdown.ts +392 -23
- package/lib/menu-commands.ts +10 -6
- package/lib/polling.ts +13 -8
- package/lib/renderer.ts +146 -9
- package/lib/telegram-api.ts +129 -54
- package/lib/telegram-ui.ts +89 -10
- package/lib/text-split.ts +216 -1
- package/package.json +1 -1
package/lib/renderer.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
import { escapeHtml } from "./html.ts";
|
|
3
6
|
import { markdownToTelegramHtml } from "./markdown.ts";
|
|
4
7
|
import type { TelegramConfig, TelegramRenderLevel, TelegramTransport, TelegramTurn } from "./types.ts";
|
|
5
8
|
import { RENDER_LEVELS } from "./types.ts";
|
|
9
|
+
import { log } from "./logger.ts";
|
|
10
|
+
|
|
11
|
+
const renderLog = log.child("renderer");
|
|
6
12
|
|
|
7
13
|
type AnyMessage = {
|
|
8
14
|
role?: string;
|
|
@@ -51,7 +57,9 @@ function contentToRenderParts(
|
|
|
51
57
|
: `🔧 ${name}\n${stringifyShort(p.arguments ?? {}, 1200)}`);
|
|
52
58
|
}
|
|
53
59
|
}
|
|
54
|
-
|
|
60
|
+
// Multiple text parts (common when a turn interleaves text around tool calls)
|
|
61
|
+
// are separated by a blank line so paragraphs don't run together on mobile.
|
|
62
|
+
return { body: body.filter(Boolean).join("\n\n"), inlineEvents };
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
function contentImages(content: unknown): Array<{ data: string; mimeType?: string }> {
|
|
@@ -65,6 +73,33 @@ function contentImages(content: unknown): Array<{ data: string; mimeType?: strin
|
|
|
65
73
|
});
|
|
66
74
|
}
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Extract renderable text + image parts from a tool result. Tools return
|
|
78
|
+
* `{ content: (TextContent | ImageContent)[], details }`; we surface the
|
|
79
|
+
* actual output text (rendered as markdown) and any images, instead of the
|
|
80
|
+
* truncated JSON blob the previous `full` mode emitted.
|
|
81
|
+
*
|
|
82
|
+
* @internal Exported for tests; not part of the public module API.
|
|
83
|
+
*/
|
|
84
|
+
export function extractToolResultParts(result: unknown): {
|
|
85
|
+
body: string;
|
|
86
|
+
images: Array<{ data: string; mimeType?: string }>;
|
|
87
|
+
} {
|
|
88
|
+
const content = (result as Record<string, any> | null | undefined)?.content;
|
|
89
|
+
const images = contentImages(content);
|
|
90
|
+
const body: string[] = [];
|
|
91
|
+
if (Array.isArray(content)) {
|
|
92
|
+
for (const part of content) {
|
|
93
|
+
if (part && typeof part === "object" && part.type === "text" && typeof part.text === "string") {
|
|
94
|
+
body.push(part.text);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} else if (typeof result === "string") {
|
|
98
|
+
body.push(result);
|
|
99
|
+
}
|
|
100
|
+
return { body: body.filter(Boolean).join("\n\n"), images };
|
|
101
|
+
}
|
|
102
|
+
|
|
68
103
|
function stringifyShort(value: unknown, max = 900): string {
|
|
69
104
|
let text: string;
|
|
70
105
|
if (typeof value === "string") text = value;
|
|
@@ -75,6 +110,63 @@ function stringifyShort(value: unknown, max = 900): string {
|
|
|
75
110
|
return text.length <= max ? text : text.slice(0, max - 1) + "…";
|
|
76
111
|
}
|
|
77
112
|
|
|
113
|
+
// ── Oversized code block → file attachment (C1) ─────────────────────────────
|
|
114
|
+
// A single fenced code block that won't fit in one Telegram message is sent
|
|
115
|
+
// as a downloadable document instead of being split across many <pre> chunks
|
|
116
|
+
// (which are painful to read on mobile). The fence is replaced in-body by a
|
|
117
|
+
// short notice so the assistant's prose still reads coherently.
|
|
118
|
+
|
|
119
|
+
const OVERSIZED_CODE_BYTES = 4000;
|
|
120
|
+
|
|
121
|
+
const LANG_EXT: Record<string, string> = {
|
|
122
|
+
ts: ".ts", tsx: ".tsx", js: ".js", jsx: ".jsx", mjs: ".mjs", cjs: ".cjs",
|
|
123
|
+
py: ".py", python: ".py", rb: ".rb", ruby: ".rb", go: ".go", rs: ".rs", rust: ".rs", java: ".java", kt: ".kt", kotlin: ".kt",
|
|
124
|
+
c: ".c", h: ".h", cpp: ".cpp", cc: ".cc", hpp: ".hpp", cs: ".cs", php: ".php",
|
|
125
|
+
swift: ".swift", sh: ".sh", bash: ".sh", zsh: ".sh", fish: ".sh", ps1: ".ps1",
|
|
126
|
+
sql: ".sql", json: ".json", yml: ".yml", yaml: ".yaml", toml: ".toml",
|
|
127
|
+
xml: ".xml", html: ".html", htm: ".html", css: ".css", scss: ".scss",
|
|
128
|
+
md: ".md", markdown: ".md", txt: ".txt", text: ".txt", dockerfile: "Dockerfile",
|
|
129
|
+
makefile: "Makefile", graphql: ".graphql", lua: ".lua", r: ".r", dart: ".dart",
|
|
130
|
+
scala: ".scala", clj: ".clj", ex: ".ex", exs: ".exs", erl: ".erl", vim: ".vim",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function langToExt(lang: string): string {
|
|
134
|
+
const key = (lang || "").trim().toLowerCase();
|
|
135
|
+
return LANG_EXT[key] ?? ".txt";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Pull fenced code blocks > OVERSIZED_CODE_BYTES out of the markdown body,
|
|
140
|
+
* replacing each with a one-line notice. Returns the trimmed body and the
|
|
141
|
+
* extracted blocks (lang + raw content) for file attachment.
|
|
142
|
+
*
|
|
143
|
+
* @internal Exported for tests; not part of the public module API.
|
|
144
|
+
*/
|
|
145
|
+
export function extractOversizedCodeBlocks(body: string): {
|
|
146
|
+
body: string;
|
|
147
|
+
blocks: Array<{ lang: string; content: string; fileName: string }>;
|
|
148
|
+
} {
|
|
149
|
+
const blocks: Array<{ lang: string; content: string; fileName: string }> = [];
|
|
150
|
+
const stripped = body.replace(/```([^\n]*)\n([\s\S]*?)\n```/g, (m, langRaw, content) => {
|
|
151
|
+
const lang = String(langRaw || "").trim();
|
|
152
|
+
const code = String(content);
|
|
153
|
+
if (Buffer.byteLength(code, "utf8") <= OVERSIZED_CODE_BYTES) return m;
|
|
154
|
+
const idx = blocks.length + 1;
|
|
155
|
+
const fileName = `code-${idx}${langToExt(lang)}`;
|
|
156
|
+
blocks.push({ lang, content: code, fileName });
|
|
157
|
+
const lines = code.split("\n").length;
|
|
158
|
+
return `\n\n📎 \`${lang || "code"} block (${lines} lines) — attached: ${fileName}\`\n\n`;
|
|
159
|
+
});
|
|
160
|
+
return { body: stripped, blocks };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function writeTempCodeFile(fileName: string, content: string): Promise<string> {
|
|
164
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-tg-code-"));
|
|
165
|
+
const filePath = join(dir, fileName);
|
|
166
|
+
await writeFile(filePath, content, "utf8");
|
|
167
|
+
return filePath;
|
|
168
|
+
}
|
|
169
|
+
|
|
78
170
|
function renderLevel(config: TelegramConfig, key: "tool" | "thinking"): TelegramRenderLevel {
|
|
79
171
|
const value = config[key];
|
|
80
172
|
return (RENDER_LEVELS as readonly string[]).includes(value ?? "") ? value! : "brief";
|
|
@@ -176,15 +268,16 @@ export function registerTelegramRenderer(
|
|
|
176
268
|
return;
|
|
177
269
|
}
|
|
178
270
|
if (options.final && Buffer.byteLength(html, "utf8") > EDIT_LIMIT) {
|
|
179
|
-
await deps.transport.editText(turn.chatId, turn.replaceMessageId, "🤖 <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch((
|
|
271
|
+
await deps.transport.editText(turn.chatId, turn.replaceMessageId, "🤖 <b>Assistant</b>\n\nFinal answer follows in separate message(s).").catch(renderLog.swallow("warn", "editText final-answer pointer failed", { chatId: turn.chatId, messageId: turn.replaceMessageId }));
|
|
180
272
|
turn.replaceMessageId = undefined;
|
|
181
273
|
await deps.transport.sendText(turn.chatId, html);
|
|
182
274
|
return;
|
|
183
275
|
}
|
|
184
276
|
try {
|
|
185
277
|
await deps.transport.editText(turn.chatId, turn.replaceMessageId, html);
|
|
186
|
-
} catch {
|
|
278
|
+
} catch (err) {
|
|
187
279
|
// Edit failed (message deleted / too many edits). Fall back to a new message.
|
|
280
|
+
renderLog.debug("editText failed; falling back to sendText", { chatId: turn.chatId, messageId: turn.replaceMessageId, err });
|
|
188
281
|
turn.replaceMessageId = undefined;
|
|
189
282
|
await deps.transport.sendText(turn.chatId, html);
|
|
190
283
|
}
|
|
@@ -201,15 +294,18 @@ export function registerTelegramRenderer(
|
|
|
201
294
|
};
|
|
202
295
|
|
|
203
296
|
pi.on("agent_start", async () => {
|
|
297
|
+
try {
|
|
204
298
|
sentInlineEvents.clear();
|
|
205
299
|
toolArgs.clear();
|
|
206
300
|
toolUpdateAt.clear();
|
|
207
301
|
const turn = deps.getActiveTurn();
|
|
208
302
|
if (!turn) return;
|
|
209
303
|
if (turn.replaceMessageId !== undefined) await deps.transport.editText(turn.chatId, turn.replaceMessageId, "🤖 <b>Working…</b>");
|
|
304
|
+
} catch (err) { renderLog.warn("render status-clear handler failed", { err }); }
|
|
210
305
|
});
|
|
211
306
|
|
|
212
307
|
pi.on("tool_execution_start", async (event) => {
|
|
308
|
+
try {
|
|
213
309
|
const level = renderLevel(deps.getConfig(), "tool");
|
|
214
310
|
if (level === "hidden") return;
|
|
215
311
|
toolArgs.set(event.toolCallId, event.args);
|
|
@@ -218,9 +314,11 @@ export function registerTelegramRenderer(
|
|
|
218
314
|
: `🔧 ${event.toolName} started
|
|
219
315
|
${stringifyShort(event.args, 1200)}`;
|
|
220
316
|
await sendInlineEvent(inline);
|
|
317
|
+
} catch (err) { renderLog.warn("render tool_execution_start handler failed", { err }); }
|
|
221
318
|
});
|
|
222
319
|
|
|
223
320
|
pi.on("tool_execution_update", async (event) => {
|
|
321
|
+
try {
|
|
224
322
|
const level = renderLevel(deps.getConfig(), "tool");
|
|
225
323
|
if (level !== "full") return;
|
|
226
324
|
const now = Date.now();
|
|
@@ -231,28 +329,48 @@ ${stringifyShort(event.args, 1200)}`;
|
|
|
231
329
|
if (!partial || partial === "{}") return;
|
|
232
330
|
await sendInlineEvent(`🔄 ${event.toolName} update
|
|
233
331
|
${partial}`);
|
|
332
|
+
} catch (err) { renderLog.warn("render tool_execution_update handler failed", { err }); }
|
|
234
333
|
});
|
|
235
334
|
|
|
236
335
|
pi.on("tool_execution_end", async (event) => {
|
|
336
|
+
try {
|
|
237
337
|
const level = renderLevel(deps.getConfig(), "tool");
|
|
238
338
|
toolUpdateAt.delete(event.toolCallId);
|
|
239
339
|
const args = toolArgs.get(event.toolCallId);
|
|
240
340
|
toolArgs.delete(event.toolCallId);
|
|
241
341
|
if (level === "hidden") return;
|
|
242
342
|
const status = event.isError ? "❌ Tool failed" : "✅ Tool finished";
|
|
243
|
-
const result = stringifyShort(event.result, event.isError ? 1800 : 900);
|
|
244
343
|
if (level === "brief") {
|
|
245
344
|
if (!event.isError) return;
|
|
246
345
|
await sendInlineEvent(formatToolFailureBrief(event.toolName, event.result, args));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
// full mode: render the tool's actual output (text + images) as a
|
|
349
|
+
// persistent new message, so users who opt into `full` see complete
|
|
350
|
+
// content instead of a truncated JSON blob. Tool results are excluded
|
|
351
|
+
// from the "never fold" rule, so long ones use an expandable blockquote.
|
|
352
|
+
const parts = extractToolResultParts(event.result);
|
|
353
|
+
const header = `${event.isError ? "❌" : "✅"} <b>${escapeHtml(event.toolName)}</b>`;
|
|
354
|
+
if (parts.body.trim()) {
|
|
355
|
+
const rendered = markdownToTelegramHtml(parts.body);
|
|
356
|
+
const expandable = rendered.length > 600 || rendered.split("\n").length > 8;
|
|
357
|
+
const tag = expandable ? "<blockquote expandable>" : "<blockquote>";
|
|
358
|
+
await send(`${tag}${header}\n${rendered}</blockquote>`);
|
|
247
359
|
} else {
|
|
248
|
-
await sendInlineEvent(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
360
|
+
await sendInlineEvent(`${status}: ${event.toolName}`);
|
|
361
|
+
}
|
|
362
|
+
for (const image of parts.images) {
|
|
363
|
+
const chatIds = currentChats();
|
|
364
|
+
for (const chatId of chatIds) {
|
|
365
|
+
await deps.transport.sendChatAction(chatId, "upload_photo");
|
|
366
|
+
await deps.transport.sendPhoto(chatId, image.data, "image").catch(renderLog.swallow("warn", "sendPhoto failed", { chatId }));
|
|
367
|
+
}
|
|
252
368
|
}
|
|
369
|
+
} catch (err) { renderLog.warn("tool-result image upload failed", { err }); }
|
|
253
370
|
});
|
|
254
371
|
|
|
255
372
|
pi.on("message_end", async (event) => {
|
|
373
|
+
try {
|
|
256
374
|
const message = event.message as AnyMessage;
|
|
257
375
|
if (message.role !== "assistant") return;
|
|
258
376
|
const config = deps.getConfig();
|
|
@@ -260,13 +378,31 @@ ${result}`
|
|
|
260
378
|
const toolLevel = renderLevel(config, "tool");
|
|
261
379
|
const rendered = contentToRenderParts(message.content, thinkingLevel, toolLevel);
|
|
262
380
|
await sendInlineEvents(rendered.inlineEvents);
|
|
263
|
-
const
|
|
381
|
+
const rawBody = rendered.body || message.errorMessage || "";
|
|
382
|
+
// Pull oversized code blocks out of the body before rendering: they'd
|
|
383
|
+
// otherwise be split across many <pre> messages (painful on mobile). They
|
|
384
|
+
// are sent as downloadable files after the body.
|
|
385
|
+
const { body, blocks: codeFiles } = extractOversizedCodeBlocks(rawBody);
|
|
264
386
|
const images = contentImages(message.content);
|
|
265
387
|
|
|
266
388
|
const hasBody = body.trim().length > 0;
|
|
267
389
|
if (hasBody) await sendToTurn(markdownToTelegramHtml(body), { final: true });
|
|
268
390
|
|
|
269
391
|
const turn = deps.getActiveTurn();
|
|
392
|
+
if (codeFiles.length > 0) {
|
|
393
|
+
const chatIds = turn ? [turn.chatId] : currentChats();
|
|
394
|
+
for (const chatId of chatIds) {
|
|
395
|
+
for (const block of codeFiles) {
|
|
396
|
+
const filePath = await writeTempCodeFile(block.fileName, block.content).catch(renderLog.swallow("warn", "writeTempCodeFile failed", { fileName: block.fileName }));
|
|
397
|
+
if (!filePath) continue;
|
|
398
|
+
await deps.transport.sendChatAction(chatId, "upload_document");
|
|
399
|
+
const lines = block.content.split("\n").length;
|
|
400
|
+
const caption = `📎 ${block.lang || "code"} block (${lines} lines)`;
|
|
401
|
+
await deps.transport.sendDocument(chatId, filePath, caption).catch(renderLog.swallow("warn", "sendDocument code block failed", { chatId, fileName: block.fileName }));
|
|
402
|
+
await rm(filePath, { force: true }).catch(renderLog.swallow("debug", "rm temp code file failed", { filePath }));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
270
406
|
for (const image of images) {
|
|
271
407
|
const chatIds = turn ? [turn.chatId] : currentChats();
|
|
272
408
|
for (const chatId of chatIds) {
|
|
@@ -279,6 +415,7 @@ ${result}`
|
|
|
279
415
|
await deps.transport.editText(turn.chatId, turn.replaceMessageId, `✅ <b>Output sent.</b>\n${noun}`);
|
|
280
416
|
turn.replaceMessageId = undefined;
|
|
281
417
|
}
|
|
418
|
+
} catch (err) { renderLog.warn("render assistant_message handler failed", { err }); }
|
|
282
419
|
});
|
|
283
420
|
|
|
284
421
|
}
|
package/lib/telegram-api.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { basename, extname } from "node:path";
|
|
3
3
|
import type { TelegramButton, TelegramConfig, TelegramSentMessage, TelegramTransport, TelegramUpdate } from "./types.ts";
|
|
4
|
-
import { stripHtml,
|
|
4
|
+
import { stripHtml, splitTelegramHtml } from "./text-split.ts";
|
|
5
|
+
import { log } from "./logger.ts";
|
|
6
|
+
|
|
7
|
+
const apiLog = log.child("telegram-api");
|
|
5
8
|
|
|
6
9
|
type TelegramFileInfo = {
|
|
7
10
|
file_id: string;
|
|
@@ -50,12 +53,17 @@ export async function telegramApi<T>(
|
|
|
50
53
|
body: Record<string, unknown>,
|
|
51
54
|
signal?: AbortSignal,
|
|
52
55
|
): Promise<T> {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
let response: Response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "content-type": "application/json" },
|
|
61
|
+
body: JSON.stringify(body),
|
|
62
|
+
signal,
|
|
63
|
+
});
|
|
64
|
+
} catch (error) {
|
|
65
|
+
throw new Error(`Telegram API request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
59
67
|
const json = (await response.json()) as TelegramApiError & { result: T };
|
|
60
68
|
if (!json.ok) throw new Error(json.description ?? `${method} failed`);
|
|
61
69
|
return json.result;
|
|
@@ -72,10 +80,15 @@ export async function getTelegramFile(token: string, fileId: string, signal?: Ab
|
|
|
72
80
|
|
|
73
81
|
export async function downloadTelegramFile(token: string, filePath: string, signal?: AbortSignal): Promise<Buffer> {
|
|
74
82
|
const encodedPath = filePath.split("/").map((segment) => encodeURIComponent(segment)).join("/");
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
let response: Response;
|
|
84
|
+
try {
|
|
85
|
+
response = await fetch(`https://api.telegram.org/file/bot${token}/${encodedPath}`, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
signal,
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throw new Error(`Telegram file download failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
91
|
+
}
|
|
79
92
|
if (!response.ok) {
|
|
80
93
|
throw new Error(`Failed to download Telegram file: ${response.status}`);
|
|
81
94
|
}
|
|
@@ -131,20 +144,60 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
131
144
|
return token;
|
|
132
145
|
};
|
|
133
146
|
|
|
147
|
+
/** True when an error represents a network-level failure (fetch rejected,
|
|
148
|
+
* DNS, connection refused/timeout, …). When the bot is unreachable the
|
|
149
|
+
* plain-text fallback retry will fail identically, so callers should skip
|
|
150
|
+
* it and let existing `.catch(swallow(...))` / status-line reporting
|
|
151
|
+
* surface the outage instead of pointlessly retrying (and logging). */
|
|
152
|
+
const isNetworkError = (err: unknown): boolean => {
|
|
153
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
154
|
+
// telegramApi wraps raw fetch failures as "Telegram API request failed: <cause>".
|
|
155
|
+
if (msg.startsWith("Telegram API request failed")) return true;
|
|
156
|
+
// sendDocument/sendPhoto wrap as "Telegram sendX failed: <cause>"; direct
|
|
157
|
+
// Node fetch errors also surface with these signatures.
|
|
158
|
+
return /\bfetch failed\b|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|UND_ERR|socket hang up|network(?:\s|_)?error/i.test(msg);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Log why an HTML-format send failed before falling back to plain text.
|
|
162
|
+
* Without this the renderer's failures were completely silent — a single
|
|
163
|
+
* unsupported tag nuked the whole message's formatting and nobody noticed.
|
|
164
|
+
*
|
|
165
|
+
* Network-level failures are intentionally NOT logged here: the polling
|
|
166
|
+
* loop already reports them via the status line / ui.notify, and logging
|
|
167
|
+
* every failed send when the bot is simply offline would only noise up the
|
|
168
|
+
* log file. */
|
|
169
|
+
const warnHtmlFallback = (label: string, err: unknown, preview: string) => {
|
|
170
|
+
if (isNetworkError(err)) return;
|
|
171
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
172
|
+
const snippet = preview.replace(/\s+/g, " ").slice(0, 120);
|
|
173
|
+
apiLog.warn(`HTML ${label} rejected; falling back to plain text`, { reason, snippet });
|
|
174
|
+
};
|
|
175
|
+
|
|
134
176
|
return {
|
|
135
177
|
async sendText(chatId, text) {
|
|
136
178
|
const sent: TelegramSentMessage[] = [];
|
|
137
|
-
|
|
179
|
+
// Use the semantic HTML splitter so multi-message sends cut at block
|
|
180
|
+
// boundaries (every chunk is independently valid Telegram HTML) instead
|
|
181
|
+
// of the legacy byte splitter that could split mid-<pre>/<blockquote>
|
|
182
|
+
// and force a plain-text fallback.
|
|
183
|
+
for (const chunk of splitTelegramHtml(text)) {
|
|
138
184
|
const body = {
|
|
139
185
|
chat_id: chatId,
|
|
140
186
|
text: chunk,
|
|
141
187
|
parse_mode: "HTML",
|
|
142
188
|
};
|
|
143
189
|
const msg = await callApi<TelegramSentMessage>("sendMessage", body)
|
|
144
|
-
.catch(() =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
190
|
+
.catch((err) => {
|
|
191
|
+
// Network failure: the plain-text retry will fail identically.
|
|
192
|
+
// Propagate so the caller's `.catch` (or renderer suppression)
|
|
193
|
+
// handles it; the polling status line already reports outages.
|
|
194
|
+
if (isNetworkError(err)) throw err;
|
|
195
|
+
warnHtmlFallback("sendMessage", err, chunk);
|
|
196
|
+
return callApi<TelegramSentMessage>("sendMessage", {
|
|
197
|
+
chat_id: chatId,
|
|
198
|
+
text: stripHtml(chunk),
|
|
199
|
+
});
|
|
200
|
+
});
|
|
148
201
|
sent.push(msg);
|
|
149
202
|
}
|
|
150
203
|
return sent;
|
|
@@ -154,48 +207,60 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
154
207
|
// Button messages cannot be split without duplicating keyboards, so keep
|
|
155
208
|
// title text short. The UI layer already truncates button labels.
|
|
156
209
|
const reply_markup = buildInlineKeyboard(rows);
|
|
157
|
-
const first =
|
|
210
|
+
const first = splitTelegramHtml(text)[0];
|
|
158
211
|
return await callApi<TelegramSentMessage>("sendMessage", {
|
|
159
212
|
chat_id: chatId,
|
|
160
213
|
text: first,
|
|
161
214
|
parse_mode: "HTML",
|
|
162
215
|
reply_markup,
|
|
163
|
-
}).catch(() =>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
216
|
+
}).catch((err) => {
|
|
217
|
+
if (isNetworkError(err)) throw err;
|
|
218
|
+
warnHtmlFallback("sendButtons", err, first);
|
|
219
|
+
return callApi<TelegramSentMessage>("sendMessage", {
|
|
220
|
+
chat_id: chatId,
|
|
221
|
+
text: stripHtml(first),
|
|
222
|
+
reply_markup,
|
|
223
|
+
});
|
|
224
|
+
});
|
|
168
225
|
},
|
|
169
226
|
|
|
170
227
|
async editText(chatId, messageId, text) {
|
|
171
|
-
const first =
|
|
228
|
+
const first = splitTelegramHtml(text)[0];
|
|
172
229
|
await callApi("editMessageText", {
|
|
173
230
|
chat_id: chatId,
|
|
174
231
|
message_id: messageId,
|
|
175
232
|
text: first,
|
|
176
233
|
parse_mode: "HTML",
|
|
177
|
-
}).catch(() =>
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
234
|
+
}).catch((err) => {
|
|
235
|
+
if (isNetworkError(err)) return; // swallow; nothing useful to retry
|
|
236
|
+
warnHtmlFallback("editMessageText", err, first);
|
|
237
|
+
return callApi("editMessageText", {
|
|
238
|
+
chat_id: chatId,
|
|
239
|
+
message_id: messageId,
|
|
240
|
+
text: stripHtml(first),
|
|
241
|
+
}).catch(apiLog.swallow("debug", "editMessageText plain-text fallback failed", { chatId, messageId }));
|
|
242
|
+
});
|
|
182
243
|
},
|
|
183
244
|
|
|
184
245
|
async editButtons(chatId, messageId, text, rows) {
|
|
185
246
|
const reply_markup = buildInlineKeyboard(rows);
|
|
186
|
-
const first =
|
|
247
|
+
const first = splitTelegramHtml(text)[0];
|
|
187
248
|
await callApi("editMessageText", {
|
|
188
249
|
chat_id: chatId,
|
|
189
250
|
message_id: messageId,
|
|
190
251
|
text: first,
|
|
191
252
|
parse_mode: "HTML",
|
|
192
253
|
reply_markup,
|
|
193
|
-
}).catch(() =>
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
254
|
+
}).catch((err) => {
|
|
255
|
+
if (isNetworkError(err)) return; // swallow; nothing useful to retry
|
|
256
|
+
warnHtmlFallback("editButtons", err, first);
|
|
257
|
+
return callApi("editMessageText", {
|
|
258
|
+
chat_id: chatId,
|
|
259
|
+
message_id: messageId,
|
|
260
|
+
text: stripHtml(first),
|
|
261
|
+
reply_markup,
|
|
262
|
+
}).catch(apiLog.swallow("debug", "editButtons plain-text fallback failed", { chatId, messageId }));
|
|
263
|
+
});
|
|
199
264
|
},
|
|
200
265
|
|
|
201
266
|
async answerCallbackQuery(callbackQueryId, text) {
|
|
@@ -210,14 +275,14 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
210
275
|
chat_id: chatId,
|
|
211
276
|
message_id: messageId,
|
|
212
277
|
reply_markup: { inline_keyboard: [] },
|
|
213
|
-
}).catch((
|
|
278
|
+
}).catch(apiLog.swallow("debug", "removeInlineKeyboard failed", { chatId, messageId }));
|
|
214
279
|
},
|
|
215
280
|
|
|
216
281
|
async deleteMessage(chatId, messageId) {
|
|
217
282
|
await callApi("deleteMessage", {
|
|
218
283
|
chat_id: chatId,
|
|
219
284
|
message_id: messageId,
|
|
220
|
-
}).catch((
|
|
285
|
+
}).catch(apiLog.swallow("warn", "deleteMessage failed", { chatId, messageId }));
|
|
221
286
|
},
|
|
222
287
|
|
|
223
288
|
async sendDocument(chatId, path, caption, signal) {
|
|
@@ -234,14 +299,19 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
234
299
|
type: inferMimeTypeFromPath(path) ?? "application/octet-stream",
|
|
235
300
|
});
|
|
236
301
|
form.set("document", documentBlob, basename(path));
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
302
|
+
try {
|
|
303
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
|
|
304
|
+
method: "POST",
|
|
305
|
+
body: form,
|
|
306
|
+
signal,
|
|
307
|
+
});
|
|
308
|
+
const json = await response.json() as { ok: boolean; description?: string };
|
|
309
|
+
if (!json.ok) throw new Error(json.description ?? "sendDocument failed");
|
|
310
|
+
return;
|
|
311
|
+
} catch (fetchError) {
|
|
312
|
+
if ((fetchError as any)?.name === "AbortError") throw fetchError;
|
|
313
|
+
throw new Error(`Telegram sendDocument failed: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
|
|
314
|
+
}
|
|
245
315
|
} catch (error) {
|
|
246
316
|
lastError = error;
|
|
247
317
|
if (attempt >= maxRetries || signal?.aborted) throw error;
|
|
@@ -272,14 +342,19 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
272
342
|
const bytes = Buffer.from(base64, "base64");
|
|
273
343
|
form.set("photo", new Blob([bytes], { type: mime }), `image.${mime.split("/")[1] ?? "png"}`);
|
|
274
344
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
345
|
+
try {
|
|
346
|
+
const response = await fetch(`https://api.telegram.org/bot${token}/sendPhoto`, {
|
|
347
|
+
method: "POST",
|
|
348
|
+
body: form,
|
|
349
|
+
signal,
|
|
350
|
+
});
|
|
351
|
+
const json = await response.json() as { ok: boolean; description?: string };
|
|
352
|
+
if (!json.ok) throw new Error(json.description ?? "sendPhoto failed");
|
|
353
|
+
return;
|
|
354
|
+
} catch (fetchError) {
|
|
355
|
+
if ((fetchError as any)?.name === "AbortError") throw fetchError;
|
|
356
|
+
throw new Error(`Telegram sendPhoto failed: ${fetchError instanceof Error ? fetchError.message : String(fetchError)}`);
|
|
357
|
+
}
|
|
283
358
|
} catch (error) {
|
|
284
359
|
lastError = error;
|
|
285
360
|
if (attempt >= maxRetries || signal?.aborted) throw error;
|
|
@@ -293,7 +368,7 @@ export function createTelegramTransport(getConfig: () => TelegramConfig): Telegr
|
|
|
293
368
|
await telegramApi(requireToken(), "sendChatAction", {
|
|
294
369
|
chat_id: chatId,
|
|
295
370
|
action,
|
|
296
|
-
}).catch((
|
|
371
|
+
}).catch(apiLog.swallow("debug", "sendChatAction failed", { chatId, action }));
|
|
297
372
|
},
|
|
298
373
|
};
|
|
299
374
|
}
|