UNPKG

telegraf

Version:

Modern Telegram Bot Framework

397 lines (370 loc) 11.5 kB
/* eslint @typescript-eslint/restrict-template-expressions: [ "error", { "allowNumber": true, "allowBoolean": true } ] */ import * as crypto from 'crypto' import * as fs from 'fs' import { stat, realpath } from 'fs/promises' import * as http from 'http' import * as https from 'https' import * as path from 'path' import fetch, { RequestInit } from 'node-fetch' import { hasProp, hasPropType } from '../helpers/check' import { InputFile, Opts, Telegram } from '../types/typegram' import { AbortSignal } from 'abort-controller' import { compactOptions } from '../helpers/compact' import MultipartStream from './multipart-stream' import TelegramError from './error' import { URL } from 'url' // eslint-disable-next-line @typescript-eslint/no-var-requires const debug = require('debug')('telegraf:client') const { isStream } = MultipartStream const WEBHOOK_REPLY_METHOD_ALLOWLIST = new Set<keyof Telegram>([ 'answerCallbackQuery', 'answerInlineQuery', 'deleteMessage', 'leaveChat', 'sendChatAction', ]) namespace ApiClient { export type Agent = http.Agent | ((parsedUrl: URL) => http.Agent) | undefined export interface Options { /** * Agent for communicating with the bot API. */ agent?: http.Agent /** * Agent for attaching files via URL. * 1. Not all agents support both `http:` and `https:`. * 2. When passing a function, create the agents once, outside of the function. * Creating new agent every request probably breaks `keepAlive`. */ attachmentAgent?: Agent apiRoot: string /** * @default 'bot' * @see https://github.com/tdlight-team/tdlight-telegram-bot-api#user-mode */ apiMode: 'bot' | 'user' webhookReply: boolean testEnv: boolean } export interface CallApiOptions { signal?: AbortSignal } } const DEFAULT_EXTENSIONS: Record<string, string | undefined> = { audio: 'mp3', photo: 'jpg', sticker: 'webp', video: 'mp4', animation: 'mp4', video_note: 'mp4', voice: 'ogg', } const DEFAULT_OPTIONS: ApiClient.Options = { apiRoot: 'https://api.telegram.org', apiMode: 'bot', webhookReply: true, agent: new https.Agent({ keepAlive: true, keepAliveMsecs: 10000, }), attachmentAgent: undefined, testEnv: false, } function includesMedia(payload: Record<string, unknown>) { return Object.entries(payload).some(([key, value]) => { if (key === 'link_preview_options') return false if (Array.isArray(value)) { return value.some( ({ media }) => media && typeof media === 'object' && (media.source || media.url) ) } return ( value && typeof value === 'object' && ((hasProp(value, 'source') && value.source) || (hasProp(value, 'url') && value.url) || (hasPropType(value, 'media', 'object') && ((hasProp(value.media, 'source') && value.media.source) || (hasProp(value.media, 'url') && value.media.url)))) ) }) } function replacer(_: unknown, value: unknown) { if (value == null) return undefined return value } function buildJSONConfig(payload: unknown): Promise<RequestInit> { return Promise.resolve({ method: 'POST', compress: true, headers: { 'content-type': 'application/json', connection: 'keep-alive' }, body: JSON.stringify(payload, replacer), }) } const FORM_DATA_JSON_FIELDS = [ 'results', 'reply_markup', 'mask_position', 'shipping_options', 'errors', ] as const async function buildFormDataConfig( payload: Opts<keyof Telegram>, agent: ApiClient.Agent ) { for (const field of FORM_DATA_JSON_FIELDS) { if (hasProp(payload, field) && typeof payload[field] !== 'string') { payload[field] = JSON.stringify(payload[field]) } } const boundary = crypto.randomBytes(32).toString('hex') const formData = new MultipartStream(boundary) await Promise.all( Object.keys(payload).map((key) => // @ts-expect-error payload[key] can obviously index payload, but TS doesn't trust us attachFormValue(formData, key, payload[key], agent) ) ) return { method: 'POST', compress: true, headers: { 'content-type': `multipart/form-data; boundary=${boundary}`, connection: 'keep-alive', }, body: formData, } } async function attachFormValue( form: MultipartStream, id: string, value: unknown, agent: ApiClient.Agent ) { if (value == null) { return } if ( typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' ) { form.addPart({ headers: { 'content-disposition': `form-data; name="${id}"` }, body: `${value}`, }) return } if (id === 'thumb' || id === 'thumbnail') { const attachmentId = crypto.randomBytes(16).toString('hex') await attachFormMedia(form, value as InputFile, attachmentId, agent) return form.addPart({ headers: { 'content-disposition': `form-data; name="${id}"` }, body: `attach://${attachmentId}`, }) } if (Array.isArray(value)) { const items = await Promise.all( value.map(async (item) => { if (typeof item.media !== 'object') { return await Promise.resolve(item) } const attachmentId = crypto.randomBytes(16).toString('hex') await attachFormMedia(form, item.media, attachmentId, agent) const thumb = item.thumb ?? item.thumbnail if (typeof thumb === 'object') { const thumbAttachmentId = crypto.randomBytes(16).toString('hex') await attachFormMedia(form, thumb, thumbAttachmentId, agent) return { ...item, media: `attach://${attachmentId}`, thumbnail: `attach://${thumbAttachmentId}`, } } return { ...item, media: `attach://${attachmentId}` } }) ) return form.addPart({ headers: { 'content-disposition': `form-data; name="${id}"` }, body: JSON.stringify(items), }) } if ( value && typeof value === 'object' && hasProp(value, 'media') && hasProp(value, 'type') && typeof value.media !== 'undefined' && typeof value.type !== 'undefined' ) { const attachmentId = crypto.randomBytes(16).toString('hex') await attachFormMedia(form, value.media as InputFile, attachmentId, agent) return form.addPart({ headers: { 'content-disposition': `form-data; name="${id}"` }, body: JSON.stringify({ ...value, media: `attach://${attachmentId}`, }), }) } return await attachFormMedia(form, value as InputFile, id, agent) } async function attachFormMedia( form: MultipartStream, media: InputFile, id: string, agent: ApiClient.Agent ) { let fileName = media.filename ?? `${id}.${DEFAULT_EXTENSIONS[id] ?? 'dat'}` if ('url' in media && media.url !== undefined) { const timeout = 500_000 // ms const res = await fetch(media.url, { agent, timeout }) return form.addPart({ headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"`, }, body: res.body, }) } if ('source' in media && media.source) { let mediaSource = media.source if (typeof media.source === 'string') { const source = await realpath(media.source) if ((await stat(source)).isFile()) { fileName = media.filename ?? path.basename(media.source) mediaSource = await fs.createReadStream(media.source) } else { throw new TypeError(`Unable to upload '${media.source}', not a file`) } } if (isStream(mediaSource) || Buffer.isBuffer(mediaSource)) { form.addPart({ headers: { 'content-disposition': `form-data; name="${id}"; filename="${fileName}"`, }, body: mediaSource, }) } } } async function answerToWebhook( response: Response, payload: Opts<keyof Telegram>, options: ApiClient.Options ): Promise<true> { if (!includesMedia(payload)) { if (!response.headersSent) { response.setHeader('content-type', 'application/json') } response.end(JSON.stringify(payload), 'utf-8') return true } const { headers, body } = await buildFormDataConfig( payload, options.attachmentAgent ) if (!response.headersSent) { for (const [key, value] of Object.entries(headers)) { response.setHeader(key, value) } } await new Promise((resolve) => { response.on('finish', resolve) body.pipe(response) }) return true } function redactToken(error: Error): never { error.message = error.message.replace( /\/(bot|user)(\d+):[^/]+\//, '/$1$2:[REDACTED]/' ) throw error } type Response = http.ServerResponse class ApiClient { readonly options: ApiClient.Options constructor( readonly token: string, options?: Partial<ApiClient.Options>, private readonly response?: Response ) { this.options = { ...DEFAULT_OPTIONS, ...compactOptions(options), } if (this.options.apiRoot.startsWith('http://')) { this.options.agent = undefined } } /** * If set to `true`, first _eligible_ call will avoid performing a POST request. * Note that such a call: * 1. cannot report errors or return meaningful values, * 2. resolves before bot API has a chance to process it, * 3. prematurely confirms the update as processed. * * https://core.telegram.org/bots/faq#how-can-i-make-requests-in-response-to-updates * https://github.com/telegraf/telegraf/pull/1250 */ set webhookReply(enable: boolean) { this.options.webhookReply = enable } get webhookReply() { return this.options.webhookReply } async callApi<M extends keyof Telegram>( method: M, payload: Opts<M>, { signal }: ApiClient.CallApiOptions = {} ): Promise<ReturnType<Telegram[M]>> { const { token, options, response } = this if ( options.webhookReply && response?.writableEnded === false && WEBHOOK_REPLY_METHOD_ALLOWLIST.has(method) ) { debug('Call via webhook', method, payload) // @ts-expect-error using webhookReply is an optimisation that doesn't respond with normal result // up to the user to deal with this return await answerToWebhook(response, { method, ...payload }, options) } if (!token) { throw new TelegramError({ error_code: 401, description: 'Bot Token is required', }) } debug('HTTP call', method, payload) const config: RequestInit = includesMedia(payload) ? await buildFormDataConfig( { method, ...payload }, options.attachmentAgent ) : await buildJSONConfig(payload) const apiUrl = new URL( `./${options.apiMode}${token}${options.testEnv ? '/test' : ''}/${method}`, options.apiRoot ) config.agent = options.agent // @ts-expect-error AbortSignal shim is missing some props from Request.AbortSignal config.signal = signal config.timeout = 500_000 // ms const res = await fetch(apiUrl, config).catch(redactToken) if (res.status >= 500) { const errorPayload = { error_code: res.status, description: res.statusText, } throw new TelegramError(errorPayload, { method, payload }) } const data = await res.json() if (!data.ok) { debug('API call failed', data) throw new TelegramError(data, { method, payload }) } return data.result } } export default ApiClient