refactor: simplify telegram delivery and outbound session resolver flow

This commit is contained in:
Peter Steinberger
2026-03-02 03:08:33 +00:00
parent 166ae8f002
commit 493ebb915b
3 changed files with 450 additions and 314 deletions

View File

@@ -882,6 +882,29 @@ function resolveFallbackSession(
};
}
type OutboundSessionResolver = (
params: ResolveOutboundSessionRouteParams,
) => OutboundSessionRoute | null | Promise<OutboundSessionRoute | null>;
const OUTBOUND_SESSION_RESOLVERS: Partial<Record<ChannelId, OutboundSessionResolver>> = {
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<OutboundSessionRoute | null> {
@@ -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: {

View File

@@ -45,6 +45,351 @@ const TELEGRAM_MEDIA_SSRF_POLICY = {
allowRfc2544BenchmarkRange: true,
};
type DeliveryProgress = {
hasReplied: boolean;
hasDelivered: boolean;
};
type ChunkTextFn = (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
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<typeof markdownToTelegramChunks> = [];
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<typeof buildInlineKeyboard>;
replyQuoteText?: string;
linkPreview?: boolean;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<void> {
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<typeof buildInlineKeyboard>;
linkPreview?: boolean;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<void> {
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> | void;
linkPreview?: boolean;
replyQuoteText?: string;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
}): Promise<void> {
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<string, unknown> = {
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<typeof markdownToTelegramChunks> = [];
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<string, unknown> = {
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(

View File

@@ -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<string>;
};
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<string> {
}
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 {