gramio
Version:
Powerful, extensible and really type-safe Telegram Bot API framework
1,258 lines (1,248 loc) • 39.8 kB
JavaScript
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 };