/** * @fileoverview Call any Telegram Bot API method with the saved bot token. * Prefer dedicated helpers like `./get-me` when they exist. * Mutating methods require `confirm: true`; use `dryRun: true` to preview. * * @example * import request from 'kody:@kody/telegram/request' * const me = await request({ method: 'getMe' }) */ import { inputRecord, optionalRecord, requiredString, secretNameFrom, secretPlaceholder, secretSetupUrl, type InputRecord, } from './validation.ts' export const TELEGRAM_API_ORIGIN = 'https://api.telegram.org' export const TELEGRAM_API_HOST = 'api.telegram.org' export const DEFAULT_SECRET_NAME = 'telegramBotToken' export type JsonRecord = Record export type TelegramResponse = { ok: boolean result?: T description?: string error_code?: number parameters?: { retry_after?: number; migrate_to_chat_id?: number } } export class TelegramApiError extends Error { readonly method: string readonly status: number readonly errorCode: number | null readonly description: string readonly nextStep: string readonly details: JsonRecord constructor( method: string, status: number, details: JsonRecord, nextStep: string, ) { const description = typeof details.description === 'string' && details.description.length > 0 ? details.description : 'Telegram Bot API request failed' super( 'Telegram Bot API ' + method + ' failed (' + status + '): ' + description + '. Next step: ' + nextStep, ) this.name = 'TelegramApiError' this.method = method this.status = status this.errorCode = typeof details.error_code === 'number' ? details.error_code : null this.description = description this.nextStep = nextStep this.details = details } } const METHOD_PATTERN = /^[a-z][a-zA-Z0-9]{1,64}$/ /** * Call any Telegram Bot API method with the saved bot token. * Prefer dedicated helpers like `./get-me` when they exist. * Mutating methods require `confirm: true`; use `dryRun: true` to preview. * * @param method - Telegram method name such as `getMe` or `sendMessage`. * @returns The validated method name. * * @example * import request from 'kody:@kody/telegram/request' * const me = await request({ method: 'getMe' }) */ export function assertTelegramMethod(method: string): string { if (!METHOD_PATTERN.test(method)) { throw new Error( "method must be a Telegram Bot API method name such as 'getMe' or 'sendMessage'.", ) } return method } /** Telegram Bot API methods whose names start with `get` are treated as reads. */ export function isReadOnlyMethod(method: string): boolean { return method.startsWith('get') } export function nextStepFor( status: number, description: string, secretName: string, ): string { const desc = description.toLowerCase() const setupUrl = secretSetupUrl(secretName) if (status === 404) { return ( 'Telegram returned 404, which usually means ' + secretName + ' is missing or empty so the Bot API path looks like /bot/getMe. Create a bot with @BotFather (/newbot), save the token at ' + setupUrl + ', and approve host api.telegram.org. Telegram Bot API does not use OAuth scopes.' ) } if (status === 401 || desc.includes('unauthorized')) { return ( 'The ' + secretName + ' bot token is missing or invalid. Create a bot with @BotFather (/newbot), then save the token at ' + setupUrl + ' and approve host api.telegram.org. Telegram Bot API does not use OAuth scopes.' ) } if (status === 403) { if (desc.includes('kicked')) { return ( 'The bot was kicked from that chat. Add it back as a member or admin. Telegram Bot API has no OAuth scopes; access comes from chat membership.' ) } if (desc.includes('blocked')) { return ( 'The user blocked the bot. They must unblock it and send /start before the bot can message them again.' ) } if (desc.includes('not a member')) { return ( 'The bot is not a member of that chat or channel. Add the bot (channels require the bot to be an administrator).' ) } if ( desc.includes('not enough rights') || desc.includes('need administrator') || desc.includes('not enough rights to') ) { return ( 'The bot is missing a chat permission Telegram described as: "' + description + '". Grant that right in the chat (often Send Messages, or channel admin). Telegram Bot API has no OAuth scopes — rights come from membership, admin status, and BotFather /setprivacy.' ) } return ( 'The bot lacks access to that chat. Add it as a member (admin for channels). To read all group messages, disable privacy mode with @BotFather /setprivacy or make the bot a group admin. Telegram Bot API has no OAuth scopes.' ) } if (status === 400 && desc.includes('chat not found')) { return ( 'chat_id is unknown to this bot. The user must send /start in a private chat, or the bot must be added to the group/channel. Public channels can use @channelusername.' ) } if (status === 409) { return ( 'getUpdates is blocked because a webhook is set or another poller is running. Call getWebhookInfo, then deleteWebhook with dryRun: true and confirm: true if you want polling.' ) } return ( 'See https://core.telegram.org/bots/api#error-handling and confirm ' + secretName + ' is saved with host api.telegram.org at ' + setupUrl ) } export type TelegramRequestInput = { method: string /** JSON body fields for the Bot API method. */ params?: Record secretName?: string } function compactParams(params: Record = {}): JsonRecord { const body: JsonRecord = {} for (const [key, value] of Object.entries(params)) { if (value === undefined || value === null || value === '') continue body[key] = value } return body } /** * Call one Telegram Bot API method with the saved bot token. * The token is interpolated as a fetch-gateway secret placeholder in the URL * path (`/bot` + secret placeholder + `/method`). Keep that placeholder * unencoded — URLSearchParams would percent-encode `{` / `}` and the gateway * would not resolve it. */ export async function telegramRequest( input: TelegramRequestInput, ): Promise { const method = assertTelegramMethod(input.method) const secretName = input.secretName ?? DEFAULT_SECRET_NAME const placeholder = secretPlaceholder(secretName) const href = TELEGRAM_API_ORIGIN + '/bot' + placeholder + '/' + method if (!href.startsWith(TELEGRAM_API_ORIGIN + '/bot')) { throw new Error('Telegram requests must stay on https://api.telegram.org.') } const body = compactParams(input.params) const response = await fetch(href, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', }, body: JSON.stringify(body), }) const text = await response.text() let parsed: TelegramResponse try { parsed = text ? (JSON.parse(text) as TelegramResponse) : { ok: false } } catch { throw new TelegramApiError( method, response.status, { description: text.slice(0, 300) }, nextStepFor(response.status, text, secretName), ) } if (!response.ok || parsed.ok !== true) { const details = parsed as JsonRecord const description = typeof parsed.description === 'string' ? parsed.description : '' const status = parsed.error_code ?? response.status throw new TelegramApiError( method, status, details, nextStepFor(status, description, secretName), ) } return parsed.result as T } const mutatingNeedsConfirm = new Set([ 'logOut', 'close', 'deleteWebhook', 'setWebhook', 'sendMessage', 'forwardMessage', 'copyMessage', 'editMessageText', 'deleteMessage', ]) /** * Call any Telegram Bot API method. Mutating methods require `confirm: true`; * use `dryRun: true` to preview without calling Telegram. * * @param params.method - Telegram method name such as `getMe` or `sendMessage`. * @param params.params - Optional method parameters object. * @param params.secretName - Optional bot-token secret name override. * @param params.dryRun - When true, preview without calling Telegram. * @param params.confirm - Required for mutating methods. * @returns Telegram `result` payload (or dry-run preview). * * @example * import request from 'kody:@kody/telegram/request' * const me = await request({ method: 'getMe' }) */ export default async function request(params: Record = {}) { const input = inputRecord(params) const method = assertTelegramMethod(requiredString(input, 'method')) const secretName = secretNameFrom(input) const body = optionalRecord(input, 'params') ?? omitControlFields(input) if (!isReadOnlyMethod(method) || mutatingNeedsConfirm.has(method)) { if (input.dryRun === true) { return { dryRun: true, method, params: body, secretName, host: TELEGRAM_API_HOST, } } if (input.confirm !== true) { throw new Error( method + ' mutates Telegram state and requires confirm: true after explicit user approval. Use dryRun: true to preview. Telegram Bot API has no OAuth scopes.', ) } } const result = await telegramRequest({ method, params: body, secretName, }) return { ok: true, method, result } } function omitControlFields(input: InputRecord): JsonRecord { const body: JsonRecord = {} const skip = new Set([ 'method', 'params', 'secretName', 'dryRun', 'confirm', ]) for (const [key, value] of Object.entries(input)) { if (skip.has(key)) continue if (value === undefined || value === null || value === '') continue body[key] = value } return body }