Files
Moltbot/src/telegram/send.ts

1246 lines
40 KiB
TypeScript

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 { mediaKindFromMime } from "../media/constants.js";
import { isGifMedia } 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 } 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 } 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<TelegramApi>;
type TelegramSendOpts = {
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;
};
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");
function createTelegramHttpLogger(cfg: ReturnType<typeof loadConfig>) {
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 resolveTelegramClientOptions(
account: ResolvedTelegramAccount,
): ApiClientOptions | undefined {
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
const fetchImpl = resolveTelegramFetch(proxyFetch, {
network: account.config.network,
});
const timeoutSeconds =
typeof account.config.timeoutSeconds === "number" &&
Number.isFinite(account.config.timeoutSeconds)
? Math.max(1, Math.floor(account.config.timeoutSeconds))
: undefined;
return fetchImpl || timeoutSeconds
? {
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
}
: undefined;
}
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<string> {
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<typeof loadConfig>;
api: TelegramApiOverride;
lookupTarget: string;
persistTarget: string;
verbose?: boolean;
}): Promise<string> {
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<string, unknown>): 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<string, unknown>,
): Record<string, unknown> | 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<string, unknown> {
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<string, unknown> = 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<T>(params: {
label: string;
verbose?: boolean;
requestHtml: (label: string) => Promise<T>;
requestPlain: (label: string) => Promise<T>;
}): Promise<T> {
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<typeof loadConfig>;
account: ResolvedTelegramAccount;
api: TelegramApi;
};
function resolveTelegramApiContext(opts: {
token?: string;
accountId?: string;
api?: TelegramApiOverride;
cfg?: ReturnType<typeof loadConfig>;
}): 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 = <T>(
fn: () => Promise<T>,
label?: string,
options?: { shouldLog?: (err: unknown) => boolean },
) => Promise<T>;
function createTelegramRequestWithDiag(params: {
cfg: ReturnType<typeof loadConfig>;
account: ResolvedTelegramAccount;
retry?: RetryConfig;
verbose?: boolean;
shouldRetry?: (err: unknown) => boolean;
useApiErrorLogging?: boolean;
}): TelegramRequestWithDiag {
const request = createTelegramRetryRunner({
retry: params.retry,
configRetry: params.account.config.retry,
verbose: params.verbose,
...(params.shouldRetry ? { shouldRetry: params.shouldRetry } : {}),
});
const logHttpError = createTelegramHttpLogger(params.cfg);
return <T>(
fn: () => Promise<T>,
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<T>(
params: Record<string, unknown> | undefined,
label: string,
verbose: boolean | undefined,
attempt: (
effectiveParams: Record<string, unknown> | undefined,
effectiveLabel: string,
) => Promise<T>,
): Promise<T> {
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 <T>(fn: () => Promise<T>, label: string) =>
params.requestWithDiag(fn, label).catch((err) => {
throw wrapTelegramChatNotFoundError(err, {
chatId: params.chatId,
input: params.input,
});
});
}
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<TelegramSendResult> {
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 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 = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
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<string, unknown>,
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<typeof api.sendMessage>[2],
),
retryLabel,
),
requestPlain: (retryLabel) => {
const plainParams = hasBaseParams
? (baseParams as Parameters<typeof api.sendMessage>[2])
: undefined;
return requestWithChatNotFound(
() =>
plainParams
? api.sendMessage(chatId, fallbackText ?? rawText, plainParams)
: api.sendMessage(chatId, fallbackText ?? rawText),
retryLabel,
);
},
});
},
);
};
if (mediaUrl) {
const media = await loadWebMedia(mediaUrl, {
maxBytes: opts.maxBytes,
localRoots: opts.mediaLocalRoots,
});
const kind = mediaKindFromMime(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)) ?? "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<string, unknown> | undefined,
) => Promise<TelegramMessageLike>,
) =>
await withTelegramThreadFallback(
mediaParams,
label,
opts.verbose,
async (effectiveParams, retryLabel) =>
requestWithChatNotFound(() => sender(effectiveParams), retryLabel),
);
const mediaSender = (() => {
if (isGif) {
return {
label: "animation",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
api.sendAnimation(
chatId,
file,
effectiveParams as Parameters<typeof api.sendAnimation>[2],
) as Promise<TelegramMessageLike>,
};
}
if (kind === "image") {
return {
label: "photo",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
api.sendPhoto(
chatId,
file,
effectiveParams as Parameters<typeof api.sendPhoto>[2],
) as Promise<TelegramMessageLike>,
};
}
if (kind === "video") {
if (isVideoNote) {
return {
label: "video_note",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
api.sendVideoNote(
chatId,
file,
effectiveParams as Parameters<typeof api.sendVideoNote>[2],
) as Promise<TelegramMessageLike>,
};
}
return {
label: "video",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
api.sendVideo(
chatId,
file,
effectiveParams as Parameters<typeof api.sendVideo>[2],
) as Promise<TelegramMessageLike>,
};
}
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<string, unknown> | undefined) =>
api.sendVoice(
chatId,
file,
effectiveParams as Parameters<typeof api.sendVoice>[2],
) as Promise<TelegramMessageLike>,
};
}
return {
label: "audio",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
api.sendAudio(
chatId,
file,
effectiveParams as Parameters<typeof api.sendAudio>[2],
) as Promise<TelegramMessageLike>,
};
}
return {
label: "document",
sender: (effectiveParams: Record<string, unknown> | undefined) =>
api.sendDocument(
chatId,
file,
effectiveParams as Parameters<typeof api.sendDocument>[2],
) as Promise<TelegramMessageLike>,
};
})();
const result = await sendMedia(mediaSender.label, mediaSender.sender);
const mediaMessageId = String(result?.message_id ?? "unknown");
const resolvedChatId = String(result?.chat?.id ?? chatId);
if (result?.message_id) {
recordSentMessage(chatId, result.message_id);
}
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).
return {
messageId: String(textRes?.message_id ?? mediaMessageId),
chatId: resolvedChatId,
};
}
return { messageId: 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 = String(res?.message_id ?? "unknown");
if (res?.message_id) {
recordSentMessage(chatId, res.message_id);
}
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: String(res?.chat?.id ?? chatId) };
}
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<typeof loadConfig>;
};
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 = <T>(
fn: () => Promise<T>,
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<string, unknown> = {
parse_mode: "HTML",
};
if (opts.linkPreview === false) {
editParams.link_preview_options = { is_disabled: true };
}
if (replyMarkup !== undefined) {
editParams.reply_markup = replyMarkup;
}
const plainParams: Record<string, unknown> = {};
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: ReturnType<typeof mediaKindFromMime>) {
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<TelegramSendResult> {
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 = String(result?.message_id ?? "unknown");
const resolvedChatId = String(result?.chat?.id ?? chatId);
if (result?.message_id) {
recordSentMessage(chatId, result.message_id);
}
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { messageId, chatId: resolvedChatId };
}
type TelegramPollOpts = {
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 = createTelegramRequestWithDiag({
cfg,
account,
retry: opts.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
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 = String(result?.message_id ?? "unknown");
const resolvedChatId = String(result?.chat?.id ?? chatId);
const pollId = result?.poll?.id;
if (result?.message_id) {
recordSentMessage(chatId, result.message_id);
}
recordChannelActivity({
channel: "telegram",
accountId: account.accountId,
direction: "outbound",
});
return { 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<TelegramCreateForumTopicResult> {
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:<id>:topic:<thread>)
// 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 request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }),
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = <T>(fn: () => Promise<T>, label?: string) =>
withTelegramApiErrorLogging({
operation: label ?? "request",
fn: () => request(fn, label),
}).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
const extra: Record<string, unknown> = {};
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,
};
}