import type { InlineKeyboardButton, InlineKeyboardMarkup, ReactionType, ReactionTypeEmoji, } from "@grammyjs/types"; import { type ApiClientOptions, Bot, HttpError, InputFile } from "grammy"; import { loadConfig } from "../config/config.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { logVerbose } from "../globals.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; import { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js"; import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js"; import { createTelegramRetryRunner } from "../infra/retry-policy.js"; import type { RetryConfig } from "../infra/retry.js"; import { redactSensitiveText } from "../logging/redact.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { MediaKind } from "../media/constants.js"; import { buildOutboundMediaLoadOptions } from "../media/load-options.js"; import { isGifMedia, kindFromMime } from "../media/mime.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import { loadWebMedia } from "../web/media.js"; import { type ResolvedTelegramAccount, resolveTelegramAccount } from "./accounts.js"; import { withTelegramApiErrorLogging } from "./api-logging.js"; import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js"; import type { TelegramInlineButtons } from "./button-types.js"; import { splitTelegramCaption } from "./caption.js"; import { resolveTelegramFetch } from "./fetch.js"; import { renderTelegramHtmlText } from "./format.js"; import { isRecoverableTelegramNetworkError, isSafeToRetrySendError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { maybePersistResolvedTelegramTarget } from "./target-writeback.js"; import { normalizeTelegramChatId, normalizeTelegramLookupTarget, parseTelegramTarget, } from "./targets.js"; import { resolveTelegramVoiceSend } from "./voice.js"; type TelegramApi = Bot["api"]; type TelegramApiOverride = Partial; type TelegramSendOpts = { cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; mediaUrl?: string; mediaLocalRoots?: readonly string[]; maxBytes?: number; api?: TelegramApiOverride; retry?: RetryConfig; textMode?: "markdown" | "html"; plainText?: string; /** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */ asVoice?: boolean; /** Send video as video note (voice bubble) instead of regular video. Defaults to false. */ asVideoNote?: boolean; /** Send message silently (no notification). Defaults to false. */ silent?: boolean; /** Message ID to reply to (for threading) */ replyToMessageId?: number; /** Quote text for Telegram reply_parameters. */ quoteText?: string; /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; /** Inline keyboard buttons (reply markup). */ buttons?: TelegramInlineButtons; }; type TelegramSendResult = { messageId: string; chatId: string; }; type TelegramMessageLike = { message_id?: number; chat?: { id?: string | number }; }; type TelegramReactionOpts = { token?: string; accountId?: string; api?: TelegramApiOverride; remove?: boolean; verbose?: boolean; retry?: RetryConfig; }; type TelegramTypingOpts = { cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; api?: TelegramApiOverride; retry?: RetryConfig; messageThreadId?: number; }; function resolveTelegramMessageIdOrThrow( result: TelegramMessageLike | null | undefined, context: string, ): number { if (typeof result?.message_id === "number" && Number.isFinite(result.message_id)) { return Math.trunc(result.message_id); } throw new Error(`Telegram ${context} returned no message_id`); } const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const THREAD_NOT_FOUND_RE = /400:\s*Bad Request:\s*message thread not found/i; const MESSAGE_NOT_MODIFIED_RE = /400:\s*Bad Request:\s*message is not modified|MESSAGE_NOT_MODIFIED/i; const CHAT_NOT_FOUND_RE = /400: Bad Request: chat not found/i; const sendLogger = createSubsystemLogger("telegram/send"); const diagLogger = createSubsystemLogger("telegram/diagnostic"); const telegramClientOptionsCache = new Map(); const MAX_TELEGRAM_CLIENT_OPTIONS_CACHE_SIZE = 64; export function resetTelegramClientOptionsCacheForTests(): void { telegramClientOptionsCache.clear(); } function createTelegramHttpLogger(cfg: ReturnType) { const enabled = isDiagnosticFlagEnabled("telegram.http", cfg); if (!enabled) { return () => {}; } return (label: string, err: unknown) => { if (!(err instanceof HttpError)) { return; } const detail = redactSensitiveText(formatUncaughtError(err.error ?? err)); diagLogger.warn(`telegram http error (${label}): ${detail}`); }; } function shouldUseTelegramClientOptionsCache(): boolean { return !process.env.VITEST && process.env.NODE_ENV !== "test"; } function buildTelegramClientOptionsCacheKey(params: { account: ResolvedTelegramAccount; timeoutSeconds?: number; }): string { const proxyKey = params.account.config.proxy?.trim() ?? ""; const autoSelectFamily = params.account.config.network?.autoSelectFamily; const autoSelectFamilyKey = typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default"; const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default"; const timeoutSecondsKey = typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default"; return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`; } function setCachedTelegramClientOptions( cacheKey: string, clientOptions: ApiClientOptions | undefined, ): ApiClientOptions | undefined { telegramClientOptionsCache.set(cacheKey, clientOptions); if (telegramClientOptionsCache.size > MAX_TELEGRAM_CLIENT_OPTIONS_CACHE_SIZE) { const oldestKey = telegramClientOptionsCache.keys().next().value; if (oldestKey !== undefined) { telegramClientOptionsCache.delete(oldestKey); } } return clientOptions; } function resolveTelegramClientOptions( account: ResolvedTelegramAccount, ): ApiClientOptions | undefined { const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && Number.isFinite(account.config.timeoutSeconds) ? Math.max(1, Math.floor(account.config.timeoutSeconds)) : undefined; const cacheEnabled = shouldUseTelegramClientOptionsCache(); const cacheKey = cacheEnabled ? buildTelegramClientOptionsCacheKey({ account, timeoutSeconds, }) : null; if (cacheKey && telegramClientOptionsCache.has(cacheKey)) { return telegramClientOptionsCache.get(cacheKey); } const proxyUrl = account.config.proxy?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; const fetchImpl = resolveTelegramFetch(proxyFetch, { network: account.config.network, }); const clientOptions = fetchImpl || timeoutSeconds ? { ...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}), ...(timeoutSeconds ? { timeoutSeconds } : {}), } : undefined; if (cacheKey) { return setCachedTelegramClientOptions(cacheKey, clientOptions); } return clientOptions; } function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) { if (explicit?.trim()) { return explicit.trim(); } if (!params.token) { throw new Error( `Telegram bot token missing for account "${params.accountId}" (set channels.telegram.accounts.${params.accountId}.botToken/tokenFile or TELEGRAM_BOT_TOKEN for default).`, ); } return params.token.trim(); } async function resolveChatId( to: string, params: { api: TelegramApiOverride; verbose?: boolean }, ): Promise { const numericChatId = normalizeTelegramChatId(to); if (numericChatId) { return numericChatId; } const lookupTarget = normalizeTelegramLookupTarget(to); const getChat = params.api.getChat; if (!lookupTarget || typeof getChat !== "function") { throw new Error("Telegram recipient must be a numeric chat ID"); } try { const chat = await getChat.call(params.api, lookupTarget); const resolved = normalizeTelegramChatId(String(chat?.id ?? "")); if (!resolved) { throw new Error(`resolved chat id is not numeric (${String(chat?.id ?? "")})`); } if (params.verbose) { sendLogger.warn(`telegram recipient ${lookupTarget} resolved to numeric chat id ${resolved}`); } return resolved; } catch (err) { const detail = formatErrorMessage(err); throw new Error( `Telegram recipient ${lookupTarget} could not be resolved to a numeric chat ID (${detail})`, { cause: err }, ); } } async function resolveAndPersistChatId(params: { cfg: ReturnType; api: TelegramApiOverride; lookupTarget: string; persistTarget: string; verbose?: boolean; }): Promise { const chatId = await resolveChatId(params.lookupTarget, { api: params.api, verbose: params.verbose, }); await maybePersistResolvedTelegramTarget({ cfg: params.cfg, rawTarget: params.persistTarget, resolvedChatId: chatId, verbose: params.verbose, }); return chatId; } function normalizeMessageId(raw: string | number): number { if (typeof raw === "number" && Number.isFinite(raw)) { return Math.trunc(raw); } if (typeof raw === "string") { const value = raw.trim(); if (!value) { throw new Error("Message id is required for Telegram actions"); } const parsed = Number.parseInt(value, 10); if (Number.isFinite(parsed)) { return parsed; } } throw new Error("Message id is required for Telegram actions"); } function isTelegramThreadNotFoundError(err: unknown): boolean { return THREAD_NOT_FOUND_RE.test(formatErrorMessage(err)); } function isTelegramMessageNotModifiedError(err: unknown): boolean { return MESSAGE_NOT_MODIFIED_RE.test(formatErrorMessage(err)); } function hasMessageThreadIdParam(params?: Record): boolean { if (!params) { return false; } const value = params.message_thread_id; if (typeof value === "number") { return Number.isFinite(value); } if (typeof value === "string") { return value.trim().length > 0; } return false; } function removeMessageThreadIdParam( params?: Record, ): Record | undefined { if (!params || !hasMessageThreadIdParam(params)) { return params; } const next = { ...params }; delete next.message_thread_id; return Object.keys(next).length > 0 ? next : undefined; } function isTelegramHtmlParseError(err: unknown): boolean { return PARSE_ERR_RE.test(formatErrorMessage(err)); } function buildTelegramThreadReplyParams(params: { targetMessageThreadId?: number; messageThreadId?: number; chatType?: "direct" | "group" | "unknown"; replyToMessageId?: number; quoteText?: string; }): Record { const messageThreadId = params.messageThreadId != null ? params.messageThreadId : params.targetMessageThreadId; const threadScope = params.chatType === "direct" ? ("dm" as const) : ("forum" as const); // Never blanket-strip DM message_thread_id by chat-id sign. // Telegram supports DM topics; stripping silently misroutes topic replies. // Keep thread id and rely on thread-not-found retry fallback for plain DMs. const threadSpec = messageThreadId != null ? { id: messageThreadId, scope: threadScope } : undefined; const threadIdParams = buildTelegramThreadParams(threadSpec); const threadParams: Record = threadIdParams ? { ...threadIdParams } : {}; if (params.replyToMessageId != null) { const replyToMessageId = Math.trunc(params.replyToMessageId); if (params.quoteText?.trim()) { threadParams.reply_parameters = { message_id: replyToMessageId, quote: params.quoteText.trim(), }; } else { threadParams.reply_to_message_id = replyToMessageId; } } return threadParams; } async function withTelegramHtmlParseFallback(params: { label: string; verbose?: boolean; requestHtml: (label: string) => Promise; requestPlain: (label: string) => Promise; }): Promise { try { return await params.requestHtml(params.label); } catch (err) { if (!isTelegramHtmlParseError(err)) { throw err; } if (params.verbose) { sendLogger.warn( `telegram ${params.label} failed with HTML parse error, retrying as plain text: ${formatErrorMessage( err, )}`, ); } return await params.requestPlain(`${params.label}-plain`); } } type TelegramApiContext = { cfg: ReturnType; account: ResolvedTelegramAccount; api: TelegramApi; }; function resolveTelegramApiContext(opts: { token?: string; accountId?: string; api?: TelegramApiOverride; cfg?: ReturnType; }): TelegramApiContext { const cfg = opts.cfg ?? loadConfig(); const account = resolveTelegramAccount({ cfg, accountId: opts.accountId, }); const token = resolveToken(opts.token, account); const client = resolveTelegramClientOptions(account); const api = (opts.api ?? new Bot(token, client ? { client } : undefined).api) as TelegramApi; return { cfg, account, api }; } type TelegramRequestWithDiag = ( fn: () => Promise, label?: string, options?: { shouldLog?: (err: unknown) => boolean }, ) => Promise; function createTelegramRequestWithDiag(params: { cfg: ReturnType; account: ResolvedTelegramAccount; retry?: RetryConfig; verbose?: boolean; shouldRetry?: (err: unknown) => boolean; /** When true, the shouldRetry predicate is used exclusively without the TELEGRAM_RETRY_RE fallback. */ strictShouldRetry?: boolean; useApiErrorLogging?: boolean; }): TelegramRequestWithDiag { const request = createTelegramRetryRunner({ retry: params.retry, configRetry: params.account.config.retry, verbose: params.verbose, ...(params.shouldRetry ? { shouldRetry: params.shouldRetry } : {}), ...(params.strictShouldRetry ? { strictShouldRetry: true } : {}), }); const logHttpError = createTelegramHttpLogger(params.cfg); return ( fn: () => Promise, label?: string, options?: { shouldLog?: (err: unknown) => boolean }, ) => { const runRequest = () => request(fn, label); const call = params.useApiErrorLogging === false ? runRequest() : withTelegramApiErrorLogging({ operation: label ?? "request", fn: runRequest, ...(options?.shouldLog ? { shouldLog: options.shouldLog } : {}), }); return call.catch((err) => { logHttpError(label ?? "request", err); throw err; }); }; } function wrapTelegramChatNotFoundError(err: unknown, params: { chatId: string; input: string }) { if (!CHAT_NOT_FOUND_RE.test(formatErrorMessage(err))) { return err; } return new Error( [ `Telegram send failed: chat not found (chat_id=${params.chatId}).`, "Likely: bot not started in DM, bot removed from group/channel, group migrated (new -100… id), or wrong bot token.", `Input was: ${JSON.stringify(params.input)}.`, ].join(" "), ); } async function withTelegramThreadFallback( params: Record | undefined, label: string, verbose: boolean | undefined, attempt: ( effectiveParams: Record | undefined, effectiveLabel: string, ) => Promise, ): Promise { try { return await attempt(params, label); } catch (err) { // Do not widen this fallback to cover "chat not found". // chat-not-found is routing/auth/membership/token; stripping thread IDs hides root cause. if (!hasMessageThreadIdParam(params) || !isTelegramThreadNotFoundError(err)) { throw err; } if (verbose) { sendLogger.warn( `telegram ${label} failed with message_thread_id, retrying without thread: ${formatErrorMessage(err)}`, ); } const retriedParams = removeMessageThreadIdParam(params); return await attempt(retriedParams, `${label}-threadless`); } } function createRequestWithChatNotFound(params: { requestWithDiag: TelegramRequestWithDiag; chatId: string; input: string; }) { return async (fn: () => Promise, label: string) => params.requestWithDiag(fn, label).catch((err) => { throw wrapTelegramChatNotFoundError(err, { chatId: params.chatId, input: params.input, }); }); } function createTelegramNonIdempotentRequestWithDiag(params: { cfg: ReturnType; account: ResolvedTelegramAccount; retry?: RetryConfig; verbose?: boolean; useApiErrorLogging?: boolean; }): TelegramRequestWithDiag { return createTelegramRequestWithDiag({ cfg: params.cfg, account: params.account, retry: params.retry, verbose: params.verbose, useApiErrorLogging: params.useApiErrorLogging, shouldRetry: (err) => isSafeToRetrySendError(err), strictShouldRetry: true, }); } export function buildInlineKeyboard( buttons?: TelegramSendOpts["buttons"], ): InlineKeyboardMarkup | undefined { if (!buttons?.length) { return undefined; } const rows = buttons .map((row) => row .filter((button) => button?.text && button?.callback_data) .map( (button): InlineKeyboardButton => ({ text: button.text, callback_data: button.callback_data, ...(button.style ? { style: button.style } : {}), }), ), ) .filter((row) => row.length > 0); if (rows.length === 0) { return undefined; } return { inline_keyboard: rows }; } export async function sendMessageTelegram( to: string, text: string, opts: TelegramSendOpts = {}, ): Promise { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: target.chatId, persistTarget: to, verbose: opts.verbose, }); const mediaUrl = opts.mediaUrl?.trim(); const mediaMaxBytes = opts.maxBytes ?? (typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb : 100) * 1024 * 1024; const replyMarkup = buildInlineKeyboard(opts.buttons); const threadParams = buildTelegramThreadReplyParams({ targetMessageThreadId: target.messageThreadId, messageThreadId: opts.messageThreadId, chatType: target.chatType, replyToMessageId: opts.replyToMessageId, quoteText: opts.quoteText, }); const hasThreadParams = Object.keys(threadParams).length > 0; const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, }); const requestWithChatNotFound = createRequestWithChatNotFound({ requestWithDiag, chatId, input: to, }); const textMode = opts.textMode ?? "markdown"; const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", accountId: account.accountId, }); const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode }); // Resolve link preview setting from config (default: enabled). const linkPreviewEnabled = account.config.linkPreview ?? true; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const sendTelegramText = async ( rawText: string, params?: Record, fallbackText?: string, ) => { return await withTelegramThreadFallback( params, "message", opts.verbose, async (effectiveParams, label) => { const htmlText = renderHtmlText(rawText); const baseParams = effectiveParams ? { ...effectiveParams } : {}; if (linkPreviewOptions) { baseParams.link_preview_options = linkPreviewOptions; } const hasBaseParams = Object.keys(baseParams).length > 0; const sendParams = { parse_mode: "HTML" as const, ...baseParams, ...(opts.silent === true ? { disable_notification: true } : {}), }; return await withTelegramHtmlParseFallback({ label, verbose: opts.verbose, requestHtml: (retryLabel) => requestWithChatNotFound( () => api.sendMessage( chatId, htmlText, sendParams as Parameters[2], ), retryLabel, ), requestPlain: (retryLabel) => { const plainParams = hasBaseParams ? (baseParams as Parameters[2]) : undefined; return requestWithChatNotFound( () => plainParams ? api.sendMessage(chatId, fallbackText ?? rawText, plainParams) : api.sendMessage(chatId, fallbackText ?? rawText), retryLabel, ); }, }); }, ); }; if (mediaUrl) { const media = await loadWebMedia( mediaUrl, buildOutboundMediaLoadOptions({ maxBytes: mediaMaxBytes, mediaLocalRoots: opts.mediaLocalRoots, }), ); const kind = kindFromMime(media.contentType ?? undefined); const isGif = isGifMedia({ contentType: media.contentType, fileName: media.fileName, }); const isVideoNote = kind === "video" && opts.asVideoNote === true; const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind ?? "document")) ?? "file"; const file = new InputFile(media.buffer, fileName); let caption: string | undefined; let followUpText: string | undefined; if (isVideoNote) { caption = undefined; followUpText = text.trim() ? text : undefined; } else { const split = splitTelegramCaption(text); caption = split.caption; followUpText = split.followUpText; } const htmlCaption = caption ? renderHtmlText(caption) : undefined; // If text exceeds Telegram's caption limit, send media without caption // then send text as a separate follow-up message. const needsSeparateText = Boolean(followUpText); // When splitting, put reply_markup only on the follow-up text (the "main" content), // not on the media message. const baseMediaParams = { ...(hasThreadParams ? threadParams : {}), ...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}), }; const mediaParams = { ...(htmlCaption ? { caption: htmlCaption, parse_mode: "HTML" as const } : {}), ...baseMediaParams, ...(opts.silent === true ? { disable_notification: true } : {}), }; const sendMedia = async ( label: string, sender: ( effectiveParams: Record | undefined, ) => Promise, ) => await withTelegramThreadFallback( mediaParams, label, opts.verbose, async (effectiveParams, retryLabel) => requestWithChatNotFound(() => sender(effectiveParams), retryLabel), ); const mediaSender = (() => { if (isGif) { return { label: "animation", sender: (effectiveParams: Record | undefined) => api.sendAnimation( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; } if (kind === "image") { return { label: "photo", sender: (effectiveParams: Record | undefined) => api.sendPhoto( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; } if (kind === "video") { if (isVideoNote) { return { label: "video_note", sender: (effectiveParams: Record | undefined) => api.sendVideoNote( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; } return { label: "video", sender: (effectiveParams: Record | undefined) => api.sendVideo( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; } if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: opts.asVoice === true, // default false (backward compatible) contentType: media.contentType, fileName, logFallback: logVerbose, }); if (useVoice) { return { label: "voice", sender: (effectiveParams: Record | undefined) => api.sendVoice( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; } return { label: "audio", sender: (effectiveParams: Record | undefined) => api.sendAudio( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; } return { label: "document", sender: (effectiveParams: Record | undefined) => api.sendDocument( chatId, file, effectiveParams as Parameters[2], ) as Promise, }; })(); const result = await sendMedia(mediaSender.label, mediaSender.sender); const mediaMessageId = resolveTelegramMessageIdOrThrow(result, "media send"); const resolvedChatId = String(result?.chat?.id ?? chatId); recordSentMessage(chatId, mediaMessageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); // If text was too long for a caption, send it as a separate follow-up message. // Use HTML conversion so markdown renders like captions. if (needsSeparateText && followUpText) { const textParams = hasThreadParams || replyMarkup ? { ...threadParams, ...(replyMarkup ? { reply_markup: replyMarkup } : {}), } : undefined; const textRes = await sendTelegramText(followUpText, textParams); // Return the text message ID as the "main" message (it's the actual content). const textMessageId = resolveTelegramMessageIdOrThrow(textRes, "text follow-up send"); recordSentMessage(chatId, textMessageId); return { messageId: String(textMessageId), chatId: resolvedChatId, }; } return { messageId: String(mediaMessageId), chatId: resolvedChatId }; } if (!text || !text.trim()) { throw new Error("Message must be non-empty for Telegram sends"); } const textParams = hasThreadParams || replyMarkup ? { ...threadParams, ...(replyMarkup ? { reply_markup: replyMarkup } : {}), } : undefined; const res = await sendTelegramText(text, textParams, opts.plainText); const messageId = resolveTelegramMessageIdOrThrow(res, "text send"); recordSentMessage(chatId, messageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); return { messageId: String(messageId), chatId: String(res?.chat?.id ?? chatId) }; } export async function sendTypingTelegram( to: string, opts: TelegramTypingOpts = {}, ): Promise<{ ok: true }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: target.chatId, persistTarget: to, verbose: opts.verbose, }); const requestWithDiag = createTelegramRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const threadParams = buildTypingThreadParams(target.messageThreadId ?? opts.messageThreadId); await requestWithDiag( () => api.sendChatAction( chatId, "typing", threadParams as Parameters[2], ), "typing", ); return { ok: true }; } export async function reactMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, emoji: string, opts: TelegramReactionOpts = {}, ): Promise<{ ok: true } | { ok: false; warning: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: rawTarget, persistTarget: rawTarget, verbose: opts.verbose, }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const remove = opts.remove === true; const trimmedEmoji = emoji.trim(); // Build the reaction array. We cast emoji to the grammY union type since // Telegram validates emoji server-side; invalid emojis fail gracefully. const reactions: ReactionType[] = remove || !trimmedEmoji ? [] : [{ type: "emoji", emoji: trimmedEmoji as ReactionTypeEmoji["emoji"] }]; if (typeof api.setMessageReaction !== "function") { throw new Error("Telegram reactions are unavailable in this bot API."); } try { await requestWithDiag(() => api.setMessageReaction(chatId, messageId, reactions), "reaction"); } catch (err: unknown) { const msg = err instanceof Error ? err.message : String(err); if (/REACTION_INVALID/i.test(msg)) { return { ok: false as const, warning: `Reaction unavailable: ${trimmedEmoji}` }; } throw err; } return { ok: true }; } type TelegramDeleteOpts = { token?: string; accountId?: string; verbose?: boolean; api?: TelegramApiOverride; retry?: RetryConfig; }; export async function deleteMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, opts: TelegramDeleteOpts = {}, ): Promise<{ ok: true }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const rawTarget = String(chatIdInput); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: rawTarget, persistTarget: rawTarget, verbose: opts.verbose, }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); await requestWithDiag(() => api.deleteMessage(chatId, messageId), "deleteMessage"); logVerbose(`[telegram] Deleted message ${messageId} from chat ${chatId}`); return { ok: true }; } type TelegramEditOpts = { token?: string; accountId?: string; verbose?: boolean; api?: TelegramApiOverride; retry?: RetryConfig; textMode?: "markdown" | "html"; /** Controls whether link previews are shown in the edited message. */ linkPreview?: boolean; /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ buttons?: TelegramInlineButtons; /** Optional config injection to avoid global loadConfig() (improves testability). */ cfg?: ReturnType; }; type TelegramEditReplyMarkupOpts = { token?: string; accountId?: string; verbose?: boolean; api?: TelegramApiOverride; retry?: RetryConfig; /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ buttons?: TelegramInlineButtons; /** Optional config injection to avoid global loadConfig() (improves testability). */ cfg?: ReturnType; }; export async function editMessageReplyMarkupTelegram( chatIdInput: string | number, messageIdInput: string | number, buttons: TelegramInlineButtons, opts: TelegramEditReplyMarkupOpts = {}, ): Promise<{ ok: true; messageId: string; chatId: string }> { const { cfg, account, api } = resolveTelegramApiContext({ ...opts, cfg: opts.cfg, }); const rawTarget = String(chatIdInput); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: rawTarget, persistTarget: rawTarget, verbose: opts.verbose, }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, }); const replyMarkup = buildInlineKeyboard(buttons) ?? { inline_keyboard: [] }; try { await requestWithDiag( () => api.editMessageReplyMarkup(chatId, messageId, { reply_markup: replyMarkup }), "editMessageReplyMarkup", { shouldLog: (err) => !isTelegramMessageNotModifiedError(err), }, ); } catch (err) { if (!isTelegramMessageNotModifiedError(err)) { throw err; } } logVerbose(`[telegram] Edited reply markup for message ${messageId} in chat ${chatId}`); return { ok: true, messageId: String(messageId), chatId }; } export async function editMessageTelegram( chatIdInput: string | number, messageIdInput: string | number, text: string, opts: TelegramEditOpts = {}, ): Promise<{ ok: true; messageId: string; chatId: string }> { const { cfg, account, api } = resolveTelegramApiContext({ ...opts, cfg: opts.cfg, }); const rawTarget = String(chatIdInput); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: rawTarget, persistTarget: rawTarget, verbose: opts.verbose, }); const messageId = normalizeMessageId(messageIdInput); const requestWithDiag = createTelegramRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, }); const requestWithEditShouldLog = ( fn: () => Promise, label?: string, shouldLog?: (err: unknown) => boolean, ) => requestWithDiag(fn, label, shouldLog ? { shouldLog } : undefined); const textMode = opts.textMode ?? "markdown"; const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", accountId: account.accountId, }); const htmlText = renderTelegramHtmlText(text, { textMode, tableMode }); // Reply markup semantics: // - buttons === undefined → don't send reply_markup (keep existing) // - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove) // - otherwise → send built inline keyboard const shouldTouchButtons = opts.buttons !== undefined; const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined; const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined; const editParams: Record = { parse_mode: "HTML", }; if (opts.linkPreview === false) { editParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { editParams.reply_markup = replyMarkup; } const plainParams: Record = {}; if (opts.linkPreview === false) { plainParams.link_preview_options = { is_disabled: true }; } if (replyMarkup !== undefined) { plainParams.reply_markup = replyMarkup; } try { await withTelegramHtmlParseFallback({ label: "editMessage", verbose: opts.verbose, requestHtml: (retryLabel) => requestWithEditShouldLog( () => api.editMessageText(chatId, messageId, htmlText, editParams), retryLabel, (err) => !isTelegramMessageNotModifiedError(err), ), requestPlain: (retryLabel) => requestWithEditShouldLog( () => Object.keys(plainParams).length > 0 ? api.editMessageText(chatId, messageId, text, plainParams) : api.editMessageText(chatId, messageId, text), retryLabel, (plainErr) => !isTelegramMessageNotModifiedError(plainErr), ), }); } catch (err) { if (isTelegramMessageNotModifiedError(err)) { // no-op: Telegram reports message content unchanged, treat as success } else { throw err; } } logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`); return { ok: true, messageId: String(messageId), chatId }; } function inferFilename(kind: MediaKind) { switch (kind) { case "image": return "image.jpg"; case "video": return "video.mp4"; case "audio": return "audio.ogg"; default: return "file.bin"; } } type TelegramStickerOpts = { token?: string; accountId?: string; verbose?: boolean; api?: TelegramApiOverride; retry?: RetryConfig; /** Message ID to reply to (for threading) */ replyToMessageId?: number; /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; }; /** * Send a sticker to a Telegram chat by file_id. * @param to - Chat ID or username (e.g., "123456789" or "@username") * @param fileId - Telegram file_id of the sticker to send * @param opts - Optional configuration */ export async function sendStickerTelegram( to: string, fileId: string, opts: TelegramStickerOpts = {}, ): Promise { if (!fileId?.trim()) { throw new Error("Telegram sticker file_id is required"); } const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: target.chatId, persistTarget: to, verbose: opts.verbose, }); const threadParams = buildTelegramThreadReplyParams({ targetMessageThreadId: target.messageThreadId, messageThreadId: opts.messageThreadId, chatType: target.chatType, replyToMessageId: opts.replyToMessageId, }); const hasThreadParams = Object.keys(threadParams).length > 0; const requestWithDiag = createTelegramRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, useApiErrorLogging: false, }); const requestWithChatNotFound = createRequestWithChatNotFound({ requestWithDiag, chatId, input: to, }); const stickerParams = hasThreadParams ? threadParams : undefined; const result = await withTelegramThreadFallback( stickerParams, "sticker", opts.verbose, async (effectiveParams, label) => requestWithChatNotFound(() => api.sendSticker(chatId, fileId.trim(), effectiveParams), label), ); const messageId = resolveTelegramMessageIdOrThrow(result, "sticker send"); const resolvedChatId = String(result?.chat?.id ?? chatId); recordSentMessage(chatId, messageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); return { messageId: String(messageId), chatId: resolvedChatId }; } type TelegramPollOpts = { cfg?: ReturnType; token?: string; accountId?: string; verbose?: boolean; api?: TelegramApiOverride; retry?: RetryConfig; /** Message ID to reply to (for threading) */ replyToMessageId?: number; /** Forum topic thread ID (for forum supergroups) */ messageThreadId?: number; /** Send message silently (no notification). Defaults to false. */ silent?: boolean; /** Whether votes are anonymous. Defaults to true (Telegram default). */ isAnonymous?: boolean; }; /** * Send a poll to a Telegram chat. * @param to - Chat ID or username (e.g., "123456789" or "@username") * @param poll - Poll input with question, options, maxSelections, and optional durationHours * @param opts - Optional configuration */ export async function sendPollTelegram( to: string, poll: PollInput, opts: TelegramPollOpts = {}, ): Promise<{ messageId: string; chatId: string; pollId?: string }> { const { cfg, account, api } = resolveTelegramApiContext(opts); const target = parseTelegramTarget(to); const chatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: target.chatId, persistTarget: to, verbose: opts.verbose, }); // Normalize the poll input (validates question, options, maxSelections) const normalizedPoll = normalizePollInput(poll, { maxOptions: 10 }); const threadParams = buildTelegramThreadReplyParams({ targetMessageThreadId: target.messageThreadId, messageThreadId: opts.messageThreadId, chatType: target.chatType, replyToMessageId: opts.replyToMessageId, }); // Build poll options as simple strings (Grammy accepts string[] or InputPollOption[]) const pollOptions = normalizedPoll.options; const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, }); const requestWithChatNotFound = createRequestWithChatNotFound({ requestWithDiag, chatId, input: to, }); const durationSeconds = normalizedPoll.durationSeconds; if (durationSeconds === undefined && normalizedPoll.durationHours !== undefined) { throw new Error( "Telegram poll durationHours is not supported. Use durationSeconds (5-600) instead.", ); } if (durationSeconds !== undefined && (durationSeconds < 5 || durationSeconds > 600)) { throw new Error("Telegram poll durationSeconds must be between 5 and 600"); } // Build poll parameters following Grammy's api.sendPoll signature // sendPoll(chat_id, question, options, other?, signal?) const pollParams = { allows_multiple_answers: normalizedPoll.maxSelections > 1, is_anonymous: opts.isAnonymous ?? true, ...(durationSeconds !== undefined ? { open_period: durationSeconds } : {}), ...(Object.keys(threadParams).length > 0 ? threadParams : {}), ...(opts.silent === true ? { disable_notification: true } : {}), }; const result = await withTelegramThreadFallback( pollParams, "poll", opts.verbose, async (effectiveParams, label) => requestWithChatNotFound( () => api.sendPoll(chatId, normalizedPoll.question, pollOptions, effectiveParams), label, ), ); const messageId = resolveTelegramMessageIdOrThrow(result, "poll send"); const resolvedChatId = String(result?.chat?.id ?? chatId); const pollId = result?.poll?.id; recordSentMessage(chatId, messageId); recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); return { messageId: String(messageId), chatId: resolvedChatId, pollId }; } // --------------------------------------------------------------------------- // Forum topic creation // --------------------------------------------------------------------------- type TelegramCreateForumTopicOpts = { token?: string; accountId?: string; api?: Bot["api"]; verbose?: boolean; retry?: RetryConfig; /** Icon color for the topic (must be one of 0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F). */ iconColor?: number; /** Custom emoji ID for the topic icon. */ iconCustomEmojiId?: string; }; export type TelegramCreateForumTopicResult = { topicId: number; name: string; chatId: string; }; /** * Create a forum topic in a Telegram supergroup. * Requires the bot to have `can_manage_topics` permission. * * @param chatId - Supergroup chat ID * @param name - Topic name (1-128 characters) * @param opts - Optional configuration */ export async function createForumTopicTelegram( chatId: string, name: string, opts: TelegramCreateForumTopicOpts = {}, ): Promise { if (!name?.trim()) { throw new Error("Forum topic name is required"); } const trimmedName = name.trim(); if (trimmedName.length > 128) { throw new Error("Forum topic name must be 128 characters or fewer"); } const cfg = loadConfig(); const account = resolveTelegramAccount({ cfg, accountId: opts.accountId, }); const token = resolveToken(opts.token, account); // Accept topic-qualified targets (e.g. telegram:group::topic:) // but createForumTopic must always target the base supergroup chat id. const client = resolveTelegramClientOptions(account); const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; const target = parseTelegramTarget(chatId); const normalizedChatId = await resolveAndPersistChatId({ cfg, api, lookupTarget: target.chatId, persistTarget: chatId, verbose: opts.verbose, }); const requestWithDiag = createTelegramNonIdempotentRequestWithDiag({ cfg, account, retry: opts.retry, verbose: opts.verbose, }); const extra: Record = {}; if (opts.iconColor != null) { extra.icon_color = opts.iconColor; } if (opts.iconCustomEmojiId?.trim()) { extra.icon_custom_emoji_id = opts.iconCustomEmojiId.trim(); } const hasExtra = Object.keys(extra).length > 0; const result = await requestWithDiag( () => api.createForumTopic(normalizedChatId, trimmedName, hasExtra ? extra : undefined), "createForumTopic", ); const topicId = result.message_thread_id; recordChannelActivity({ channel: "telegram", accountId: account.accountId, direction: "outbound", }); return { topicId, name: result.name ?? trimmedName, chatId: normalizedChatId, }; }