From 981d0db61fe178f5cfdfc31eb6dec4b91354c2b3 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 6 Mar 2026 12:02:05 +0100 Subject: [PATCH 01/13] feat(managed-commands): hooks --- src/commands/_base.ts | 32 ++++++- src/commands/audit.ts | 40 ++++---- src/commands/ban.ts | 18 +--- src/commands/banall.ts | 4 - src/commands/del.ts | 4 +- src/commands/kick.ts | 4 +- src/commands/link-admin-dashboard.ts | 6 +- src/commands/mute.ts | 18 +--- src/commands/report.ts | 1 - src/commands/role.ts | 83 +++++++---------- src/lib/managed-commands/command.ts | 10 ++ src/lib/managed-commands/index.ts | 131 ++++++++++++++++----------- src/utils/users.ts | 19 ++++ 13 files changed, 200 insertions(+), 170 deletions(-) diff --git a/src/commands/_base.ts b/src/commands/_base.ts index 8c90b6b..6bbae6d 100644 --- a/src/commands/_base.ts +++ b/src/commands/_base.ts @@ -1,11 +1,12 @@ import type { ConversationData, VersionedState } from "@grammyjs/conversations" - import { api } from "@/backend" import { isAllowedInGroups, ManagedCommands } from "@/lib/managed-commands" import { RedisFallbackAdapter } from "@/lib/redis-fallback-adapter" import { logger } from "@/logger" import { redis } from "@/redis" import type { Role } from "@/utils/types" +import { printCtxFrom } from "@/utils/users" +import { wait } from "@/utils/wait" const adapter = new RedisFallbackAdapter>({ redis, @@ -15,7 +16,34 @@ const adapter = new RedisFallbackAdapter>({ export const _commandsBase = new ManagedCommands({ adapter, - logger, + hooks: { + wrongScope: async ({ context, command }) => { + await context.deleteMessage() + logger.info( + `[ManagedCommands] command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat.` + ) + }, + missingPermissions: async ({ context, command }) => { + logger.info( + { command_permissions: command.permissions }, + `[ManagedCommands] command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` + ) + // Inform the user of restricted access + const reply = await context.reply("You are not allowed to execute this command") + await context.deleteMessage() + void wait(3000).then(() => reply.delete()) + }, + handlerError: async ({ context, command, error }) => { + logger.error({ error, command: command.trigger }, `Error in handler for command '/${command.trigger}'`) + await context.reply(`An error occurred: ${String(error)}`) + }, + beforeCommand: async ({ context }) => { + if (context.chat.type !== "private") { + // silently delete the command trigger if the command is used in a group, to reduce noise + context.deleteMessage().catch(() => {}) + } + }, + }, permissionHandler: async ({ command, context: ctx }) => { if (!command.permissions) return true if (!ctx.from) return false diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 2ede185..9fb13d3 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -23,29 +23,25 @@ _commandsBase.createCommand({ return } - try { - const list = await api.tg.auditLog.getById.query({ targetId: userId }) - await context.reply( - fmt( - ({ b, n, i, u, link, code }) => [ - b`๐Ÿงพ Audit Log: ${args.username}\n`, - ...list.flatMap((el) => [ - `------------------------------------`, - n`${u`${b`${el.type.toUpperCase()}`}`} ${i`at ${fmtDate(el.createdAt)}`}`, - el.until ? n`${b`Until:`} ${fmtDate(el.until)}` : undefined, - el.groupId ? n`${b`Group:`} ${el.groupTitle} [${code`${el.groupId}`}]` : undefined, - n`${b`Admin ID:`} ${link(el.adminId.toString(), `tg://user?id=${el.adminId}`)}`, - el.reason ? n`${b`Reason:`} ${el.reason}` : undefined, - ]), + const list = await api.tg.auditLog.getById.query({ targetId: userId }) + await context.reply( + fmt( + ({ b, n, i, u, link, code }) => [ + b`๐Ÿงพ Audit Log: ${args.username}\n`, + ...list.flatMap((el) => [ `------------------------------------`, - ], - { - sep: "\n", - } - ) + n`${u`${b`${el.type.toUpperCase()}`}`} ${i`at ${fmtDate(el.createdAt)}`}`, + el.until ? n`${b`Until:`} ${fmtDate(el.until)}` : undefined, + el.groupId ? n`${b`Group:`} ${el.groupTitle} [${code`${el.groupId}`}]` : undefined, + n`${b`Admin ID:`} ${link(el.adminId.toString(), `tg://user?id=${el.adminId}`)}`, + el.reason ? n`${b`Reason:`} ${el.reason}` : undefined, + ]), + `------------------------------------`, + ], + { + sep: "\n", + } ) - } catch (err) { - await context.reply(`There was an error: \n${String(err)}`) - } + ) }, }) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index d51fe40..05fb7b8 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -20,7 +20,6 @@ _commandsBase allowedGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { - await context.deleteMessage() if (!repliedTo.from) { logger.error("ban: no repliedTo.from field (the msg was sent in a channel)") return @@ -28,8 +27,7 @@ _commandsBase const res = await Moderation.ban(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(() => msg.delete()) }, }) .createCommand({ @@ -51,7 +49,6 @@ _commandsBase allowedGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { - await context.deleteMessage() if (!repliedTo.from) { logger.error("ban: no repliedTo.from field (the msg was sent in a channel)") return @@ -66,8 +63,7 @@ _commandsBase args.reason ) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(() => msg.delete()) }, }) .createCommand({ @@ -80,15 +76,13 @@ _commandsBase allowedGroupAdmins: true, }, handler: async ({ args, context }) => { - await context.deleteMessage() const userId: number | null = typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username if (!userId) { logger.debug(`unban: no userId for username ${args.username}`) const msg = await context.reply(fmt(({ b }) => b`@${context.from.username} user not found`)) - await wait(5000) - await msg.delete() + void wait(5000).then(() => msg.delete()) return } @@ -96,14 +90,12 @@ _commandsBase if (!user) { const msg = await context.reply("Error: cannot find this user") logger.error({ userId }, "UNBAN: cannot retrieve the user") - await wait(5000) - await msg.delete() + void wait(5000).then(() => msg.delete()) return } const res = await Moderation.unban(user, context.chat, context.from) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(() => msg.delete()) }, }) diff --git a/src/commands/banall.ts b/src/commands/banall.ts index 286caea..8636dd0 100644 --- a/src/commands/banall.ts +++ b/src/commands/banall.ts @@ -29,8 +29,6 @@ _commandsBase }, ], handler: async ({ args, context }) => { - await context.deleteMessage() - const userId: number | null = typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username @@ -78,8 +76,6 @@ _commandsBase }, ], handler: async ({ args, context }) => { - await context.deleteMessage() - const userId: number | null = typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username diff --git a/src/commands/del.ts b/src/commands/del.ts index fb73bda..333cd4a 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -14,7 +14,6 @@ _commandsBase.createCommand({ description: "Deletes the replied to message", reply: "required", handler: async ({ repliedTo, context }) => { - await context.deleteMessage() const { text, type } = getText(repliedTo) logger.info({ action: "delete_message", @@ -26,7 +25,6 @@ _commandsBase.createCommand({ const res = await Moderation.deleteMessages([repliedTo], context.from, "Command /del") // TODO: better error and ok response const msg = await context.reply(res.isErr() ? "Cannot delete the message" : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(() => msg.delete()) }, }) diff --git a/src/commands/kick.ts b/src/commands/kick.ts index 66b3689..94282bd 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -15,7 +15,6 @@ _commandsBase.createCommand({ allowedGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { - await context.deleteMessage() if (!repliedTo.from) { logger.error("kick: no repliedTo.from field (the msg was sent in a channel)") return @@ -23,7 +22,6 @@ _commandsBase.createCommand({ const res = await Moderation.kick(repliedTo.from, context.chat, context.from, [repliedTo], args.reason) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) }, }) diff --git a/src/commands/link-admin-dashboard.ts b/src/commands/link-admin-dashboard.ts index 78f255b..6a8b5da 100644 --- a/src/commands/link-admin-dashboard.ts +++ b/src/commands/link-admin-dashboard.ts @@ -30,8 +30,6 @@ async function cancel( ctx: ConversationMenuContext> ) { await ctx.editMessageText(fmt(({ n, code }) => n`Linking procedure was canceled. Send ${code`/link`} to restart it.`)) - await wait(4000) - await ctx.deleteMessage() ctx.menu.close() await conv.halt() } @@ -41,7 +39,6 @@ _commandsBase.createCommand({ scope: "private", description: "Verify the login code for the admin dashboard", handler: async ({ context, conversation }) => { - await context.deleteMessage() // we need username if (context.from.username === undefined) { await context.reply(fmt(() => `You need to set a username to use this command`)) @@ -112,7 +109,6 @@ _commandsBase.createCommand({ ) } - await wait(4000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) }, }) diff --git a/src/commands/mute.ts b/src/commands/mute.ts index 09db062..fcdc542 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -28,7 +28,6 @@ _commandsBase allowedGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { - await context.deleteMessage() if (!repliedTo.from) { logger.error("tmute: no repliedTo.from field (the msg was sent in a channel)") return @@ -43,8 +42,7 @@ _commandsBase args.reason ) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) }, }) .createCommand({ @@ -58,7 +56,6 @@ _commandsBase allowedGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { - await context.deleteMessage() if (!repliedTo.from) { logger.error("mute: no repliedTo.from field (the msg was sent in a channel)") return @@ -66,8 +63,7 @@ _commandsBase const res = await Moderation.mute(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) }, }) .createCommand({ @@ -80,14 +76,12 @@ _commandsBase allowedGroupAdmins: true, }, handler: async ({ args, context }) => { - await context.deleteMessage() const userId: number | null = typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username if (!userId) { logger.debug(`unmute: no userId for username ${args.username}`) const msg = await context.reply(fmt(({ b }) => b`@${context.from.username} user not found`)) - await wait(5000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) return } @@ -95,14 +89,12 @@ _commandsBase if (!user) { const msg = await context.reply("Error: cannot find this user") logger.error({ userId }, "UNMUTE: cannot retrieve the user") - await wait(5000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) return } const res = await Moderation.unmute(user, context.chat, context.from) const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") - await wait(5000) - await msg.delete() + void wait(5000).then(async () => msg.delete()) }, }) diff --git a/src/commands/report.ts b/src/commands/report.ts index 7df8a9d..99f43a0 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -8,7 +8,6 @@ _commandsBase.createCommand({ scope: "group", reply: "required", handler: async ({ context, repliedTo }) => { - await context.deleteMessage() if (!repliedTo.from) { logger.error("report: no repliedTo.from field (the msg was sent in a channel)") return diff --git a/src/commands/role.ts b/src/commands/role.ts index 6cfe399..34b8269 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -26,14 +26,10 @@ _commandsBase return } - try { - const { roles } = await api.tg.permissions.getRoles.query({ userId }) - await context.reply( - fmt(({ b }) => (roles?.length ? [`Roles:`, b`${roles.join(" ")}`] : "This user has no roles")) - ) - } catch (err) { - await context.reply(`There was an error: \n${String(err)}`) - } + const { roles } = await api.tg.permissions.getRoles.query({ userId }) + await context.reply( + fmt(({ b }) => (roles?.length ? [`Roles:`, b`${roles.join(" ")}`] : "This user has no roles")) + ) }, }) .createCommand({ @@ -60,30 +56,22 @@ _commandsBase return } - try { - const { roles, error } = await api.tg.permissions.addRole.mutate({ - userId, - adderId: context.from.id, - role: args.role, - }) - - if (error) { - await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) - return - } + const { roles, error } = await api.tg.permissions.addRole.mutate({ + userId, + adderId: context.from.id, + role: args.role, + }) - await context.reply( - fmt( - ({ b, n }) => [b`โœ… Role added!`, n`${b`Username:`} ${args.username}`, n`${b`Updated roles:`} ${roles}`], - { - sep: "\n", - } - ) - ) - await context.deleteMessage() - } catch (err) { - await context.reply(`There was an error: \n${String(err)}`) + if (error) { + await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) + return } + + await context.reply( + fmt(({ b, n }) => [b`โœ… Role added!`, n`${b`Username:`} ${args.username}`, n`${b`Updated roles:`} ${roles}`], { + sep: "\n", + }) + ) }, }) .createCommand({ @@ -110,29 +98,22 @@ _commandsBase return } - try { - const { roles, error } = await api.tg.permissions.removeRole.mutate({ - userId, - removerId: context.from.id, - role: args.role, - }) + const { roles, error } = await api.tg.permissions.removeRole.mutate({ + userId, + removerId: context.from.id, + role: args.role, + }) - if (error) { - await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) - return - } + if (error) { + await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) + return + } - await context.reply( - fmt( - ({ b, n }) => [b`โœ… Role removed!`, n`${b`Username:`} ${args.username}`, n`${b`Updated roles:`} ${roles}`], - { - sep: "\n", - } - ) + await context.reply( + fmt( + ({ b, n }) => [b`โœ… Role removed!`, n`${b`Username:`} ${args.username}`, n`${b`Updated roles:`} ${roles}`], + { sep: "\n" } ) - await context.deleteMessage() - } catch (err) { - await context.reply(`There was an error: \n${String(err)}`) - } + ) }, }) diff --git a/src/lib/managed-commands/command.ts b/src/lib/managed-commands/command.ts index a69bf01..1231d00 100644 --- a/src/lib/managed-commands/command.ts +++ b/src/lib/managed-commands/command.ts @@ -66,6 +66,14 @@ export type CommandConversation > +/** + * Represents a command that can be registered in the ManagedCommands collection. + * + * @template A The type of the command arguments, this should be an array of {@link ArgumentOptions} + * @template R The type of the command reply, this should be "required", "optional" or undefined + * @template S The scope of the command, this should be "private", "group" or "both" + * @template TRole The type of the roles used in permissions, this should be a string literal type representing the possible roles in the bot (e.g. "admin" | "moderator" | "user") + */ export interface Command< A extends CommandArgs, R extends CommandReplyTo, @@ -133,6 +141,8 @@ export interface Command< }) => Promise } +export type AnyCommand = Command + /** * Type guard to check if a command is allowed in groups. * @param cmd The command to check diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index a2caaf2..a965db2 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -11,10 +11,12 @@ import { Composer, MemorySessionStorage } from "grammy" import type { ChatMember, Message } from "grammy/types" import type { Result } from "neverthrow" import { err, ok } from "neverthrow" -import type { LogFn } from "pino" +import z from "zod" import { fmt } from "@/utils/format" import { wait } from "@/utils/wait" +import type { CommandsCollection } from "./collection" import type { + AnyCommand, ArgumentMap, ArgumentOptions, Command, @@ -30,7 +32,7 @@ import type { ManagedCommandsFlavor } from "./context" export type PermissionHandler = (arg: { context: CommandContext - command: Command + command: AnyCommand }) => Promise type DefaultRoles = ChatMember["status"] @@ -45,13 +47,29 @@ const defaultPermissionHandler: PermissionHandler = async ({ co return true } -interface Logger { - info: LogFn - error: LogFn -} -const defaultLogger: Logger = { - info: console.log, - error: console.error, +export type Hook = ( + params: Params & { + context: CommandContext + command: AnyCommand + } +) => Promise +export type ManagedCommandsHooks = { + /** + * Called when a command is invoked in the wrong scope (e.g. a private-only command is invoked in a group) + */ + wrongScope?: Hook + /** + * Called when a user without the required permissions invokes a command + */ + missingPermissions?: Hook + /** + * Called when an error is thrown in the command handler + */ + handlerError?: Hook + /** + * Called before executing the command handler, can be used to implement custom pre-handler logic, for example logging or analytics + */ + beforeCommand?: Hook } export interface ManagedCommandsOptions { @@ -84,22 +102,34 @@ export interface ManagedCommandsOptions permissionHandler: PermissionHandler /** - * The logger to use for logging messages, you can pass your pino logger here - * @example - * ```ts - * import pino from "pino" - * const logger = pino({ - * level: "info", - * }) - * const commands = new ManagedCommands({ - * logger: logger, - * }) - * ``` - * @default console.log + * Hooks to execute on specific events */ - logger: Logger + hooks: ManagedCommandsHooks } +/** + * A class to manage commands in a grammY bot, with support for argument parsing, permission handling, and hooks for various events. + * You can create commands with specific triggers, arguments, and permissions, and the class will handle the parsing and execution of the commands, as well as checking permissions and executing hooks. + * + * To use, create an instance of the class and pass it as middleware to your bot. Then, use the `createCommand` method to add commands to the instance. + * @example + * ```ts + * const commands = new ManagedCommands() + * commands.createCommand({ + * trigger: "ping", + * description: "Replies with pong", + * handler: async ({ context }) => { + * await context.reply("pong") + * }, + * }) + * + * bot.use(commands) + * ``` + * + * @typeParam TRole A string type representing the possible roles for command permissions. This is used in the `permissions` field of the command options and in the `permissionHandler`. + * @typeParam C The context type for the bot, used in the hooks and permission handler. Defaults to `Context`. + * @see Command for the options available when creating a command + */ export class ManagedCommands< TRole extends string = DefaultRoles, C extends ManagedCommandsFlavor = ManagedCommandsFlavor, @@ -108,7 +138,7 @@ export class ManagedCommands< private composer = new Composer() private commands: Command[] = [] private permissionHandler: PermissionHandler - private logger: Logger + private hooks: ManagedCommandsHooks private adapter: ConversationStorage /** @@ -145,7 +175,7 @@ export class ManagedCommands< } else { if (isTypedArgumentOptions(argument)) { const data = argument.type.safeParse(value) - if (!data.success) return err(data.error.message) + if (!data.success) return err(z.prettifyError(data.error)) else return ok(data.data) } return ok(value) @@ -205,7 +235,7 @@ export class ManagedCommands< * @param cmd The command to print usage for * @returns A markdown formatted string representing the usage of the command */ - private static formatCommandUsage(cmd: Command): string { + private static formatCommandUsage(cmd: AnyCommand): string { const args = cmd.args ?? [] const scope = cmd.scope === "private" ? "Private Chat" : cmd.scope === "group" ? "Groups" : "Groups and Private Chat" @@ -270,7 +300,8 @@ export class ManagedCommands< */ constructor(options?: Partial>) { this.permissionHandler = options?.permissionHandler ?? defaultPermissionHandler - this.logger = options?.logger ?? defaultLogger + // this.logger = options?.logger ?? defaultLogger + this.hooks = options?.hooks ?? {} this.adapter = options?.adapter ?? new MemorySessionStorage() this.composer.use( @@ -345,13 +376,22 @@ export class ManagedCommands< const { args, repliedTo } = requirements.value + if (this.hooks.beforeCommand) + await this.hooks.beforeCommand({ context: ctx as CommandContext, command: cmd }) + // Fianlly execute the handler - await cmd.handler({ - context: ctx, - conversation: conv, - args, - repliedTo, - }) + await cmd + .handler({ + context: ctx, + conversation: conv, + args, + repliedTo, + }) + .catch(async (error) => { + if (this.hooks.handlerError) + await this.hooks.handlerError({ context: ctx as CommandContext, command: cmd, error }) + else throw error + }) }, { id: cmd.trigger, // the conversation ID is set to the command trigger @@ -364,10 +404,7 @@ export class ManagedCommands< (cmd.scope === "private" && ctx.chat.type !== "private") || (cmd.scope === "group" && ctx.chat.type !== "supergroup" && ctx.chat.type !== "group") ) { - await ctx.deleteMessage() - this.logger.info( - `[ManagedCommands] command '/${cmd.trigger}' with scope '${cmd.scope}' invoked by ${this.printUsername(ctx)} in a '${ctx.chat.type}' chat.` - ) + if (this.hooks.wrongScope) await this.hooks.wrongScope({ context: ctx, command: cmd }) return } @@ -375,14 +412,7 @@ export class ManagedCommands< if (cmd.permissions) { const allowed = await this.permissionHandler({ command: cmd, context: ctx }) if (!allowed) { - this.logger.info( - { command_permissions: cmd.permissions }, - `[ManagedCommands] command '/${cmd.trigger}' invoked by ${this.printUsername(ctx)} without permissions` - ) - // Inform the user of restricted access - const reply = await ctx.reply("You are not allowed to execute this command") - await ctx.deleteMessage() - setTimeout(() => void reply.delete(), 3000) + if (this.hooks.missingPermissions) await this.hooks.missingPermissions({ context: ctx, command: cmd }) return } } @@ -393,15 +423,10 @@ export class ManagedCommands< return this } - /** - * Creates a string that can be logged with the username and id of the user - * who invoked the command - * @param ctx The context of the command - * @returns a string that can be logged with username and id - */ - private printUsername(ctx: CommandContext) { - if (!ctx.from) return "" - return `@${ctx.from.username ?? ""} [${ctx.from.id}]` + addCollection(collection: CommandsCollection) { + collection.flush().forEach((cmd) => { + this.createCommand(cmd) + }) } /** diff --git a/src/utils/users.ts b/src/utils/users.ts index 62f8bc5..a5b3fbd 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -7,3 +7,22 @@ export async function getUser(userId: number, ctx: C | null): const chatUser = ctx ? await ctx.getChatMember(userId).catch(() => null) : null return chatUser?.user ?? MessageUserStorage.getInstance().getStoredUser(userId) } + +/** + * Formats a user's username and ID for logging. + * @param user grammY User object + * @returns formatted username (if available) and user_id + */ +export function printUsername(user: User): string { + return `@${user.username ?? ""} [${user.id}]` +} + +/** + * Formats the context's `from` user information for logging. + * @param ctx grammY Context object + * @returns formatted username and user_id of the context's `from` user, or "" if not available + */ +export function printCtxFrom(ctx: C): string { + if (!ctx.from) return "" + return printUsername(ctx.from) +} From 0e1e66574098d2b4053eb0871ac664ee8f9dffcb Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 6 Mar 2026 13:10:15 +0100 Subject: [PATCH 02/13] feat(managed-commands): collections --- src/commands/_base.ts | 82 ------------------ src/commands/audit.ts | 6 +- src/commands/ban.ts | 6 +- src/commands/banall.ts | 4 +- src/commands/del.ts | 5 +- src/commands/grants.ts | 6 +- src/commands/index.ts | 111 +++++++++++++++++++++---- src/commands/kick.ts | 6 +- src/commands/link-admin-dashboard.ts | 6 +- src/commands/mute.ts | 6 +- src/commands/report.ts | 5 +- src/commands/role.ts | 4 +- src/commands/search.ts | 7 +- src/commands/test/args.ts | 4 +- src/commands/test/db.ts | 5 +- src/commands/test/format.ts | 5 +- src/commands/test/index.ts | 11 ++- src/commands/test/menu.ts | 4 +- src/commands/userid.ts | 6 +- src/lib/managed-commands/collection.ts | 39 +++++++++ src/lib/managed-commands/index.ts | 37 +++++++-- 21 files changed, 215 insertions(+), 150 deletions(-) delete mode 100644 src/commands/_base.ts create mode 100644 src/lib/managed-commands/collection.ts diff --git a/src/commands/_base.ts b/src/commands/_base.ts deleted file mode 100644 index 6bbae6d..0000000 --- a/src/commands/_base.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { ConversationData, VersionedState } from "@grammyjs/conversations" -import { api } from "@/backend" -import { isAllowedInGroups, ManagedCommands } from "@/lib/managed-commands" -import { RedisFallbackAdapter } from "@/lib/redis-fallback-adapter" -import { logger } from "@/logger" -import { redis } from "@/redis" -import type { Role } from "@/utils/types" -import { printCtxFrom } from "@/utils/users" -import { wait } from "@/utils/wait" - -const adapter = new RedisFallbackAdapter>({ - redis, - prefix: "conv", - logger, -}) - -export const _commandsBase = new ManagedCommands({ - adapter, - hooks: { - wrongScope: async ({ context, command }) => { - await context.deleteMessage() - logger.info( - `[ManagedCommands] command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat.` - ) - }, - missingPermissions: async ({ context, command }) => { - logger.info( - { command_permissions: command.permissions }, - `[ManagedCommands] command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` - ) - // Inform the user of restricted access - const reply = await context.reply("You are not allowed to execute this command") - await context.deleteMessage() - void wait(3000).then(() => reply.delete()) - }, - handlerError: async ({ context, command, error }) => { - logger.error({ error, command: command.trigger }, `Error in handler for command '/${command.trigger}'`) - await context.reply(`An error occurred: ${String(error)}`) - }, - beforeCommand: async ({ context }) => { - if (context.chat.type !== "private") { - // silently delete the command trigger if the command is used in a group, to reduce noise - context.deleteMessage().catch(() => {}) - } - }, - }, - permissionHandler: async ({ command, context: ctx }) => { - if (!command.permissions) return true - if (!ctx.from) return false - - const { allowedRoles, excludedRoles } = command.permissions - - if (isAllowedInGroups(command)) { - const { allowedGroupAdmins, allowedGroupsId, excludedGroupsId } = command.permissions - const { status: groupRole } = await ctx.getChatMember(ctx.from.id) - - if (allowedGroupsId && !allowedGroupsId.includes(ctx.chatId)) return false - if (excludedGroupsId?.includes(ctx.chatId)) return false - if (allowedGroupAdmins) { - const isDbAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) - const isTgAdmin = groupRole === "administrator" || groupRole === "creator" - if (isDbAdmin || isTgAdmin) return true - } - } - - const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) - if (!roles) return false - - // blacklist is stronger than whitelist - if (allowedRoles?.every((r) => !roles.includes(r))) return false - if (excludedRoles?.some((r) => roles.includes(r))) return false - - return true - }, -}).createCommand({ - trigger: "ping", - scope: "private", - description: "Replies with pong", - handler: async ({ context }) => { - await context.reply("pong") - }, -}) diff --git a/src/commands/audit.ts b/src/commands/audit.ts index 9fb13d3..153cf2f 100644 --- a/src/commands/audit.ts +++ b/src/commands/audit.ts @@ -1,10 +1,10 @@ import { api } from "@/backend" +import { CommandsCollection } from "@/lib/managed-commands" import { fmt, fmtDate } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" +import type { Role } from "@/utils/types" -import { _commandsBase } from "./_base" - -_commandsBase.createCommand({ +export const audit = new CommandsCollection("Auditing").createCommand({ trigger: "audit", scope: "private", description: "Get audit of an user", diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 05fb7b8..5f96048 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,14 +1,14 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import { numberOrString } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" -_commandsBase +export const ban = new CommandsCollection("Banning") .createCommand({ trigger: "ban", args: [{ key: "reason", optional: true, description: "Optional reason to ban the user" }], diff --git a/src/commands/banall.ts b/src/commands/banall.ts index 8636dd0..81a0a1d 100644 --- a/src/commands/banall.ts +++ b/src/commands/banall.ts @@ -1,14 +1,14 @@ import type { User } from "grammy/types" import z from "zod" import { api } from "@/backend" +import { CommandsCollection } from "@/lib/managed-commands" import { modules } from "@/modules" import { getTelegramId } from "@/utils/telegram-id" import { numberOrString, type Role } from "@/utils/types" -import { _commandsBase } from "./_base" const BYPASS_ROLES: Role[] = ["president", "owner", "direttivo"] -_commandsBase +export const banAll = new CommandsCollection("Ban All") .createCommand({ trigger: "ban_all", description: "PREMA BAN a user from all the Network's groups", diff --git a/src/commands/del.ts b/src/commands/del.ts index 333cd4a..26a52b2 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -1,10 +1,11 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { getText } from "@/utils/messages" +import type { Role } from "@/utils/types" import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" -_commandsBase.createCommand({ +export const del = new CommandsCollection("Deletion").createCommand({ trigger: "del", scope: "group", permissions: { diff --git a/src/commands/grants.ts b/src/commands/grants.ts index 8097785..f1efc50 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -3,14 +3,14 @@ import type { User } from "grammy/types" import z from "zod" import { api } from "@/backend" import type { ConversationContext } from "@/lib/managed-commands" +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { modules } from "@/modules" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import { numberOrString } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -52,7 +52,7 @@ const askDurationMsg = fmt(({ n, b }) => [b`How long should the special grant la sep: "\n", }) -_commandsBase.createCommand({ +export const grants = new CommandsCollection("Grants").createCommand({ trigger: "grant", description: "Grant special permissions to a user allowing them to bypass the Auto-Moderation stack", scope: "private", diff --git a/src/commands/index.ts b/src/commands/index.ts index e5bd179..de08eb9 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,15 +1,96 @@ -import "./test" -import "./mute" -import "./audit" -import "./ban" -import "./banall" -import "./kick" -import "./del" -import "./search" -import "./role" -import "./userid" -import "./link-admin-dashboard" -import "./report" -import "./grants" - -export { _commandsBase as commands } from "./_base" +import type { ConversationData, VersionedState } from "@grammyjs/conversations" +import { api } from "@/backend" +import { isAllowedInGroups, ManagedCommands } from "@/lib/managed-commands" +import { RedisFallbackAdapter } from "@/lib/redis-fallback-adapter" +import { logger } from "@/logger" +import { redis } from "@/redis" +import type { Role } from "@/utils/types" +import { printCtxFrom } from "@/utils/users" +import { wait } from "@/utils/wait" +import { audit } from "./audit" +import { ban } from "./ban" +import { banAll } from "./banall" +import { del } from "./del" +import { grants } from "./grants" +import { kick } from "./kick" +import { linkAdminDashboard } from "./link-admin-dashboard" +import { mute } from "./mute" +import { report } from "./report" +import { role } from "./role" +import { search } from "./search" +import { userid } from "./userid" + +const adapter = new RedisFallbackAdapter>({ + redis, + prefix: "conv", + logger, +}) + +export const commands = new ManagedCommands({ + adapter, + hooks: { + wrongScope: async ({ context, command }) => { + await context.deleteMessage() + logger.info( + `[ManagedCommands] command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat.` + ) + }, + missingPermissions: async ({ context, command }) => { + logger.info( + { command_permissions: command.permissions }, + `[ManagedCommands] command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` + ) + // Inform the user of restricted access + const reply = await context.reply("You are not allowed to execute this command") + await context.deleteMessage() + void wait(3000).then(() => reply.delete()) + }, + handlerError: async ({ context, command, error }) => { + logger.error({ error, command: command.trigger }, `Error in handler for command '/${command.trigger}'`) + await context.reply(`An error occurred: ${String(error)}`) + }, + beforeCommand: async ({ context }) => { + if (context.chat.type !== "private") { + // silently delete the command trigger if the command is used in a group, to reduce noise + context.deleteMessage().catch(() => {}) + } + }, + }, + permissionHandler: async ({ command, context: ctx }) => { + if (!command.permissions) return true + if (!ctx.from) return false + + const { allowedRoles, excludedRoles } = command.permissions + + if (isAllowedInGroups(command)) { + const { allowedGroupAdmins, allowedGroupsId, excludedGroupsId } = command.permissions + const { status: groupRole } = await ctx.getChatMember(ctx.from.id) + + if (allowedGroupsId && !allowedGroupsId.includes(ctx.chatId)) return false + if (excludedGroupsId?.includes(ctx.chatId)) return false + if (allowedGroupAdmins) { + const isDbAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) + const isTgAdmin = groupRole === "administrator" || groupRole === "creator" + if (isDbAdmin || isTgAdmin) return true + } + } + + const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) + if (!roles) return false + + // blacklist is stronger than whitelist + if (allowedRoles?.every((r) => !roles.includes(r))) return false + if (excludedRoles?.some((r) => roles.includes(r))) return false + + return true + }, +}) + .createCommand({ + trigger: "ping", + scope: "private", + description: "Replies with pong", + handler: async ({ context }) => { + await context.reply("pong") + }, + }) + .withCollection(audit, ban, banAll, del, grants, kick, linkAdminDashboard, mute, report, role, search, userid) diff --git a/src/commands/kick.ts b/src/commands/kick.ts index 94282bd..537bab8 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -1,10 +1,10 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" +import type { Role } from "@/utils/types" import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" - -_commandsBase.createCommand({ +export const kick = new CommandsCollection("Kicking").createCommand({ trigger: "kick", args: [{ key: "reason", optional: true, description: "Optional reason to kick the user" }], description: "Kick a user from a group", diff --git a/src/commands/link-admin-dashboard.ts b/src/commands/link-admin-dashboard.ts index 6a8b5da..bbf0305 100644 --- a/src/commands/link-admin-dashboard.ts +++ b/src/commands/link-admin-dashboard.ts @@ -1,13 +1,13 @@ import type { ConversationMenuContext } from "@grammyjs/conversations" import { api } from "@/backend" import type { ConversationContext } from "@/lib/managed-commands" +import { CommandsCollection } from "@/lib/managed-commands" import type { CommandConversation } from "@/lib/managed-commands/command" import { logger } from "@/logger" import { fmt } from "@/utils/format" +import type { Role } from "@/utils/types" import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" - const mainMsg = fmt(({ b }) => [b`๐Ÿ”— Admin dashboard link`, b`\nStatus: โณ WAITING FOR CODE`], { sep: "\n" }) const warnMsg = fmt( @@ -34,7 +34,7 @@ async function cancel( await conv.halt() } -_commandsBase.createCommand({ +export const linkAdminDashboard = new CommandsCollection("Link Admin Dashboard").createCommand({ trigger: "link", scope: "private", description: "Verify the login code for the admin dashboard", diff --git a/src/commands/mute.ts b/src/commands/mute.ts index fcdc542..9380d83 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,14 +1,14 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import { numberOrString } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" -_commandsBase +export const mute = new CommandsCollection("Muting") .createCommand({ trigger: "tmute", args: [ diff --git a/src/commands/report.ts b/src/commands/report.ts index 99f43a0..7b5a4e5 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,8 +1,9 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { modules } from "@/modules" -import { _commandsBase } from "./_base" +import type { Role } from "@/utils/types" -_commandsBase.createCommand({ +export const report = new CommandsCollection("Reporting").createCommand({ trigger: "report", description: "Report a message to admins", scope: "group", diff --git a/src/commands/role.ts b/src/commands/role.ts index 34b8269..a20f81d 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -1,11 +1,11 @@ import { z } from "zod" import { api } from "@/backend" +import { CommandsCollection } from "@/lib/managed-commands" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" import { numberOrString, type Role } from "@/utils/types" -import { _commandsBase } from "./_base" -_commandsBase +export const role = new CommandsCollection("Roles") .createCommand({ trigger: "getroles", scope: "private", diff --git a/src/commands/search.ts b/src/commands/search.ts index 40b4b18..a76b46b 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,16 +1,15 @@ import { InlineKeyboard } from "grammy" - import { api } from "@/backend" +import { CommandsCollection } from "@/lib/managed-commands" import { fmt } from "@/utils/format" - -import { _commandsBase } from "./_base" +import type { Role } from "@/utils/types" const LIMIT = 9 type Group = Awaited>["groups"][number] type LinkedGroup = Group & { link: string } -_commandsBase.createCommand({ +export const search = new CommandsCollection("Search").createCommand({ trigger: "search", scope: "both", description: "Search groups by title", diff --git a/src/commands/test/args.ts b/src/commands/test/args.ts index 651aee4..7116316 100644 --- a/src/commands/test/args.ts +++ b/src/commands/test/args.ts @@ -1,6 +1,6 @@ -import { _commandsBase } from "../_base" +import { CommandsCollection } from "@/lib/managed-commands" -_commandsBase.createCommand({ +export const testargs = new CommandsCollection().createCommand({ trigger: "test_args", scope: "private", description: "Test args", diff --git a/src/commands/test/db.ts b/src/commands/test/db.ts index c882225..a178c40 100644 --- a/src/commands/test/db.ts +++ b/src/commands/test/db.ts @@ -1,9 +1,8 @@ import { api } from "@/backend" +import { CommandsCollection } from "@/lib/managed-commands" import { fmt } from "@/utils/format" -import { _commandsBase } from "../_base" - -_commandsBase.createCommand({ +export const testdb = new CommandsCollection().createCommand({ trigger: "test_db", scope: "private", description: "Test postgres db through the backend", diff --git a/src/commands/test/format.ts b/src/commands/test/format.ts index 9ec5447..5df6a22 100644 --- a/src/commands/test/format.ts +++ b/src/commands/test/format.ts @@ -1,8 +1,7 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { fmt } from "@/utils/format" -import { _commandsBase } from "../_base" - -_commandsBase.createCommand({ +export const testformat = new CommandsCollection().createCommand({ trigger: "test_format", scope: "private", description: "Test the formatting", diff --git a/src/commands/test/index.ts b/src/commands/test/index.ts index 672a9c8..989fbf5 100644 --- a/src/commands/test/index.ts +++ b/src/commands/test/index.ts @@ -1,4 +1,7 @@ -import "./args" -import "./db" -import "./menu" -import "./format" +import { CommandsCollection } from "@/lib/managed-commands" +import { testargs } from "./args" +import { testdb } from "./db" +import { testformat } from "./format" +import { testmenu } from "./menu" + +export const test = new CommandsCollection("Test").withCollection(testargs, testdb, testformat, testmenu) diff --git a/src/commands/test/menu.ts b/src/commands/test/menu.ts index 4de52b4..bb8ed17 100644 --- a/src/commands/test/menu.ts +++ b/src/commands/test/menu.ts @@ -1,6 +1,6 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { MenuGenerator } from "@/lib/menu" import { logger } from "@/logger" -import { _commandsBase } from "../_base" const generateMenu = MenuGenerator.getInstance().create<{ messageId: number @@ -34,7 +34,7 @@ const generateMenu = MenuGenerator.getInstance().create<{ ], ]) -_commandsBase.createCommand({ +export const testmenu = new CommandsCollection().createCommand({ trigger: "testmenu", scope: "private", description: "Quick conversation", diff --git a/src/commands/userid.ts b/src/commands/userid.ts index 1c904ee..35a5a17 100644 --- a/src/commands/userid.ts +++ b/src/commands/userid.ts @@ -1,10 +1,10 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" +import type { Role } from "@/utils/types" -import { _commandsBase } from "./_base" - -_commandsBase.createCommand({ +export const userid = new CommandsCollection("User IDs").createCommand({ trigger: "userid", scope: "private", description: "Gets the ID of a username", diff --git a/src/lib/managed-commands/collection.ts b/src/lib/managed-commands/collection.ts new file mode 100644 index 0000000..449273c --- /dev/null +++ b/src/lib/managed-commands/collection.ts @@ -0,0 +1,39 @@ +import type { AnyCommand, Command, CommandArgs, CommandReplyTo, CommandScope } from "./command" + +export class CommandsCollection { + private flushed = false + private commands: AnyCommand[] = [] + + constructor(public name?: string) {} + + /** + * Creates a new command and adds it to the list of commands + * @param cmd The options for the command to create, see {@link Command} + * @returns `this` instance for chaining + */ + createCommand( + cmd: Command + ): CommandsCollection { + if (this.flushed) { + throw new Error("Cannot add commands after the collection has been flushed") + } + this.commands.push(cmd) + return this + } + + withCollection(...collections: CommandsCollection[]): CommandsCollection { + if (this.flushed) { + throw new Error("Cannot add commands after the collection has been flushed") + } + this.commands.push(...collections.flatMap((c) => c.flush())) + return this + } + + flush(): AnyCommand[] { + if (this.flushed) { + throw new Error("The collection has already been flushed") + } + this.flushed = true + return this.commands + } +} diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index a965db2..605d714 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -1,3 +1,4 @@ +export { CommandsCollection } from "./collection" export type { CommandScopedContext } from "./command" export { isAllowedInGroups, isAllowedInPrivateOnly } from "./command" export * from "./context" @@ -339,7 +340,7 @@ export class ManagedCommands< */ createCommand( cmd: Command - ) { + ): this { cmd.scope = cmd.scope ?? ("both" as S) // default to both this.commands.push(cmd) // add the command to the list this.commands.sort((a, b) => a.trigger.localeCompare(b.trigger)) // sort the commands by alphabetical order of the trigger @@ -384,7 +385,7 @@ export class ManagedCommands< .handler({ context: ctx, conversation: conv, - args, + args: args as ArgumentMap, repliedTo, }) .catch(async (error) => { @@ -423,10 +424,34 @@ export class ManagedCommands< return this } - addCollection(collection: CommandsCollection) { - collection.flush().forEach((cmd) => { - this.createCommand(cmd) - }) + /** + * Adds all the commands from a CommandsCollection to the ManagedCommands instance + * @param collection The CommandsCollection to add + * @returns The ManagedCommands instance for chaining + * @example + * ```ts + * const collection = new CommandsCollection() + * .createCommand({ + * trigger: "ping", + * description: "Replies with pong", + * handler: async ({ context }) => { + * await context.reply("pong") + * }, + * }) + * + * const commands = new ManagedCommands() + * commands.addCollection(collection) + * + * bot.use(commands) + * ``` + */ + withCollection(...collections: CommandsCollection[]): this { + collections + .flatMap((c) => c.flush()) + .forEach((cmd) => { + this.createCommand(cmd) + }) + return this } /** From 0b15f44c4a7a5bbb3523018a79ad38e98bf62c10 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 6 Mar 2026 13:50:04 +0100 Subject: [PATCH 03/13] fix: hooks consistency --- src/commands/index.ts | 9 +++++---- src/lib/managed-commands/index.ts | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index de08eb9..2e24738 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -32,13 +32,13 @@ export const commands = new ManagedCommands({ wrongScope: async ({ context, command }) => { await context.deleteMessage() logger.info( - `[ManagedCommands] command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat.` + `[ManagedCommands] Command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat` ) }, missingPermissions: async ({ context, command }) => { logger.info( { command_permissions: command.permissions }, - `[ManagedCommands] command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` + `[ManagedCommands] Command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` ) // Inform the user of restricted access const reply = await context.reply("You are not allowed to execute this command") @@ -46,10 +46,11 @@ export const commands = new ManagedCommands({ void wait(3000).then(() => reply.delete()) }, handlerError: async ({ context, command, error }) => { - logger.error({ error, command: command.trigger }, `Error in handler for command '/${command.trigger}'`) + logger.error({ error }, `[ManagedCommands] Error in handler for command '/${command.trigger}'`) + // TODO: we should figure out what to tell the user, maybe if we have some telemetry we can produce an error report id here? await context.reply(`An error occurred: ${String(error)}`) }, - beforeCommand: async ({ context }) => { + beforeHandler: async ({ context }) => { if (context.chat.type !== "private") { // silently delete the command trigger if the command is used in a group, to reduce noise context.deleteMessage().catch(() => {}) diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 605d714..7233ca7 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -48,29 +48,29 @@ const defaultPermissionHandler: PermissionHandler = async ({ co return true } -export type Hook = ( +export type Hook = ( params: Params & { context: CommandContext - command: AnyCommand + command: AnyCommand } ) => Promise -export type ManagedCommandsHooks = { +export type ManagedCommandsHooks = { /** * Called when a command is invoked in the wrong scope (e.g. a private-only command is invoked in a group) */ - wrongScope?: Hook + wrongScope?: Hook /** * Called when a user without the required permissions invokes a command */ - missingPermissions?: Hook + missingPermissions?: Hook /** * Called when an error is thrown in the command handler */ - handlerError?: Hook + handlerError?: Hook /** * Called before executing the command handler, can be used to implement custom pre-handler logic, for example logging or analytics */ - beforeCommand?: Hook + beforeHandler?: Hook } export interface ManagedCommandsOptions { @@ -105,7 +105,7 @@ export interface ManagedCommandsOptions /** * Hooks to execute on specific events */ - hooks: ManagedCommandsHooks + hooks: ManagedCommandsHooks } /** @@ -139,7 +139,7 @@ export class ManagedCommands< private composer = new Composer() private commands: Command[] = [] private permissionHandler: PermissionHandler - private hooks: ManagedCommandsHooks + private hooks: ManagedCommandsHooks private adapter: ConversationStorage /** @@ -377,8 +377,8 @@ export class ManagedCommands< const { args, repliedTo } = requirements.value - if (this.hooks.beforeCommand) - await this.hooks.beforeCommand({ context: ctx as CommandContext, command: cmd }) + if (this.hooks.beforeHandler) + await this.hooks.beforeHandler({ context: ctx as CommandContext, command: cmd }) // Fianlly execute the handler await cmd From 9a56885e37af0ea815f99ac24b937f5514882888 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 6 Mar 2026 13:50:21 +0100 Subject: [PATCH 04/13] fix: remove try-catch --- src/commands/grants.ts | 346 ++++++++++++++++++++--------------------- 1 file changed, 170 insertions(+), 176 deletions(-) diff --git a/src/commands/grants.ts b/src/commands/grants.ts index f1efc50..a694c00 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -4,7 +4,6 @@ import z from "zod" import { api } from "@/backend" import type { ConversationContext } from "@/lib/managed-commands" import { CommandsCollection } from "@/lib/managed-commands" -import { logger } from "@/logger" import { modules } from "@/modules" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -73,199 +72,194 @@ export const grants = new CommandsCollection("Grants").createCommand({ }, ], handler: async ({ args, context, conversation }) => { - try { - const userId: number | null = await conversation.external(async () => - typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username - ) - - if (userId === null) { - await context.reply(fmt(({ n }) => n`Not a valid userId or username not in our cache`)) - return - } + const userId: number | null = await conversation.external(async () => + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + ) - const dbUser = await conversation.external(() => api.tg.users.get.query({ userId })) - if (!dbUser || dbUser.error) { - await context.reply(fmt(({ n }) => n`This user is not in our cache, we cannot proceed.`)) - return - } + if (userId === null) { + await context.reply(fmt(({ n }) => n`Not a valid userId or username not in our cache`)) + return + } - const target: User = { - id: userId, - first_name: dbUser.user.firstName, - last_name: dbUser.user.lastName, - username: dbUser.user.username, - is_bot: dbUser.user.isBot, - language_code: dbUser.user.langCode, - } + const dbUser = await conversation.external(() => api.tg.users.get.query({ userId })) + if (!dbUser || dbUser.error) { + await context.reply(fmt(({ n }) => n`This user is not in our cache, we cannot proceed.`)) + return + } - const today = new Date(await conversation.now()) - const startDate = new Date(await conversation.now()) - let grantDuration = duration.zod.parse("2h") - const endDate = () => new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) - const baseMsg = () => mainMsg(target, startDate, endDate(), grantDuration.raw, args.reason) + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } - async function changeDuration(ctx: ConversationMenuContext>, durationStr: string) { - grantDuration = duration.zod.parse(durationStr) - ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) - ctx.menu.nav("grants-main") - } + const today = new Date(await conversation.now()) + const startDate = new Date(await conversation.now()) + let grantDuration = duration.zod.parse("2h") + const endDate = () => new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) + const baseMsg = () => mainMsg(target, startDate, endDate(), grantDuration.raw, args.reason) - async function changeStartDate(ctx: ConversationMenuContext>, delta: number) { - startDate.setDate(today.getDate() + delta) - ctx.editMessageText( - fmt(({ skip, b }) => [skip`${baseMsg()}`, b`๐Ÿ•“ Changing start TIME`], { sep: "\n\n" }), - { reply_markup: ctx.msg?.reply_markup } - ) - ctx.menu.nav("grants-start-time") - } - - async function changeStartTime( - ctx: ConversationMenuContext>, - hour: number, - minutes: number - ) { - // TODO: check timezone match between bot and user - startDate.setHours(hour) - startDate.setMinutes(minutes) - ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) - ctx.menu.nav("grants-main") - } + async function changeDuration(ctx: ConversationMenuContext>, durationStr: string) { + grantDuration = duration.zod.parse(durationStr) + ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ctx.menu.nav("grants-main") + } - const backToMain = conversation - .menu("grants-back-to-main", { parent: "grants-main" }) - .back("โ—€๏ธ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + async function changeStartDate(ctx: ConversationMenuContext>, delta: number) { + startDate.setDate(today.getDate() + delta) + ctx.editMessageText( + fmt(({ skip, b }) => [skip`${baseMsg()}`, b`๐Ÿ•“ Changing start TIME`], { sep: "\n\n" }), + { reply_markup: ctx.msg?.reply_markup } + ) + ctx.menu.nav("grants-start-time") + } - const durationMenu = conversation - .menu("grants-duration", { parent: "grants-main" }) - .text("30m", (ctx) => changeDuration(ctx, "30m")) - .text("2h", (ctx) => changeDuration(ctx, "2h")) - .text("6h", (ctx) => changeDuration(ctx, "6h")) - .text("1d", (ctx) => changeDuration(ctx, "1d")) - .row() - .text("โœ๏ธ Custom", async (ctx) => { - ctx.menu.nav("grants-back-to-main") - await ctx.editMessageText( - fmt(({ skip }) => [skip`${baseMsg()}`, skip`${askDurationMsg}`], { sep: "\n\n" }), - { reply_markup: backToMain } - ) - let text: string - do { - const res = await conversation.waitFor(":text") - res.deleteMessage() - text = res.msg.text - } while (!duration.zod.safeParse(text).success) + async function changeStartTime( + ctx: ConversationMenuContext>, + hour: number, + minutes: number + ) { + // TODO: check timezone match between bot and user + startDate.setHours(hour) + startDate.setMinutes(minutes) + ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ctx.menu.nav("grants-main") + } - await changeDuration(ctx, text) - }) - .row() - .back("โ—€๏ธ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + const backToMain = conversation + .menu("grants-back-to-main", { parent: "grants-main" }) + .back("โ—€๏ธ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) - const _startTimeMenu = conversation - .menu("grants-start-time", { parent: "grants-main" }) - .text(`Now: ${timeFormat.format(today)}`, (ctx) => changeStartTime(ctx, today.getHours(), today.getMinutes())) - .row() - .text("8:00", (ctx) => changeStartTime(ctx, 8, 0)) - .text("9:00", (ctx) => changeStartTime(ctx, 9, 0)) - .text("10:00", (ctx) => changeStartTime(ctx, 10, 0)) - .text("11:00", (ctx) => changeStartTime(ctx, 11, 0)) - .text("12:00", (ctx) => changeStartTime(ctx, 12, 0)) - .row() - .text("13:00", (ctx) => changeStartTime(ctx, 13, 0)) - .text("14:00", (ctx) => changeStartTime(ctx, 14, 0)) - .text("15:00", (ctx) => changeStartTime(ctx, 15, 0)) - .text("16:00", (ctx) => changeStartTime(ctx, 16, 0)) - .text("17:00", (ctx) => changeStartTime(ctx, 17, 0)) - .row() - .text("18:00", (ctx) => changeStartTime(ctx, 18, 0)) - .text("19:00", (ctx) => changeStartTime(ctx, 19, 0)) - .text("20:00", (ctx) => changeStartTime(ctx, 20, 0)) - .text("21:00", (ctx) => changeStartTime(ctx, 21, 0)) - .text("22:00", (ctx) => changeStartTime(ctx, 22, 0)) - .row() - .back( - () => `โšช๏ธ Keep current time ${timeFormat.format(startDate)}`, - (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + const durationMenu = conversation + .menu("grants-duration", { parent: "grants-main" }) + .text("30m", (ctx) => changeDuration(ctx, "30m")) + .text("2h", (ctx) => changeDuration(ctx, "2h")) + .text("6h", (ctx) => changeDuration(ctx, "6h")) + .text("1d", (ctx) => changeDuration(ctx, "1d")) + .row() + .text("โœ๏ธ Custom", async (ctx) => { + ctx.menu.nav("grants-back-to-main") + await ctx.editMessageText( + fmt(({ skip }) => [skip`${baseMsg()}`, skip`${askDurationMsg}`], { sep: "\n\n" }), + { reply_markup: backToMain } ) + let text: string + do { + const res = await conversation.waitFor(":text") + res.deleteMessage() + text = res.msg.text + } while (!duration.zod.safeParse(text).success) - const startDateMenu = conversation - .menu("grants-start-date", { parent: "grants-main" }) - .text( - () => `Today ${dateFormat.format(today)}`, - (ctx) => changeStartDate(ctx, 0) - ) - .row() - .text(dateFormat.format(getDateWithDelta(today, 1)), (ctx) => changeStartDate(ctx, 1)) - .text(dateFormat.format(getDateWithDelta(today, 2)), (ctx) => changeStartDate(ctx, 2)) - .text(dateFormat.format(getDateWithDelta(today, 3)), (ctx) => changeStartDate(ctx, 3)) - .row() - .text(dateFormat.format(getDateWithDelta(today, 4)), (ctx) => changeStartDate(ctx, 4)) - .text(dateFormat.format(getDateWithDelta(today, 5)), (ctx) => changeStartDate(ctx, 5)) - .text(dateFormat.format(getDateWithDelta(today, 6)), (ctx) => changeStartDate(ctx, 6)) - .text(dateFormat.format(getDateWithDelta(today, 7)), (ctx) => changeStartDate(ctx, 7)) - .row() - .back("โ—€๏ธ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + await changeDuration(ctx, text) + }) + .row() + .back("โ—€๏ธ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) - const mainMenu = conversation - .menu("grants-main") - .text("โœ… Confirm", async (ctx) => { - ctx.menu.close() - const { success, error } = await api.tg.grants.create - .mutate({ - userId: target.id, - adderId: context.from.id, - reason: args.reason, - since: startDate, - until: endDate(), - }) - .catch(() => ({ success: false, error: "API_CALL_ERROR" })) + const _startTimeMenu = conversation + .menu("grants-start-time", { parent: "grants-main" }) + .text(`Now: ${timeFormat.format(today)}`, (ctx) => changeStartTime(ctx, today.getHours(), today.getMinutes())) + .row() + .text("8:00", (ctx) => changeStartTime(ctx, 8, 0)) + .text("9:00", (ctx) => changeStartTime(ctx, 9, 0)) + .text("10:00", (ctx) => changeStartTime(ctx, 10, 0)) + .text("11:00", (ctx) => changeStartTime(ctx, 11, 0)) + .text("12:00", (ctx) => changeStartTime(ctx, 12, 0)) + .row() + .text("13:00", (ctx) => changeStartTime(ctx, 13, 0)) + .text("14:00", (ctx) => changeStartTime(ctx, 14, 0)) + .text("15:00", (ctx) => changeStartTime(ctx, 15, 0)) + .text("16:00", (ctx) => changeStartTime(ctx, 16, 0)) + .text("17:00", (ctx) => changeStartTime(ctx, 17, 0)) + .row() + .text("18:00", (ctx) => changeStartTime(ctx, 18, 0)) + .text("19:00", (ctx) => changeStartTime(ctx, 19, 0)) + .text("20:00", (ctx) => changeStartTime(ctx, 20, 0)) + .text("21:00", (ctx) => changeStartTime(ctx, 21, 0)) + .text("22:00", (ctx) => changeStartTime(ctx, 22, 0)) + .row() + .back( + () => `โšช๏ธ Keep current time ${timeFormat.format(startDate)}`, + (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ) - if (success) { - await ctx.editMessageText( - fmt(({ b, skip }) => [skip`${baseMsg()}`, b`โœ… Special Permissions Granted`], { sep: "\n\n" }) - ) + const startDateMenu = conversation + .menu("grants-start-date", { parent: "grants-main" }) + .text( + () => `Today ${dateFormat.format(today)}`, + (ctx) => changeStartDate(ctx, 0) + ) + .row() + .text(dateFormat.format(getDateWithDelta(today, 1)), (ctx) => changeStartDate(ctx, 1)) + .text(dateFormat.format(getDateWithDelta(today, 2)), (ctx) => changeStartDate(ctx, 2)) + .text(dateFormat.format(getDateWithDelta(today, 3)), (ctx) => changeStartDate(ctx, 3)) + .row() + .text(dateFormat.format(getDateWithDelta(today, 4)), (ctx) => changeStartDate(ctx, 4)) + .text(dateFormat.format(getDateWithDelta(today, 5)), (ctx) => changeStartDate(ctx, 5)) + .text(dateFormat.format(getDateWithDelta(today, 6)), (ctx) => changeStartDate(ctx, 6)) + .text(dateFormat.format(getDateWithDelta(today, 7)), (ctx) => changeStartDate(ctx, 7)) + .row() + .back("โ—€๏ธ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) - await modules.get("tgLogger").grants({ - action: "CREATE", - target, - by: context.from, - since: startDate, - reason: args.reason, - duration: grantDuration, - }) - } else { - await ctx.editMessageText(fmt(({ b, skip }) => [skip`${baseMsg()}`, b`โ‰๏ธ Error: ${error}`], { sep: "\n\n" })) - } + const mainMenu = conversation + .menu("grants-main") + .text("โœ… Confirm", async (ctx) => { + ctx.menu.close() + const { success, error } = await api.tg.grants.create + .mutate({ + userId: target.id, + adderId: context.from.id, + reason: args.reason, + since: startDate, + until: endDate(), + }) + .catch(() => ({ success: false, error: "API_CALL_ERROR" })) - await conversation.halt() - }) - .row() - .submenu("๐Ÿ“† Change Start Date", startDateMenu, (ctx) => - ctx.editMessageText( - fmt(({ skip, b }) => [skip`${baseMsg()}`, b`๐Ÿ“† Changing start DATE`], { sep: "\n\n" }), - { reply_markup: ctx.msg?.reply_markup } + if (success) { + await ctx.editMessageText( + fmt(({ b, skip }) => [skip`${baseMsg()}`, b`โœ… Special Permissions Granted`], { sep: "\n\n" }) ) + + await modules.get("tgLogger").grants({ + action: "CREATE", + target, + by: context.from, + since: startDate, + reason: args.reason, + duration: grantDuration, + }) + } else { + await ctx.editMessageText(fmt(({ b, skip }) => [skip`${baseMsg()}`, b`โ‰๏ธ Error: ${error}`], { sep: "\n\n" })) + } + + await conversation.halt() + }) + .row() + .submenu("๐Ÿ“† Change Start Date", startDateMenu, (ctx) => + ctx.editMessageText( + fmt(({ skip, b }) => [skip`${baseMsg()}`, b`๐Ÿ“† Changing start DATE`], { sep: "\n\n" }), + { reply_markup: ctx.msg?.reply_markup } ) - .submenu("โฑ๏ธ Change Duration", durationMenu, (ctx) => - ctx.editMessageText( - fmt(({ skip, b }) => [skip`${baseMsg()}`, b`โฑ๏ธ Changing grant DURATION`], { sep: "\n\n" }), - { reply_markup: ctx.msg?.reply_markup } - ) + ) + .submenu("โฑ๏ธ Change Duration", durationMenu, (ctx) => + ctx.editMessageText( + fmt(({ skip, b }) => [skip`${baseMsg()}`, b`โฑ๏ธ Changing grant DURATION`], { sep: "\n\n" }), + { reply_markup: ctx.msg?.reply_markup } ) - .row() - .text("โŒ Cancel", async (ctx) => { - await ctx.editMessageText(fmt(({ b, skip }) => [skip`${baseMsg()}`, b`โŒ Grant Cancelled`], { sep: "\n\n" })) - ctx.menu.close() - await wait(5000) - await ctx.deleteMessage().catch(() => {}) - await conversation.halt() - }) + ) + .row() + .text("โŒ Cancel", async (ctx) => { + await ctx.editMessageText(fmt(({ b, skip }) => [skip`${baseMsg()}`, b`โŒ Grant Cancelled`], { sep: "\n\n" })) + ctx.menu.close() + await wait(5000) + await ctx.deleteMessage().catch(() => {}) + await conversation.halt() + }) - const msg = await context.reply(baseMsg(), { reply_markup: mainMenu }) - await conversation.waitUntil(() => false, { maxMilliseconds: 60 * 60 * 1000 }) - await msg.delete() - } catch (err) { - logger.error({ err }, "Error in grant command") - await context.deleteMessage() - } + const msg = await context.reply(baseMsg(), { reply_markup: mainMenu }) + await conversation.waitUntil(() => false, { maxMilliseconds: 60 * 60 * 1000 }) + await msg.delete() }, }) From a5488f02214cb0936e1ad560e005509adee86a4f Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 14 Mar 2026 10:13:39 +0100 Subject: [PATCH 05/13] docs: fix typo in withCollection docstring --- src/lib/managed-commands/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 7233ca7..3cc7f16 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -134,8 +134,7 @@ export interface ManagedCommandsOptions export class ManagedCommands< TRole extends string = DefaultRoles, C extends ManagedCommandsFlavor = ManagedCommandsFlavor, -> implements MiddlewareObj -{ +> implements MiddlewareObj { private composer = new Composer() private commands: Command[] = [] private permissionHandler: PermissionHandler @@ -440,7 +439,7 @@ export class ManagedCommands< * }) * * const commands = new ManagedCommands() - * commands.addCollection(collection) + * commands.withCollection(collection) * * bot.use(commands) * ``` From 3339a601abc7559d72f79f534e313b02ba40c0d4 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sun, 15 Mar 2026 16:21:24 +0100 Subject: [PATCH 06/13] feat: command trigger alises --- src/commands/report.ts | 2 +- src/lib/managed-commands/command.ts | 4 +++- src/lib/managed-commands/index.ts | 33 +++++++++++++++++++++++------ src/utils/types.ts | 1 + 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/commands/report.ts b/src/commands/report.ts index d21bc4f..550a1c4 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -5,7 +5,7 @@ import { fmt } from "@/utils/format" import type { Role } from "@/utils/types" export const report = new CommandsCollection("Reporting").createCommand({ - trigger: "report", + trigger: ["report", "admin"], description: "Report a message to admins", scope: "group", reply: "required", diff --git a/src/lib/managed-commands/command.ts b/src/lib/managed-commands/command.ts index 1231d00..a7c1b9e 100644 --- a/src/lib/managed-commands/command.ts +++ b/src/lib/managed-commands/command.ts @@ -2,6 +2,7 @@ import type { Conversation } from "@grammyjs/conversations" import type { Context } from "grammy" import type { Message } from "grammy/types" import type { z } from "zod" +import type { MaybeArray } from "@/utils/types" import type { ConversationContext } from "./context" interface BaseArgumentOptions { @@ -82,8 +83,9 @@ export interface Command< > { /** * The command trigger, the string that will be used to call the command. + * If an array is provided, all entries will be used as aliases for the command */ - trigger: string + trigger: MaybeArray /** * The scope of the command, can be "private", "group" or "both". * @default "both" diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 3cc7f16..d5e520a 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -134,12 +134,14 @@ export interface ManagedCommandsOptions export class ManagedCommands< TRole extends string = DefaultRoles, C extends ManagedCommandsFlavor = ManagedCommandsFlavor, -> implements MiddlewareObj { +> implements MiddlewareObj +{ private composer = new Composer() private commands: Command[] = [] private permissionHandler: PermissionHandler private hooks: ManagedCommandsHooks private adapter: ConversationStorage + private registeredTriggers = new Set() /** * Parses the `reply_to_message` field from the message object @@ -253,6 +255,15 @@ export class ManagedCommands< ]) } + /** + * Generate a unique ID for a command based on its trigger(s), used for conversation IDs. + * @param cmd The command + * @returns a unique ID for the command based on its trigger(s) + */ + private static commandID(cmd: AnyCommand) { + return typeof cmd.trigger === "string" ? cmd.trigger : cmd.trigger.join("_") + } + /** * Creates a new instance of ManagedCommands, which can be used as a middleware * @example @@ -340,9 +351,21 @@ export class ManagedCommands< createCommand( cmd: Command ): this { + const triggers = Array.isArray(cmd.trigger) ? cmd.trigger : [cmd.trigger] + for (const trigger of triggers) { + if (this.registeredTriggers.has(trigger)) { + throw new Error( + `[ManagedCommands] Trigger '${trigger}' is already registered (aliases: [${triggers.join(", ")}])` + ) + } + this.registeredTriggers.add(trigger) + } + cmd.scope = cmd.scope ?? ("both" as S) // default to both this.commands.push(cmd) // add the command to the list - this.commands.sort((a, b) => a.trigger.localeCompare(b.trigger)) // sort the commands by alphabetical order of the trigger + // TODO: rethink sorting + // this.commands.sort((a, b) => a.trigger.localeCompare(b.trigger)) // sort the commands by alphabetical order of the trigger + const id = ManagedCommands.commandID(cmd) // create a conversation that handles the command execution this.composer.use( @@ -393,9 +416,7 @@ export class ManagedCommands< else throw error }) }, - { - id: cmd.trigger, // the conversation ID is set to the command trigger - } + { id } ) ) this.composer.command(cmd.trigger, async (ctx) => { @@ -418,7 +439,7 @@ export class ManagedCommands< } // enter the conversation that handles the command execution - await ctx.conversation.enter(cmd.trigger) + await ctx.conversation.enter(id) }) return this } diff --git a/src/utils/types.ts b/src/utils/types.ts index 53b0903..cd879f5 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -15,6 +15,7 @@ export type ContextWith

> = Exclude = T | Promise +export type MaybeArray = T | T[] export type Context = ManagedCommandsFlavor export type Role = ApiInput["tg"]["permissions"]["addRole"]["role"] From 8967dba0ee49d39e275d0b47765ca04fdcede2bc Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sun, 15 Mar 2026 16:22:00 +0100 Subject: [PATCH 07/13] fix: comment typo --- src/lib/managed-commands/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index d5e520a..917a77b 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -402,7 +402,7 @@ export class ManagedCommands< if (this.hooks.beforeHandler) await this.hooks.beforeHandler({ context: ctx as CommandContext, command: cmd }) - // Fianlly execute the handler + // Finally execute the handler await cmd .handler({ context: ctx, From 96f2b4e9278a3061db5da918d98bbca843010c5b Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sun, 15 Mar 2026 17:24:36 +0100 Subject: [PATCH 08/13] refactor: permission handling in commands --- src/commands/ban.ts | 6 +- src/commands/del.ts | 2 +- src/commands/index.ts | 39 ++++-------- src/commands/kick.ts | 2 +- src/commands/mute.ts | 6 +- src/lib/managed-commands/command.ts | 12 +++- src/lib/managed-commands/index.ts | 99 ++++++++++++++++++----------- 7 files changed, 93 insertions(+), 73 deletions(-) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 140c228..559c294 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -18,7 +18,7 @@ export const ban = new CommandsCollection("Banning") reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { if (!repliedTo.from) { @@ -46,7 +46,7 @@ export const ban = new CommandsCollection("Banning") reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { if (!repliedTo.from) { @@ -72,7 +72,7 @@ export const ban = new CommandsCollection("Banning") scope: "group", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context }) => { const userId: number | null = diff --git a/src/commands/del.ts b/src/commands/del.ts index 723bb0b..9a6328a 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -9,7 +9,7 @@ export const del = new CommandsCollection("Deletion").createCommand({ scope: "group", permissions: { allowedRoles: ["admin", "owner", "direttivo"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, description: "Deletes the replied to message", reply: "required", diff --git a/src/commands/index.ts b/src/commands/index.ts index 2e24738..835f90a 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,6 +1,6 @@ import type { ConversationData, VersionedState } from "@grammyjs/conversations" import { api } from "@/backend" -import { isAllowedInGroups, ManagedCommands } from "@/lib/managed-commands" +import { ManagedCommands } from "@/lib/managed-commands" import { RedisFallbackAdapter } from "@/lib/redis-fallback-adapter" import { logger } from "@/logger" import { redis } from "@/redis" @@ -56,34 +56,17 @@ export const commands = new ManagedCommands({ context.deleteMessage().catch(() => {}) } }, + overrideGroupAdminCheck: async (userId, groupId, ctx) => { + const { status: groupRole } = await ctx.getChatMember(userId) + if (groupRole === "administrator" || groupRole === "creator") return true + const isDbAdmin = await api.tg.permissions.checkGroup.query({ userId, groupId }) + return isDbAdmin + }, }, - permissionHandler: async ({ command, context: ctx }) => { - if (!command.permissions) return true - if (!ctx.from) return false - - const { allowedRoles, excludedRoles } = command.permissions - - if (isAllowedInGroups(command)) { - const { allowedGroupAdmins, allowedGroupsId, excludedGroupsId } = command.permissions - const { status: groupRole } = await ctx.getChatMember(ctx.from.id) - - if (allowedGroupsId && !allowedGroupsId.includes(ctx.chatId)) return false - if (excludedGroupsId?.includes(ctx.chatId)) return false - if (allowedGroupAdmins) { - const isDbAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) - const isTgAdmin = groupRole === "administrator" || groupRole === "creator" - if (isDbAdmin || isTgAdmin) return true - } - } - - const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) - if (!roles) return false - - // blacklist is stronger than whitelist - if (allowedRoles?.every((r) => !roles.includes(r))) return false - if (excludedRoles?.some((r) => roles.includes(r))) return false - - return true + getUserRoles: async (userId) => { + // TODO: cache this to avoid hitting the db on every command + const { roles } = await api.tg.permissions.getRoles.query({ userId }) + return roles || [] }, }) .createCommand({ diff --git a/src/commands/kick.ts b/src/commands/kick.ts index 72b6522..96b59b3 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -12,7 +12,7 @@ export const kick = new CommandsCollection("Kicking").createCommand({ reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { if (!repliedTo.from) { diff --git a/src/commands/mute.ts b/src/commands/mute.ts index 517be2f..8f69074 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -26,7 +26,7 @@ export const mute = new CommandsCollection("Muting") reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { if (!repliedTo.from) { @@ -53,7 +53,7 @@ export const mute = new CommandsCollection("Muting") reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { if (!repliedTo.from) { @@ -72,7 +72,7 @@ export const mute = new CommandsCollection("Muting") scope: "group", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, handler: async ({ args, context }) => { const userId: number | null = diff --git a/src/lib/managed-commands/command.ts b/src/lib/managed-commands/command.ts index a7c1b9e..8f7019b 100644 --- a/src/lib/managed-commands/command.ts +++ b/src/lib/managed-commands/command.ts @@ -44,12 +44,22 @@ export type CommandReplyTo = "required" | "optional" | undefined export type CommandScope = "private" | "group" | "both" interface PrivatePermissions { + /** The roles that are allowed to use the command */ allowedRoles?: TRole[] + /** The roles that are excluded from using the command */ excludedRoles?: TRole[] } interface GroupPermissions extends PrivatePermissions { - allowedGroupAdmins: boolean + /** + * Whether to allow group admins to use the command, without considering their external role + * + * You can use hooks to override what is considered a group admin, by default it considers users with + * Telegram Chat Role of "administrator" or "creator" as group admins + */ + allowGroupAdmins: boolean + /** Group IDs where the command is allowed */ allowedGroupsId?: number[] + /** Group IDs where the command is not allowed, if a group ID is in both allowedGroupsId and excludedGroupsId, the exclusion takes precedence */ excludedGroupsId?: number[] } type Permissions = S extends "private" diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 917a77b..10f0951 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -9,7 +9,7 @@ import { hydrate } from "@grammyjs/hydrate" import { hydrateReply, parseMode } from "@grammyjs/parse-mode" import type { CommandContext, Context, MiddlewareObj } from "grammy" import { Composer, MemorySessionStorage } from "grammy" -import type { ChatMember, Message } from "grammy/types" +import type { Message } from "grammy/types" import type { Result } from "neverthrow" import { err, ok } from "neverthrow" import z from "zod" @@ -28,26 +28,9 @@ import type { CommandScopedContext, RepliedTo, } from "./command" -import { isTypedArgumentOptions } from "./command" +import { isAllowedInGroups, isTypedArgumentOptions } from "./command" import type { ManagedCommandsFlavor } from "./context" -export type PermissionHandler = (arg: { - context: CommandContext - command: AnyCommand -}) => Promise - -type DefaultRoles = ChatMember["status"] -const defaultPermissionHandler: PermissionHandler = async ({ context, command }) => { - const { allowedRoles, excludedRoles } = command.permissions ?? {} - if (!context.from) return false - const member = await context.getChatMember(context.from.id) - - if (allowedRoles && !allowedRoles.includes(member.status)) return false - if (excludedRoles?.includes(member.status)) return false - - return true -} - export type Hook = ( params: Params & { context: CommandContext @@ -71,9 +54,15 @@ export type ManagedCommandsHooks + /** + * A function to override what counts as a "Group Admin", by default it considers users with Telegram Chat Role of + * "administrator" or "creator" as group admins, but you can override this to implement your own logic, + * for example by checking an external database of admins + */ + overrideGroupAdminCheck?: (userId: number, chatId: number, context: CommandContext) => Promise } -export interface ManagedCommandsOptions { +export interface IManagedCommandsOptions { /** * The storage adapter to use for persisting conversations. * - {@link https://grammy.dev/plugins/conversations#persisting-conversations conversations plugin documentation} @@ -83,24 +72,27 @@ export interface ManagedCommandsOptions adapter: ConversationStorage /** - * The permission handler to use for checking user permissions. + * A function to get externally defined roles for a specific user. * - * By default, this checks the user's status in the chat (e.g. admin, member, - * etc.) against the allowed and excluded roles. - * - * You can override this to implement your own permission logic. * @example * ```ts * const commands = new ManagedCommands({ - * permissionHandler: async ({ command, context }) => { - * const { allowedRoles, excludedRoles } = command.permissions - * if (Math.random() > 0.5) return true // don't gable, kids - * return false + * getUserRoles: async (userId, context) => { + * const roles = await db.getUserRoles(userId) // Array<"admin" | "user">[] + * return roles * }, + * }).createCommand({ + * trigger: "admincmd", + * permissions: { + * allowedRoles: ["admin"], + * }, + * handler: async ({ context }) => { + * await context.reply("You are an admin!") + * }), * }) * ``` */ - permissionHandler: PermissionHandler + getUserRoles: (userId: number, context: CommandContext) => Promise /** * Hooks to execute on specific events @@ -108,6 +100,10 @@ export interface ManagedCommandsOptions hooks: ManagedCommandsHooks } +export type ManagedCommandsOptions = string extends TRole + ? Omit, "getUserRoles"> & { getUserRoles?: never } + : IManagedCommandsOptions + /** * A class to manage commands in a grammY bot, with support for argument parsing, permission handling, and hooks for various events. * You can create commands with specific triggers, arguments, and permissions, and the class will handle the parsing and execution of the commands, as well as checking permissions and executing hooks. @@ -132,13 +128,13 @@ export interface ManagedCommandsOptions * @see Command for the options available when creating a command */ export class ManagedCommands< - TRole extends string = DefaultRoles, + TRole extends string = string, C extends ManagedCommandsFlavor = ManagedCommandsFlavor, > implements MiddlewareObj { private composer = new Composer() private commands: Command[] = [] - private permissionHandler: PermissionHandler + private getUserRoles: (userId: number, context: CommandContext) => Promise private hooks: ManagedCommandsHooks private adapter: ConversationStorage private registeredTriggers = new Set() @@ -309,9 +305,8 @@ export class ManagedCommands< * * @param options The options to use for the ManagedCommands instance */ - constructor(options?: Partial>) { - this.permissionHandler = options?.permissionHandler ?? defaultPermissionHandler - // this.logger = options?.logger ?? defaultLogger + constructor(options?: ManagedCommandsOptions) { + this.getUserRoles = options?.getUserRoles ?? (async () => []) this.hooks = options?.hooks ?? {} this.adapter = options?.adapter ?? new MemorySessionStorage() @@ -343,6 +338,38 @@ export class ManagedCommands< }) } + private async checkPermissions(command: AnyCommand, ctx: CommandContext): Promise { + if (!command.permissions) return true + if (!ctx.from) return false + + const { allowedRoles, excludedRoles } = command.permissions + + if (isAllowedInGroups(command)) { + const { allowGroupAdmins, allowedGroupsId, excludedGroupsId } = command.permissions + + if (allowedGroupsId && !allowedGroupsId.includes(ctx.chatId)) return false + if (excludedGroupsId?.includes(ctx.chatId)) return false + + if (allowGroupAdmins) { + if (this.hooks.overrideGroupAdminCheck) { + const isAdmin = await this.hooks.overrideGroupAdminCheck(ctx.from.id, ctx.chatId, ctx) + if (isAdmin) return true + } else { + const { status: groupRole } = await ctx.getChatMember(ctx.from.id) + if (groupRole === "administrator" || groupRole === "creator") return true + } + } + } + + const roles = await this.getUserRoles(ctx.from.id, ctx) + + // blacklist is stronger than whitelist + if (allowedRoles?.every((r) => !roles.includes(r))) return false + if (excludedRoles?.some((r) => roles.includes(r))) return false + + return true + } + /** * Creates a new command and adds it to the list of commands * @param cmd The options for the command to create, see {@link Command} @@ -431,7 +458,7 @@ export class ManagedCommands< // delete the command call if the user is not allowed to use it if (cmd.permissions) { - const allowed = await this.permissionHandler({ command: cmd, context: ctx }) + const allowed = await this.checkPermissions(cmd, ctx) if (!allowed) { if (this.hooks.missingPermissions) await this.hooks.missingPermissions({ context: ctx, command: cmd }) return From ec830f63e97f264f5727d30fb2cc60a4a589d23e Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sun, 15 Mar 2026 17:53:13 +0100 Subject: [PATCH 09/13] fix: formatted usage errors --- src/lib/managed-commands/index.ts | 13 +++++-------- src/utils/duration.ts | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 10f0951..73604fa 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -14,7 +14,7 @@ import type { Result } from "neverthrow" import { err, ok } from "neverthrow" import z from "zod" import { fmt } from "@/utils/format" -import { wait } from "@/utils/wait" +import { ephemeral } from "@/utils/messages" import type { CommandsCollection } from "./collection" import type { AnyCommand, @@ -410,17 +410,14 @@ export class ManagedCommands< if (message.chat.type !== "private") await ctx.deleteMessage() const msg = await ctx.reply( - fmt(({ b, skip }) => [ + fmt(({ b, code }) => [ `Error:`, b`${requirements.error.join("\n")}`, - `\n\nUsage:`, - skip`\n${ManagedCommands.formatCommandUsage(cmd)}`, + `\nSee usage with:`, + code`/help ${Array.isArray(cmd.trigger) ? cmd.trigger[0] : cmd.trigger}`, ]) ) - if (ctx.chat.type !== "private") { - await wait(5000) - await msg.delete() - } + if (ctx.chat.type !== "private") void ephemeral(msg, 10_000) // delete the error message after some time in groups, no need to keep it return } diff --git a/src/utils/duration.ts b/src/utils/duration.ts index 70a1b9d..20ae017 100644 --- a/src/utils/duration.ts +++ b/src/utils/duration.ts @@ -22,7 +22,7 @@ const Durations: Record = { } const zDuration = z .string() - .regex(durationRegex) + .regex(durationRegex, "Format must be where unit can be m, h, d, w") .transform((a) => { const parsed = parseInt(a.slice(0, -1), 10) * Durations[a.slice(-1) as DurationUnit] From dff797ab990b646ead8b812d882f5e7827290044 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sun, 15 Mar 2026 19:21:49 +0100 Subject: [PATCH 10/13] feat: categories in help formatting --- src/commands/index.ts | 13 ++----- src/commands/link-admin-dashboard.ts | 2 +- src/commands/{ => management}/audit.ts | 0 src/commands/{ => management}/grants.ts | 0 src/commands/management/index.ts | 8 ++++ src/commands/{ => management}/role.ts | 0 src/commands/{ => management}/userid.ts | 2 +- src/commands/{ => moderation}/ban.ts | 0 src/commands/{ => moderation}/banall.ts | 0 src/commands/{ => moderation}/del.ts | 0 src/commands/moderation/index.ts | 9 +++++ src/commands/{ => moderation}/kick.ts | 0 src/commands/{ => moderation}/mute.ts | 0 src/commands/report.ts | 2 +- src/commands/search.ts | 2 +- src/lib/managed-commands/index.ts | 51 ++++++++++++++++++++----- 16 files changed, 65 insertions(+), 24 deletions(-) rename src/commands/{ => management}/audit.ts (100%) rename src/commands/{ => management}/grants.ts (100%) create mode 100644 src/commands/management/index.ts rename src/commands/{ => management}/role.ts (100%) rename src/commands/{ => management}/userid.ts (90%) rename src/commands/{ => moderation}/ban.ts (100%) rename src/commands/{ => moderation}/banall.ts (100%) rename src/commands/{ => moderation}/del.ts (100%) create mode 100644 src/commands/moderation/index.ts rename src/commands/{ => moderation}/kick.ts (100%) rename src/commands/{ => moderation}/mute.ts (100%) diff --git a/src/commands/index.ts b/src/commands/index.ts index 835f90a..a2924c7 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -7,18 +7,11 @@ import { redis } from "@/redis" import type { Role } from "@/utils/types" import { printCtxFrom } from "@/utils/users" import { wait } from "@/utils/wait" -import { audit } from "./audit" -import { ban } from "./ban" -import { banAll } from "./banall" -import { del } from "./del" -import { grants } from "./grants" -import { kick } from "./kick" import { linkAdminDashboard } from "./link-admin-dashboard" -import { mute } from "./mute" +import { management } from "./management" +import { moderation } from "./moderation" import { report } from "./report" -import { role } from "./role" import { search } from "./search" -import { userid } from "./userid" const adapter = new RedisFallbackAdapter>({ redis, @@ -77,4 +70,4 @@ export const commands = new ManagedCommands({ await context.reply("pong") }, }) - .withCollection(audit, ban, banAll, del, grants, kick, linkAdminDashboard, mute, report, role, search, userid) + .withCollection(linkAdminDashboard, report, search, management, moderation) diff --git a/src/commands/link-admin-dashboard.ts b/src/commands/link-admin-dashboard.ts index bbf0305..62bca0d 100644 --- a/src/commands/link-admin-dashboard.ts +++ b/src/commands/link-admin-dashboard.ts @@ -34,7 +34,7 @@ async function cancel( await conv.halt() } -export const linkAdminDashboard = new CommandsCollection("Link Admin Dashboard").createCommand({ +export const linkAdminDashboard = new CommandsCollection().createCommand({ trigger: "link", scope: "private", description: "Verify the login code for the admin dashboard", diff --git a/src/commands/audit.ts b/src/commands/management/audit.ts similarity index 100% rename from src/commands/audit.ts rename to src/commands/management/audit.ts diff --git a/src/commands/grants.ts b/src/commands/management/grants.ts similarity index 100% rename from src/commands/grants.ts rename to src/commands/management/grants.ts diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts new file mode 100644 index 0000000..be8f0dd --- /dev/null +++ b/src/commands/management/index.ts @@ -0,0 +1,8 @@ +import { CommandsCollection } from "@/lib/managed-commands" +import type { Role } from "@/utils/types" +import { audit } from "./audit" +import { grants } from "./grants" +import { role } from "./role" +import { userid } from "./userid" + +export const management = new CommandsCollection("Management").withCollection(audit, grants, role, userid) diff --git a/src/commands/role.ts b/src/commands/management/role.ts similarity index 100% rename from src/commands/role.ts rename to src/commands/management/role.ts diff --git a/src/commands/userid.ts b/src/commands/management/userid.ts similarity index 90% rename from src/commands/userid.ts rename to src/commands/management/userid.ts index 35a5a17..e64c23f 100644 --- a/src/commands/userid.ts +++ b/src/commands/management/userid.ts @@ -14,7 +14,7 @@ export const userid = new CommandsCollection("User IDs").createCommand({ const id = await getTelegramId(username) if (!id) { logger.warn(`[/userid] username @${username} not in our cache`) - await context.reply(fmt(() => `Username @${username} not in our cache`)) + await context.reply(fmt(({ u }) => u`Username @${username} not in our cache`)) return } diff --git a/src/commands/ban.ts b/src/commands/moderation/ban.ts similarity index 100% rename from src/commands/ban.ts rename to src/commands/moderation/ban.ts diff --git a/src/commands/banall.ts b/src/commands/moderation/banall.ts similarity index 100% rename from src/commands/banall.ts rename to src/commands/moderation/banall.ts diff --git a/src/commands/del.ts b/src/commands/moderation/del.ts similarity index 100% rename from src/commands/del.ts rename to src/commands/moderation/del.ts diff --git a/src/commands/moderation/index.ts b/src/commands/moderation/index.ts new file mode 100644 index 0000000..82cae60 --- /dev/null +++ b/src/commands/moderation/index.ts @@ -0,0 +1,9 @@ +import { CommandsCollection } from "@/lib/managed-commands" +import type { Role } from "@/utils/types" +import { ban } from "./ban" +import { banAll } from "./banall" +import { del } from "./del" +import { kick } from "./kick" +import { mute } from "./mute" + +export const moderation = new CommandsCollection("Moderation").withCollection(ban, banAll, del, kick, mute) diff --git a/src/commands/kick.ts b/src/commands/moderation/kick.ts similarity index 100% rename from src/commands/kick.ts rename to src/commands/moderation/kick.ts diff --git a/src/commands/mute.ts b/src/commands/moderation/mute.ts similarity index 100% rename from src/commands/mute.ts rename to src/commands/moderation/mute.ts diff --git a/src/commands/report.ts b/src/commands/report.ts index 550a1c4..6c123dc 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -4,7 +4,7 @@ import { modules } from "@/modules" import { fmt } from "@/utils/format" import type { Role } from "@/utils/types" -export const report = new CommandsCollection("Reporting").createCommand({ +export const report = new CommandsCollection().createCommand({ trigger: ["report", "admin"], description: "Report a message to admins", scope: "group", diff --git a/src/commands/search.ts b/src/commands/search.ts index a76b46b..735ed41 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -9,7 +9,7 @@ const LIMIT = 9 type Group = Awaited>["groups"][number] type LinkedGroup = Group & { link: string } -export const search = new CommandsCollection("Search").createCommand({ +export const search = new CommandsCollection().createCommand({ trigger: "search", scope: "both", description: "Search groups by title", diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 73604fa..cf23c58 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -133,7 +133,7 @@ export class ManagedCommands< > implements MiddlewareObj { private composer = new Composer() - private commands: Command[] = [] + private commands: Record[]> = {} private getUserRoles: (userId: number, context: CommandContext) => Promise private hooks: ManagedCommandsHooks private adapter: ConversationStorage @@ -239,7 +239,7 @@ export class ManagedCommands< cmd.scope === "private" ? "Private Chat" : cmd.scope === "group" ? "Groups" : "Groups and Private Chat" return fmt(({ n, b, i }) => [ - `/${cmd.trigger}`, + typeof cmd.trigger === "string" ? `/${cmd.trigger}` : cmd.trigger.map((t) => `/${t}`).join(" | "), ...args.map(({ key, optional }) => (optional ? n`[${i`${key}`}]` : n`<${i`${key}`}>`)), i`\nDesc:`, b`${cmd.description ?? "No description"}`, @@ -251,6 +251,15 @@ export class ManagedCommands< ]) } + private static formatCommandShort(cmd: AnyCommand): string { + const args = cmd.args ?? [] + return fmt(({ i, n }) => [ + typeof cmd.trigger === "string" ? `/${cmd.trigger}` : cmd.trigger.map((t) => `/${t}`).join(" | "), + ...args.map(({ key, optional }) => (optional ? i` [${key}]` : i` <${key}>`)), + n`\n\t${cmd.description ?? "No description"}`, + ]) + } + /** * Generate a unique ID for a command based on its trigger(s), used for conversation IDs. * @param cmd The command @@ -328,16 +337,36 @@ export class ManagedCommands< const text = ctx.message?.text ?? "" const [_, cmdArg] = text.replaceAll("/", "").split(" ") if (cmdArg) { - const cmd = this.commands.find((c) => c.trigger === cmdArg) + const cmd = this.getCommands().find((c) => c.trigger === cmdArg) if (!cmd) return ctx.reply(fmt(() => "Command not found. See /help.")) return ctx.reply(ManagedCommands.formatCommandUsage(cmd)) } - return ctx.reply(this.commands.map((cmd) => ManagedCommands.formatCommandUsage(cmd)).join("\n\n")) + const reply = fmt( + ({ u, b, skip, n, code }) => [ + b`Available commands:`, + ...Object.entries(this.commands).flatMap(([collection, cmds]) => [ + collection === "default" ? "" : u`${b`\n${collection}:`}`, + ...cmds.flatMap((cmd) => [skip`${ManagedCommands.formatCommandShort(cmd)}`]), + ]), + n`\n\nType ${code`\/help `} for more details on a specific command.`, + ], + { sep: "\n" } + ) + + return ctx.reply(reply) }) } + public getCommands() { + const cmds: AnyCommand[] = [] + for (const collection in this.commands) { + cmds.push(...this.commands[collection]) + } + return cmds + } + private async checkPermissions(command: AnyCommand, ctx: CommandContext): Promise { if (!command.permissions) return true if (!ctx.from) return false @@ -376,7 +405,8 @@ export class ManagedCommands< * @returns The ManagedCommands instance for chaining */ createCommand( - cmd: Command + cmd: Command, + collection: string = "default" ): this { const triggers = Array.isArray(cmd.trigger) ? cmd.trigger : [cmd.trigger] for (const trigger of triggers) { @@ -389,7 +419,8 @@ export class ManagedCommands< } cmd.scope = cmd.scope ?? ("both" as S) // default to both - this.commands.push(cmd) // add the command to the list + this.commands[collection] = this.commands[collection] ?? [] + this.commands[collection].push(cmd) // TODO: rethink sorting // this.commands.sort((a, b) => a.trigger.localeCompare(b.trigger)) // sort the commands by alphabetical order of the trigger const id = ManagedCommands.commandID(cmd) @@ -490,11 +521,11 @@ export class ManagedCommands< * ``` */ withCollection(...collections: CommandsCollection[]): this { - collections - .flatMap((c) => c.flush()) - .forEach((cmd) => { - this.createCommand(cmd) + collections.forEach((c) => { + c.flush().forEach((cmd) => { + this.createCommand(cmd, c.name) }) + }) return this } From b2441aa162768c3e8396a017df9f5013a431b2d9 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Tue, 17 Mar 2026 23:56:59 +0100 Subject: [PATCH 11/13] fix: coderabbit review --- src/commands/index.ts | 7 +++---- src/commands/management/audit.ts | 2 +- src/lib/managed-commands/index.ts | 8 +++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index a2924c7..e3f42da 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,9 +4,9 @@ import { ManagedCommands } from "@/lib/managed-commands" import { RedisFallbackAdapter } from "@/lib/redis-fallback-adapter" import { logger } from "@/logger" import { redis } from "@/redis" +import { ephemeral } from "@/utils/messages" import type { Role } from "@/utils/types" import { printCtxFrom } from "@/utils/users" -import { wait } from "@/utils/wait" import { linkAdminDashboard } from "./link-admin-dashboard" import { management } from "./management" import { moderation } from "./moderation" @@ -23,7 +23,7 @@ export const commands = new ManagedCommands({ adapter, hooks: { wrongScope: async ({ context, command }) => { - await context.deleteMessage() + await context.deleteMessage().catch(() => {}) logger.info( `[ManagedCommands] Command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat` ) @@ -34,9 +34,8 @@ export const commands = new ManagedCommands({ `[ManagedCommands] Command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` ) // Inform the user of restricted access - const reply = await context.reply("You are not allowed to execute this command") + void ephemeral(context.reply("You are not allowed to execute this command")) await context.deleteMessage() - void wait(3000).then(() => reply.delete()) }, handlerError: async ({ context, command, error }) => { logger.error({ error }, `[ManagedCommands] Error in handler for command '/${command.trigger}'`) diff --git a/src/commands/management/audit.ts b/src/commands/management/audit.ts index 153cf2f..5cd12af 100644 --- a/src/commands/management/audit.ts +++ b/src/commands/management/audit.ts @@ -7,7 +7,7 @@ import type { Role } from "@/utils/types" export const audit = new CommandsCollection("Auditing").createCommand({ trigger: "audit", scope: "private", - description: "Get audit of an user", + description: "Get the audit log of a user", args: [{ key: "username", optional: false, description: "Username or userid" }], permissions: { allowedRoles: ["hr", "owner", "direttivo"], diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index cf23c58..639056e 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -337,8 +337,10 @@ export class ManagedCommands< const text = ctx.message?.text ?? "" const [_, cmdArg] = text.replaceAll("/", "").split(" ") if (cmdArg) { - const cmd = this.getCommands().find((c) => c.trigger === cmdArg) - if (!cmd) return ctx.reply(fmt(() => "Command not found. See /help.")) + const cmd = this.getCommands().find((c) => + Array.isArray(c.trigger) ? c.trigger.includes(cmdArg) : c.trigger === cmdArg + ) + if (!cmd) return ctx.reply(fmt(() => "Command not found. See /help for available commands.")) return ctx.reply(ManagedCommands.formatCommandUsage(cmd)) } @@ -373,7 +375,7 @@ export class ManagedCommands< const { allowedRoles, excludedRoles } = command.permissions - if (isAllowedInGroups(command)) { + if (isAllowedInGroups(command) && (ctx.chat.type === "group" || ctx.chat.type === "supergroup")) { const { allowGroupAdmins, allowedGroupsId, excludedGroupsId } = command.permissions if (allowedGroupsId && !allowedGroupsId.includes(ctx.chatId)) return false From 50e7bc7bf24ed9893325e0369c8fafa6887a2145 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Wed, 18 Mar 2026 17:18:04 +0100 Subject: [PATCH 12/13] fix: coderabbit comments --- src/commands/index.ts | 2 +- src/lib/managed-commands/index.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/commands/index.ts b/src/commands/index.ts index e3f42da..54420af 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -29,13 +29,13 @@ export const commands = new ManagedCommands({ ) }, missingPermissions: async ({ context, command }) => { + await context.deleteMessage().catch(() => {}) logger.info( { command_permissions: command.permissions }, `[ManagedCommands] Command '/${command.trigger}' invoked by ${printCtxFrom(context)} without permissions` ) // Inform the user of restricted access void ephemeral(context.reply("You are not allowed to execute this command")) - await context.deleteMessage() }, handlerError: async ({ context, command, error }) => { logger.error({ error }, `[ManagedCommands] Error in handler for command '/${command.trigger}'`) diff --git a/src/lib/managed-commands/index.ts b/src/lib/managed-commands/index.ts index 639056e..fde7487 100644 --- a/src/lib/managed-commands/index.ts +++ b/src/lib/managed-commands/index.ts @@ -266,7 +266,9 @@ export class ManagedCommands< * @returns a unique ID for the command based on its trigger(s) */ private static commandID(cmd: AnyCommand) { - return typeof cmd.trigger === "string" ? cmd.trigger : cmd.trigger.join("_") + // only available characters in command triggers are a-z, 0-9 and _ + // https://core.telegram.org/bots/features#commands + return typeof cmd.trigger === "string" ? cmd.trigger : cmd.trigger.join("-") } /** From 4aa3c565b545e4171c76675941133f32061c5691 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Wed, 18 Mar 2026 17:21:32 +0100 Subject: [PATCH 13/13] fix: typos in command descriptions --- src/commands/moderation/banall.ts | 2 +- src/commands/moderation/mute.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/moderation/banall.ts b/src/commands/moderation/banall.ts index 81a0a1d..2f87543 100644 --- a/src/commands/moderation/banall.ts +++ b/src/commands/moderation/banall.ts @@ -72,7 +72,7 @@ export const banAll = new CommandsCollection("Ban All") { key: "username", type: numberOrString, - description: "The username or the user id of the user you want to update the role", + description: "The username or the user id of the user you want to unban from all groups", }, ], handler: async ({ args, context }) => { diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts index 8f69074..7cecb15 100644 --- a/src/commands/moderation/mute.ts +++ b/src/commands/moderation/mute.ts @@ -17,9 +17,9 @@ export const mute = new CommandsCollection("Muting") key: "duration", type: duration.zod, optional: false, - description: `How long to mutate the user. ${duration.formatDesc}`, + description: `How long to mute the user. ${duration.formatDesc}`, }, - { key: "reason", optional: true, description: "Optional reason to mutate the user" }, + { key: "reason", optional: true, description: "Optional reason to mute the user" }, ], description: "Temporary mute a user from a group", scope: "group", @@ -47,7 +47,7 @@ export const mute = new CommandsCollection("Muting") }) .createCommand({ trigger: "mute", - args: [{ key: "reason", optional: true, description: "Optional reason to mutate the user" }], + args: [{ key: "reason", optional: true, description: "Optional reason to mute the user" }], description: "Permanently mute a user from a group", scope: "group", reply: "required",