/** * Telegram Bot API client for sending/receiving messages */ import { Result, TaggedError } from "better-result" import type { LogFn } from "./log" export interface TelegramConfig { botToken: string chatId: string // Channel, group, or DM chat ID threadId?: number // Optional thread/topic ID for forum groups log?: LogFn // Optional logger function baseUrl?: string // Optional custom base URL (for testing) } export interface TelegramPhotoSize { file_id: string file_unique_id: string width: number height: number file_size?: number } export interface TelegramVoice { file_id: string file_unique_id: string duration: number mime_type?: string file_size?: number } export interface TelegramMessage { message_id: number from?: { id: number first_name: string username?: string is_bot?: boolean } chat: { id: number type: string } message_thread_id?: number date: number text?: string caption?: string photo?: TelegramPhotoSize[] voice?: TelegramVoice reply_to_message?: TelegramMessage } export interface CallbackQuery { id: string from: { id: number first_name: string username?: string } message?: TelegramMessage data?: string // Callback data from inline button } export interface TelegramUpdate { update_id: number message?: TelegramMessage callback_query?: CallbackQuery } interface GetUpdatesResponse { ok: boolean result: TelegramUpdate[] error_code?: number description?: string } interface SendMessageResponse { ok: boolean result: TelegramMessage error_code?: number description?: string } // Custom error for fatal Telegram errors (chat not found, etc.) export class TelegramFatalError extends TaggedError("TelegramFatalError")<{ message: string code: number cause?: unknown }>() {} export class TelegramApiError extends TaggedError("TelegramApiError")<{ message: string cause: unknown }>() { constructor(args: { cause: unknown }) { const causeMessage = args.cause instanceof Error ? args.cause.message : String(args.cause) super({ ...args, message: `Telegram API error: ${causeMessage}` }) } } export type TelegramResult = Result export interface InlineKeyboardButton { text: string callback_data?: string url?: string web_app?: { url: string } } export interface InlineKeyboardMarkup { inline_keyboard: InlineKeyboardButton[][] } export class TelegramClient { private baseUrl: string private chatId: string private threadId?: number private lastUpdateId = 0 private log: LogFn constructor(config: TelegramConfig) { this.baseUrl = config.baseUrl ?? `https://api.telegram.org/bot${config.botToken}` this.chatId = config.chatId this.threadId = config.threadId this.log = config.log ?? (() => {}) } /** * Send a message to the configured chat/thread */ async sendMessage( text: string, options?: { replyMarkup?: InlineKeyboardMarkup replyToMessageId?: number } ): Promise> { // Telegram has a 4096 character limit per message const maxLength = 4096 const chunks = this.splitMessage(text, maxLength) this.log("debug", "Preparing to send message", { textLength: text.length, chunks: chunks.length, chatId: this.chatId, threadId: this.threadId, hasReplyMarkup: !!options?.replyMarkup, }) let lastMessage: TelegramMessage | null = null for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i] const isLastChunk = i === chunks.length - 1 const params: Record = { chat_id: this.chatId, text: chunk, parse_mode: "Markdown", } if (this.threadId) { params.message_thread_id = this.threadId } if (options?.replyToMessageId) { params.reply_to_message_id = options.replyToMessageId } // Only add reply markup to the last chunk if (isLastChunk && options?.replyMarkup) { params.reply_markup = options.replyMarkup } this.log("debug", "Sending chunk to Telegram API", { chunkIndex: i, chunkLength: chunk.length, isLastChunk, }) try { const response = await fetch(`${this.baseUrl}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) const data = (await response.json()) as SendMessageResponse this.log("debug", "Telegram API response", { ok: data.ok, messageId: data.result?.message_id, }) if (!data.ok) { // Check for fatal errors (chat not found, etc.) if (data.error_code === 400 && data.description?.includes("chat not found")) { this.log("error", "Chat not found - stopping", { chatId: this.chatId, response: data }) return Result.err( new TelegramFatalError({ message: `Chat not found: ${this.chatId}`, code: 400 }) ) } this.log("warn", "Markdown failed, retrying without parse_mode", { response: data, text: chunk }) // Retry without markdown if it fails (markdown can be finicky) params.parse_mode = undefined const retryResponse = await fetch(`${this.baseUrl}/sendMessage`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) const retryData = (await retryResponse.json()) as SendMessageResponse if (retryData.ok) { lastMessage = retryData.result this.log("info", "Message sent successfully (plain text)", { messageId: retryData.result.message_id, }) } else { // Check for fatal errors on retry too if (retryData.error_code === 400 && retryData.description?.includes("chat not found")) { this.log("error", "Chat not found - stopping", { chatId: this.chatId, response: retryData }) return Result.err( new TelegramFatalError({ message: `Chat not found: ${this.chatId}`, code: 400 }) ) } this.log("error", "Failed to send message", { response: retryData }) } } else { lastMessage = data.result this.log("info", "Message sent successfully", { messageId: data.result.message_id }) } } catch (error) { this.log("error", "Error sending message", { error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } if (lastMessage) { return Result.ok(lastMessage) } return Result.err( new TelegramApiError({ cause: new Error("Failed to send Telegram message"), }) ) } /** * Edit an existing message's text and/or reply markup */ async editMessage( messageId: number, text: string, options?: { replyMarkup?: InlineKeyboardMarkup } ): Promise> { this.log("debug", "Editing message", { messageId, textLength: text.length }) const params: Record = { chat_id: this.chatId, message_id: messageId, text, parse_mode: "Markdown", } if (options?.replyMarkup) { params.reply_markup = options.replyMarkup } try { const response = await fetch(`${this.baseUrl}/editMessageText`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) const data = (await response.json()) as { ok: boolean } if (!data.ok) { this.log("warn", "Edit with markdown failed, retrying plain", { messageId }) // Retry without markdown params.parse_mode = undefined const retryResponse = await fetch(`${this.baseUrl}/editMessageText`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) const retryData = (await retryResponse.json()) as { ok: boolean } this.log("debug", "Edit retry result", { messageId, ok: retryData.ok }) return Result.ok(retryData.ok) } this.log("debug", "Message edited successfully", { messageId }) return Result.ok(true) } catch (error) { this.log("error", "Error editing message", { messageId, error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Update a forum topic name */ async editForumTopic(threadId: number, name: string): Promise> { this.log("debug", "Editing forum topic", { threadId, nameLength: name.length }) const params: Record = { chat_id: this.chatId, message_thread_id: threadId, name, } try { const response = await fetch(`${this.baseUrl}/editForumTopic`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) const data = (await response.json()) as { ok: boolean result?: boolean error_code?: number description?: string } if (!data.ok) { this.log("error", "Failed to edit forum topic", { threadId, response: data }) return Result.err(new TelegramApiError({ cause: data.description || "Edit forum topic failed" })) } this.log("info", "Forum topic edited", { threadId }) return Result.ok(true) } catch (error) { this.log("error", "Error editing forum topic", { threadId, error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Answer a callback query (acknowledge button press) */ async answerCallbackQuery( callbackQueryId: string, options?: { text?: string; showAlert?: boolean } ): Promise> { this.log("debug", "Answering callback query", { callbackQueryId, text: options?.text }) const params: Record = { callback_query_id: callbackQueryId, } if (options?.text) { params.text = options.text } if (options?.showAlert) { params.show_alert = options.showAlert } try { const response = await fetch(`${this.baseUrl}/answerCallbackQuery`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) const data = (await response.json()) as { ok: boolean } this.log("debug", "Callback query answered", { ok: data.ok }) return Result.ok(data.ok) } catch (error) { this.log("error", "Error answering callback query", { error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Get new updates (messages and callback queries) using long polling */ async getUpdates(timeout = 30): Promise> { try { const params = new URLSearchParams({ offset: String(this.lastUpdateId + 1), timeout: String(timeout), allowed_updates: JSON.stringify(["message", "callback_query"]), }) const response = await fetch(`${this.baseUrl}/getUpdates?${params}`) const data = (await response.json()) as GetUpdatesResponse if (!data.ok) { this.log("error", "Failed to get updates from Telegram API", { response: data }) if (data.error_code === 409) { return Result.err( new TelegramFatalError({ message: data.description || "Conflict", code: 409, }) ) } if (data.error_code === 401) { return Result.err( new TelegramFatalError({ message: data.description || "Unauthorized", code: 401, }) ) } return Result.err(new TelegramApiError({ cause: data.description || "Unknown error" })) } const updates: TelegramUpdate[] = [] for (const update of data.result) { this.lastUpdateId = update.update_id // Filter messages to our target chat/thread if (update.message) { const msg = update.message const chatMatches = String(msg.chat.id) === this.chatId const threadMatches = this.threadId ? msg.message_thread_id === this.threadId : true if (chatMatches && threadMatches) { updates.push(update) this.log("info", "Message matched filter", { updateId: update.update_id, from: msg.from?.username || msg.from?.first_name, preview: msg.text?.slice(0, 50), }) } } // Include callback queries from our chat if (update.callback_query?.message) { const chatMatches = String(update.callback_query.message.chat.id) === this.chatId this.log("debug", "Processing callback query", { updateId: update.update_id, callbackData: update.callback_query.data, chatMatches, }) if (chatMatches) { updates.push(update) this.log("info", "Callback query matched", { updateId: update.update_id, data: update.callback_query.data, }) } } } this.log("debug", "Polling complete", { totalReceived: data.result.length, matchedUpdates: updates.length, }) return Result.ok(updates) } catch (error) { this.log("error", "Error getting updates", { error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Split a long message into chunks that fit Telegram's limit */ private splitMessage(text: string, maxLength: number): string[] { if (text.length <= maxLength) { return [text] } const chunks: string[] = [] let remaining = text while (remaining.length > 0) { if (remaining.length <= maxLength) { chunks.push(remaining) break } // Try to split at a newline let splitIndex = remaining.lastIndexOf("\n", maxLength) if (splitIndex === -1 || splitIndex < maxLength / 2) { // Try to split at a space splitIndex = remaining.lastIndexOf(" ", maxLength) } if (splitIndex === -1 || splitIndex < maxLength / 2) { // Force split at maxLength splitIndex = maxLength } chunks.push(remaining.slice(0, splitIndex)) remaining = remaining.slice(splitIndex).trimStart() } return chunks } /** * Send typing indicator to show the bot is working * Returns a stop function to cancel the typing indicator */ startTyping(intervalMs = 2500): () => void { // Send immediately this.sendTypingAction() // Telegram typing indicator lasts ~5 seconds, so refresh every 2.5 seconds by default // to ensure continuous typing even with network delays const interval = setInterval(() => { this.sendTypingAction() }, intervalMs) return () => { clearInterval(interval) } } /** * Send a single typing action */ async sendTypingAction(): Promise> { const params: Record = { chat_id: this.chatId, action: "typing", } if (this.threadId) { params.message_thread_id = this.threadId } try { await fetch(`${this.baseUrl}/sendChatAction`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }) return Result.ok(undefined) } catch (error) { this.log("debug", "Failed to send typing action", { error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Get bot info to verify the token is valid */ async getMe(): Promise> { const result = await Result.tryPromise({ try: async () => { const response = await fetch(`${this.baseUrl}/getMe`) return (await response.json()) as { ok: boolean; result?: { id: number; username: string } } }, catch: (error) => new TelegramApiError({ cause: error }), }) if (result.status === "error") { return Result.err(result.error) } if (!result.value.ok || !result.value.result) { return Result.err(new TelegramApiError({ cause: "Invalid bot response" })) } return Result.ok(result.value.result) } /** * Set bot commands (menu button) */ async setMyCommands( commands: Array<{ command: string; description: string }> ): Promise> { this.log("debug", "Setting bot commands", { count: commands.length }) try { const response = await fetch(`${this.baseUrl}/setMyCommands`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ commands }), }) const data = (await response.json()) as { ok: boolean; description?: string } if (!data.ok) { this.log("error", "Failed to set bot commands", { response: data }) return Result.err(new TelegramApiError({ cause: data.description || "Set commands failed" })) } this.log("info", "Bot commands set", { commands: commands.map((c) => c.command) }) return Result.ok(true) } catch (error) { this.log("error", "Error setting bot commands", { error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Get a file's download URL from Telegram * @param fileId The file_id from a photo/document/etc * @returns The file URL, or null on failure */ async getFileUrl(fileId: string): Promise> { this.log("debug", "Getting file info", { fileId }) const result = await Result.tryPromise({ try: async () => { const response = await fetch(`${this.baseUrl}/getFile?file_id=${encodeURIComponent(fileId)}`) return (await response.json()) as { ok: boolean result?: { file_id: string; file_unique_id: string; file_size?: number; file_path?: string } description?: string } }, catch: (error) => new TelegramApiError({ cause: error }), }) if (result.status === "error") { this.log("error", "Error getting file URL", { fileId, error: result.error.message }) return Result.err(result.error) } if (!result.value.ok || !result.value.result?.file_path) { this.log("error", "Failed to get file info", { response: result.value }) return Result.err(new TelegramApiError({ cause: result.value.description || "Invalid file info" })) } // Construct the download URL // Format: https://api.telegram.org/file/bot/ const downloadUrl = `${this.baseUrl.replace("/bot", "/file/bot")}/${result.value.result.file_path}` this.log("debug", "Got file URL", { fileId, filePath: result.value.result.file_path }) return Result.ok(downloadUrl) } /** * Download a file from Telegram and return it as a base64 data URL * @param fileId The file_id from a photo/document/etc * @param mimeType The MIME type for the data URL (e.g., "image/jpeg") * @returns The base64 data URL, or null on failure */ async downloadFileAsDataUrl( fileId: string, mimeType: string ): Promise> { const fileUrlResult = await this.getFileUrl(fileId) if (fileUrlResult.status === "error") { return Result.err(fileUrlResult.error) } const fileUrl = fileUrlResult.value try { this.log("debug", "Downloading file", { fileUrl }) const response = await fetch(fileUrl) if (!response.ok) { this.log("error", "Failed to download file", { status: response.status }) return Result.err(new TelegramApiError({ cause: `Download failed: ${response.status}` })) } const buffer = await response.arrayBuffer() const base64 = Buffer.from(buffer).toString("base64") const dataUrl = `data:${mimeType};base64,${base64}` this.log("debug", "File downloaded", { size: buffer.byteLength }) return Result.ok(dataUrl) } catch (error) { this.log("error", "Error downloading file", { error: String(error) }) return Result.err(new TelegramApiError({ cause: error })) } } /** * Build inline keyboard from options */ static buildInlineKeyboard( options: Array<{ label: string; callbackData: string }>, options2?: { columns?: number; addOther?: boolean; otherCallbackData?: string } ): InlineKeyboardMarkup { const columns = options2?.columns ?? 2 const keyboard: InlineKeyboardButton[][] = [] let currentRow: InlineKeyboardButton[] = [] for (const opt of options) { currentRow.push({ text: opt.label, callback_data: opt.callbackData, }) if (currentRow.length >= columns) { keyboard.push(currentRow) currentRow = [] } } // Add remaining buttons if (currentRow.length > 0) { keyboard.push(currentRow) } // Add "Other" button for freetext input if (options2?.addOther) { keyboard.push([ { text: "Other (type reply)", callback_data: options2.otherCallbackData ?? "other", }, ]) } return { inline_keyboard: keyboard } } }