mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
refactor: simplify telegram delivery and outbound session resolver flow
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user