fix: handle fetch/API errors in telegram delivery to prevent gateway crashes

Wrap all bot.api.sendXxx() media calls in delivery.ts with error handler
that logs failures before re-throwing. This ensures network failures are
properly logged with context instead of causing unhandled promise rejections
that crash the gateway.

Also wrap the fetch() call in telegram onboarding with try/catch to
gracefully handle network errors during username lookup.

Fixes #2487

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
wolfred
2026-01-26 18:31:18 -07:00
committed by Shadow
parent d5f2924b5a
commit 241436a525
2 changed files with 50 additions and 26 deletions

View File

@@ -80,14 +80,20 @@ async function promptTelegramAllowFrom(params: {
if (!token) return null; if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`; const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`; const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
const res = await fetch(url); try {
const data = (await res.json().catch(() => null)) as { const res = await fetch(url);
ok?: boolean; if (!res.ok) return null;
result?: { id?: number | string }; const data = (await res.json().catch(() => null)) as {
} | null; ok?: boolean;
const id = data?.ok ? data?.result?.id : undefined; result?: { id?: number | string };
if (typeof id === "number" || typeof id === "string") return String(id); } | null;
return null; const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
} catch {
// Network error during username lookup - return null to prompt user for numeric ID
return null;
}
}; };
const parseInput = (value: string) => const parseInput = (value: string) =>

View File

@@ -25,6 +25,24 @@ import type { TelegramContext } from "./types.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
/**
* Wraps a Telegram API call with error logging. Ensures network failures are
* logged with context before propagating, preventing silent unhandled rejections.
*/
async function withMediaErrorHandler<T>(
operation: string,
runtime: RuntimeEnv,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (err) {
const errText = formatErrorMessage(err);
runtime.error?.(danger(`telegram ${operation} failed: ${errText}`));
throw err;
}
}
export async function deliverReplies(params: { export async function deliverReplies(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
chatId: string; chatId: string;
@@ -146,17 +164,17 @@ export async function deliverReplies(params: {
mediaParams.message_thread_id = threadParams.message_thread_id; mediaParams.message_thread_id = threadParams.message_thread_id;
} }
if (isGif) { if (isGif) {
await bot.api.sendAnimation(chatId, file, { await withMediaErrorHandler("sendAnimation", runtime, () =>
...mediaParams, bot.api.sendAnimation(chatId, file, { ...mediaParams }),
}); );
} else if (kind === "image") { } else if (kind === "image") {
await bot.api.sendPhoto(chatId, file, { await withMediaErrorHandler("sendPhoto", runtime, () =>
...mediaParams, bot.api.sendPhoto(chatId, file, { ...mediaParams }),
}); );
} else if (kind === "video") { } else if (kind === "video") {
await bot.api.sendVideo(chatId, file, { await withMediaErrorHandler("sendVideo", runtime, () =>
...mediaParams, bot.api.sendVideo(chatId, file, { ...mediaParams }),
}); );
} else if (kind === "audio") { } else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({ const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@@ -169,9 +187,9 @@ export async function deliverReplies(params: {
// Switch typing indicator to record_voice before sending. // Switch typing indicator to record_voice before sending.
await params.onVoiceRecording?.(); await params.onVoiceRecording?.();
try { try {
await bot.api.sendVoice(chatId, file, { await withMediaErrorHandler("sendVoice", runtime, () =>
...mediaParams, bot.api.sendVoice(chatId, file, { ...mediaParams }),
}); );
} catch (voiceErr) { } catch (voiceErr) {
// Fall back to text if voice messages are forbidden in this chat. // Fall back to text if voice messages are forbidden in this chat.
// This happens when the recipient has Telegram Premium privacy settings // This happens when the recipient has Telegram Premium privacy settings
@@ -204,14 +222,14 @@ export async function deliverReplies(params: {
} }
} else { } else {
// Audio file - displays with metadata (title, duration) - DEFAULT // Audio file - displays with metadata (title, duration) - DEFAULT
await bot.api.sendAudio(chatId, file, { await withMediaErrorHandler("sendAudio", runtime, () =>
...mediaParams, bot.api.sendAudio(chatId, file, { ...mediaParams }),
}); );
} }
} else { } else {
await bot.api.sendDocument(chatId, file, { await withMediaErrorHandler("sendDocument", runtime, () =>
...mediaParams, bot.api.sendDocument(chatId, file, { ...mediaParams }),
}); );
} }
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;