This project serves as an open-source template for building server-side applications, with a focus on integrating Telegram bot functionalities using Koa
, TypeScript
, and the node-telegram-bot-api
library. It incorporates a robust setup, including the custom defineTGCommand
and defineTGQueryCallback
methods to manage Telegram commands and inline button interactions efficiently. The project leverages tsup
for building, with integrated ESLint + Prettier for maintaining clean, well-formatted code. It provides a scalable and maintainable foundation for developers looking to create web services or APIs with seamless Telegram bot integration.
This is the data exchange and network communication layer implemented using Node.js Koa WebServer.
Cloudflare provides free global CDN service. We just need to bind the domain to the server port.
Without a domain, it is impossible to use the Telegram webhook, and the Telegram Bot Client will fallback to polling mode.
This is the base web service implemented using Node.js. Through Koa, we can implement REST APIs and external nodes for the Telegram WebHook.
Here are some implementation details for future reference.
When we need modularized routes, we require a convenient design pattern to break free from logic that is purely convention-based.
Here, we use a factory-like pattern to provide a simple and intuitive modular division, known as the Route Define.
This pattern defines parameters and decouples Koa Router callback functions (Middleware Callback) to achieve modularization. Here’s the continuation of the translation:
// Router Define parameters
export interface RouterOptions {
prefix?: string;
options?: Router.IRouterOptions;
middlewares?: Middleware[];
setup?: () => Promise<void>;
routes?: Router[];
health?: boolean;
}
- The
prefix
parameter is the same as the configuration inRouter.IRouterOptions
, which is also a definition parameter for KoaRouter
. - The
middlewares
field provides the same middleware to all sub-routes. - The
setup
field is used to support the initialization of the router. - The
routes
field defines the sub-routes under this router. For example, theuser
route can share the:id
query sub-route. - The
health
field provides a simple health check service sub-route.
Next, we need to register a setup
method that will be executed during the application bootstrap stage:
// Collect the setup configuration of Router Define and execute during the application’s bootstrap phase
let setups: Required<RouterOptions>['setup'][] = [];
// Register the Router Define’s setup value during bootstrap
export const routerSetup = async () => {
for (const setup of setups) {
await setup(); // Execute independently to avoid complex scheduling
}
// Clear setups
setups = [];
};
Note that routes may have hierarchical relationships. To avoid overly complex scheduling, the execution process here is simplified, and this should be considered when using it.
Now, let’s implement the defineRouter
method. It’s quite straightforward:
export function defineRouter(options: RouterOptions) {
const router = new Router({ prefix: options.prefix, ...options.options });
// Add health check service
if (options.health) {
router.get('/health', (ctx) => (ctx.body = 'OK'));
}
// Register middlewares
if (options.middlewares) {
router.use(...options.middlewares);
}
// Register sub-routes
options.routes?.forEach((route) => {
router.use(route.routes());
});
// Register setup
if (options.setup) {
setups.push(options.setup);
}
return router;
}
What’s clever here is that the middlewares in the middlewares
field are registered before the routes and in the order they are added. This way, the execution order of middlewares is maintained, following Koa's onion model.
To use it, simply import the defineRouter
method:
export const userRouter = defineRouter({
prefix: '/user',
// Simple password-based authentication control
middlewares: [authPassword(ENV.SERVER_AUTH_PASSWORD)],
});
Common middlewares include REST wrapping middlewares, authentication middlewares, form validation middlewares, etc.
This middleware wraps the route's return data and defines the relevant return codes.
import { Middleware } from 'koa';
import logger from '../common/logger.js';
export const restful: Middleware = async (ctx, next) => {
try {
await next();
if (!ctx.body) {
ctx.status = 404; // 404 for unknown return values
ctx.body = { code: 404, message: 'Not Found' };
return;
}
// Skip custom code or wrap the body
if (ctx.body.code === undefined) ctx.body = { code: 0, data: ctx.body, message: ctx.body.message ?? 'Success' };
} catch (error: unknown) {
console.error(error);
logger.error(`Internal Server Error: ${error}`);
ctx.status = 500; // Errors thrown by the Router are treated as service errors
ctx.body = { code: 500, message: 'Internal Server Error' };
}
};
Using zod
for form validation, we can decouple form validation logic by defining DTO (Data Transfer Object) schemas.
import { Context, Next } from 'koa';
import { ZodObject, ZodRawShape, ZodError } from 'zod';
import logger from '../common/logger.js';
import _ from 'lodash';
export const dto =
<T extends ZodRawShape, S extends ZodObject<T>>(schema: S) =>
async (ctx: Context, next: Next) => {
try {
// Validate schema
schema.parse(ctx.request.body);
} catch (error: unknown) {
// Handle validation failures
const errors = _.map((error as ZodError).errors, 'message');
logger.error(`Validation Error: ${errors}`);
ctx.status = 412;
ctx.body = { code: 412, message: 'DTO check error.', error };
return;
}
await next();
};
A simple authentication middleware using a local password. This authentication method is not suitable for public use, and for more complex mechanisms, jwt
can be used.
import { Context, Next } from 'koa';
export const authPassword = (psd: string) => async (ctx: Context, next: Next) => {
// Check if the local password matches
if (ctx.header.authorization !== psd) {
ctx.status = 401;
ctx.body = { code: 401, message: 'Unauthorized' }; // Throw an unauthorized request
return;
} else {
await next();
}
};
Using the jsonwebtoken
package to provide JWT signature functionality, we can implement a simple JWT validation middleware:
import jsonwebtoken from 'jsonwebtoken';
import { User } from '@prisma/client';
import { prisma } from '../common/prisma.js';
export const authJwt = () => async (ctx: Context, next: Next) => {
try {
if (!ctx.header.authorization) throw new Error('No authorization header');
const jwtUser: User = jsonwebtoken.verify(ctx.header.authorization, ENV.SERVER_AUTH_PASSWORD) as User;
const user = await prisma.user.findUnique({ where: { id: jwtUser.id } });
if (!user) throw new Error('Unauthorized');
ctx.state.user = user; // Register the user in subsequent middlewares and routes
} catch (error) {
ctx.status = 401;
ctx.body = { code: 401, message: 'Unauthorized' };
return;
}
await next();
};
This RPC service is primarily implemented by sharing Prisma’s data operation permissions. We use the ZenStack OpenAPI plugin to implement the middleware.
Since ZenStack does not directly provide a KOA middleware, we need to patch it ourselves:
function ZenStackMiddlewareKoaAdapter(ops: MiddlewareOptions) {
const handle = ZenStackMiddleware({ ...ops, sendResponse: false });
return async (ctx: Context, next: Next) => {
const status = (code: number) => (ctx.status = code);
const get = (key: string) => ctx.request.query[key];
await handle(
Object.assign(ctx.request, { status, get, path: ctx.request.path.replace('/api/rest', '') }) as ExpectedAnyData,
ctx.response as ExpectedAnyData,
next
);
const locals = (ctx.response as unknown as { locals: { status: number; body: object } }).locals;
ctx.body = Object.assign(locals.body, { code: locals.status });
};
}
You can invoke this middleware by passing in the configuration:
router.all(
/\/api\/rest\/.*/g,
ZenStackMiddlewareKoaAdapter({
getPrisma: () => prisma,
handler: RestApiHandler({ endpoint: `${ENV.APP_HOST}/api/rest` }),
logger: {
error: (...args) => {
console.error(...args);
console.trace();
},
},
})
);
In actual usage, frequent calls to the Telegram API often encounter rate-limiting issues. This requires us to add a rate limiter to control the frequency of these API calls.
We can implement a rate limiter using Bottleneck, or alternatively use p-queue or other similar packages. The methods are similar:
import Bottleneck from 'bottleneck';
import logger from '../../common/logger.js';
// Rate Limiter Controller
export class RateLimiterControl {
failedRetryTime = 1000; // Retry interval
failedRetryLimit = 10; // Retry limit
protected rateLimiter = new Bottleneck({
// Configure 30 QPS limit
minTime: Math.ceil(1000 / 30),
maxConcurrent: 30,
});
// Logic to check retry on failure
protected checkJobFailError(_error: unknown, retryCount: number): boolean {
return retryCount < this.failedRetryLimit;
}
constructor() {
// Retry requests and error handling
this.rateLimiter.on('failed', (error, jobInfo) => {
if (this.checkJobFailError(error, jobInfo.retryCount)) {
logger.error(error);
logger.warn(
`Job ${jobInfo.options.id} failed ${jobInfo.retryCount + 1}. Retrying after ${
this.failedRetryTime
}ms retries.`
);
return this.failedRetryTime;
}
logger.error(error);
return;
});
}
}
In the Node.js community, the Telegram API is provided by node-telegram-bot-api. It’s unlikely that we would create our own Node.js package for this, so we can use a Proxy
to intercept its method calls.
export class RateLimiterControl {
// ...
createRateLimiterProxy<T extends object>(target: T) {
return new Proxy(target, {
get: (target, prop, receiver) => {
const origProperty = target[prop as keyof T];
// Intercept method calls
if (typeof origProperty === 'function') {
// More filters can be added here
return this.rateLimiter.wrap(origProperty.bind(target));
}
return Reflect.get(target, prop, receiver);
},
}) as unknown as T;
}
}
import TelegramBot from 'node-telegram-bot-api';
// Extend the rate limiter controller
export class TelegramBotClient extends RateLimiterControl {
bot: TelegramBot;
constructor(token: string, options?: TelegramBot.ConstructorOptions) {
super();
// Update rate limit settings
this.rateLimiter.updateSettings({
id: 'TelegramTGClient', // Shared rate limit settings
minTime: 500,
maxConcurrent: 2,
});
// Intercept original TelegramBot methods
this.bot = this.createRateLimiterProxy(new TelegramBot(token, options));
}
}
After wrapping the TGClientClass, you can add your custom methods, such as sendMDMessage
. Here, I’m sharing my method for formatting Telegram MarkdownV2 Message, allowing you to write local markdown templates for sending messages.
Here’s the implementation:
export function formatMarkdownMessages(text: string) {
const escape = /[_*[\]()~`>#+\-=|{}.!]/;
const replaceEscape = /[~>+=|.!]/;
const doubleEscape = /[`_*]/;
const bracketsEscape = /[{}[]()]/;
// Split string by newline
const strArr = text.split('\n');
// ? Not supporting table(|) and code block(```)
for (const [rowIndex, rawStr] of strArr.entries()) {
const stack: string[] = [];
const indexes: number[] = [];
// Handle special characters * and continuous #, like **bold**, typically composed of two *
let rawArr = rawStr.replace(/\*{2}|#{2,}\s/g, (match) => match?.[0] ?? '').split('');
for (const [i, char] of rawArr.entries()) {
// Skip characters that don't require escaping
if (!escape.test(char)) continue;
// Skip already escaped characters
if (rawArr[i - 1] === '\\') continue;
// Handle special character #
if (char === '#') {
if (i === 0) {
rawArr = ['*', formatMarkdownMessages(rawArr.slice(rawArr[i + 1] === ' ' ? 2 : 1).join('')), '*'];
break;
}
rawArr[i] = '\\#';
}
// Handle special character -, format subsequent spaces
if (char === '-') {
if (rawArr[i + 1] === ' ' && i === 0) {
rawArr[i] = '';
rawArr[i + 1] = '';
} else {
rawArr[i] = '\\-';
}
continue;
}
// Handle characters that need direct replacement
if (replaceEscape.test(char)) {
rawArr[i] = `\\${char}`;
continue;
}
// Handle characters that need double escaping
if (doubleEscape.test(char)) {
if (stack.at(-1) === char) {
stack.pop();
indexes.pop();
} else {
stack.push(char);
indexes.push(i);
}
continue;
}
// Handle parentheses, non-link parentheses need to be escaped
if (bracketsEscape.test(char)) {
stack.push(char);
indexes.push(i);
continue;
}
}
// Check if parentheses are matched
let start = 0;
for (const [i, char] of stack.entries()) {
if (char === '[') {
start = i;
} else if (char === ')' && i - start === 3) {
for (let j = start; j <= i; j++) {
indexes[j] = -1;
}
}
}
// Escape illegal characters
for (const i of indexes) {
if (i === -1) continue;
rawArr[i] = `\\${rawArr[i]}`;
}
strArr[rowIndex] = rawArr.join('');
}
return strArr.join('\n');
}
// Usage in TelegramBotClient
export class TelegramBotClient extends RateLimiterControl {
sendMDMessage(
chatId: number | string,
text: string,
options?: TelegramBot.SendMessageOptions & { autoformat?: boolean }
) {
return this.bot.sendMessage(chatId, options?.autoformat !== false ? formatMarkdownMessages(text) : text, {
parse_mode: 'MarkdownV2',
...options,
});
}
}
The design pattern of a Telegram Bot is similar to the command system in a terminal. When a user sends a message, the corresponding command action is executed.
Generally, when a user sends a command, the execution logic follows an interactive, linear process.
All messages should come from the webhook gateway as part of the architecture design. In node-telegram-bot-api
, every command is triggered through the EventEmitter
.
Below are some common callback examples:
bot.on('message', (msg) => {
if (msg.text) {
// Parsing text messages
logger.debug(`[TGM] @${msg.chat.username}_${msg.chat.id}: ${msg.text}`);
}
if (msg.document) {
// Parsing document messages, mainly for user-provided files. Telegram provides a file_id for download.
logger.debug(`[TGM] @${msg.chat.username}_${msg.chat.id}: [Document]`);
const document = msg.document;
if (document) {
// Obtain download link via file_id
botClient.bot.getFileLink(document.file_id).then((link) => {
logger.debug(`[TGM] @${msg.chat.username}_${msg.chat.id}: ${link}`);
});
}
}
if (msg.photo) {
// Parsing photo messages. Telegram provides a file_id to retrieve the link.
logger.debug(`[TGM] @${msg.chat.username}_${msg.chat.id}: [Photo]`);
const photo = msg.photo.at(-1);
if (photo) {
// Obtain photo URL via file_id
botClient.bot.getFileLink(photo.file_id).then((link) => {
logger.debug(`[TGM] @${msg.chat.username}_${msg.chat.id}: ${link}`);
});
}
}
if (msg.contact) {
// Parsing contact messages
logger.debug(`[TGM] @${msg.chat.username}_${msg.chat.id}: [Contact]`);
}
});
botClient.bot.on('callback_query', (query) => {
// Callback for inline button clicks
logger.debug(`[TGCallbackQuery] @${query.from.username}_${query.from.id}: ${query.data}`);
});
Using callbacks to implement business logic may not be abstract enough, and the level of data operation encapsulation may not be high. By dissecting the execution flow diagram, we can see that each command execution may require the user to provide additional data inputs.
I was inspired by citty for CLI command encapsulation logic, and I implemented a similar defineTGCommand
API. Below is my implementation.
In business scenarios, users' input types can vary greatly. We need to support not only simple string types but also boolean
, number
, and even file
types.
In TypeScript, we can leverage its generic capabilities to achieve this and make future business logic development easier:
// Define supported argument types
type TGCommandArgTypes = 'string' | 'boolean' | 'number' | 'file';
// Utility type
type Recordable<T = any> = Record<string, T>;
// Define concrete argument types
export type TGCommandArgs = Recordable<{
type?: TGCommandArgTypes; // The type of the argument
required?: boolean; // Whether the argument is required
description?: string; // Description provided to the user for the argument
templateFilepath?: string; // Template file path
contentType?: string; // Data type of the template file
}>;
By utilizing TypeScript's conditional and mapped types, we can enforce and map generics:
type ArgsFromConfig<T extends TGCommandArgs> = {
[K in keyof T]: T[K]['type'] extends 'string' // If the type is 'string', the data type is a string
? string
: T[K]['type'] extends 'boolean' // If the type is 'boolean', the data type is boolean
? boolean
: T[K]['type'] extends 'number' // If the type is 'number', the data type is a number
? number
: T[K]['type'] extends 'file' // If the type is 'file', the data type is a URL string
? `https://${string}`
: never; // If parsing fails, the type is never
};
When executing a command, we pass the defined Args
and other parameters to the callback function. We can define a detailed Context
for use:
export type TGCommandContext<T extends TGCommandArgs> = {
command: string; // The current command being executed
msg: TelegramBot.Message; // The original message from the user
chatId: TelegramBot.Message['chat']['id']; // The user's chatId
match?: RegExpMatchArray | null; // Matched items when a user sends a command like /command_name?q=query
data?: Recordable<string | number | boolean>; // Initialized data
args: ArgsFromConfig<T>; // Arguments for the command
};
Finally, we add this to the callback, completing the TGCommand definition:
type TGCommand<T extends TGCommandArgs> = {
command: string; // The defined command
description: string; // Description for the command, used with setMyCommands
// Setup callback
setup?: (ctx: Omit<TGCommandContext<T>, 'msg' | 'match' | 'args' | 'chatId'>) => unknown;
callback?: (ctx: TGCommandContext<T>) => unknown; // Execution callback
data?: Record<string, string | number>; // Initialized data
args?: T; // Arguments for the command
};
export function defineTGCommand<T extends TGCommandArgs>(params: TGCommand<T>) {
// Internal logic...
}
Just defining types won't help us fully encapsulate the logic. Let's break it down step by step.
Specifically, we first need to parse the command's arguments and prompt the user to provide the required data, which may involve sending and replying to multiple messages. This can be achieved using a relatively complex Promise-based solution:
// Utility function to get the argument value
function getArgsValue(type: TGCommandArgTypes, value: string) {
return type === 'boolean' ? value === 'true' : type === 'number' ? parseInt(value) : value;
}
// Retrieve command arguments
async function getCommandArgs<T extends TGCommandArgs, R extends ArgsFromConfig<T>, K extends keyof R, V extends R[K]>(
client: TelegramBotClient,
msgId: string,
msg: TelegramBot.Message,
args?: T
) {
// Use the result variable to store results
const result = {} as R;
// Return result if no arguments are provided
if (!args) return result;
for (const [key, { type = 'string', required, description, ...fileInfo }] of Object.entries(args)) {
// Use a Promise to get a single argument
await new Promise<void>((resolve, reject) => {
const callback = async (newMsg: TelegramBot.Message) => {
// Skip messages from other users
if (newMsg.chat.id !== msg.chat.id) return;
// Handle completion
const done = (err?: Error) => {
if (err) {
logger.error(err);
reject(err);
} else {
resolve();
}
client.bot.off('message', callback);
};
// Skip optional arguments with /skip command
if (newMsg.text === '/skip' && !required) return done();
// Boolean argument
if (/^\/(yes|no)/.test(newMsg.text ?? '')) {
if (type !== 'boolean') return done(new Error('Boolean type required'));
result[key as K] = (newMsg.text === '/yes') as V;
return done();
}
// Handle file type data
if (type === 'file') {
if (!newMsg.document) return done(new Error('File is required'));
try {
// Write the file to a temporary directory
const filepath = path.resolve(`temp/${newMsg.document.file_unique_id}-${newMsg.document.file_name}`);
result[key as K] = (await client.downloadFileMessage(newMsg, filepath)) as V;
await redis.set(RedisKeys.tg.argsFile(msgId), filepath);
} catch (error) {
return done(error as Error);
}
return done();
}
// Handle text arguments
if (!newMsg.text) return done(new Error('Text is required'));
result[key as K] = getArgsValue(type, newMsg.text) as V;
done();
};
client.bot.on('message', callback);
});
}
return result;
}
Next, we add logic to send a prompt message to the user:
function generateArgsTipMessage(arg: TGCommandArgs[string]) {
let msg = '';
if (arg.type === 'boolean') {
// Boolean type
msg = `${arg.description} Please choose Yes(/yes) or No(/no).`;
} else if (arg.type === 'file') {
// File type
msg = `${arg.description ?? 'Please download the file and fill it out according to the template, then resend it.'}`;
} else {
msg = `Please enter ${arg.description || ''}`; // Text type
}
if (!arg.required) {
msg += ' or skip(/skip)'; // Add a skip option for optional arguments
}
return msg;
}
Integrate this message into the getCommandArgs
function and add special handling for file types:
// getCommandArgs
// ...
for (const [
/* ... */
] of Object.entries(args)) {
// ...
client.bot.on('message', callback);
// File types require sending a template file
if (type === 'file') {
if (!fileInfo.templateFilepath) return reject(new Error('Filepath is required for file type'));
sendTemplate(client, msg, {
type,
required,
description,
...fileInfo,
});
return;
}
// Send argument prompt message
client.sendMDMessage(msg.chat.id, generateArgsTipMessage({ type, required, description }));
}
Finally, for temporary files, you can delete them using fs.unlink
:
// Delete temporary file
async function unlinkArgsFile(msgId: string) {
const filepath = await redis.get(RedisKeys.tg.argsFile(msgId));
if (filepath) {
await fs.unlink(filepath);
}
}
To make it convenient for other scenarios to execute commands programmatically, we can implement a dispatchCommand
method that abstracts and handles the execution logic:
// Command collection
const commands = new Map<string, TGCommand<any>>();
// Execute command
export async function dispatchCommand(command: string, msg: TelegramBot.Message, match?: RegExpMatchArray | null) {
if (!commands.has(command)) return; // Skip invalid commands, you can add an error message
const msgId = msg.message_id.toString();
const { args: commandArgs, data, callback } = commands.get(command) as TGCommand<TGCommandArgs>;
try {
const ctx: TGCommandContext<TGCommandArgs> = {
command,
client: botClient, // Instance of TelegramBotClient
msg,
match,
data,
chatId: msg.chat.id,
args: {},
};
// Get command arguments
ctx.args = await getCommandArgs(botClient, msgId, msg, commandArgs);
// Execute callback
await callback(ctx);
} catch (error) {
logger.error(`Error executing command ${command}: ${(error as Error)?.message}`);
} finally {
await unlinkArgsFile(msgId);
}
}
Add an onText
listener to capture messages sent by users and execute commands using dispatchCommand
:
export function defineTGCommand<T extends TGCommandArgs>({ command, ...other }: TGCommand<T>) {
// Check for duplicate commands
if (commands.has(command)) {
logger.warn(`Command ${command} already exists, overwriting`);
botClient.bot.removeTextListener(new RegExp(`^/${command}(\?.*)?$`));
}
// Save command to collection
commands.set(command, { command, ...other });
// Listen and match parameter messages
botClient.bot.onText(new RegExp(`^/${command}(\?.*)?$`), async (msg, match) => {
// Execute command
await dispatchCommand(command, msg, match);
});
}
Finally, you can provide a setup
method to invoke the TG API's getMyCommands
to retrieve old commands and check whether they need updating. If there are new commands, call setMyCommands
to update them.
Here’s the implementation of setupTGCommands
:
export async function setupTGCommands(): Promise<boolean> {
// Check and create temporary folder directory
if ((await fs.access(path.resolve('temp')).catch(() => false)) === false) {
await fs.mkdir(path.resolve('temp'));
}
try {
// Get old commands
const oldCommands = await botClient.bot.getMyCommands();
const oldCommandsHash = new Set(oldCommands.map(({ command }) => command));
let updated = false; // Flag to indicate updates
const newCommands: TelegramBot.BotCommand[] = [];
for (const [command, config] of commands) {
// If new commands exist, set update flag
if (!oldCommandsHash.has(command)) updated = true;
try {
// Execute setup parameters for the command
await config.setup?.({ command, client: botClient, data: config.data });
} catch (error) {
logger.error(`Error setting up command ${command}: ${(error as Error)?.message}`);
}
// Add new command to newCommands array
newCommands.push({ command, description: config.description });
}
// Update commands if needed
if (updated) {
logger.info('Updating Telegram commands');
await botClient.bot.setMyCommands(newCommands);
}
return updated;
} catch (error) {
logger.error(error);
return setupTGCommands(); // Retry automatically
}
}
In Telegram, non-command scenarios—such as interactive operations frequently seen in inline buttons—are handled through Query Callbacks. These callbacks work similarly to commands but are used for branching interactions rather than entry points for logical flows.
We can define query callbacks using a similar approach to the defineCommand
function, encapsulating them in a way that allows reuse across all Telegram messages with inline_keyboard
buttons. The implementation is similar to defineCommand
, so let’s jump into the code for reference.
type TGQueryArgs = Recordable<{
type?: 'string' | 'boolean' | 'number';
required?: boolean;
}>;
type ArgsFromConfig<T extends TGQueryArgs> = {
[K in keyof T]: T[K]['type'] extends 'string'
? string
: T[K]['type'] extends 'boolean'
? boolean
: T[K]['type'] extends 'number'
? number
: never;
};
type IsRequired<T extends TGQueryArgs> = {
[K in keyof T]: T[K]['required'] extends true ? K : never;
}[keyof T];
type TGQueryCallbackContext<T extends TGQueryArgs> = {
query: string;
client: TelegramBotClient;
msg?: TelegramBot.Message;
args: ArgsFromConfig<T>;
};
type TGQueryCallbackConfig<T extends TGQueryArgs> = {
query: string;
text: string;
callback?: (ctx: TGQueryCallbackContext<T>) => unknown;
args?: T;
cleanup?: (ctx: TGQueryCallbackContext<T>) => unknown;
};
const queryCallbacks = new Map<string, TGQueryCallbackConfig<any>>();
export function setupTGQueries() {
botClient.bot.on('callback_query', async (query) => {
const throwError = (message: string) => {
logger.error(`[TGCallbackQuery] @${query.from.username}_${query.from.id}: ${message}`);
botClient.bot.answerCallbackQuery(query.id, { text: message, show_alert: true });
};
if (!query.data) return throwError('No data');
const [command, argsKey] = query.data.split(':');
if (!command) return throwError('No query');
const callback = queryCallbacks.get(command);
if (!callback) return throwError('Query not found');
const parserArgs = {} as ArgsFromConfig<typeof callback.args>;
if (argsKey) {
const cacheParams = await redis.get(RedisKeys.tg.cbParams(command, argsKey));
if (cacheParams) Object.assign(parserArgs, JSON.parse(cacheParams));
}
logger.debug(`[TGCallbackQuery] @${query.from.username}_${query.from.id}: ${command} ${safeStringify(parserArgs)}`);
// Check required args
for (const key in callback.args) {
if (callback.args[key]?.required && parserArgs[key] === undefined) {
return throwError(`Missing required argument: ${key}`);
}
}
const ctx = { query: command, client: botClient, msg: query.message, args: parserArgs };
try {
botClient.bot.answerCallbackQuery(query.id);
await callback.callback?.(ctx);
await callback.cleanup?.(ctx);
} catch (error) {
logger.error(`Error executing query callback ${command}: ${(error as Error)?.message}`);
}
});
}
export function defineTGQueryCallback<T extends TGQueryArgs>(
config: TGQueryCallbackConfig<T> & { args: TGQueryArgs }
): (args: Partial<ArgsFromConfig<T>> & Pick<ArgsFromConfig<T>, IsRequired<T>>) => TelegramBot.InlineKeyboardButton;
export function defineTGQueryCallback<T extends TGQueryArgs>(
config: TGQueryCallbackConfig<T> & { args?: TGQueryArgs }
): TelegramBot.InlineKeyboardButton;
export function defineTGQueryCallback<
T extends TGQueryArgs,
C extends TGQueryCallbackConfig<T>,
R = C extends { args: TGQueryArgs }
? (args: ArgsFromConfig<T>) => TelegramBot.InlineKeyboardButton
: TelegramBot.InlineKeyboardButton
>(config: TGQueryCallbackConfig<T>): R {
queryCallbacks.set(config.query, config);
if (config.args) {
if (config.query.length > 64 - 9) throw new Error('Query length must be less than 55 characters(9 for key)');
return ((args: Partial<ArgsFromConfig<T>> & Pick<ArgsFromConfig<T>, IsRequired<T>>) => {
const key = nanoid(8);
const params: Partial<ArgsFromConfig<T>> = {};
for (const key in config.args) {
const value = args?.[key];
if (config.args[key]?.required && value === undefined) {
throw new Error(`Missing required argument: ${key}`);
}
params[key] = value;
}
redis.set(RedisKeys.tg.cbParams(config.query, key), safeStringify(params));
return {
text: config.text.replace(/\{(\w+)\}/g, (_, key) => {
return params[key as keyof typeof params] as string;
}),
callback_data: `${config.query}:${key}`,
};
}) as R;
}
return {
text: config.text,
callback_data: config.query,
} as R;
}
Here are some common query callback definitions that you can use for your Telegram Bot.
export const close = defineTGQueryCallback({
query: 'close',
text: '❌ Close',
callback: async ({ client, msg }) => {
if (!msg) return;
await client.bot.editMessageText('This message has been closed!', {
chat_id: msg.chat.id,
message_id: msg.message_id,
reply_markup: {
inline_keyboard: [[del]],
},
});
},
});
export const del = defineTGQueryCallback({
query: 'delete',
text: '🗑 Delete',
callback: async ({ client, msg }) => {
if (!msg) return;
await client.bot.deleteMessage(msg.chat.id, msg.message_id);
},
});
// Centralized definition
export const Queries = {
/** Close message */
close,
/** Delete message */
del,
};
// Actual usage
const reply_markup = {
inline_keyboard: [[Queries.close], [Queries.del]],
};