diff --git a/src/infra/outbound/outbound-session.ts b/src/infra/outbound/outbound-session.ts index 641bdac310d..b310f454923 100644 --- a/src/infra/outbound/outbound-session.ts +++ b/src/infra/outbound/outbound-session.ts @@ -882,6 +882,29 @@ function resolveFallbackSession( }; } +type OutboundSessionResolver = ( + params: ResolveOutboundSessionRouteParams, +) => OutboundSessionRoute | null | Promise; + +const OUTBOUND_SESSION_RESOLVERS: Partial> = { + slack: resolveSlackSession, + discord: resolveDiscordSession, + telegram: resolveTelegramSession, + whatsapp: resolveWhatsAppSession, + signal: resolveSignalSession, + imessage: resolveIMessageSession, + matrix: resolveMatrixSession, + msteams: resolveMSTeamsSession, + mattermost: resolveMattermostSession, + bluebubbles: resolveBlueBubblesSession, + "nextcloud-talk": resolveNextcloudTalkSession, + zalo: resolveZaloSession, + zalouser: resolveZalouserSession, + nostr: resolveNostrSession, + tlon: resolveTlonSession, + feishu: resolveFeishuSession, +}; + export async function resolveOutboundSessionRoute( params: ResolveOutboundSessionRouteParams, ): Promise { @@ -889,42 +912,12 @@ export async function resolveOutboundSessionRoute( if (!target) { return null; } - switch (params.channel) { - case "slack": - return await resolveSlackSession({ ...params, target }); - case "discord": - return resolveDiscordSession({ ...params, target }); - case "telegram": - return resolveTelegramSession({ ...params, target }); - case "whatsapp": - return resolveWhatsAppSession({ ...params, target }); - case "signal": - return resolveSignalSession({ ...params, target }); - case "imessage": - return resolveIMessageSession({ ...params, target }); - case "matrix": - return resolveMatrixSession({ ...params, target }); - case "msteams": - return resolveMSTeamsSession({ ...params, target }); - case "mattermost": - return resolveMattermostSession({ ...params, target }); - case "bluebubbles": - return resolveBlueBubblesSession({ ...params, target }); - case "nextcloud-talk": - return resolveNextcloudTalkSession({ ...params, target }); - case "zalo": - return resolveZaloSession({ ...params, target }); - case "zalouser": - return resolveZalouserSession({ ...params, target }); - case "nostr": - return resolveNostrSession({ ...params, target }); - case "tlon": - return resolveTlonSession({ ...params, target }); - case "feishu": - return resolveFeishuSession({ ...params, target }); - default: - return resolveFallbackSession({ ...params, target }); + const nextParams = { ...params, target }; + const resolver = OUTBOUND_SESSION_RESOLVERS[params.channel]; + if (!resolver) { + return resolveFallbackSession(nextParams); } + return await resolver(nextParams); } export async function ensureOutboundSessionEntry(params: { diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 62e5f01ea3c..eb47956ed0b 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -45,6 +45,351 @@ const TELEGRAM_MEDIA_SSRF_POLICY = { allowRfc2544BenchmarkRange: true, }; +type DeliveryProgress = { + hasReplied: boolean; + hasDelivered: boolean; +}; + +type ChunkTextFn = (markdown: string) => ReturnType; + +function buildChunkTextResolver(params: { + textLimit: number; + chunkMode: ChunkMode; + tableMode?: MarkdownTableMode; +}): ChunkTextFn { + return (markdown: string) => { + const markdownChunks = + params.chunkMode === "newline" + ? chunkMarkdownTextWithMode(markdown, params.textLimit, params.chunkMode) + : [markdown]; + const chunks: ReturnType = []; + for (const chunk of markdownChunks) { + const nested = markdownToTelegramChunks(chunk, params.textLimit, { + tableMode: params.tableMode, + }); + if (!nested.length && chunk) { + chunks.push({ + html: wrapFileReferencesInHtml( + markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }), + ), + text: chunk, + }); + continue; + } + chunks.push(...nested); + } + return chunks; + }; +} + +function resolveReplyToForSend(params: { + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): number | undefined { + return params.replyToId && (params.replyToMode === "all" || !params.progress.hasReplied) + ? params.replyToId + : undefined; +} + +function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void { + if (replyToId && !progress.hasReplied) { + progress.hasReplied = true; + } +} + +function markDelivered(progress: DeliveryProgress): void { + progress.hasDelivered = true; +} + +async function deliverTextReply(params: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + chunkText: ChunkTextFn; + replyText: string; + replyMarkup?: ReturnType; + replyQuoteText?: string; + linkPreview?: boolean; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + const chunks = params.chunkText(params.replyText); + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + if (!chunk) { + continue; + } + const shouldAttachButtons = i === 0 && params.replyMarkup; + const replyToForChunk = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, { + replyToMessageId: replyToForChunk, + replyQuoteText: params.replyQuoteText, + thread: params.thread, + textMode: "html", + plainText: chunk.text, + linkPreview: params.linkPreview, + replyMarkup: shouldAttachButtons ? params.replyMarkup : undefined, + }); + markReplyApplied(params.progress, replyToForChunk); + markDelivered(params.progress); + } +} + +async function sendPendingFollowUpText(params: { + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + chunkText: ChunkTextFn; + text: string; + replyMarkup?: ReturnType; + linkPreview?: boolean; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + const chunks = params.chunkText(params.text); + for (let i = 0; i < chunks.length; i += 1) { + const chunk = chunks[i]; + const replyToForFollowUp = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + await sendTelegramText(params.bot, params.chatId, chunk.html, params.runtime, { + replyToMessageId: replyToForFollowUp, + thread: params.thread, + textMode: "html", + plainText: chunk.text, + linkPreview: params.linkPreview, + replyMarkup: i === 0 ? params.replyMarkup : undefined, + }); + markReplyApplied(params.progress, replyToForFollowUp); + markDelivered(params.progress); + } +} + +async function deliverMediaReply(params: { + reply: ReplyPayload; + mediaList: string[]; + bot: Bot; + chatId: string; + runtime: RuntimeEnv; + thread?: TelegramThreadSpec | null; + tableMode?: MarkdownTableMode; + mediaLocalRoots?: readonly string[]; + chunkText: ChunkTextFn; + onVoiceRecording?: () => Promise | void; + linkPreview?: boolean; + replyQuoteText?: string; + replyMarkup?: ReturnType; + replyToId?: number; + replyToMode: ReplyToMode; + progress: DeliveryProgress; +}): Promise { + let first = true; + let pendingFollowUpText: string | undefined; + for (const mediaUrl of params.mediaList) { + const isFirstMedia = first; + const media = await loadWebMedia(mediaUrl, { + localRoots: params.mediaLocalRoots, + }); + const kind = mediaKindFromMime(media.contentType ?? undefined); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); + const file = new InputFile(media.buffer, fileName); + const { caption, followUpText } = splitTelegramCaption( + isFirstMedia ? (params.reply.text ?? undefined) : undefined, + ); + const htmlCaption = caption + ? renderTelegramHtmlText(caption, { tableMode: params.tableMode }) + : undefined; + if (followUpText) { + pendingFollowUpText = followUpText; + } + first = false; + const replyToMessageId = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText; + const mediaParams: Record = { + caption: htmlCaption, + ...(htmlCaption ? { parse_mode: "HTML" } : {}), + ...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}), + ...buildTelegramSendParams({ + replyToMessageId, + thread: params.thread, + }), + }; + if (isGif) { + await sendTelegramWithThreadFallback({ + operation: "sendAnimation", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendAnimation(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + } else if (kind === "image") { + await sendTelegramWithThreadFallback({ + operation: "sendPhoto", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendPhoto(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + } else if (kind === "video") { + await sendTelegramWithThreadFallback({ + operation: "sendVideo", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendVideo(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + } else if (kind === "audio") { + const { useVoice } = resolveTelegramVoiceSend({ + wantsVoice: params.reply.audioAsVoice === true, + contentType: media.contentType, + fileName, + logFallback: logVerbose, + }); + if (useVoice) { + await params.onVoiceRecording?.(); + try { + await sendTelegramWithThreadFallback({ + operation: "sendVoice", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + shouldLog: (err) => !isVoiceMessagesForbidden(err), + send: (effectiveParams) => + params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + } catch (voiceErr) { + if (isVoiceMessagesForbidden(voiceErr)) { + const fallbackText = params.reply.text; + if (!fallbackText || !fallbackText.trim()) { + throw voiceErr; + } + logVerbose( + "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", + ); + const voiceFallbackReplyTo = resolveReplyToForSend({ + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + await sendTelegramVoiceFallbackText({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + text: fallbackText, + chunkText: params.chunkText, + replyToId: voiceFallbackReplyTo, + thread: params.thread, + linkPreview: params.linkPreview, + replyMarkup: params.replyMarkup, + replyQuoteText: params.replyQuoteText, + }); + markReplyApplied(params.progress, voiceFallbackReplyTo); + markDelivered(params.progress); + continue; + } + if (isCaptionTooLong(voiceErr)) { + logVerbose( + "telegram sendVoice caption too long; resending voice without caption + text separately", + ); + const noCaptionParams = { ...mediaParams }; + delete noCaptionParams.caption; + delete noCaptionParams.parse_mode; + await sendTelegramWithThreadFallback({ + operation: "sendVoice", + runtime: params.runtime, + thread: params.thread, + requestParams: noCaptionParams, + send: (effectiveParams) => + params.bot.api.sendVoice(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + const fallbackText = params.reply.text; + if (fallbackText?.trim()) { + await sendTelegramVoiceFallbackText({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + text: fallbackText, + chunkText: params.chunkText, + replyToId: undefined, + thread: params.thread, + linkPreview: params.linkPreview, + replyMarkup: params.replyMarkup, + }); + } + markReplyApplied(params.progress, replyToMessageId); + continue; + } + throw voiceErr; + } + } else { + await sendTelegramWithThreadFallback({ + operation: "sendAudio", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendAudio(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + } + } else { + await sendTelegramWithThreadFallback({ + operation: "sendDocument", + runtime: params.runtime, + thread: params.thread, + requestParams: mediaParams, + send: (effectiveParams) => + params.bot.api.sendDocument(params.chatId, file, { ...effectiveParams }), + }); + markDelivered(params.progress); + } + markReplyApplied(params.progress, replyToMessageId); + if (pendingFollowUpText && isFirstMedia) { + await sendPendingFollowUpText({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + chunkText: params.chunkText, + text: pendingFollowUpText, + replyMarkup: params.replyMarkup, + linkPreview: params.linkPreview, + replyToId: params.replyToId, + replyToMode: params.replyToMode, + progress: params.progress, + }); + pendingFollowUpText = undefined; + } + } +} + export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; @@ -64,59 +409,27 @@ export async function deliverReplies(params: { /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; }): Promise<{ delivered: boolean }> { - const { - replies, - chatId, - runtime, - bot, - replyToMode, - textLimit, - thread, - linkPreview, - replyQuoteText, - } = params; - const chunkMode = params.chunkMode ?? "length"; - let hasReplied = false; - let hasDelivered = false; - const markDelivered = () => { - hasDelivered = true; + const progress: DeliveryProgress = { + hasReplied: false, + hasDelivered: false, }; - const chunkText = (markdown: string) => { - const markdownChunks = - chunkMode === "newline" - ? chunkMarkdownTextWithMode(markdown, textLimit, chunkMode) - : [markdown]; - const chunks: ReturnType = []; - for (const chunk of markdownChunks) { - const nested = markdownToTelegramChunks(chunk, textLimit, { tableMode: params.tableMode }); - if (!nested.length && chunk) { - chunks.push({ - html: wrapFileReferencesInHtml( - markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }), - ), - text: chunk, - }); - continue; - } - chunks.push(...nested); - } - return chunks; - }; - for (const reply of replies) { + const chunkText = buildChunkTextResolver({ + textLimit: params.textLimit, + chunkMode: params.chunkMode ?? "length", + tableMode: params.tableMode, + }); + for (const reply of params.replies) { const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; if (!reply?.text && !hasMedia) { if (reply?.audioAsVoice) { logVerbose("telegram reply has audioAsVoice without media/text; skipping"); continue; } - runtime.error?.(danger("reply missing text/media")); + params.runtime.error?.(danger("reply missing text/media")); continue; } - const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); - // Evaluate lazily so `hasReplied` is checked at each send site. - // When replyToMode is "first", only the first chunk/media item gets the reply-to. - const resolveReplyTo = () => - replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; + const replyToId = + params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); const mediaList = reply.mediaUrls?.length ? reply.mediaUrls : reply.mediaUrl @@ -127,230 +440,43 @@ export async function deliverReplies(params: { | undefined; const replyMarkup = buildInlineKeyboard(telegramData?.buttons); if (mediaList.length === 0) { - const chunks = chunkText(reply.text || ""); - for (let i = 0; i < chunks.length; i += 1) { - const chunk = chunks[i]; - if (!chunk) { - continue; - } - // Only attach buttons to the first chunk. - const shouldAttachButtons = i === 0 && replyMarkup; - const replyToForChunk = resolveReplyTo(); - await sendTelegramText(bot, chatId, chunk.html, runtime, { - replyToMessageId: replyToForChunk, - replyQuoteText, - thread, - textMode: "html", - plainText: chunk.text, - linkPreview, - replyMarkup: shouldAttachButtons ? replyMarkup : undefined, - }); - if (replyToForChunk && !hasReplied) { - hasReplied = true; - } - markDelivered(); - } + await deliverTextReply({ + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + chunkText, + replyText: reply.text || "", + replyMarkup, + replyQuoteText: params.replyQuoteText, + linkPreview: params.linkPreview, + replyToId, + replyToMode: params.replyToMode, + progress, + }); continue; } - // media with optional caption on first item - let first = true; - // Track if we need to send a follow-up text message after media - // (when caption exceeds Telegram's 1024-char limit) - let pendingFollowUpText: string | undefined; - for (const mediaUrl of mediaList) { - const isFirstMedia = first; - const media = await loadWebMedia(mediaUrl, { - localRoots: params.mediaLocalRoots, - }); - const kind = mediaKindFromMime(media.contentType ?? undefined); - const isGif = isGifMedia({ - contentType: media.contentType, - fileName: media.fileName, - }); - const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); - const file = new InputFile(media.buffer, fileName); - // Caption only on first item; if text exceeds limit, defer to follow-up message. - const { caption, followUpText } = splitTelegramCaption( - isFirstMedia ? (reply.text ?? undefined) : undefined, - ); - const htmlCaption = caption - ? renderTelegramHtmlText(caption, { tableMode: params.tableMode }) - : undefined; - if (followUpText) { - pendingFollowUpText = followUpText; - } - first = false; - const replyToMessageId = resolveReplyTo(); - const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; - const mediaParams: Record = { - caption: htmlCaption, - ...(htmlCaption ? { parse_mode: "HTML" } : {}), - ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}), - ...buildTelegramSendParams({ - replyToMessageId, - thread, - }), - }; - if (isGif) { - await sendTelegramWithThreadFallback({ - operation: "sendAnimation", - runtime, - thread, - requestParams: mediaParams, - send: (effectiveParams) => bot.api.sendAnimation(chatId, file, { ...effectiveParams }), - }); - markDelivered(); - } else if (kind === "image") { - await sendTelegramWithThreadFallback({ - operation: "sendPhoto", - runtime, - thread, - requestParams: mediaParams, - send: (effectiveParams) => bot.api.sendPhoto(chatId, file, { ...effectiveParams }), - }); - markDelivered(); - } else if (kind === "video") { - await sendTelegramWithThreadFallback({ - operation: "sendVideo", - runtime, - thread, - requestParams: mediaParams, - send: (effectiveParams) => bot.api.sendVideo(chatId, file, { ...effectiveParams }), - }); - markDelivered(); - } else if (kind === "audio") { - const { useVoice } = resolveTelegramVoiceSend({ - wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) - contentType: media.contentType, - fileName, - logFallback: logVerbose, - }); - if (useVoice) { - // Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]]) - // Switch typing indicator to record_voice before sending. - await params.onVoiceRecording?.(); - try { - await sendTelegramWithThreadFallback({ - operation: "sendVoice", - runtime, - thread, - requestParams: mediaParams, - shouldLog: (err) => !isVoiceMessagesForbidden(err), - send: (effectiveParams) => bot.api.sendVoice(chatId, file, { ...effectiveParams }), - }); - markDelivered(); - } catch (voiceErr) { - // Fall back to text if voice messages are forbidden in this chat. - if (isVoiceMessagesForbidden(voiceErr)) { - const fallbackText = reply.text; - if (!fallbackText || !fallbackText.trim()) { - throw voiceErr; - } - logVerbose( - "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", - ); - await sendTelegramVoiceFallbackText({ - bot, - chatId, - runtime, - text: fallbackText, - chunkText, - replyToId: resolveReplyTo(), - thread, - linkPreview, - replyMarkup, - replyQuoteText, - }); - if (replyToId && !hasReplied) { - hasReplied = true; - } - markDelivered(); - continue; - } - if (isCaptionTooLong(voiceErr)) { - logVerbose( - "telegram sendVoice caption too long; resending voice without caption + text separately", - ); - const noCaptionParams = { ...mediaParams }; - delete noCaptionParams.caption; - delete noCaptionParams.parse_mode; - await withTelegramApiErrorLogging({ - operation: "sendVoice", - runtime, - fn: () => bot.api.sendVoice(chatId, file, { ...noCaptionParams }), - }); - markDelivered(); - const fallbackText = reply.text; - if (fallbackText?.trim()) { - await sendTelegramVoiceFallbackText({ - bot, - chatId, - runtime, - text: fallbackText, - chunkText, - replyToId: undefined, - thread, - linkPreview, - replyMarkup, - }); - } - if (replyToMessageId && !hasReplied) { - hasReplied = true; - } - continue; - } - throw voiceErr; - } - } else { - // Audio file - displays with metadata (title, duration) - DEFAULT - await sendTelegramWithThreadFallback({ - operation: "sendAudio", - runtime, - thread, - requestParams: mediaParams, - send: (effectiveParams) => bot.api.sendAudio(chatId, file, { ...effectiveParams }), - }); - markDelivered(); - } - } else { - await sendTelegramWithThreadFallback({ - operation: "sendDocument", - runtime, - thread, - requestParams: mediaParams, - send: (effectiveParams) => bot.api.sendDocument(chatId, file, { ...effectiveParams }), - }); - markDelivered(); - } - if (replyToId && !hasReplied) { - hasReplied = true; - } - // Send deferred follow-up text right after the first media item. - // Chunk it in case it's extremely long (same logic as text-only replies). - if (pendingFollowUpText && isFirstMedia) { - const chunks = chunkText(pendingFollowUpText); - for (let i = 0; i < chunks.length; i += 1) { - const chunk = chunks[i]; - const replyToForFollowUp = resolveReplyTo(); - await sendTelegramText(bot, chatId, chunk.html, runtime, { - replyToMessageId: replyToForFollowUp, - thread, - textMode: "html", - plainText: chunk.text, - linkPreview, - replyMarkup: i === 0 ? replyMarkup : undefined, - }); - if (replyToForFollowUp && !hasReplied) { - hasReplied = true; - } - markDelivered(); - } - pendingFollowUpText = undefined; - } - } + await deliverMediaReply({ + reply, + mediaList, + bot: params.bot, + chatId: params.chatId, + runtime: params.runtime, + thread: params.thread, + tableMode: params.tableMode, + mediaLocalRoots: params.mediaLocalRoots, + chunkText, + onVoiceRecording: params.onVoiceRecording, + linkPreview: params.linkPreview, + replyQuoteText: params.replyQuoteText, + replyMarkup, + replyToId, + replyToMode: params.replyToMode, + progress, + }); } - return { delivered: hasDelivered }; + return { delivered: progress.hasDelivered }; } export async function resolveMedia( diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index ebcb6a7ecbd..91a5ef9931d 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -37,13 +37,34 @@ function isProxyLikeDispatcher(dispatcher: unknown): boolean { return typeof ctorName === "string" && ctorName.includes("ProxyAgent"); } -const FALLBACK_RETRY_ERROR_CODES = new Set([ +const FALLBACK_RETRY_ERROR_CODES = [ "ETIMEDOUT", "ENETUNREACH", "EHOSTUNREACH", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_SOCKET", -]); +] as const; + +type Ipv4FallbackContext = { + message: string; + codes: Set; +}; + +type Ipv4FallbackRule = { + name: string; + matches: (ctx: Ipv4FallbackContext) => boolean; +}; + +const IPV4_FALLBACK_RULES: readonly Ipv4FallbackRule[] = [ + { + name: "fetch-failed-envelope", + matches: ({ message }) => message.includes("fetch failed"), + }, + { + name: "known-network-code", + matches: ({ codes }) => FALLBACK_RETRY_ERROR_CODES.some((code) => codes.has(code)), + }, +]; // Node 22 workaround: enable autoSelectFamily to allow IPv4 fallback on broken IPv6 networks. // Many networks have IPv6 configured but not routed, causing "Network is unreachable" errors. @@ -149,21 +170,17 @@ function collectErrorCodes(err: unknown): Set { } function shouldRetryWithIpv4Fallback(err: unknown): boolean { - const message = - err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : ""; - if (!message.includes("fetch failed")) { - return false; - } - const codes = collectErrorCodes(err); - if (codes.size === 0) { - return false; - } - for (const code of codes) { - if (FALLBACK_RETRY_ERROR_CODES.has(code)) { - return true; + const ctx: Ipv4FallbackContext = { + message: + err && typeof err === "object" && "message" in err ? String(err.message).toLowerCase() : "", + codes: collectErrorCodes(err), + }; + for (const rule of IPV4_FALLBACK_RULES) { + if (!rule.matches(ctx)) { + return false; } } - return false; + return true; } function applyTelegramIpv4Fallback(): void {