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<string, unknown>
export type TelegramResponse<T = unknown> = {
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}$/
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<string, unknown>
secretName?: string
}
function compactParams(params: Record<string, unknown> = {}): 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<T = unknown>(
input: TelegramRequestInput,
): Promise<T> {
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<T>
try {
parsed = text ? (JSON.parse(text) as TelegramResponse<T>) : { 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.
*/
export default async function request(params: Record<string, unknown> = {}) {
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
}