UNPKG

gramio

Version:

Powerful, extensible and really type-safe Telegram Bot API framework

1,258 lines (1,248 loc) 39.8 kB
import fs from 'node:fs/promises'; import { Readable } from 'node:stream'; import { CallbackData } from '@gramio/callback-data'; export * from '@gramio/callback-data'; import { contextsMappings, PhotoAttachment } from '@gramio/contexts'; export * from '@gramio/contexts'; import { isMediaUpload, convertJsonToFormData, extractFilesToFormData } from '@gramio/files'; export * from '@gramio/files'; import { FormattableMap } from '@gramio/format'; export * from '@gramio/format'; import debug from 'debug'; import { E as ErrorKind, s as sleep, w as withRetries, T as TelegramError, d as debug$updates, I as IS_BUN, a as simplifyObject, t as timeoutWebhook } from './utils-CJfJNxc_.js'; import { Composer as Composer$1, noopNext } from 'middleware-io'; export * from '@gramio/keyboards'; class Composer { composer = Composer$1.builder(); length = 0; composed; onError; constructor(onError) { this.onError = onError || ((_, error) => { throw error; }); this.recompose(); } /** Register handler to one or many Updates */ on(updateName, handler) { return this.use(async (context, next) => { if (context.is(updateName)) return await handler(context, next); return await next(); }); } /** Register handler to any Update */ use(handler) { this.composer.caught(this.onError).use(handler); return this.recompose(); } /** * Derive some data to handlers * * @example * ```ts * new Bot("token").derive((context) => { * return { * superSend: () => context.send("Derived method") * } * }) * ``` */ derive(updateNameOrHandler, handler) { if (typeof updateNameOrHandler === "function") this.use(async (context, next) => { for (const [key, value] of Object.entries( await updateNameOrHandler(context) )) { context[key] = value; } return await next(); }); else if (handler) this.on(updateNameOrHandler, async (context, next) => { for (const [key, value] of Object.entries(await handler(context))) { context[key] = value; } return await next(); }); return this; } recompose() { this.composed = this.composer.compose(); this.length = this.composer.length; return this; } compose(context, next = noopNext) { this.composed(context, next); } composeWait(context, next = noopNext) { return this.composed(context, next); } } class Plugin { /** * @internal * Set of Plugin data * * */ _ = { /** Name of plugin */ name: "", /** List of plugin dependencies. If user does't extend from listed there dependencies it throw a error */ dependencies: [], /** remap generic type. {} in runtime */ Errors: {}, /** remap generic type. {} in runtime */ Derives: {}, /** Composer */ composer: new Composer(), /** Store plugin preRequests hooks */ preRequests: [], /** Store plugin onResponses hooks */ onResponses: [], /** Store plugin onResponseErrors hooks */ onResponseErrors: [], /** * Store plugin groups * * If you use `on` or `use` in group and on plugin-level groups handlers are registered after plugin-level handlers * */ groups: [], /** Store plugin onStarts hooks */ onStarts: [], /** Store plugin onStops hooks */ onStops: [], /** Store plugin onErrors hooks */ onErrors: [], /** Map of plugin errors */ errorsDefinitions: {}, decorators: {} }; "~" = this._; /** Create new Plugin. Please provide `name` */ constructor(name, { dependencies } = {}) { this._.name = name; if (dependencies) this._.dependencies = dependencies; } /** Currently not isolated!!! * * > [!WARNING] * > If you use `on` or `use` in a `group` and at the plugin level, the group handlers are registered **after** the handlers at the plugin level */ group(grouped) { this._.groups.push(grouped); return this; } /** * Register custom class-error in plugin **/ error(kind, error) { error[ErrorKind] = kind; this._.errorsDefinitions[kind] = error; return this; } derive(updateNameOrHandler, handler) { this._.composer.derive(updateNameOrHandler, handler); return this; } decorate(nameOrValue, value) { if (typeof nameOrValue === "string") this._.decorators[nameOrValue] = value; else { for (const [name, value2] of Object.entries(nameOrValue)) { this._.decorators[name] = value2; } } return this; } /** Register handler to one or many Updates */ on(updateName, handler) { this._.composer.on(updateName, handler); return this; } /** Register handler to any Updates */ use(handler) { this._.composer.use(handler); return this; } preRequest(methodsOrHandler, handler) { if ((typeof methodsOrHandler === "string" || Array.isArray(methodsOrHandler)) && handler) this._.preRequests.push([handler, methodsOrHandler]); else if (typeof methodsOrHandler === "function") this._.preRequests.push([methodsOrHandler, void 0]); return this; } onResponse(methodsOrHandler, handler) { if ((typeof methodsOrHandler === "string" || Array.isArray(methodsOrHandler)) && handler) this._.onResponses.push([handler, methodsOrHandler]); else if (typeof methodsOrHandler === "function") this._.onResponses.push([methodsOrHandler, void 0]); return this; } onResponseError(methodsOrHandler, handler) { if ((typeof methodsOrHandler === "string" || Array.isArray(methodsOrHandler)) && handler) this._.onResponseErrors.push([handler, methodsOrHandler]); else if (typeof methodsOrHandler === "function") this._.onResponseErrors.push([methodsOrHandler, void 0]); return this; } /** * This hook called when the bot is `started`. * * @example * ```typescript * import { Bot } from "gramio"; * * const bot = new Bot(process.env.TOKEN!).onStart( * ({ plugins, info, updatesFrom }) => { * console.log(`plugin list - ${plugins.join(", ")}`); * console.log(`bot username is @${info.username}`); * console.log(`updates from ${updatesFrom}`); * } * ); * * bot.start(); * ``` * * [Documentation](https://gramio.dev/hooks/on-start.html) * */ onStart(handler) { this._.onStarts.push(handler); return this; } /** * This hook called when the bot stops. * * @example * ```typescript * import { Bot } from "gramio"; * * const bot = new Bot(process.env.TOKEN!).onStop( * ({ plugins, info, updatesFrom }) => { * console.log(`plugin list - ${plugins.join(", ")}`); * console.log(`bot username is @${info.username}`); * } * ); * * bot.start(); * bot.stop(); * ``` * * [Documentation](https://gramio.dev/hooks/on-stop.html) * */ onStop(handler) { this._.onStops.push(handler); return this; } onError(updateNameOrHandler, handler) { if (typeof updateNameOrHandler === "function") { this._.onErrors.push(updateNameOrHandler); return this; } if (handler) { this._.onErrors.push(async (errContext) => { if (errContext.context.is(updateNameOrHandler)) await handler(errContext); }); } return this; } /** * ! ** At the moment, it can only pick up types** */ extend(plugin) { return this; } } class UpdateQueue { updateQueue = []; pendingUpdates = /* @__PURE__ */ new Set(); handler; onIdleResolver; onIdlePromise; isActive = false; constructor(handler) { this.handler = handler; } add(update) { this.updateQueue.push(update); this.start(); } start() { this.isActive = true; while (this.updateQueue.length && this.isActive) { const update = this.updateQueue.shift(); if (!update) continue; const promise = this.handler(update); this.pendingUpdates.add(promise); promise.finally(async () => { this.pendingUpdates.delete(promise); if (this.pendingUpdates.size === 0 && this.updateQueue.length === 0 && this.onIdleResolver) { this.onIdleResolver(); this.onIdleResolver = void 0; } }); } } async stop(timeout = 3e3) { if (this.updateQueue.length === 0 && this.pendingUpdates.size === 0) { return; } this.onIdlePromise = new Promise((resolve) => { this.onIdleResolver = resolve; }); await Promise.race([this.onIdlePromise, sleep(timeout)]); this.isActive = false; } } class Updates { bot; isStarted = false; isRequestActive = false; offset = 0; composer; queue; stopPollingPromiseResolve; constructor(bot, onError) { this.bot = bot; this.composer = new Composer(onError); this.queue = new UpdateQueue(this.handleUpdate.bind(this)); } async handleUpdate(data, mode = "wait") { const updateType = Object.keys(data).at(1); const UpdateContext = contextsMappings[updateType]; if (!UpdateContext) throw new Error(updateType); const updatePayload = data[updateType]; if (!updatePayload) throw new Error("Unsupported event??"); try { let context = new UpdateContext({ bot: this.bot, update: data, // @ts-expect-error payload: updatePayload, type: updateType, updateId: data.update_id }); if ("isEvent" in context && context.isEvent() && context.eventType) { const payload = data.message ?? data.edited_message ?? data.channel_post ?? data.edited_channel_post ?? data.business_message; if (!payload) throw new Error("Unsupported event??"); context = new contextsMappings[context.eventType]({ bot: this.bot, update: data, payload, // @ts-expect-error type: context.eventType, updateId: data.update_id }); } return mode === "wait" ? this.composer.composeWait(context) : this.composer.compose(context); } catch (error) { throw new Error(`[UPDATES] Update type ${updateType} not supported.`); } } /** @deprecated use bot.start instead @internal */ startPolling(params = {}, options = {}) { if (this.isStarted) throw new Error("[UPDATES] Polling already started!"); this.isStarted = true; this.startFetchLoop(params, options); return; } async startFetchLoop(params = {}, options = {}) { if (options.dropPendingUpdates) await this.dropPendingUpdates(options.deleteWebhookOnConflict); while (this.isStarted) { try { this.isRequestActive = true; const updates = await withRetries( () => this.bot.api.getUpdates({ timeout: 30, ...params, offset: this.offset }) ); this.isRequestActive = false; const updateId = updates.at(-1)?.update_id; this.offset = updateId ? updateId + 1 : this.offset; for await (const update of updates) { this.queue.add(update); } } catch (error) { if (error instanceof TelegramError) { if (error.code === 409 && error.message.includes("deleteWebhook")) { if (options.deleteWebhookOnConflict) await this.bot.api.deleteWebhook(); continue; } } console.error("Error received when fetching updates", error); await sleep(this.bot.options.api.retryGetUpdatesWait ?? 1e3); } } this.stopPollingPromiseResolve?.(); } async dropPendingUpdates(deleteWebhookOnConflict = false) { const result = await this.bot.api.getUpdates({ // The negative offset can be specified to retrieve updates starting from *-offset* update from the end of the updates queue. // All previous updates will be forgotten. offset: -1, timeout: 0, suppress: true }); if (result instanceof TelegramError) { if (result.code === 409 && result.message.includes("deleteWebhook")) { if (deleteWebhookOnConflict) { await this.bot.api.deleteWebhook({ drop_pending_updates: true }); return; } } throw result; } const lastUpdateId = result.at(-1)?.update_id; debug$updates( "Dropping pending updates... Set offset to last update id %s + 1", lastUpdateId ); this.offset = lastUpdateId ? lastUpdateId + 1 : this.offset; } /** * ! Soon waitPendingRequests param default will changed to true */ stopPolling(waitPendingRequests = false) { this.isStarted = false; if (!this.isRequestActive || !waitPendingRequests) return Promise.resolve(); return new Promise((resolve) => { this.stopPollingPromiseResolve = resolve; }); } } class Bot { /** @deprecated use `~` instead*/ _ = { /** @deprecated @internal. Remap generic */ derives: {} }; /** @deprecated use `~.derives` instead @internal. Remap generic */ __Derives; "~" = this._; filters = { context: (name) => (context) => context.is(name) }; /** Options provided to instance */ options; /** Bot data (filled in when calling bot.init/bot.start) */ info; /** * Send API Request to Telegram Bot API * * @example * ```ts * const response = await bot.api.sendMessage({ * chat_id: "@gramio_forum", * text: "some text", * }); * ``` * * [Documentation](https://gramio.dev/bot-api.html) */ api = new Proxy({}, { get: (_target, method) => ( // @ts-expect-error // biome-ignore lint/suspicious/noAssignInExpressions: <explanation> _target[method] ??= (args) => this._callApi(method, args) ) }); lazyloadPlugins = []; dependencies = []; errorsDefinitions = { TELEGRAM: TelegramError }; errorHandler(context, error) { if (!this.hooks.onError.length) return console.error("[Default Error Handler]", context, error); return this.runImmutableHooks("onError", { context, //@ts-expect-error ErrorKind exists if user register error-class with .error("kind", SomeError); kind: error.constructor[ErrorKind] ?? "UNKNOWN", error }); } /** This instance handle updates */ updates = new Updates(this, this.errorHandler.bind(this)); hooks = { preRequest: [], onResponse: [], onResponseError: [], onError: [], onStart: [], onStop: [] }; constructor(tokenOrOptions, optionsRaw) { const token = typeof tokenOrOptions === "string" ? tokenOrOptions : tokenOrOptions?.token; const options = typeof tokenOrOptions === "object" ? tokenOrOptions : optionsRaw; if (!token) throw new Error("Token is required!"); if (typeof token !== "string") throw new Error(`Token is ${typeof token} but it should be a string!`); this.options = { ...options, token, api: { baseURL: "https://api.telegram.org/bot", retryGetUpdatesWait: 1e3, ...options?.api } }; if (options?.info) this.info = options.info; if (!(optionsRaw?.plugins && "format" in optionsRaw.plugins && !optionsRaw.plugins.format)) this.extend( new Plugin("@gramio/format").preRequest((context) => { if (!context.params) return context; const formattable = FormattableMap[context.method]; if (formattable) context.params = formattable(context.params); return context; }) ); } async runHooks(type, context) { let data = context; for await (const hook of this.hooks[type]) { data = await hook(data); } return data; } async runImmutableHooks(type, ...context) { for await (const hook of this.hooks[type]) { await hook(...context); } } async _callApi(method, params = {}) { const debug$api$method = debug(`gramio:api:${method}`); let url = `${this.options.api.baseURL}${this.options.token}/${this.options.api.useTest ? "test/" : ""}${method}`; const reqOptions = { method: "POST", ...this.options.api.fetchOptions, // @ts-ignore types node/bun and global missmatch headers: new Headers(this.options.api.fetchOptions?.headers) }; const context = await this.runHooks( "preRequest", // TODO: fix type error // @ts-expect-error { method, params } ); params = context.params; if (params && isMediaUpload(method, params)) { if (IS_BUN) { const formData = await convertJsonToFormData(method, params); reqOptions.body = formData; } else { const [formData, paramsWithoutFiles] = await extractFilesToFormData( method, params ); reqOptions.body = formData; const simplifiedParams = simplifyObject(paramsWithoutFiles); url += `?${new URLSearchParams(simplifiedParams).toString()}`; } } else { reqOptions.headers.set("Content-Type", "application/json"); reqOptions.body = JSON.stringify(params); } debug$api$method("options: %j", reqOptions); const response = await fetch(url, reqOptions); const data = await response.json(); debug$api$method("response: %j", data); if (!data.ok) { const err = new TelegramError(data, method, params); this.runImmutableHooks("onResponseError", err, this.api); if (!params?.suppress) throw err; return err; } this.runImmutableHooks( "onResponse", // TODO: fix type error // @ts-expect-error { method, params, response: data.result } ); return data.result; } async downloadFile(attachment, path) { function getFileId(attachment2) { if (attachment2 instanceof PhotoAttachment) { return attachment2.bigSize.fileId; } if ("fileId" in attachment2 && typeof attachment2.fileId === "string") return attachment2.fileId; if ("file_id" in attachment2) return attachment2.file_id; throw new Error("Invalid attachment"); } const fileId = typeof attachment === "string" ? attachment : getFileId(attachment); const file = await this.api.getFile({ file_id: fileId }); const url = `${this.options.api.baseURL.replace("/bot", "/file/bot")}${this.options.token}/${file.file_path}`; const res = await fetch(url); if (path) { if (!res.body) throw new Error("Response without body (should be never throw)"); await fs.writeFile(path, Readable.fromWeb(res.body)); return path; } const buffer = await res.arrayBuffer(); return buffer; } /** * Register custom class-error for type-safe catch in `onError` hook * * @example * ```ts * export class NoRights extends Error { * needRole: "admin" | "moderator"; * * constructor(role: "admin" | "moderator") { * super(); * this.needRole = role; * } * } * * const bot = new Bot(process.env.TOKEN!) * .error("NO_RIGHTS", NoRights) * .onError(({ context, kind, error }) => { * if (context.is("message") && kind === "NO_RIGHTS") * return context.send( * format`You don't have enough rights! You need to have an «${bold( * error.needRole * )}» role.` * ); * }); * * bot.updates.on("message", (context) => { * if (context.text === "bun") throw new NoRights("admin"); * }); * ``` */ error(kind, error) { error[ErrorKind] = kind; this.errorsDefinitions[kind] = error; return this; } onError(updateNameOrHandler, handler) { if (typeof updateNameOrHandler === "function") { this.hooks.onError.push(updateNameOrHandler); return this; } if (handler) { this.hooks.onError.push(async (errContext) => { if (errContext.context.is(updateNameOrHandler)) await handler(errContext); }); } return this; } derive(updateNameOrHandler, handler) { this.updates.composer.derive(updateNameOrHandler, handler); return this; } decorate(nameOrRecordValue, value) { for (const contextName of Object.keys( contextsMappings )) { if (typeof nameOrRecordValue === "string") Object.defineProperty( contextsMappings[contextName].prototype, nameOrRecordValue, { value, configurable: true } ); else Object.defineProperties( contextsMappings[contextName].prototype, Object.keys(nameOrRecordValue).reduce( (acc, key) => { acc[key] = { value: nameOrRecordValue[key], configurable: true }; return acc; }, {} ) ); } return this; } /** * This hook called when the bot is `started`. * * @example * ```typescript * import { Bot } from "gramio"; * * const bot = new Bot(process.env.TOKEN!).onStart( * ({ plugins, info, updatesFrom }) => { * console.log(`plugin list - ${plugins.join(", ")}`); * console.log(`bot username is @${info.username}`); * console.log(`updates from ${updatesFrom}`); * } * ); * * bot.start(); * ``` * * [Documentation](https://gramio.dev/hooks/on-start.html) * */ onStart(handler) { this.hooks.onStart.push(handler); return this; } /** * This hook called when the bot stops. * * @example * ```typescript * import { Bot } from "gramio"; * * const bot = new Bot(process.env.TOKEN!).onStop( * ({ plugins, info, updatesFrom }) => { * console.log(`plugin list - ${plugins.join(", ")}`); * console.log(`bot username is @${info.username}`); * } * ); * * bot.start(); * bot.stop(); * ``` * * [Documentation](https://gramio.dev/hooks/on-stop.html) * */ onStop(handler) { this.hooks.onStop.push(handler); return this; } preRequest(methodsOrHandler, handler) { if (typeof methodsOrHandler === "string" || Array.isArray(methodsOrHandler)) { if (!handler) throw new Error("TODO:"); const methods = typeof methodsOrHandler === "string" ? [methodsOrHandler] : methodsOrHandler; this.hooks.preRequest.push(async (context) => { if (methods.includes(context.method)) return handler(context); return context; }); } else this.hooks.preRequest.push(methodsOrHandler); return this; } onResponse(methodsOrHandler, handler) { if (typeof methodsOrHandler === "string" || Array.isArray(methodsOrHandler)) { if (!handler) throw new Error("TODO:"); const methods = typeof methodsOrHandler === "string" ? [methodsOrHandler] : methodsOrHandler; this.hooks.onResponse.push(async (context) => { if (methods.includes(context.method)) return handler(context); return context; }); } else this.hooks.onResponse.push(methodsOrHandler); return this; } onResponseError(methodsOrHandler, handler) { if (typeof methodsOrHandler === "string" || Array.isArray(methodsOrHandler)) { if (!handler) throw new Error("TODO:"); const methods = typeof methodsOrHandler === "string" ? [methodsOrHandler] : methodsOrHandler; this.hooks.onResponseError.push(async (context) => { if (methods.includes(context.method)) return handler(context); return context; }); } else this.hooks.onResponseError.push(methodsOrHandler); return this; } // onExperimental( // // filter: Filters, // filter: ( // f: Filters< // Context<typeof this> & Derives["global"], // [{ equal: { prop: number }; addition: { some: () => 2 } }] // >, // ) => Filters, // handler: Handler<Context<typeof this> & Derives["global"]>, // ) {} /** Register handler to one or many Updates */ on(updateName, handler) { this.updates.composer.on(updateName, handler); return this; } /** Register handler to any Updates */ use(handler) { this.updates.composer.use(handler); return this; } /** * Extend {@link Plugin} logic and types * * @example * ```ts * import { Plugin, Bot } from "gramio"; * * export class PluginError extends Error { * wow: "type" | "safe" = "type"; * } * * const plugin = new Plugin("gramio-example") * .error("PLUGIN", PluginError) * .derive(() => { * return { * some: ["derived", "props"] as const, * }; * }); * * const bot = new Bot(process.env.TOKEN!) * .extend(plugin) * .onError(({ context, kind, error }) => { * if (context.is("message") && kind === "PLUGIN") { * console.log(error.wow); * } * }) * .use((context) => { * console.log(context.some); * }); * ``` */ extend(plugin) { if (plugin instanceof Promise) { this.lazyloadPlugins.push(plugin); return this; } if (plugin._.dependencies.some((dep) => !this.dependencies.includes(dep))) throw new Error( `The \xAB${plugin._.name}\xBB plugin needs dependencies registered before: ${plugin._.dependencies.filter((dep) => !this.dependencies.includes(dep)).join(", ")}` ); if (plugin._.composer.length) { this.use(plugin._.composer.composed); } this.decorate(plugin._.decorators); for (const [key, value] of Object.entries(plugin._.errorsDefinitions)) { if (this.errorsDefinitions[key]) this.errorsDefinitions[key] = value; } for (const value of plugin._.preRequests) { const [preRequest, updateName] = value; if (!updateName) this.preRequest(preRequest); else this.preRequest(updateName, preRequest); } for (const value of plugin._.onResponses) { const [onResponse, updateName] = value; if (!updateName) this.onResponse(onResponse); else this.onResponse(updateName, onResponse); } for (const value of plugin._.onResponseErrors) { const [onResponseError, updateName] = value; if (!updateName) this.onResponseError(onResponseError); else this.onResponseError(updateName, onResponseError); } for (const handler of plugin._.groups) { this.group(handler); } for (const value of plugin._.onErrors) { this.onError(value); } for (const value of plugin._.onStarts) { this.onStart(value); } for (const value of plugin._.onStops) { this.onStop(value); } this.dependencies.push(plugin._.name); return this; } /** * Register handler to reaction (`message_reaction` update) * * @example * ```ts * new Bot().reaction("👍", async (context) => { * await context.reply(`Thank you!`); * }); * ``` * */ reaction(trigger, handler) { const reactions = Array.isArray(trigger) ? trigger : [trigger]; return this.on("message_reaction", (context, next) => { const newReactions = []; for (const reaction of context.newReactions) { if (reaction.type !== "emoji") continue; const foundIndex = context.oldReactions.findIndex( (oldReaction) => oldReaction.type === "emoji" && oldReaction.emoji === reaction.emoji ); if (foundIndex === -1) { newReactions.push(reaction); } else { context.oldReactions.splice(foundIndex, 1); } } if (!newReactions.some( (x) => x.type === "emoji" && reactions.includes(x.emoji) )) return next(); return handler(context); }); } /** * Register handler to `callback_query` event * * @example * ```ts * const someData = new CallbackData("example").number("id"); * * new Bot() * .command("start", (context) => * context.send("some", { * reply_markup: new InlineKeyboard().text( * "example", * someData.pack({ * id: 1, * }) * ), * }) * ) * .callbackQuery(someData, (context) => { * context.queryData; // is type-safe * }); * ``` */ callbackQuery(trigger, handler) { return this.on("callback_query", (context, next) => { if (!context.data) return next(); if (typeof trigger === "string" && context.data !== trigger) return next(); if (trigger instanceof RegExp) { if (!trigger.test(context.data)) return next(); context.queryData = context.data.match(trigger); } if (trigger instanceof CallbackData) { if (!trigger.regexp().test(context.data)) return next(); context.queryData = trigger.unpack(context.data); } return handler(context); }); } /** Register handler to `chosen_inline_result` update */ chosenInlineResult(trigger, handler) { return this.on("chosen_inline_result", (context, next) => { if (typeof trigger === "string" && context.query === trigger || // @ts-expect-error typeof trigger === "function" && trigger(context) || trigger instanceof RegExp && trigger.test(context.query)) { context.args = trigger instanceof RegExp ? context.query?.match(trigger) : null; return handler(context); } return next(); }); } /** * Register handler to `inline_query` update * * @example * ```ts * new Bot().inlineQuery( * /regular expression with (.*)/i, * async (context) => { * if (context.args) { * await context.answer( * [ * InlineQueryResult.article( * "id-1", * context.args[1], * InputMessageContent.text("some"), * { * reply_markup: new InlineKeyboard().text( * "some", * "callback-data" * ), * } * ), * ], * { * cache_time: 0, * } * ); * } * }, * { * onResult: (context) => context.editText("Message edited!"), * } * ); * ``` * */ inlineQuery(trigger, handler, options = {}) { if (options.onResult) this.chosenInlineResult(trigger, options.onResult); return this.on("inline_query", (context, next) => { if (typeof trigger === "string" && context.query === trigger || // @ts-expect-error typeof trigger === "function" && trigger(context) || trigger instanceof RegExp && trigger.test(context.query)) { context.args = trigger instanceof RegExp ? context.query?.match(trigger) : null; return handler(context); } return next(); }); } /** * Register handler to `message` and `business_message` event * * new Bot().hears(/regular expression with (.*)/i, async (context) => { * if (context.args) await context.send(`Params ${context.args[1]}`); * }); */ hears(trigger, handler) { return this.on("message", (context, next) => { const text = context.text ?? context.caption; if (typeof trigger === "string" && text === trigger || Array.isArray(trigger) && text && trigger.includes(text) || // @ts-expect-error typeof trigger === "function" && trigger(context) || trigger instanceof RegExp && text && trigger.test(text)) { context.args = trigger instanceof RegExp ? text?.match(trigger) : null; return handler(context); } return next(); }); } /** * Register handler to `message` and `business_message` event when entities contains a command * * new Bot().command("start", async (context) => { * return context.send(`You message is /start ${context.args}`); * }); */ command(command, handler) { const normalizedCommands = typeof command === "string" ? [command] : Array.from(command); for (const cmd of normalizedCommands) { if (cmd.startsWith("/")) throw new Error(`Do not use / in command name (${cmd})`); } return this.on(["message", "business_message"], (context, next) => { if (context.entities?.some((entity) => { if (entity.type !== "bot_command" || entity.offset > 0) return false; const cmd = context.text?.slice(1, entity.length)?.replace( this.info?.username ? `@${this.info.username}` : /@[a-zA-Z0-9_]+/, "" ); context.args = context.text?.slice(entity.length).trim() || null; return normalizedCommands.some( (normalizedCommand) => cmd === normalizedCommand ); })) return handler(context); return next(); }); } /** Currently not isolated!!! */ group(grouped) { return grouped(this); } /** * Init bot. Call it manually only if you doesn't use {@link Bot.start} */ async init() { await Promise.all( this.lazyloadPlugins.map(async (plugin) => this.extend(await plugin)) ); if (!this.info) { const info = await this.api.getMe({ suppress: true }); if (info instanceof TelegramError) { if (info.code === 404) info.message = "The bot token is incorrect. Check it in BotFather."; throw info; } this.info = info; } } /** * Start receive updates via long-polling or webhook * * @example * ```ts * import { Bot } from "gramio"; * * const bot = new Bot("") // put you token here * .command("start", (context) => context.send("Hi!")) * .onStart(console.log); * * bot.start(); * ``` */ async start({ webhook, longPolling, dropPendingUpdates, allowedUpdates, deleteWebhook: deleteWebhookRaw } = {}) { await this.init(); const deleteWebhook = deleteWebhookRaw ?? "on-conflict-with-polling"; if (!webhook) { if (deleteWebhook === true) await withRetries( () => this.api.deleteWebhook({ drop_pending_updates: dropPendingUpdates }) ); this.updates.startPolling( { ...longPolling, allowed_updates: allowedUpdates }, { dropPendingUpdates, deleteWebhookOnConflict: deleteWebhook === "on-conflict-with-polling" } ); this.runImmutableHooks("onStart", { plugins: this.dependencies, // biome-ignore lint/style/noNonNullAssertion: bot.init() guarantees this.info info: this.info, updatesFrom: "long-polling" }); return this.info; } if (this.updates.isStarted) this.updates.stopPolling(); if (webhook !== true) await withRetries( async () => this.api.setWebhook({ ...typeof webhook === "string" ? { url: webhook } : webhook, drop_pending_updates: dropPendingUpdates, allowed_updates: allowedUpdates // suppress: true, }) ); this.runImmutableHooks("onStart", { plugins: this.dependencies, // biome-ignore lint/style/noNonNullAssertion: bot.init() guarantees this.info info: this.info, updatesFrom: "webhook" }); return this.info; } /** * Stops receiving events via long-polling or webhook * */ async stop(timeout = 3e3) { await Promise.all( [ this.updates.queue.stop(timeout), this.updates.isStarted ? this.updates.stopPolling() : void 0 ].filter(Boolean) ); await this.runImmutableHooks("onStop", { plugins: this.dependencies, // biome-ignore lint/style/noNonNullAssertion: bot.init() guarantees this.info info: this.info }); } } const SECRET_TOKEN_HEADER = "X-Telegram-Bot-Api-Secret-Token"; const WRONG_TOKEN_ERROR = "secret token is invalid"; const RESPONSE_OK = "ok!"; const responseOK = () => new Response(RESPONSE_OK); const responseUnauthorized = () => new Response(WRONG_TOKEN_ERROR, { status: 401 // @ts-ignore }); const frameworks = { elysia: ({ body, headers }) => ({ update: body, header: headers[SECRET_TOKEN_HEADER], unauthorized: responseUnauthorized, response: responseOK }), fastify: (request, reply) => ({ update: request.body, header: request.headers[SECRET_TOKEN_HEADER], unauthorized: () => reply.code(401).send(WRONG_TOKEN_ERROR), response: () => reply.send(RESPONSE_OK) }), hono: (c) => ({ update: c.req.json(), header: c.req.header(SECRET_TOKEN_HEADER), unauthorized: () => c.text(WRONG_TOKEN_ERROR, 401), response: responseOK }), express: (req, res) => ({ update: req.body, header: req.header(SECRET_TOKEN_HEADER), unauthorized: () => res.status(401).send(WRONG_TOKEN_ERROR), response: () => res.send(RESPONSE_OK) }), koa: (ctx) => ({ update: ctx.request.body, header: ctx.get(SECRET_TOKEN_HEADER), unauthorized: () => { ctx.status === 401; ctx.body = WRONG_TOKEN_ERROR; }, response: () => { ctx.status = 200; ctx.body = RESPONSE_OK; } }), http: (req, res) => ({ update: new Promise((resolve) => { let body = ""; req.on("data", (chunk) => { body += chunk.toString(); }); req.on("end", () => resolve(JSON.parse(body))); }), header: req.headers[SECRET_TOKEN_HEADER.toLowerCase()], unauthorized: () => res.writeHead(401).end(WRONG_TOKEN_ERROR), response: () => res.writeHead(200).end(RESPONSE_OK) }), "std/http": (req) => ({ update: req.json(), header: req.headers.get(SECRET_TOKEN_HEADER), response: responseOK, unauthorized: responseUnauthorized }), "Bun.serve": (req) => ({ update: req.json(), header: req.headers.get(SECRET_TOKEN_HEADER), response: responseOK, unauthorized: responseUnauthorized }), cloudflare: (req) => ({ update: req.json(), header: req.headers.get(SECRET_TOKEN_HEADER), response: responseOK, unauthorized: responseUnauthorized }), Request: (req) => ({ update: req.json(), header: req.headers.get(SECRET_TOKEN_HEADER), response: responseOK, unauthorized: responseUnauthorized }) }; function webhookHandler(bot, framework, secretTokenOrOptions) { const frameworkAdapter = frameworks[framework]; const secretToken = typeof secretTokenOrOptions === "string" ? secretTokenOrOptions : secretTokenOrOptions?.secretToken; const shouldWaitOptions = typeof secretTokenOrOptions === "string" ? false : secretTokenOrOptions?.shouldWait; const isShouldWait = shouldWaitOptions && (typeof shouldWaitOptions === "object" || typeof shouldWaitOptions === "boolean"); return async (...args) => { const { update, response, header, unauthorized } = frameworkAdapter( ...args ); if (secretToken && header !== secretToken) return unauthorized(); if (!isShouldWait) { bot.updates.queue.add(await update); if (response) return response(); } else { const timeoutOptions = typeof shouldWaitOptions === "object" ? shouldWaitOptions : void 0; const timeout = timeoutOptions?.timeout ?? 3e4; const onTimeout = timeoutOptions?.onTimeout ?? "throw"; await timeoutWebhook( bot.updates.handleUpdate(await update, "wait"), timeout, onTimeout ); if (response) return response(); } }; } Symbol.metadata ??= Symbol("Symbol.metadata"); export { Bot, Composer, ErrorKind, Plugin, TelegramError, Updates, webhookHandler };