diff --git a/src/commands/_base.ts b/src/commands/_base.ts deleted file mode 100644 index 8c90b6b..0000000 --- a/src/commands/_base.ts +++ /dev/null @@ -1,54 +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" - -const adapter = new RedisFallbackAdapter>({ - redis, - prefix: "conv", - logger, -}) - -export const _commandsBase = new ManagedCommands({ - adapter, - logger, - 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 deleted file mode 100644 index 2ede185..0000000 --- a/src/commands/audit.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { api } from "@/backend" -import { fmt, fmtDate } from "@/utils/format" -import { getTelegramId } from "@/utils/telegram-id" - -import { _commandsBase } from "./_base" - -_commandsBase.createCommand({ - trigger: "audit", - scope: "private", - description: "Get audit of an user", - args: [{ key: "username", optional: false, description: "Username or userid" }], - permissions: { - allowedRoles: ["hr", "owner", "direttivo"], - }, - handler: async ({ context, args }) => { - let userId: number | null = parseInt(args.username, 10) - if (Number.isNaN(userId)) { - userId = await getTelegramId(args.username) - } - - if (userId === null) { - await context.reply("Not a valid userId or username not in our cache") - 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, - ]), - `------------------------------------`, - ], - { - sep: "\n", - } - ) - ) - } catch (err) { - await context.reply(`There was an error: \n${String(err)}`) - } - }, -}) diff --git a/src/commands/grants.ts b/src/commands/grants.ts deleted file mode 100644 index 8097785..0000000 --- a/src/commands/grants.ts +++ /dev/null @@ -1,271 +0,0 @@ -import type { ConversationMenuContext } from "@grammyjs/conversations" -import type { User } from "grammy/types" -import z from "zod" -import { api } from "@/backend" -import type { ConversationContext } 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 { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" - -const dateFormat = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", -}) - -const timeFormat = new Intl.DateTimeFormat(undefined, { - timeStyle: "short", - hour12: false, -}) - -const datetimeFormat = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - hour12: false, -}) - -const getDateWithDelta = (date: Date, deltaDay: number) => { - const newDate = new Date(date.getTime()) - newDate.setDate(newDate.getDate() + deltaDay) - return newDate -} - -const mainMsg = (user: User, startTime: Date, endTime: Date, duration: string, reason?: string) => - fmt( - ({ n, b, u }) => [ - b`๐Ÿ” Grant Special Permissions`, - n`${b`Target:`} ${fmtUser(user)}`, - n`${b`Start Time:`} ${datetimeFormat.format(startTime)}`, - n`${b`End Time:`} ${datetimeFormat.format(endTime)} (${duration})`, - reason ? n`${b`Reason:`} ${reason}` : undefined, - endTime.getTime() < Date.now() - ? b`\n${u`INVALID:`} END datetime is in the past, change start date or duration.` - : undefined, - ], - { sep: "\n" } - ) - -const askDurationMsg = fmt(({ n, b }) => [b`How long should the special grant last?`, n`${duration.formatDesc}`], { - sep: "\n", -}) - -_commandsBase.createCommand({ - trigger: "grant", - description: "Grant special permissions to a user allowing them to bypass the Auto-Moderation stack", - scope: "private", - permissions: { - allowedRoles: ["direttivo"], - }, - args: [ - { - key: "username", - type: numberOrString, - description: "The username or the user id of the user you want to grant special permissions to", - }, - { - key: "reason", - type: z.string(), - description: "The reason why you are granting special permissions to the user", - optional: true, - }, - ], - 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 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 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 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 changeDuration(ctx: ConversationMenuContext>, durationStr: string) { - grantDuration = duration.zod.parse(durationStr) - ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) - ctx.menu.nav("grants-main") - } - - 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") - } - - const backToMain = conversation - .menu("grants-back-to-main", { parent: "grants-main" }) - .back("โ—€๏ธ Back", (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) - - await changeDuration(ctx, text) - }) - .row() - .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 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 })) - - 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" })) - - 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 } - ) - ) - .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() - } - }, -}) diff --git a/src/commands/index.ts b/src/commands/index.ts index e5bd179..54420af 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,15 +1,72 @@ -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" +import type { ConversationData, VersionedState } from "@grammyjs/conversations" +import { api } from "@/backend" +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 { linkAdminDashboard } from "./link-admin-dashboard" +import { management } from "./management" +import { moderation } from "./moderation" +import { report } from "./report" +import { search } from "./search" -export { _commandsBase as commands } from "./_base" +const adapter = new RedisFallbackAdapter>({ + redis, + prefix: "conv", + logger, +}) + +export const commands = new ManagedCommands({ + adapter, + hooks: { + wrongScope: async ({ context, command }) => { + await context.deleteMessage().catch(() => {}) + logger.info( + `[ManagedCommands] Command '/${command.trigger}' with scope '${command.scope}' invoked by ${printCtxFrom(context)} in a '${context.chat.type}' chat` + ) + }, + 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")) + }, + handlerError: async ({ context, command, error }) => { + 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)}`) + }, + 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(() => {}) + } + }, + 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 + }, + }, + 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({ + trigger: "ping", + scope: "private", + description: "Replies with pong", + handler: async ({ context }) => { + await context.reply("pong") + }, + }) + .withCollection(linkAdminDashboard, report, search, management, moderation) diff --git a/src/commands/link-admin-dashboard.ts b/src/commands/link-admin-dashboard.ts index 78f255b..62bca0d 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( @@ -30,18 +30,15 @@ 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() } -_commandsBase.createCommand({ +export const linkAdminDashboard = new CommandsCollection().createCommand({ trigger: "link", 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/management/audit.ts b/src/commands/management/audit.ts new file mode 100644 index 0000000..5cd12af --- /dev/null +++ b/src/commands/management/audit.ts @@ -0,0 +1,47 @@ +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" + +export const audit = new CommandsCollection("Auditing").createCommand({ + trigger: "audit", + scope: "private", + description: "Get the audit log of a user", + args: [{ key: "username", optional: false, description: "Username or userid" }], + permissions: { + allowedRoles: ["hr", "owner", "direttivo"], + }, + handler: async ({ context, args }) => { + let userId: number | null = parseInt(args.username, 10) + if (Number.isNaN(userId)) { + userId = await getTelegramId(args.username) + } + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + 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, + ]), + `------------------------------------`, + ], + { + sep: "\n", + } + ) + ) + }, +}) diff --git a/src/commands/management/grants.ts b/src/commands/management/grants.ts new file mode 100644 index 0000000..a694c00 --- /dev/null +++ b/src/commands/management/grants.ts @@ -0,0 +1,265 @@ +import type { ConversationMenuContext } from "@grammyjs/conversations" +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 { modules } from "@/modules" +import { duration } from "@/utils/duration" +import { fmt, fmtUser } from "@/utils/format" +import { getTelegramId } from "@/utils/telegram-id" +import { numberOrString, type Role } from "@/utils/types" +import { wait } from "@/utils/wait" + +const dateFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", +}) + +const timeFormat = new Intl.DateTimeFormat(undefined, { + timeStyle: "short", + hour12: false, +}) + +const datetimeFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + hour12: false, +}) + +const getDateWithDelta = (date: Date, deltaDay: number) => { + const newDate = new Date(date.getTime()) + newDate.setDate(newDate.getDate() + deltaDay) + return newDate +} + +const mainMsg = (user: User, startTime: Date, endTime: Date, duration: string, reason?: string) => + fmt( + ({ n, b, u }) => [ + b`๐Ÿ” Grant Special Permissions`, + n`${b`Target:`} ${fmtUser(user)}`, + n`${b`Start Time:`} ${datetimeFormat.format(startTime)}`, + n`${b`End Time:`} ${datetimeFormat.format(endTime)} (${duration})`, + reason ? n`${b`Reason:`} ${reason}` : undefined, + endTime.getTime() < Date.now() + ? b`\n${u`INVALID:`} END datetime is in the past, change start date or duration.` + : undefined, + ], + { sep: "\n" } + ) + +const askDurationMsg = fmt(({ n, b }) => [b`How long should the special grant last?`, n`${duration.formatDesc}`], { + sep: "\n", +}) + +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", + permissions: { + allowedRoles: ["direttivo"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to grant special permissions to", + }, + { + key: "reason", + type: z.string(), + description: "The reason why you are granting special permissions to the user", + optional: true, + }, + ], + handler: async ({ args, context, conversation }) => { + 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 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 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 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 changeDuration(ctx: ConversationMenuContext>, durationStr: string) { + grantDuration = duration.zod.parse(durationStr) + ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ctx.menu.nav("grants-main") + } + + 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") + } + + const backToMain = conversation + .menu("grants-back-to-main", { parent: "grants-main" }) + .back("โ—€๏ธ Back", (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) + + await changeDuration(ctx, text) + }) + .row() + .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 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 })) + + 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" })) + + 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 } + ) + ) + .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() + }, +}) 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 60% rename from src/commands/role.ts rename to src/commands/management/role.ts index 6cfe399..a20f81d 100644 --- a/src/commands/role.ts +++ b/src/commands/management/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", @@ -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/commands/userid.ts b/src/commands/management/userid.ts similarity index 70% rename from src/commands/userid.ts rename to src/commands/management/userid.ts index 1c904ee..e64c23f 100644 --- a/src/commands/userid.ts +++ b/src/commands/management/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", @@ -14,7 +14,7 @@ _commandsBase.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 76% rename from src/commands/ban.ts rename to src/commands/moderation/ban.ts index db4831e..559c294 100644 --- a/src/commands/ban.ts +++ b/src/commands/moderation/ban.ts @@ -1,14 +1,15 @@ +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 { ephemeral } from "@/utils/messages" 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" }], @@ -17,21 +18,16 @@ _commandsBase reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 } const res = await Moderation.ban(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) .createCommand({ @@ -50,10 +46,9 @@ _commandsBase reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 @@ -67,11 +62,7 @@ _commandsBase [repliedTo], args.reason ) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) .createCommand({ @@ -81,18 +72,16 @@ _commandsBase scope: "group", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 } @@ -100,16 +89,11 @@ _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) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) diff --git a/src/commands/banall.ts b/src/commands/moderation/banall.ts similarity index 95% rename from src/commands/banall.ts rename to src/commands/moderation/banall.ts index 286caea..2f87543 100644 --- a/src/commands/banall.ts +++ b/src/commands/moderation/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", @@ -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 @@ -74,12 +72,10 @@ _commandsBase { 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 }) => { - 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/moderation/del.ts similarity index 63% rename from src/commands/del.ts rename to src/commands/moderation/del.ts index ccc1515..9a6328a 100644 --- a/src/commands/del.ts +++ b/src/commands/moderation/del.ts @@ -1,20 +1,19 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" -import { getText } from "@/utils/messages" -import { wait } from "@/utils/wait" -import { _commandsBase } from "./_base" +import { ephemeral, getText } from "@/utils/messages" +import type { Role } from "@/utils/types" -_commandsBase.createCommand({ +export const del = new CommandsCollection("Deletion").createCommand({ trigger: "del", scope: "group", permissions: { allowedRoles: ["admin", "owner", "direttivo"], - allowedGroupAdmins: true, + allowGroupAdmins: true, }, 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", @@ -24,10 +23,6 @@ _commandsBase.createCommand({ }) const res = await Moderation.deleteMessages([repliedTo], context.from, "Command /del") - if (res.isErr()) { - const msg = await context.reply("Cannot delete the message") - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply("Cannot delete the message")) }, }) 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 66% rename from src/commands/kick.ts rename to src/commands/moderation/kick.ts index 2a98759..96b59b3 100644 --- a/src/commands/kick.ts +++ b/src/commands/moderation/kick.ts @@ -1,10 +1,10 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" -import { wait } from "@/utils/wait" +import { ephemeral } from "@/utils/messages" +import type { Role } from "@/utils/types" -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", @@ -12,20 +12,15 @@ _commandsBase.createCommand({ reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 } const res = await Moderation.kick(repliedTo.from, context.chat, context.from, [repliedTo], args.reason) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) diff --git a/src/commands/mute.ts b/src/commands/moderation/mute.ts similarity index 72% rename from src/commands/mute.ts rename to src/commands/moderation/mute.ts index db8cf16..7cecb15 100644 --- a/src/commands/mute.ts +++ b/src/commands/moderation/mute.ts @@ -1,14 +1,15 @@ +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 { ephemeral } from "@/utils/messages" 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: [ @@ -16,19 +17,18 @@ _commandsBase 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", reply: "required", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 @@ -42,36 +42,27 @@ _commandsBase [repliedTo], args.reason ) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) .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", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 } const res = await Moderation.mute(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) .createCommand({ @@ -81,17 +72,15 @@ _commandsBase scope: "group", permissions: { excludedRoles: ["creator"], - allowedGroupAdmins: true, + allowGroupAdmins: 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 } @@ -99,16 +88,11 @@ _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) - if (res.isErr()) { - const msg = await context.reply(res.error.fmtError) - await wait(5000) - await msg.delete() - } + if (res.isErr()) void ephemeral(context.reply(res.error.fmtError)) }, }) diff --git a/src/commands/report.ts b/src/commands/report.ts index 9ebb0cb..6c123dc 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,15 +1,15 @@ +import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" import { modules } from "@/modules" import { fmt } from "@/utils/format" -import { _commandsBase } from "./_base" +import type { Role } from "@/utils/types" -_commandsBase.createCommand({ - trigger: "report", +export const report = new CommandsCollection().createCommand({ + trigger: ["report", "admin"], description: "Report a message to admins", 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/search.ts b/src/commands/search.ts index 40b4b18..735ed41 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().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/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/command.ts b/src/lib/managed-commands/command.ts index a69bf01..8f7019b 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 { @@ -43,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" @@ -66,6 +77,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, @@ -74,8 +93,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" @@ -133,6 +153,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..fde7487 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" @@ -8,13 +9,15 @@ 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 type { LogFn } from "pino" +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, ArgumentMap, ArgumentOptions, Command, @@ -25,36 +28,41 @@ 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: Command -}) => 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 -} - -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 + */ + beforeHandler?: Hook + /** + * 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} @@ -64,52 +72,72 @@ 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 /** - * 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 } +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. + * + * 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, + TRole extends string = string, C extends ManagedCommandsFlavor = ManagedCommandsFlavor, > implements MiddlewareObj { private composer = new Composer() - private commands: Command[] = [] - private permissionHandler: PermissionHandler - private logger: Logger + private commands: Record[]> = {} + private getUserRoles: (userId: number, context: CommandContext) => Promise + private hooks: ManagedCommandsHooks private adapter: ConversationStorage + private registeredTriggers = new Set() /** * Parses the `reply_to_message` field from the message object @@ -145,7 +173,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,13 +233,13 @@ 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" 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"}`, @@ -223,6 +251,26 @@ 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 + * @returns a unique ID for the command based on its trigger(s) + */ + private static commandID(cmd: AnyCommand) { + // 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("-") + } + /** * Creates a new instance of ManagedCommands, which can be used as a middleware * @example @@ -268,9 +316,9 @@ 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() this.composer.use( @@ -291,27 +339,95 @@ export class ManagedCommands< const text = ctx.message?.text ?? "" const [_, cmdArg] = text.replaceAll("/", "").split(" ") if (cmdArg) { - const cmd = this.commands.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)) } - 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 + + const { allowedRoles, excludedRoles } = command.permissions + + 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 + 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} * @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) { + 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 + 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) // create a conversation that handles the command execution this.composer.use( @@ -329,33 +445,37 @@ 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 } const { args, repliedTo } = requirements.value - // Fianlly execute the handler - await cmd.handler({ - context: ctx, - conversation: conv, - args, - repliedTo, - }) + if (this.hooks.beforeHandler) + await this.hooks.beforeHandler({ context: ctx as CommandContext, command: cmd }) + + // Finally execute the handler + await cmd + .handler({ + context: ctx, + conversation: conv, + args: args as ArgumentMap, + 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 - } + { id } ) ) this.composer.command(cmd.trigger, async (ctx) => { @@ -364,44 +484,53 @@ 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 } // 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) { - 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 } } // enter the conversation that handles the command execution - await ctx.conversation.enter(cmd.trigger) + await ctx.conversation.enter(id) }) 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 + * 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.withCollection(collection) + * + * bot.use(commands) + * ``` */ - private printUsername(ctx: CommandContext) { - if (!ctx.from) return "" - return `@${ctx.from.username ?? ""} [${ctx.from.id}]` + withCollection(...collections: CommandsCollection[]): this { + collections.forEach((c) => { + c.flush().forEach((cmd) => { + this.createCommand(cmd, c.name) + }) + }) + return this } /** 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] diff --git a/src/utils/messages.ts b/src/utils/messages.ts index 0888f8e..90c4e25 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,4 +1,7 @@ +import type { MessageXFragment } from "@grammyjs/hydrate/out/data/message" import type { Message, User } from "grammy/types" +import type { MaybePromise } from "./types" +import { wait } from "./wait" type TextReturn = M extends { text: string } ? { text: string; type: "TEXT" } @@ -32,3 +35,21 @@ export function createFakeMessage(chatId: number, messageId: number, from: User, }, } } + +/** + * Deletes a sent message after a specified timeout. Useful for sending ephemeral + * messages that should disappear after a while. + * + * Fails silently if the message cannot be deleted (e.g. due to missing permissions), + * so it can be used without awaiting it. + * + * @param message The message to delete or its promise + * @param timeout Timeout in ms, defaults to 5 seconds + * @returns a void promise that resolves after the message is deleted (or if the deletion fails) + */ +export async function ephemeral(message: MaybePromise, timeout = 5000): Promise { + const msg = await Promise.resolve(message) + await wait(timeout) + .then(() => msg.delete()) + .catch(() => {}) +} 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"] 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) +}