Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/telegram

src/request.ts

296 lines · 8.1 KB · TypeScript
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
}