mirror of
https://github.com/yuangyaa/openclaw.git
synced 2026-07-14 11:07:29 +08:00
* fix(gateway): avoid premature agent.wait completion on transient errors * fix(agent): preemptively guard tool results against context overflow * fix: harden tool-result context guard and add message_id metadata * fix: use importOriginal in session-key mock to include DEFAULT_ACCOUNT_ID The run.skill-filter test was mocking ../../routing/session-key.js with only buildAgentMainSessionKey and normalizeAgentId, but the module also exports DEFAULT_ACCOUNT_ID which is required transitively by src/web/auth-store.ts. Switch to importOriginal pattern so all real exports are preserved alongside the mocked functions. * pi-runner: guard accumulated tool-result overflow in transformContext * PI runner: compact overflowing tool-result context * Subagent: harden tool-result context recovery * Enhance tool-result context handling by adding support for legacy tool outputs and improving character estimation for message truncation. This includes a new function to create legacy tool results and updates to existing functions to better manage context overflow scenarios. * Enhance iMessage handling by adding reply tag support in send functions and tests. This includes modifications to prepend or rewrite reply tags based on provided replyToId, ensuring proper message formatting for replies. * Enhance message delivery across multiple channels by implementing sticky reply context for chunked messages. This includes preserving reply references in Discord, Telegram, and iMessage, ensuring that follow-up messages maintain their intended reply targets. Additionally, improve handling of reply tags in system prompts and tests to support consistent reply behavior. * Enhance read tool functionality by implementing auto-paging across chunks when no explicit limit is provided, scaling output budget based on model context window. Additionally, add tests for adaptive reading behavior and capped continuation guidance for large outputs. Update related functions to support these features. * Refine tool-result context management by stripping oversized read-tool details payloads during compaction, ensuring repeated read calls do not bypass context limits. Introduce new utility functions for handling truncation content and enhance character estimation for tool results. Add tests to validate the removal of excessive details in context overflow scenarios. * Refine message delivery logic in Matrix and Telegram by introducing a flag to track if a text chunk was sent. This ensures that replies are only marked as delivered when a text chunk has been successfully sent, improving the accuracy of reply handling in both channels. * fix: tighten reply threading coverage and prep fixes (#19508) (thanks @tyler6204)
582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import { type Bot, GrammyError, InputFile } from "grammy";
|
|
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
|
|
import type { ReplyPayload } from "../../auto-reply/types.js";
|
|
import type { ReplyToMode } from "../../config/config.js";
|
|
import type { MarkdownTableMode } from "../../config/types.base.js";
|
|
import { danger, logVerbose, warn } from "../../globals.js";
|
|
import { formatErrorMessage } from "../../infra/errors.js";
|
|
import { retryAsync } from "../../infra/retry.js";
|
|
import { mediaKindFromMime } from "../../media/constants.js";
|
|
import { fetchRemoteMedia } from "../../media/fetch.js";
|
|
import { isGifMedia } from "../../media/mime.js";
|
|
import { saveMediaBuffer } from "../../media/store.js";
|
|
import type { RuntimeEnv } from "../../runtime.js";
|
|
import { loadWebMedia } from "../../web/media.js";
|
|
import { withTelegramApiErrorLogging } from "../api-logging.js";
|
|
import type { TelegramInlineButtons } from "../button-types.js";
|
|
import { splitTelegramCaption } from "../caption.js";
|
|
import {
|
|
markdownToTelegramChunks,
|
|
markdownToTelegramHtml,
|
|
renderTelegramHtmlText,
|
|
wrapFileReferencesInHtml,
|
|
} from "../format.js";
|
|
import { buildInlineKeyboard } from "../send.js";
|
|
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
|
|
import { resolveTelegramVoiceSend } from "../voice.js";
|
|
import {
|
|
buildTelegramThreadParams,
|
|
resolveTelegramMediaPlaceholder,
|
|
resolveTelegramReplyId,
|
|
type TelegramThreadSpec,
|
|
} from "./helpers.js";
|
|
import type { StickerMetadata, TelegramContext } from "./types.js";
|
|
|
|
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
|
|
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
|
|
const FILE_TOO_BIG_RE = /file is too big/i;
|
|
|
|
export async function deliverReplies(params: {
|
|
replies: ReplyPayload[];
|
|
chatId: string;
|
|
token: string;
|
|
runtime: RuntimeEnv;
|
|
bot: Bot;
|
|
mediaLocalRoots?: readonly string[];
|
|
replyToMode: ReplyToMode;
|
|
textLimit: number;
|
|
thread?: TelegramThreadSpec | null;
|
|
tableMode?: MarkdownTableMode;
|
|
chunkMode?: ChunkMode;
|
|
/** Callback invoked before sending a voice message to switch typing indicator. */
|
|
onVoiceRecording?: () => Promise<void> | void;
|
|
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
|
linkPreview?: boolean;
|
|
/** 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 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 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"));
|
|
continue;
|
|
}
|
|
const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
|
const replyToMessageIdForPayload =
|
|
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
|
|
const mediaList = reply.mediaUrls?.length
|
|
? reply.mediaUrls
|
|
: reply.mediaUrl
|
|
? [reply.mediaUrl]
|
|
: [];
|
|
const telegramData = reply.channelData?.telegram as
|
|
| { buttons?: TelegramInlineButtons }
|
|
| undefined;
|
|
const replyMarkup = buildInlineKeyboard(telegramData?.buttons);
|
|
if (mediaList.length === 0) {
|
|
const chunks = chunkText(reply.text || "");
|
|
let sentTextChunk = false;
|
|
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;
|
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
|
replyToMessageId: replyToMessageIdForPayload,
|
|
replyQuoteText,
|
|
thread,
|
|
textMode: "html",
|
|
plainText: chunk.text,
|
|
linkPreview,
|
|
replyMarkup: shouldAttachButtons ? replyMarkup : undefined,
|
|
});
|
|
sentTextChunk = true;
|
|
markDelivered();
|
|
}
|
|
if (replyToMessageIdForPayload && !hasReplied && sentTextChunk) {
|
|
hasReplied = true;
|
|
}
|
|
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 = replyToMessageIdForPayload;
|
|
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 withTelegramApiErrorLogging({
|
|
operation: "sendAnimation",
|
|
runtime,
|
|
fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} else if (kind === "image") {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendPhoto",
|
|
runtime,
|
|
fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} else if (kind === "video") {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendVideo",
|
|
runtime,
|
|
fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
|
|
});
|
|
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 withTelegramApiErrorLogging({
|
|
operation: "sendVoice",
|
|
runtime,
|
|
shouldLog: (err) => !isVoiceMessagesForbidden(err),
|
|
fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
} catch (voiceErr) {
|
|
// Fall back to text if voice messages are forbidden in this chat.
|
|
// This happens when the recipient has Telegram Premium privacy settings
|
|
// that block voice messages (Settings > Privacy > Voice Messages).
|
|
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: replyToMessageIdForPayload,
|
|
thread,
|
|
linkPreview,
|
|
replyMarkup,
|
|
replyQuoteText,
|
|
});
|
|
if (replyToMessageIdForPayload && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
markDelivered();
|
|
// Skip this media item; continue with next.
|
|
continue;
|
|
}
|
|
throw voiceErr;
|
|
}
|
|
} else {
|
|
// Audio file - displays with metadata (title, duration) - DEFAULT
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendAudio",
|
|
runtime,
|
|
fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
|
|
});
|
|
markDelivered();
|
|
}
|
|
} else {
|
|
await withTelegramApiErrorLogging({
|
|
operation: "sendDocument",
|
|
runtime,
|
|
fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
|
|
});
|
|
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];
|
|
await sendTelegramText(bot, chatId, chunk.html, runtime, {
|
|
replyToMessageId: replyToMessageIdForPayload,
|
|
thread,
|
|
textMode: "html",
|
|
plainText: chunk.text,
|
|
linkPreview,
|
|
replyMarkup: i === 0 ? replyMarkup : undefined,
|
|
});
|
|
markDelivered();
|
|
}
|
|
pendingFollowUpText = undefined;
|
|
}
|
|
if (replyToMessageIdForPayload && !hasReplied) {
|
|
hasReplied = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { delivered: hasDelivered };
|
|
}
|
|
|
|
export async function resolveMedia(
|
|
ctx: TelegramContext,
|
|
maxBytes: number,
|
|
token: string,
|
|
proxyFetch?: typeof fetch,
|
|
): Promise<{
|
|
path: string;
|
|
contentType?: string;
|
|
placeholder: string;
|
|
stickerMetadata?: StickerMetadata;
|
|
} | null> {
|
|
const msg = ctx.message;
|
|
const downloadAndSaveTelegramFile = async (filePath: string, fetchImpl: typeof fetch) => {
|
|
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
|
|
const fetched = await fetchRemoteMedia({
|
|
url,
|
|
fetchImpl,
|
|
filePathHint: filePath,
|
|
});
|
|
const originalName = fetched.fileName ?? filePath;
|
|
return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName);
|
|
};
|
|
|
|
// Handle stickers separately - only static stickers (WEBP) are supported
|
|
if (msg.sticker) {
|
|
const sticker = msg.sticker;
|
|
// Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported
|
|
if (sticker.is_animated || sticker.is_video) {
|
|
logVerbose("telegram: skipping animated/video sticker (only static stickers supported)");
|
|
return null;
|
|
}
|
|
if (!sticker.file_id) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const file = await ctx.getFile();
|
|
if (!file.file_path) {
|
|
logVerbose("telegram: getFile returned no file_path for sticker");
|
|
return null;
|
|
}
|
|
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
|
if (!fetchImpl) {
|
|
logVerbose("telegram: fetch not available for sticker download");
|
|
return null;
|
|
}
|
|
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
|
|
|
// Check sticker cache for existing description
|
|
const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null;
|
|
if (cached) {
|
|
logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`);
|
|
const fileId = sticker.file_id ?? cached.fileId;
|
|
const emoji = sticker.emoji ?? cached.emoji;
|
|
const setName = sticker.set_name ?? cached.setName;
|
|
if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) {
|
|
// Refresh cached sticker metadata on hits so sends/searches use latest file_id.
|
|
cacheSticker({
|
|
...cached,
|
|
fileId,
|
|
emoji,
|
|
setName,
|
|
});
|
|
}
|
|
return {
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: "<media:sticker>",
|
|
stickerMetadata: {
|
|
emoji,
|
|
setName,
|
|
fileId,
|
|
fileUniqueId: sticker.file_unique_id,
|
|
cachedDescription: cached.description,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Cache miss - return metadata for vision processing
|
|
return {
|
|
path: saved.path,
|
|
contentType: saved.contentType,
|
|
placeholder: "<media:sticker>",
|
|
stickerMetadata: {
|
|
emoji: sticker.emoji ?? undefined,
|
|
setName: sticker.set_name ?? undefined,
|
|
fileId: sticker.file_id,
|
|
fileUniqueId: sticker.file_unique_id,
|
|
},
|
|
};
|
|
} catch (err) {
|
|
logVerbose(`telegram: failed to process sticker: ${String(err)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const m =
|
|
msg.photo?.[msg.photo.length - 1] ??
|
|
msg.video ??
|
|
msg.video_note ??
|
|
msg.document ??
|
|
msg.audio ??
|
|
msg.voice;
|
|
if (!m?.file_id) {
|
|
return null;
|
|
}
|
|
|
|
let file: { file_path?: string };
|
|
try {
|
|
file = await retryAsync(() => ctx.getFile(), {
|
|
attempts: 3,
|
|
minDelayMs: 1000,
|
|
maxDelayMs: 4000,
|
|
jitter: 0.2,
|
|
label: "telegram:getFile",
|
|
shouldRetry: isRetryableGetFileError,
|
|
onRetry: ({ attempt, maxAttempts }) =>
|
|
logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`),
|
|
});
|
|
} catch (err) {
|
|
// Handle "file is too big" separately - Telegram Bot API has a 20MB download limit
|
|
if (isFileTooBigError(err)) {
|
|
logVerbose(
|
|
warn(
|
|
"telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment",
|
|
),
|
|
);
|
|
return null;
|
|
}
|
|
// All retries exhausted — return null so the message still reaches the agent
|
|
// with a type-based placeholder (e.g. <media:audio>) instead of being dropped.
|
|
logVerbose(`telegram: getFile failed after retries: ${String(err)}`);
|
|
return null;
|
|
}
|
|
if (!file.file_path) {
|
|
throw new Error("Telegram getFile returned no file_path");
|
|
}
|
|
const fetchImpl = proxyFetch ?? globalThis.fetch;
|
|
if (!fetchImpl) {
|
|
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
|
}
|
|
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
|
|
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
|
|
return { path: saved.path, contentType: saved.contentType, placeholder };
|
|
}
|
|
|
|
function isVoiceMessagesForbidden(err: unknown): boolean {
|
|
if (err instanceof GrammyError) {
|
|
return VOICE_FORBIDDEN_RE.test(err.description);
|
|
}
|
|
return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err));
|
|
}
|
|
|
|
/**
|
|
* Returns true if the error is Telegram's "file is too big" error.
|
|
* This happens when trying to download files >20MB via the Bot API.
|
|
* Unlike network errors, this is a permanent error and should not be retried.
|
|
*/
|
|
function isFileTooBigError(err: unknown): boolean {
|
|
if (err instanceof GrammyError) {
|
|
return FILE_TOO_BIG_RE.test(err.description);
|
|
}
|
|
return FILE_TOO_BIG_RE.test(formatErrorMessage(err));
|
|
}
|
|
|
|
/**
|
|
* Returns true if the error is a transient network error that should be retried.
|
|
* Returns false for permanent errors like "file is too big" (400 Bad Request).
|
|
*/
|
|
function isRetryableGetFileError(err: unknown): boolean {
|
|
// Don't retry "file is too big" - it's a permanent 400 error
|
|
if (isFileTooBigError(err)) {
|
|
return false;
|
|
}
|
|
// Retry all other errors (network issues, timeouts, etc.)
|
|
return true;
|
|
}
|
|
|
|
async function sendTelegramVoiceFallbackText(opts: {
|
|
bot: Bot;
|
|
chatId: string;
|
|
runtime: RuntimeEnv;
|
|
text: string;
|
|
chunkText: (markdown: string) => ReturnType<typeof markdownToTelegramChunks>;
|
|
replyToId?: number;
|
|
thread?: TelegramThreadSpec | null;
|
|
linkPreview?: boolean;
|
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
|
replyQuoteText?: string;
|
|
}): Promise<void> {
|
|
const chunks = opts.chunkText(opts.text);
|
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
const chunk = chunks[i];
|
|
await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, {
|
|
replyToMessageId: opts.replyToId,
|
|
replyQuoteText: opts.replyQuoteText,
|
|
thread: opts.thread,
|
|
textMode: "html",
|
|
plainText: chunk.text,
|
|
linkPreview: opts.linkPreview,
|
|
replyMarkup: i === 0 ? opts.replyMarkup : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
function buildTelegramSendParams(opts?: {
|
|
replyToMessageId?: number;
|
|
thread?: TelegramThreadSpec | null;
|
|
}): Record<string, unknown> {
|
|
const threadParams = buildTelegramThreadParams(opts?.thread);
|
|
const params: Record<string, unknown> = {};
|
|
if (opts?.replyToMessageId) {
|
|
params.reply_to_message_id = opts.replyToMessageId;
|
|
}
|
|
if (threadParams) {
|
|
params.message_thread_id = threadParams.message_thread_id;
|
|
}
|
|
return params;
|
|
}
|
|
|
|
async function sendTelegramText(
|
|
bot: Bot,
|
|
chatId: string,
|
|
text: string,
|
|
runtime: RuntimeEnv,
|
|
opts?: {
|
|
replyToMessageId?: number;
|
|
replyQuoteText?: string;
|
|
thread?: TelegramThreadSpec | null;
|
|
textMode?: "markdown" | "html";
|
|
plainText?: string;
|
|
linkPreview?: boolean;
|
|
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
|
|
},
|
|
): Promise<number | undefined> {
|
|
const baseParams = buildTelegramSendParams({
|
|
replyToMessageId: opts?.replyToMessageId,
|
|
thread: opts?.thread,
|
|
});
|
|
// Add link_preview_options when link preview is disabled.
|
|
const linkPreviewEnabled = opts?.linkPreview ?? true;
|
|
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
|
|
const textMode = opts?.textMode ?? "markdown";
|
|
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
|
|
try {
|
|
const res = await withTelegramApiErrorLogging({
|
|
operation: "sendMessage",
|
|
runtime,
|
|
shouldLog: (err) => !PARSE_ERR_RE.test(formatErrorMessage(err)),
|
|
fn: () =>
|
|
bot.api.sendMessage(chatId, htmlText, {
|
|
parse_mode: "HTML",
|
|
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
|
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
|
...baseParams,
|
|
}),
|
|
});
|
|
return res.message_id;
|
|
} catch (err) {
|
|
const errText = formatErrorMessage(err);
|
|
if (PARSE_ERR_RE.test(errText)) {
|
|
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
|
|
const fallbackText = opts?.plainText ?? text;
|
|
const res = await withTelegramApiErrorLogging({
|
|
operation: "sendMessage",
|
|
runtime,
|
|
fn: () =>
|
|
bot.api.sendMessage(chatId, fallbackText, {
|
|
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
|
|
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
|
|
...baseParams,
|
|
}),
|
|
});
|
|
return res.message_id;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|