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/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
- return { body: body.filter(Boolean).join("\n"), inlineEvents };
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(() => undefined);
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(result && result !== "{}"
249
- ? `${status}: ${event.toolName}
250
- ${result}`
251
- : `${status}: ${event.toolName}`);
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 body = rendered.body || message.errorMessage || "";
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
  }
@@ -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, splitTelegramText } from "./text-split.ts";
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
- const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
54
- method: "POST",
55
- headers: { "content-type": "application/json" },
56
- body: JSON.stringify(body),
57
- signal,
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
- const response = await fetch(`https://api.telegram.org/file/bot${token}/${encodedPath}`, {
76
- method: "GET",
77
- signal,
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
- for (const chunk of splitTelegramText(text)) {
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(() => callApi<TelegramSentMessage>("sendMessage", {
145
- chat_id: chatId,
146
- text: stripHtml(chunk),
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 = splitTelegramText(text)[0];
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(() => callApi<TelegramSentMessage>("sendMessage", {
164
- chat_id: chatId,
165
- text: stripHtml(first),
166
- reply_markup,
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 = splitTelegramText(text)[0];
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(() => callApi("editMessageText", {
178
- chat_id: chatId,
179
- message_id: messageId,
180
- text: stripHtml(first),
181
- }).catch(() => undefined));
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 = splitTelegramText(text)[0];
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(() => callApi("editMessageText", {
194
- chat_id: chatId,
195
- message_id: messageId,
196
- text: stripHtml(first),
197
- reply_markup,
198
- }).catch(() => undefined));
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(() => undefined);
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(() => undefined);
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
- const response = await fetch(`https://api.telegram.org/bot${token}/sendDocument`, {
238
- method: "POST",
239
- body: form,
240
- signal,
241
- });
242
- const json = await response.json() as { ok: boolean; description?: string };
243
- if (!json.ok) throw new Error(json.description ?? "sendDocument failed");
244
- return;
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
- const response = await fetch(`https://api.telegram.org/bot${token}/sendPhoto`, {
276
- method: "POST",
277
- body: form,
278
- signal,
279
- });
280
- const json = await response.json() as { ok: boolean; description?: string };
281
- if (!json.ok) throw new Error(json.description ?? "sendPhoto failed");
282
- return;
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(() => undefined);
371
+ }).catch(apiLog.swallow("debug", "sendChatAction failed", { chatId, action }));
297
372
  },
298
373
  };
299
374
  }