From ffbc2c09536b0ce09da0cacfe4f55475385854d0 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sat, 17 Jan 2026 15:54:00 +0100 Subject: [PATCH 01/16] feat: check whitelist --- package.json | 2 +- pnpm-lock.yaml | 10 +- .../auto-moderation-stack/index.ts | 134 ++++++++++++------ 3 files changed, 95 insertions(+), 51 deletions(-) diff --git a/package.json b/package.json index 2e5ab7c..f5cc0c3 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "@grammyjs/menu": "^1.3.1", "@grammyjs/parse-mode": "^1.11.1", "@grammyjs/runner": "^2.0.3", - "@polinetwork/backend": "^0.12.0", + "@polinetwork/backend": "^0.13.1", "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", "@types/ssdeep.js": "^0.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fa61b2..7ac02b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(grammy@1.37.0) '@polinetwork/backend': - specifier: ^0.12.0 - version: 0.12.0 + specifier: ^0.13.1 + version: 0.13.1 '@t3-oss/env-core': specifier: ^0.13.4 version: 0.13.4(arktype@2.1.20)(typescript@5.7.3)(zod@4.1.11) @@ -425,8 +425,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@polinetwork/backend@0.12.0': - resolution: {integrity: sha512-W4SN1/aoth3Uk77su9UJ7hR1z3VVT72amuLQp/rjNBqP7ca8+XJuAMhlYM4awxyh0fKXlDfQEGwYr0y356ESSw==} + '@polinetwork/backend@0.13.1': + resolution: {integrity: sha512-wx7JTxM9GAYFY4ESriJcFq4gT2DwFGW3Lqaa1R++0qI81xpxaB6Da5be2Z5TBDKmKepOD3DFmGaBJfsygZdsjA==} engines: {node: '>=24.8.0', pnpm: '>=10.17.1'} '@redis/bloom@1.2.0': @@ -1785,7 +1785,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@polinetwork/backend@0.12.0': {} + '@polinetwork/backend@0.13.1': {} '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index daf2a78..b29ee7b 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -2,6 +2,7 @@ import type { Filter, MiddlewareObj } from "grammy" import { Composer } from "grammy" import type { Message } from "grammy/types" import ssdeep from "ssdeep.js" +import { api } from "@/backend" import { modules } from "@/modules" import { mute } from "@/modules/moderation" import { redis } from "@/redis" @@ -17,6 +18,14 @@ import { AIModeration } from "./ai-moderation" import { MULTI_CHAT_SPAM, NON_LATIN } from "./constants" import { checkForAllowedLinks } from "./functions" +export type WhitelistType = { + role: "creator" | "admin" | "user" +} + +type ModerationContext = Filter & { + whitelisted?: WhitelistType +} + /** * # Auto-Moderation stack * ## Handles automatic message moderation. @@ -33,14 +42,23 @@ import { checkForAllowedLinks } from "./functions" */ export class AutoModerationStack implements MiddlewareObj { // the composer that holds all middlewares - private composer = new Composer() + private composer = new Composer>() // AI moderation instance private aiModeration: AIModeration = new AIModeration() constructor() { this.composer + .on(["message", "edited_message"]) .fork() // fork the processing, this stack executes in parallel to the rest of the bot - .filter(async (ctx) => !(await this.isWhitelisted(ctx))) // skip if the message is whitelisted + .filter(async (ctx) => { + const whitelistType = await this.isWhitelisted(ctx) + if (whitelistType) { + // creators can skip moderation entirely + if (whitelistType.role === "creator") return false + ctx.whitelisted = whitelistType + } + return true + }) // skip if the message is whitelisted // register all middlewares .on( ["message::url", "message::text_link", "edited_message::url", "edited_message::text_link"], @@ -72,8 +90,18 @@ export class AutoModerationStack implements MiddlewareObj * @returns true if the message is exempt and therefore should be ignored by * the moderation stack, false otherwise */ - private async isWhitelisted(_ctx: C): Promise { - return false + private async isWhitelisted(ctx: ModerationContext): Promise { + const { status } = await ctx.getAuthor() + if (status === "creator") return { role: "creator" } + if (status === "administrator") return { role: "admin" } + + const isAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) + if (isAdmin) return { role: "admin" } + + const grant = await api.tg.grants.checkUser.query({ userId: ctx.from.id }) + if (grant.isGranted) return { role: "user" } + + return null } /** @@ -81,7 +109,10 @@ export class AutoModerationStack implements MiddlewareObj * If a link is not allowed, mutes the user for 1 minute and deletes the message. */ private async linkHandler( - ctx: Filter + ctx: Filter< + ModerationContext, + "message::url" | "message::text_link" | "edited_message::url" | "edited_message::text_link" + > ) { // check both messages sent and edited const message = ctx.message ?? ctx.editedMessage @@ -91,25 +122,30 @@ export class AutoModerationStack implements MiddlewareObj .map((e) => e.url) .concat([getText(message).text]) const allowed = await checkForAllowedLinks(links) + if (!allowed) { - await mute({ - ctx, - from: ctx.me, - target: ctx.from, - reason: "Shared link not allowed", - duration: duration.zod.parse("1m"), // 1 minute - message, - }) - const msg = await ctx.reply( - fmt(({ b }) => [ - b`${fmtUser(ctx.from)}`, - "The link you shared is not allowed.", - "Please refrain from sharing links that could be considered spam", - ]) - ) - await wait(5000) - await msg.delete() - return + if (ctx.whitelisted) { + // log the action but do not mute + } else { + await mute({ + ctx, + from: ctx.me, + target: ctx.from, + reason: "Shared link not allowed", + duration: duration.zod.parse("1m"), // 1 minute + message, + }) + const msg = await ctx.reply( + fmt(({ b }) => [ + b`${fmtUser(ctx.from)}`, + "The link you shared is not allowed.", + "Please refrain from sharing links that could be considered spam", + ]) + ) + await wait(5000) + await msg.delete() + return + } } } @@ -117,7 +153,7 @@ export class AutoModerationStack implements MiddlewareObj * Checks messages for harmful content using AI moderation. * If harmful content is detected, mutes the user and deletes the message. */ - private async harmfulContentHandler(ctx: Filter) { + private async harmfulContentHandler(ctx: Filter, "message">) { const message = ctx.message const flaggedCategories = await this.aiModeration.checkForHarmfulContent(ctx) @@ -125,24 +161,28 @@ export class AutoModerationStack implements MiddlewareObj const reasons = flaggedCategories.map((cat) => ` - ${cat.category} (${(cat.score * 100).toFixed(1)}%)`).join("\n") if (flaggedCategories.some((cat) => cat.aboveThreshold)) { - // above threshold, mute user and delete the message - await mute({ - ctx, - from: ctx.me, - target: ctx.from, - reason: `Automatic moderation detected harmful content\n${reasons}`, - duration: duration.zod.parse("1d"), // 1 day - message, - }) + if (ctx.whitelisted) { + // TODO: check for temporary grant + } else { + // above threshold, mute user and delete the message + await mute({ + ctx, + from: ctx.me, + target: ctx.from, + reason: `Automatic moderation detected harmful content\n${reasons}`, + duration: duration.zod.parse("1d"), // 1 day + message, + }) - const msg = await ctx.reply( - fmt(({ i, b }) => [ - b`⚠️ Message from ${fmtUser(ctx.from)} was deleted automatically due to harmful content.`, - i`If you think this is a mistake, please contact the group administrators.`, - ]) - ) - await wait(5000) - await msg.delete() + const msg = await ctx.reply( + fmt(({ i, b }) => [ + b`⚠️ Message from ${fmtUser(ctx.from)} was deleted automatically due to harmful content.`, + i`If you think this is a mistake, please contact the group administrators.`, + ]) + ) + await wait(5000) + await msg.delete() + } } else { // no flagged category is above the threshold, still log it for manual review await modules.get("tgLogger").moderationAction({ @@ -161,7 +201,7 @@ export class AutoModerationStack implements MiddlewareObj * Handles messages containing a high percentage of non-latin characters to avoid most spam bots. * If the percentage of non-latin characters is too high, mutes the user for 10 minutes and deletes the message. */ - private async nonLatinHandler(ctx: Filter) { + private async nonLatinHandler(ctx: Filter, "message:text" | "message:caption">) { const text = ctx.message.caption ?? ctx.message.text const match = text.match(NON_LATIN.REGEX) @@ -187,7 +227,7 @@ export class AutoModerationStack implements MiddlewareObj /** * Handles messages sent to multiple chats with similar content. */ - private async multichatSpamHandler(ctx: Filter) { + private async multichatSpamHandler(ctx: Filter, "message:text" | "message:media">) { if (ctx.from.is_bot) return const { text } = getText(ctx.message) if (text === null) return @@ -203,7 +243,11 @@ export class AutoModerationStack implements MiddlewareObj const similarMessages: Message[] = await Promise.all( range .map((r) => r.split("|")) - .map(([hash, chatId, messageId]) => ({ hash, chatId: Number(chatId), messageId: Number(messageId) })) + .map(([hash, chatId, messageId]) => ({ + hash, + chatId: Number(chatId), + messageId: Number(messageId), + })) .filter((v) => ssdeep.similarity(v.hash, hash) > MULTI_CHAT_SPAM.SIMILARITY_THR) .map(async (v) => { const msg = await MessageUserStorage.getInstance().get(v.chatId, v.messageId) @@ -239,6 +283,6 @@ export class AutoModerationStack implements MiddlewareObj } middleware() { - return this.composer.middleware() + return (this.composer as MiddlewareObj).middleware() } } From c9184224ca4c88b5039927abba137eb8c090f490 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sun, 18 Jan 2026 18:54:05 +0100 Subject: [PATCH 02/16] feat: grants logs --- src/modules/index.ts | 1 + src/modules/tg-logger/grants.ts | 111 ++++++++++++++++++++++++++++++++ src/modules/tg-logger/index.ts | 58 +++++++++++++++-- src/modules/tg-logger/types.ts | 22 +++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/modules/tg-logger/grants.ts diff --git a/src/modules/index.ts b/src/modules/index.ts index ab24a97..ce77656 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -17,6 +17,7 @@ export const modules = new ModuleCoordinator( actionRequired: 10, groupManagement: 33, deletedMessages: 130, + grants: 402, }), webSocket: new WebSocketClient(), banAll: new BanAllQueue(), diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts new file mode 100644 index 0000000..66a793f --- /dev/null +++ b/src/modules/tg-logger/grants.ts @@ -0,0 +1,111 @@ +import type { Context } from "grammy" +import type { Message, User } from "grammy/types" +import { type ApiOutput, api } from "@/backend" +import { type CallbackCtx, MenuGenerator } from "@/lib/menu" +import { modules } from ".." + +type GrantedMessage = { + message: Message + chatId: number + target: User + deleted: boolean + interrupted: boolean +} + +async function handleInterrupt(ctx: CallbackCtx, target: User) { + const res = await api.tg.grants.interrupt.mutate({ interruptedById: ctx.from.id, userId: target.id }) + if (!res.success) { + return { error: res.error } + } + + await modules.get("tgLogger").grant({ action: "INTERRUPT", by: ctx.from, target: target }) + return { error: null } +} + +type Error = ApiOutput["tg"]["grants"]["interrupt"]["error"] | "CANNOT_DELETE" | null +const getFeedback = (error: Error): string | null => { + switch (error) { + case null: + return null + case "NOT_FOUND": + return "☑️ Grant already expired or interrupted" + case "UNAUTHORIZED": + return "❌ You don't have enough permissions" + case "INTERNAL_SERVER_ERROR": + return "⁉️ Backend error, please check logs" + case "CANNOT_DELETE": + return "⁉️ Cannot delete, maybe message already deleted" + } +} + +async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Promise<{ error: Error }> { + const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) + if (!roles?.includes("direttivo")) return { error: "UNAUTHORIZED" } + + const res = await modules + .get("tgLogger") + .delete([data.message], "[GRANT] Manual deleted message sent by granted user", ctx.from) + + if (!res?.count) { + return { + error: "CANNOT_DELETE", + } + } + + return { error: null } +} + +/** + * Interactive menu for interacting with granted message. + * + * @param data - {link @GrantedMessage} grant info + */ +export const grantMessageMenu = MenuGenerator.getInstance().create("grants-message", [ + [ + { + text: "🗑", + cb: async ({ ctx, data }) => { + if (data.deleted) return { feedback: "☑️ Message already deleted" } + const { error } = await handleDelete(ctx, data) + if (!error && data.interrupted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + return { + feedback: getFeedback(error) ?? "✅ Message deleted", + newData: !error ? { ...data, deleted: true } : undefined, + } + }, + }, + { + text: "🛑", + cb: async ({ ctx, data }) => { + if (data.interrupted) return { feedback: "☑️ Grant already interrupted" } + const { error } = await handleInterrupt(ctx, data.target) + if (!error && data.deleted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + return { + feedback: getFeedback(error) ?? "✅ Grant Interrupted", + newData: !error ? { ...data, interrupted: true } : undefined, + } + }, + }, + ], +]) + +/** + * Interactive menu for interacting with newly created grant. + * + * @param data - {link @GrantedMessage} grant info + */ +export const grantCreatedMenu = MenuGenerator.getInstance().create("grants-create", [ + [ + { + text: "🛑 Interrupt", + cb: async ({ ctx, data }) => { + const { error } = await handleInterrupt(ctx, data) + if (!error || error === "NOT_FOUND") + await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + return { + feedback: getFeedback(error) ?? "✅ Grant Interrupted", + } + }, + }, + ], +]) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 855cf2e..cf69728 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -4,9 +4,10 @@ import { api } from "@/backend" import { Module } from "@/lib/modules" import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" -import { fmt, fmtChat, fmtUser } from "@/utils/format" +import { fmt, fmtChat, fmtDate, fmtUser } from "@/utils/format" import type { ModuleShared } from "@/utils/types" import { type BanAll, banAllMenu, getBanAllText } from "./ban-all" +import { grantCreatedMenu, grantMessageMenu } from "./grants" import { getReportText, type Report, reportMenu } from "./report" import type * as Types from "./types" @@ -18,6 +19,7 @@ type Topics = { adminActions: number exceptions: number groupManagement: number + grants: number } export class TgLogger extends Module { @@ -320,11 +322,10 @@ export class TgLogger extends Module { case "LEAVE_FAIL": msg = fmt( - ({ b, n, i }) => [ - b`‼ Cannot Left`, + ({ b, n }) => [ + b`‼ Leave failed`, n`${b`Group:`} ${fmtChat(props.chat)}`, n`${b`Added by:`} ${fmtUser(props.addedBy)}`, - n`${i`This user does not have enough permissions to add the bot`}`, ], { sep: "\n", @@ -366,6 +367,55 @@ export class TgLogger extends Module { return msg } + public async grant(props: Types.GrantLog): Promise { + let msg: string + switch (props.action) { + case "USAGE": { + const { invite_link } = await this.shared.api.getChat(props.chat.id) + msg = fmt(({ n, b }) => [ + b`💬 Spam-message detected`, + n`${b`From:`} ${fmtUser(props.from)}`, + n`${b`Chat:`} ${fmtChat(props.chat, invite_link)}`, + ]) + const usageMenu = await grantMessageMenu({ + target: props.from, + interrupted: false, + deleted: false, + chatId: props.chat.id, + message: props.message, + }) + await this.log(this.topics.grants, msg, { reply_markup: usageMenu, disable_notification: false }) + await this.forward(this.topics.grants, props.chat.id, [props.message.message_id]) + return msg + } + + case "CREATE": { + msg = fmt(({ n, b }) => [ + b`✳ New Grant`, + n`${b`Target:`} ${fmtUser(props.target)}`, + n`${b`By:`} ${fmtUser(props.by)}`, + props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, + n`\n${b`Valid since:`} ${fmtDate(props.since)}`, + n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})`, + ]) + + const createMenu = await grantCreatedMenu(props.target) + await this.log(this.topics.grants, msg, { reply_markup: createMenu, disable_notification: false }) + return msg + } + + case "INTERRUPT": + msg = fmt(({ n, b }) => [ + b`🛑 Grant Interruption`, + n`${b`Target:`} ${fmtUser(props.target)}`, + n`${b`By:`} ${fmtUser(props.by)}`, + ]) + + await this.log(this.topics.grants, msg, { reply_markup: undefined, disable_notification: false }) + return msg + } + } + public async exception(props: Types.ExceptionLog, context?: string): Promise { const contextFmt = context ? fmt(({ n, b }) => n`\n${b`Context:`} ${context}`) : undefined let msg: string = "" diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index a85e502..3ce20fc 100644 --- a/src/modules/tg-logger/types.ts +++ b/src/modules/tg-logger/types.ts @@ -91,3 +91,25 @@ export type DeleteResult = { count: number link: string } + +export type GrantLog = {} & ( + | { + action: "USAGE" + from: User + message: Message + chat: Chat + } + | { + action: "CREATE" + target: User + by: User + since: Date + duration: Duration + reason?: string + } + | { + action: "INTERRUPT" + target: User + by: User + } +) From 868fb0277cab803434d37d1e5d94988f13841fd3 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sun, 18 Jan 2026 23:22:58 +0100 Subject: [PATCH 03/16] chore: fix docstring for linking param type --- src/modules/tg-logger/grants.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts index 66a793f..aa7b8c1 100644 --- a/src/modules/tg-logger/grants.ts +++ b/src/modules/tg-logger/grants.ts @@ -58,7 +58,7 @@ async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Pr /** * Interactive menu for interacting with granted message. * - * @param data - {link @GrantedMessage} grant info + * @param data - {@link GrantedMessage} grant info */ export const grantMessageMenu = MenuGenerator.getInstance().create("grants-message", [ [ @@ -92,7 +92,7 @@ export const grantMessageMenu = MenuGenerator.getInstance().create().create("grants-create", [ [ From eac321c48e87c77bc04d7434f1d63b099144d708 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Sun, 18 Jan 2026 23:34:27 +0100 Subject: [PATCH 04/16] feat: /grant command --- package.json | 2 +- pnpm-lock.yaml | 74 ++++++------ src/commands/banall.ts | 10 +- src/commands/grants.ts | 252 +++++++++++++++++++++++++++++++++++++++++ src/commands/role.ts | 8 +- src/utils/types.ts | 7 ++ 6 files changed, 300 insertions(+), 53 deletions(-) create mode 100644 src/commands/grants.ts diff --git a/package.json b/package.json index f5cc0c3..b47557a 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "author": "", "license": "ISC", "devDependencies": { - "@biomejs/biome": "2.2.4", + "@biomejs/biome": "2.3.11", "@trpc/server": "11.5.1", "@types/node": "^22.13.1", "globals": "^15.14.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ac02b7..2c37046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,8 +79,8 @@ importers: version: 4.1.11 devDependencies: '@biomejs/biome': - specifier: 2.2.4 - version: 2.2.4 + specifier: 2.3.11 + version: 2.3.11 '@trpc/server': specifier: 11.5.1 version: 11.5.1(typescript@5.7.3) @@ -117,55 +117,55 @@ packages: '@ark/util@0.46.0': resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} - '@biomejs/biome@2.2.4': - resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} + '@biomejs/biome@2.3.11': + resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.2.4': - resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} + '@biomejs/cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.2.4': - resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} + '@biomejs/cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.2.4': - resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} + '@biomejs/cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.2.4': - resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} + '@biomejs/cli-linux-arm64@2.3.11': + resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.2.4': - resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} + '@biomejs/cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.2.4': - resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} + '@biomejs/cli-linux-x64@2.3.11': + resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.2.4': - resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} + '@biomejs/cli-win32-arm64@2.3.11': + resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.2.4': - resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} + '@biomejs/cli-win32-x64@2.3.11': + resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -1586,39 +1586,39 @@ snapshots: '@ark/util@0.46.0': {} - '@biomejs/biome@2.2.4': + '@biomejs/biome@2.3.11': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.2.4 - '@biomejs/cli-darwin-x64': 2.2.4 - '@biomejs/cli-linux-arm64': 2.2.4 - '@biomejs/cli-linux-arm64-musl': 2.2.4 - '@biomejs/cli-linux-x64': 2.2.4 - '@biomejs/cli-linux-x64-musl': 2.2.4 - '@biomejs/cli-win32-arm64': 2.2.4 - '@biomejs/cli-win32-x64': 2.2.4 + '@biomejs/cli-darwin-arm64': 2.3.11 + '@biomejs/cli-darwin-x64': 2.3.11 + '@biomejs/cli-linux-arm64': 2.3.11 + '@biomejs/cli-linux-arm64-musl': 2.3.11 + '@biomejs/cli-linux-x64': 2.3.11 + '@biomejs/cli-linux-x64-musl': 2.3.11 + '@biomejs/cli-win32-arm64': 2.3.11 + '@biomejs/cli-win32-x64': 2.3.11 - '@biomejs/cli-darwin-arm64@2.2.4': + '@biomejs/cli-darwin-arm64@2.3.11': optional: true - '@biomejs/cli-darwin-x64@2.2.4': + '@biomejs/cli-darwin-x64@2.3.11': optional: true - '@biomejs/cli-linux-arm64-musl@2.2.4': + '@biomejs/cli-linux-arm64-musl@2.3.11': optional: true - '@biomejs/cli-linux-arm64@2.2.4': + '@biomejs/cli-linux-arm64@2.3.11': optional: true - '@biomejs/cli-linux-x64-musl@2.2.4': + '@biomejs/cli-linux-x64-musl@2.3.11': optional: true - '@biomejs/cli-linux-x64@2.2.4': + '@biomejs/cli-linux-x64@2.3.11': optional: true - '@biomejs/cli-win32-arm64@2.2.4': + '@biomejs/cli-win32-arm64@2.3.11': optional: true - '@biomejs/cli-win32-x64@2.2.4': + '@biomejs/cli-win32-x64@2.3.11': optional: true '@cspotcode/source-map-support@0.8.1': diff --git a/src/commands/banall.ts b/src/commands/banall.ts index 9423360..286caea 100644 --- a/src/commands/banall.ts +++ b/src/commands/banall.ts @@ -3,15 +3,9 @@ import z from "zod" import { api } from "@/backend" import { modules } from "@/modules" import { getTelegramId } from "@/utils/telegram-id" -import type { Role } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { _commandsBase } from "./_base" -const numberOrString = z.string().transform((s) => { - const n = Number(s) - if (!Number.isNaN(n) && s.trim() !== "") return n - return s -}) - const BYPASS_ROLES: Role[] = ["president", "owner", "direttivo"] _commandsBase @@ -26,7 +20,7 @@ _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 ban from all groups", }, { key: "reason", diff --git a/src/commands/grants.ts b/src/commands/grants.ts new file mode 100644 index 0000000..6657bcc --- /dev/null +++ b/src/commands/grants.ts @@ -0,0 +1,252 @@ +import type { User } from "grammy/types" +import z from "zod" +import { api } from "@/backend" +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", + timeStyle: "short", +}) + +const askStart = (user: User, reason?: string) => + fmt( + ({ n, b }) => [ + b`🔐 Grant Special Permissions`, + n`${b`Target:`} ${fmtUser(user)}`, + reason ? n`${b`Reason:`} ${reason}\n` : "", + b`When should the special grant start?`, + `(Default: now)`, + ], + { sep: "\n" } + ) + +const askDuration = (user: User, startTime: string, reason?: string) => + fmt( + ({ n, b }) => [ + b`🔐 Grant Special Permissions`, + n`${b`Target:`} ${fmtUser(user)}`, + reason ? n`${b`Reason:`} ${reason}\n` : "", + n`${b`Start Time:`} ${startTime}`, + b`\nHow long should the special grant last?`, + `(${duration.formatDesc} - Default: 2 hours)`, + ], + { sep: "\n" } + ) + +const askConfirm = (user: User, startTime: string, endTime: string, duration: string, reason?: string) => + fmt( + ({ n, b }) => [ + b`🔐 Grant Special Permissions`, + n`${b`Target:`} ${fmtUser(user)}`, + reason ? n`${b`Reason:`} ${reason}\n` : "", + n`${b`Start Time:`} ${startTime}`, + n`${b`End Time:`} ${endTime} (${duration})`, + b`\nConfirm granting special permissions to this user?`, + ], + { sep: "\n" } + ) + +const doneMsg = (user: User, startTime: string, endTime: string, duration: string, reason?: string) => + fmt( + ({ n, b }) => [ + b`🔐 Grant Special Permissions`, + b`✅ Special Permissions Granted`, + n`${b`Target:`} ${fmtUser(user)}`, + reason ? n`${b`Reason:`} ${reason}\n` : "", + n`${b`Start Time:`} ${startTime}`, + n`${b`End Time:`} ${endTime} (${duration})`, + ], + { sep: "\n" } + ) + +const cancelMsg = (user: User) => + fmt(({ n, b }) => [b`🔐 Grant Special Permissions`, b`❌ Grant Cancelled`, n`${b`Target:`} ${fmtUser(user)}`], { + sep: "\n", + }) + +type GrantConversationState = "askStart" | "askDuration" | "askConfirm" | "done" +function previousState(current: GrantConversationState) { + if (current === "askConfirm") return "askDuration" + if (current === "askDuration") return "askStart" + return current +} +function nextState(current: GrantConversationState) { + if (current === "askStart") return "askDuration" + if (current === "askDuration") return "askConfirm" + return current +} + +_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 }) => { + let state: GrantConversationState = "askStart" + 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("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("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 startDate = new Date(await conversation.now()) + let grantDuration = duration.zod.parse("2h") + + const messageString = () => { + switch (state) { + case "askStart": + return askStart(target, args.reason) + case "askDuration": + return askDuration(target, dateFormat.format(startDate), args.reason) + case "askConfirm": { + const endDate = new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) + return askConfirm( + target, + dateFormat.format(startDate), + dateFormat.format(endDate), + grantDuration.raw, + args.reason + ) + } + case "done": { + const endDate = new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) + return doneMsg( + target, + dateFormat.format(startDate), + dateFormat.format(endDate), + grantDuration.raw, + args.reason + ) + } + } + } + + function menuForState(s: GrantConversationState) { + if (s === "askStart") return firstMenu + if (s === "askConfirm") return confirmMenu + if (s === "done") return undefined + return menu + } + + async function updateToNewState(ctx: typeof context, newState: GrantConversationState) { + state = newState + await ctx.editMessageText(messageString(), { reply_markup: menuForState(state) }) + await conversation.rewind(checkpoint) + } + + async function cancel(ctx: typeof context) { + await ctx.editMessageText(cancelMsg(target)) + } + + const menu = conversation + .menu() + .text("◀️ Prev", (ctx) => updateToNewState(ctx, previousState(state))) + .text("Next ▶️", (ctx) => updateToNewState(ctx, nextState(state))) + .row() + .text("Cancel", (ctx) => cancel(ctx)) + + const firstMenu = conversation + .menu() + .text("Next ▶️", (ctx) => updateToNewState(ctx, "askDuration")) + .row() + .text("Cancel", (ctx) => cancel(ctx)) + + const confirmMenu = conversation + .menu() + .text("Confirm ✅", (ctx) => updateToNewState(ctx, "done")) + .row() + .text("◀️ Prev", (ctx) => updateToNewState(ctx, "askDuration")) + .row() + .text("Cancel", (ctx) => cancel(ctx)) + + await context.reply(askStart(target, args.reason), { reply_markup: firstMenu }) + + const checkpoint = conversation.checkpoint() + + void conversation + .waitUntil(() => state === "askStart") + .andFor("message:text") + .then(async (ctx) => { + await ctx.deleteMessage() + const response = ctx.message.text + const parsedDate = Date.parse(response ?? "") + if (!Number.isNaN(parsedDate)) { + startDate.setTime(parsedDate) + await updateToNewState(context, "askDuration") + } else { + void context + .reply("Invalid date format, please try again. (e.g. 2024-12-31 14:00)") + .then((m) => wait(10_000).then(() => m.delete())) + await conversation.rewind(checkpoint) + } + }) + + void conversation + .waitUntil(() => state === "askDuration") + .andFor("message:text") + .then(async (ctx) => { + await ctx.deleteMessage() + const response = ctx.message.text + const parsedDuration = duration.zod.safeParse(response ?? "") + if (parsedDuration.success) { + grantDuration = parsedDuration.data + await updateToNewState(context, "askConfirm") + } else { + void context + .reply(`Invalid duration format, please try again. ${duration.formatDesc}`) + .then((m) => wait(10_000).then(() => m.delete())) + await conversation.rewind(checkpoint) + } + }) + + await conversation.waitUntil(() => state === "done") + + // do the thing + const grantEndDate = new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) + await api.tg.grants.create.mutate({ + userId: target.id, + adderId: context.from.id, + reason: args.reason, + since: startDate, + until: grantEndDate, + }) + }, +}) diff --git a/src/commands/role.ts b/src/commands/role.ts index e955341..6cfe399 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -2,15 +2,9 @@ import { z } from "zod" import { api } from "@/backend" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import type { Role } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { _commandsBase } from "./_base" -const numberOrString = z.string().transform((s) => { - const n = Number(s) - if (!Number.isNaN(n) && s.trim() !== "") return n - return s -}) - _commandsBase .createCommand({ trigger: "getroles", diff --git a/src/utils/types.ts b/src/utils/types.ts index 0cc4327..0703c63 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,5 +1,6 @@ import type { Api, Context as TContext } from "grammy" import type { UserFromGetMe } from "grammy/types" +import z from "zod" import type { ApiInput } from "@/backend" import type { ManagedCommandsFlavor } from "@/lib/managed-commands" @@ -22,3 +23,9 @@ export type ModuleShared = { api: Api botInfo: UserFromGetMe } + +export const numberOrString = z.string().transform((s) => { + const n = Number(s) + if (!Number.isNaN(n) && s.trim() !== "") return n + return s +}) From 2101fd860a0e115a2e158c2af762bd835c1377e3 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Tue, 20 Jan 2026 00:33:48 +0100 Subject: [PATCH 05/16] feat: almost everything done for grants --- src/commands/grants.ts | 410 +++++++++--------- src/commands/index.ts | 1 + .../auto-moderation-stack/index.ts | 67 +-- src/modules/tg-logger/grants.ts | 16 +- src/modules/tg-logger/index.ts | 22 +- 5 files changed, 278 insertions(+), 238 deletions(-) diff --git a/src/commands/grants.ts b/src/commands/grants.ts index 6657bcc..0f77fc4 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -1,4 +1,4 @@ -import type { User } from "grammy/types" +import type { Message, User } from "grammy/types" import z from "zod" import { api } from "@/backend" import { duration } from "@/utils/duration" @@ -7,79 +7,49 @@ import { getTelegramId } from "@/utils/telegram-id" import { numberOrString } from "@/utils/types" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" +import { modules } from "@/modules" +import { logger } from "@/logger" +import { ConversationMenuContext, conversations } from "@grammyjs/conversations" +import { ConversationContext } from "@/lib/managed-commands" const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", +}) + +const timeFormat = new Intl.DateTimeFormat(undefined, { timeStyle: "short", + hour12: false }) -const askStart = (user: User, reason?: string) => - fmt( - ({ n, b }) => [ - b`🔐 Grant Special Permissions`, - n`${b`Target:`} ${fmtUser(user)}`, - reason ? n`${b`Reason:`} ${reason}\n` : "", - b`When should the special grant start?`, - `(Default: now)`, - ], - { sep: "\n" } - ) +const datetimeFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + hour12: false +}) -const askDuration = (user: User, startTime: string, reason?: string) => - fmt( - ({ n, b }) => [ - b`🔐 Grant Special Permissions`, - n`${b`Target:`} ${fmtUser(user)}`, - reason ? n`${b`Reason:`} ${reason}\n` : "", - n`${b`Start Time:`} ${startTime}`, - b`\nHow long should the special grant last?`, - `(${duration.formatDesc} - Default: 2 hours)`, - ], - { sep: "\n" } - ) +const getDateWithDelta = (date: Date, deltaDay: number) => { + const newDate = new Date(date.getTime()) + newDate.setDate(newDate.getDate() + deltaDay) + return newDate +} -const askConfirm = (user: User, startTime: string, endTime: string, duration: string, reason?: string) => +const mainMsg = (user: User, startTime: Date, endTime: Date, duration: string, reason?: string) => fmt( - ({ n, b }) => [ + ({ n, b, u }) => [ b`🔐 Grant Special Permissions`, n`${b`Target:`} ${fmtUser(user)}`, - reason ? n`${b`Reason:`} ${reason}\n` : "", - n`${b`Start Time:`} ${startTime}`, - n`${b`End Time:`} ${endTime} (${duration})`, - b`\nConfirm granting special permissions to this user?`, - ], - { sep: "\n" } - ) + 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, -const doneMsg = (user: User, startTime: string, endTime: string, duration: string, reason?: string) => - fmt( - ({ n, b }) => [ - b`🔐 Grant Special Permissions`, - b`✅ Special Permissions Granted`, - n`${b`Target:`} ${fmtUser(user)}`, - reason ? n`${b`Reason:`} ${reason}\n` : "", - n`${b`Start Time:`} ${startTime}`, - n`${b`End Time:`} ${endTime} (${duration})`, ], { sep: "\n" } ) -const cancelMsg = (user: User) => - fmt(({ n, b }) => [b`🔐 Grant Special Permissions`, b`❌ Grant Cancelled`, n`${b`Target:`} ${fmtUser(user)}`], { - sep: "\n", - }) - -type GrantConversationState = "askStart" | "askDuration" | "askConfirm" | "done" -function previousState(current: GrantConversationState) { - if (current === "askConfirm") return "askDuration" - if (current === "askDuration") return "askStart" - return current -} -function nextState(current: GrantConversationState) { - if (current === "askStart") return "askDuration" - if (current === "askDuration") return "askConfirm" - return current -} +const askDurationMsg = fmt(({ n, b }) => [b`How long should the special grant last?`, n`${duration.formatDesc}`], { + sep: "\n", +}) _commandsBase.createCommand({ trigger: "grant", @@ -102,151 +72,201 @@ _commandsBase.createCommand({ }, ], handler: async ({ args, context, conversation }) => { - let state: GrantConversationState = "askStart" - 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("Not a valid userId or username not in our cache") - return - } + try { + 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("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 startDate = new Date(await conversation.now()) - let grantDuration = duration.zod.parse("2h") - - const messageString = () => { - switch (state) { - case "askStart": - return askStart(target, args.reason) - case "askDuration": - return askDuration(target, dateFormat.format(startDate), args.reason) - case "askConfirm": { - const endDate = new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) - return askConfirm( - target, - dateFormat.format(startDate), - dateFormat.format(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, + } + + 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.update() + ctx.menu.nav("grants-main") + } + + const backToMain = conversation.menu("grants-back-to-main", { parent: "grants-main" }).back("◀️ Back", (ctx) => + ctx.editMessageText( + fmt(({ skip }) => [skip`${baseMsg()}`], { sep: "\n" }), + { 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 } ) - } - case "done": { - const endDate = new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) - return doneMsg( - target, - dateFormat.format(startDate), - dateFormat.format(endDate), - grantDuration.raw, - args.reason + 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") + + const _startTimeMenu = conversation + .menu("grants-start-time", { parent: "grants-main" }) + .text(() => `Now: ${timeFormat.format(new Date())}`, (ctx) => changeStartTime(ctx, new Date().getHours(), new Date().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) => { + await api.tg.grants.create.mutate({ + userId: target.id, + adderId: context.from.id, + reason: args.reason, + since: startDate, + until: endDate(), + }) + + await ctx.editMessageText( + fmt(({ b, skip }) => [skip`${baseMsg()}`, b`✅ Special Permissions Granted`], { sep: "\n\n" }) ) - } - } - } - function menuForState(s: GrantConversationState) { - if (s === "askStart") return firstMenu - if (s === "askConfirm") return confirmMenu - if (s === "done") return undefined - return menu - } + await modules.get("tgLogger").grants({ + action: "CREATE", + target, + by: context.from, + since: startDate, + reason: args.reason, + duration: grantDuration, + }) - async function updateToNewState(ctx: typeof context, newState: GrantConversationState) { - state = newState - await ctx.editMessageText(messageString(), { reply_markup: menuForState(state) }) - await conversation.rewind(checkpoint) - } + ctx.menu.close() + 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 conversation.halt() + await wait(3000) + await ctx.deleteMessage() + }) - async function cancel(ctx: typeof context) { - await ctx.editMessageText(cancelMsg(target)) + 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() + await conversation.halt() } - - const menu = conversation - .menu() - .text("◀️ Prev", (ctx) => updateToNewState(ctx, previousState(state))) - .text("Next ▶️", (ctx) => updateToNewState(ctx, nextState(state))) - .row() - .text("Cancel", (ctx) => cancel(ctx)) - - const firstMenu = conversation - .menu() - .text("Next ▶️", (ctx) => updateToNewState(ctx, "askDuration")) - .row() - .text("Cancel", (ctx) => cancel(ctx)) - - const confirmMenu = conversation - .menu() - .text("Confirm ✅", (ctx) => updateToNewState(ctx, "done")) - .row() - .text("◀️ Prev", (ctx) => updateToNewState(ctx, "askDuration")) - .row() - .text("Cancel", (ctx) => cancel(ctx)) - - await context.reply(askStart(target, args.reason), { reply_markup: firstMenu }) - - const checkpoint = conversation.checkpoint() - - void conversation - .waitUntil(() => state === "askStart") - .andFor("message:text") - .then(async (ctx) => { - await ctx.deleteMessage() - const response = ctx.message.text - const parsedDate = Date.parse(response ?? "") - if (!Number.isNaN(parsedDate)) { - startDate.setTime(parsedDate) - await updateToNewState(context, "askDuration") - } else { - void context - .reply("Invalid date format, please try again. (e.g. 2024-12-31 14:00)") - .then((m) => wait(10_000).then(() => m.delete())) - await conversation.rewind(checkpoint) - } - }) - - void conversation - .waitUntil(() => state === "askDuration") - .andFor("message:text") - .then(async (ctx) => { - await ctx.deleteMessage() - const response = ctx.message.text - const parsedDuration = duration.zod.safeParse(response ?? "") - if (parsedDuration.success) { - grantDuration = parsedDuration.data - await updateToNewState(context, "askConfirm") - } else { - void context - .reply(`Invalid duration format, please try again. ${duration.formatDesc}`) - .then((m) => wait(10_000).then(() => m.delete())) - await conversation.rewind(checkpoint) - } - }) - - await conversation.waitUntil(() => state === "done") - - // do the thing - const grantEndDate = new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) - await api.tg.grants.create.mutate({ - userId: target.id, - adderId: context.from.id, - reason: args.reason, - since: startDate, - until: grantEndDate, - }) }, }) diff --git a/src/commands/index.ts b/src/commands/index.ts index 602da23..e5bd179 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -10,5 +10,6 @@ import "./role" import "./userid" import "./link-admin-dashboard" import "./report" +import "./grants" export { _commandsBase as commands } from "./_base" diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index b29ee7b..65b5c34 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -82,8 +82,8 @@ export class AutoModerationStack implements MiddlewareObj * Checks if the message should be ignored by the moderation stack. * * TODO: implement a proper whitelist system - * - [ ] check if the user is privileged (admin, mod, etc) - * - [ ] check if the message is explicitly allowed by Direttivo (e.g. via a command) + * - [x] check if the user is privileged (admin, mod, etc) + * - [x] check if the message is explicitly allowed by Direttivo (e.g. via a command) * - [ ] check if the chat allows specific types of content (?) * * @param ctx The context of the message @@ -114,39 +114,48 @@ export class AutoModerationStack implements MiddlewareObj "message::url" | "message::text_link" | "edited_message::url" | "edited_message::text_link" > ) { - // check both messages sent and edited - const message = ctx.message ?? ctx.editedMessage + const message = ctx.msg // extract all links from the message, might be inside entities, or inside the message text body const links = ctx .entities("text_link") .map((e) => e.url) .concat([getText(message).text]) + const allowed = await checkForAllowedLinks(links) + if (allowed) return - if (!allowed) { - if (ctx.whitelisted) { - // log the action but do not mute - } else { - await mute({ - ctx, - from: ctx.me, - target: ctx.from, - reason: "Shared link not allowed", - duration: duration.zod.parse("1m"), // 1 minute + if (ctx.whitelisted) { + // no mod action + if (ctx.whitelisted.role === "user") { + // log the grant usage + await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, message, }) - const msg = await ctx.reply( - fmt(({ b }) => [ - b`${fmtUser(ctx.from)}`, - "The link you shared is not allowed.", - "Please refrain from sharing links that could be considered spam", - ]) - ) - await wait(5000) - await msg.delete() - return } + return } + + await mute({ + ctx, + from: ctx.me, + target: ctx.from, + reason: "Shared link not allowed", + duration: duration.zod.parse("1m"), // 1 minute + message, + }) + const msg = await ctx.reply( + fmt(({ b }) => [ + b`${fmtUser(ctx.from)}`, + "The link you shared is not allowed.", + "Please refrain from sharing links that could be considered spam", + ]) + ) + await wait(5000) + await msg.delete() + return } /** @@ -162,7 +171,13 @@ export class AutoModerationStack implements MiddlewareObj if (flaggedCategories.some((cat) => cat.aboveThreshold)) { if (ctx.whitelisted) { - // TODO: check for temporary grant + // log the action but do not mute + if (ctx.whitelisted.role === "user") await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, + message, + }) } else { // above threshold, mute user and delete the message await mute({ @@ -228,7 +243,7 @@ export class AutoModerationStack implements MiddlewareObj * Handles messages sent to multiple chats with similar content. */ private async multichatSpamHandler(ctx: Filter, "message:text" | "message:media">) { - if (ctx.from.is_bot) return + if (ctx.from.is_bot || ctx.whitelisted) return const { text } = getText(ctx.message) if (text === null) return if (text.length < MULTI_CHAT_SPAM.LENGTH_THR) return // skip because too short diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts index aa7b8c1..a6d4408 100644 --- a/src/modules/tg-logger/grants.ts +++ b/src/modules/tg-logger/grants.ts @@ -3,6 +3,7 @@ import type { Message, User } from "grammy/types" import { type ApiOutput, api } from "@/backend" import { type CallbackCtx, MenuGenerator } from "@/lib/menu" import { modules } from ".." +import { logger } from "@/logger" type GrantedMessage = { message: Message @@ -14,11 +15,12 @@ type GrantedMessage = { async function handleInterrupt(ctx: CallbackCtx, target: User) { const res = await api.tg.grants.interrupt.mutate({ interruptedById: ctx.from.id, userId: target.id }) + logger.debug({ res }, "handleInterrupt function in grants menu") if (!res.success) { return { error: res.error } } - await modules.get("tgLogger").grant({ action: "INTERRUPT", by: ctx.from, target: target }) + await modules.get("tgLogger").grants({ action: "INTERRUPT", by: ctx.from, target: target }) return { error: null } } @@ -44,7 +46,7 @@ async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Pr const res = await modules .get("tgLogger") - .delete([data.message], "[GRANT] Manual deleted message sent by granted user", ctx.from) + .delete([data.message], "[GRANT] Manual deletion of message sent by granted user", ctx.from) if (!res?.count) { return { @@ -67,7 +69,7 @@ export const grantMessageMenu = MenuGenerator.getInstance().create { if (data.deleted) return { feedback: "☑️ Message already deleted" } const { error } = await handleDelete(ctx, data) - if (!error && data.interrupted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + if (!error && data.interrupted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => { }) return { feedback: getFeedback(error) ?? "✅ Message deleted", newData: !error ? { ...data, deleted: true } : undefined, @@ -79,10 +81,11 @@ export const grantMessageMenu = MenuGenerator.getInstance().create { if (data.interrupted) return { feedback: "☑️ Grant already interrupted" } const { error } = await handleInterrupt(ctx, data.target) - if (!error && data.deleted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + const noError = !error || error === "NOT_FOUND" + if (noError && data.deleted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => { }) return { feedback: getFeedback(error) ?? "✅ Grant Interrupted", - newData: !error ? { ...data, interrupted: true } : undefined, + newData: noError ? { ...data, interrupted: true } : undefined, } }, }, @@ -100,8 +103,9 @@ export const grantCreatedMenu = MenuGenerator.getInstance().create { const { error } = await handleInterrupt(ctx, data) + logger.info({ error }, "handleInterrupt error output in created menu") if (!error || error === "NOT_FOUND") - await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => { }) return { feedback: getFeedback(error) ?? "✅ Grant Interrupted", } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index cf69728..1cfbbda 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -163,13 +163,13 @@ export class TgLogger extends Module { const voters = direttivo.members.map((m) => ({ user: m.user ? { - id: m.userId, - first_name: m.user.firstName, - last_name: m.user.lastName, - username: m.user.username, - is_bot: m.user.isBot, - language_code: m.user.langCode, - } + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } : { id: m.userId }, isPresident: m.isPresident, vote: undefined, @@ -367,7 +367,7 @@ export class TgLogger extends Module { return msg } - public async grant(props: Types.GrantLog): Promise { + public async grants(props: Types.GrantLog): Promise { let msg: string switch (props.action) { case "USAGE": { @@ -376,7 +376,7 @@ export class TgLogger extends Module { b`💬 Spam-message detected`, n`${b`From:`} ${fmtUser(props.from)}`, n`${b`Chat:`} ${fmtChat(props.chat, invite_link)}`, - ]) + ], { sep: "\n" }) const usageMenu = await grantMessageMenu({ target: props.from, interrupted: false, @@ -397,7 +397,7 @@ export class TgLogger extends Module { props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, n`\n${b`Valid since:`} ${fmtDate(props.since)}`, n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})`, - ]) + ], { sep: "\n" }) const createMenu = await grantCreatedMenu(props.target) await this.log(this.topics.grants, msg, { reply_markup: createMenu, disable_notification: false }) @@ -409,7 +409,7 @@ export class TgLogger extends Module { b`🛑 Grant Interruption`, n`${b`Target:`} ${fmtUser(props.target)}`, n`${b`By:`} ${fmtUser(props.by)}`, - ]) + ], { sep: "\n" }) await this.log(this.topics.grants, msg, { reply_markup: undefined, disable_notification: false }) return msg From 4d13009e3f2883447632170b650d3d015ee5379f Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Tue, 20 Jan 2026 00:35:59 +0100 Subject: [PATCH 06/16] chore: biome --- biome.jsonc | 2 +- package.json | 2 +- src/commands/grants.ts | 43 +++++++------- .../auto-moderation-stack/index.ts | 13 ++-- src/modules/tg-logger/grants.ts | 8 +-- src/modules/tg-logger/index.ts | 59 +++++++++++-------- 6 files changed, 69 insertions(+), 58 deletions(-) diff --git a/biome.jsonc b/biome.jsonc index 56b3583..4ac8973 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": false }, "formatter": { diff --git a/package.json b/package.json index b47557a..9d47b72 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "vitest", "typecheck": "tsc --noEmit", "check": "biome check", - "check:fix": "biome check --write" + "check:fix": "biome check --write --unsafe" }, "keywords": [], "author": "", diff --git a/src/commands/grants.ts b/src/commands/grants.ts index 0f77fc4..3018b0f 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -1,16 +1,16 @@ -import type { Message, User } from "grammy/types" +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" -import { modules } from "@/modules" -import { logger } from "@/logger" -import { ConversationMenuContext, conversations } from "@grammyjs/conversations" -import { ConversationContext } from "@/lib/managed-commands" const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", @@ -18,13 +18,13 @@ const dateFormat = new Intl.DateTimeFormat(undefined, { const timeFormat = new Intl.DateTimeFormat(undefined, { timeStyle: "short", - hour12: false + hour12: false, }) const datetimeFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short", - hour12: false + hour12: false, }) const getDateWithDelta = (date: Date, deltaDay: number) => { @@ -41,8 +41,9 @@ const mainMsg = (user: User, startTime: Date, endTime: Date, duration: string, r 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, - + endTime.getTime() < Date.now() + ? b`\n${u`INVALID:`} END datetime is in the past, change start date or duration.` + : undefined, ], { sep: "\n" } ) @@ -101,14 +102,7 @@ _commandsBase.createCommand({ 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 baseMsg = () => mainMsg(target, startDate, endDate(), grantDuration.raw, args.reason) async function changeDuration(ctx: ConversationMenuContext>, durationStr: string) { grantDuration = duration.zod.parse(durationStr) @@ -172,7 +166,10 @@ _commandsBase.createCommand({ const _startTimeMenu = conversation .menu("grants-start-time", { parent: "grants-main" }) - .text(() => `Now: ${timeFormat.format(new Date())}`, (ctx) => changeStartTime(ctx, new Date().getHours(), new Date().getMinutes())) + .text( + () => `Now: ${timeFormat.format(new Date())}`, + (ctx) => changeStartTime(ctx, new Date().getHours(), new Date().getMinutes()) + ) .row() .text("8:00", (ctx) => changeStartTime(ctx, 8, 0)) .text("9:00", (ctx) => changeStartTime(ctx, 9, 0)) @@ -192,13 +189,17 @@ _commandsBase.createCommand({ .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 }) + .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)) + .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)) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index 65b5c34..a509208 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -172,12 +172,13 @@ export class AutoModerationStack implements MiddlewareObj if (flaggedCategories.some((cat) => cat.aboveThreshold)) { if (ctx.whitelisted) { // log the action but do not mute - if (ctx.whitelisted.role === "user") await modules.get("tgLogger").grants({ - action: "USAGE", - from: ctx.from, - chat: ctx.chat, - message, - }) + if (ctx.whitelisted.role === "user") + await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, + message, + }) } else { // above threshold, mute user and delete the message await mute({ diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts index a6d4408..5780b61 100644 --- a/src/modules/tg-logger/grants.ts +++ b/src/modules/tg-logger/grants.ts @@ -2,8 +2,8 @@ import type { Context } from "grammy" import type { Message, User } from "grammy/types" import { type ApiOutput, api } from "@/backend" import { type CallbackCtx, MenuGenerator } from "@/lib/menu" -import { modules } from ".." import { logger } from "@/logger" +import { modules } from ".." type GrantedMessage = { message: Message @@ -69,7 +69,7 @@ export const grantMessageMenu = MenuGenerator.getInstance().create { if (data.deleted) return { feedback: "☑️ Message already deleted" } const { error } = await handleDelete(ctx, data) - if (!error && data.interrupted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => { }) + if (!error && data.interrupted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) return { feedback: getFeedback(error) ?? "✅ Message deleted", newData: !error ? { ...data, deleted: true } : undefined, @@ -82,7 +82,7 @@ export const grantMessageMenu = MenuGenerator.getInstance().create { }) + if (noError && data.deleted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) return { feedback: getFeedback(error) ?? "✅ Grant Interrupted", newData: noError ? { ...data, interrupted: true } : undefined, @@ -105,7 +105,7 @@ export const grantCreatedMenu = MenuGenerator.getInstance().create { }) + await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) return { feedback: getFeedback(error) ?? "✅ Grant Interrupted", } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 1cfbbda..298a3e4 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -163,13 +163,13 @@ export class TgLogger extends Module { const voters = direttivo.members.map((m) => ({ user: m.user ? { - id: m.userId, - first_name: m.user.firstName, - last_name: m.user.lastName, - username: m.user.username, - is_bot: m.user.isBot, - language_code: m.user.langCode, - } + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } : { id: m.userId }, isPresident: m.isPresident, vote: undefined, @@ -372,11 +372,14 @@ export class TgLogger extends Module { switch (props.action) { case "USAGE": { const { invite_link } = await this.shared.api.getChat(props.chat.id) - msg = fmt(({ n, b }) => [ - b`💬 Spam-message detected`, - n`${b`From:`} ${fmtUser(props.from)}`, - n`${b`Chat:`} ${fmtChat(props.chat, invite_link)}`, - ], { sep: "\n" }) + msg = fmt( + ({ n, b }) => [ + b`💬 Spam-message detected`, + n`${b`From:`} ${fmtUser(props.from)}`, + n`${b`Chat:`} ${fmtChat(props.chat, invite_link)}`, + ], + { sep: "\n" } + ) const usageMenu = await grantMessageMenu({ target: props.from, interrupted: false, @@ -390,14 +393,17 @@ export class TgLogger extends Module { } case "CREATE": { - msg = fmt(({ n, b }) => [ - b`✳ New Grant`, - n`${b`Target:`} ${fmtUser(props.target)}`, - n`${b`By:`} ${fmtUser(props.by)}`, - props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, - n`\n${b`Valid since:`} ${fmtDate(props.since)}`, - n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})`, - ], { sep: "\n" }) + msg = fmt( + ({ n, b }) => [ + b`✳ New Grant`, + n`${b`Target:`} ${fmtUser(props.target)}`, + n`${b`By:`} ${fmtUser(props.by)}`, + props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, + n`\n${b`Valid since:`} ${fmtDate(props.since)}`, + n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})`, + ], + { sep: "\n" } + ) const createMenu = await grantCreatedMenu(props.target) await this.log(this.topics.grants, msg, { reply_markup: createMenu, disable_notification: false }) @@ -405,11 +411,14 @@ export class TgLogger extends Module { } case "INTERRUPT": - msg = fmt(({ n, b }) => [ - b`🛑 Grant Interruption`, - n`${b`Target:`} ${fmtUser(props.target)}`, - n`${b`By:`} ${fmtUser(props.by)}`, - ], { sep: "\n" }) + msg = fmt( + ({ n, b }) => [ + b`🛑 Grant Interruption`, + n`${b`Target:`} ${fmtUser(props.target)}`, + n`${b`By:`} ${fmtUser(props.by)}`, + ], + { sep: "\n" } + ) await this.log(this.topics.grants, msg, { reply_markup: undefined, disable_notification: false }) return msg From 3e421a59ac36b1fdfe11569a675a5a6587f5fba7 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 01:33:58 +0100 Subject: [PATCH 07/16] fix: bugs --- src/commands/grants.ts | 18 +++++++----------- src/modules/tg-logger/index.ts | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/commands/grants.ts b/src/commands/grants.ts index 3018b0f..ae1fc93 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -128,16 +128,12 @@ _commandsBase.createCommand({ startDate.setHours(hour) startDate.setMinutes(minutes) ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) - ctx.menu.update() ctx.menu.nav("grants-main") } - const backToMain = conversation.menu("grants-back-to-main", { parent: "grants-main" }).back("◀️ Back", (ctx) => - ctx.editMessageText( - fmt(({ skip }) => [skip`${baseMsg()}`], { sep: "\n" }), - { 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 durationMenu = conversation .menu("grants-duration", { parent: "grants-main" }) @@ -162,13 +158,13 @@ _commandsBase.createCommand({ await changeDuration(ctx, text) }) .row() - .back("◀️ Back") + .back("◀️ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + const convNow = new Date(await conversation.now()) const _startTimeMenu = conversation .menu("grants-start-time", { parent: "grants-main" }) - .text( - () => `Now: ${timeFormat.format(new Date())}`, - (ctx) => changeStartTime(ctx, new Date().getHours(), new Date().getMinutes()) + .text(`Now: ${timeFormat.format(convNow)}`, (ctx) => + changeStartTime(ctx, convNow.getHours(), convNow.getMinutes()) ) .row() .text("8:00", (ctx) => changeStartTime(ctx, 8, 0)) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 298a3e4..b5901ef 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -283,7 +283,7 @@ export class TgLogger extends Module { ? n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})` : undefined, - "reason" in props && props.reason ? fmt(({ n, b }) => n`${b`Reason:`} ${props.reason}`) : undefined, + "reason" in props && props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, /// per-action specific info, like MULTI_CHAT ...others.map((o) => skip`${o}`), From 9b078639433b6166cad609191a22b786b2917fd4 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 01:34:47 +0100 Subject: [PATCH 08/16] chore: update TODO.md --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 927a400..fdd5df3 100644 --- a/TODO.md +++ b/TODO.md @@ -27,7 +27,7 @@ - [x] check harmful messages - [x] check spam across different groups (mute + del) - [ ] exception to send our whatsapp links? - - [ ] do not delete Direttivo's allowed messages + - [x] do not delete Direttivo's allowed messages (/grant command) - [x] check if user has username - [ ] group-specific moderation (eg. #cerco #vendo in polihouse) see [here](https://github.com/PoliNetworkOrg/PoliNetworkBot_CSharp/blob/03c7434f06323ffdec301cb105d1d3b2c1ed4a95/PoliNetworkBot_CSharp/Code/Bots/Moderation/Blacklist/Blacklist.cs#L84) - [x] role management From 85dfed50309b4aa0f7aad73c0f9c8ff1407a7812 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Wed, 21 Jan 2026 01:41:16 +0100 Subject: [PATCH 09/16] fix: prevent useless checks on the bot itself --- src/middlewares/auto-moderation-stack/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index b29ee7b..3252068 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -51,6 +51,7 @@ export class AutoModerationStack implements MiddlewareObj .on(["message", "edited_message"]) .fork() // fork the processing, this stack executes in parallel to the rest of the bot .filter(async (ctx) => { + if (ctx.from.id === ctx.me.id) return false // skip messages from the bot itself const whitelistType = await this.isWhitelisted(ctx) if (whitelistType) { // creators can skip moderation entirely From 9a44de20e1e11e71e4f04ea42acd478b1f2edeed Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 01:50:08 +0100 Subject: [PATCH 10/16] fix: conv halt --- src/commands/grants.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/commands/grants.ts b/src/commands/grants.ts index ae1fc93..cef53ea 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -252,9 +252,9 @@ _commandsBase.createCommand({ .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() - await wait(3000) - await ctx.deleteMessage() }) const msg = await context.reply(baseMsg(), { reply_markup: mainMenu }) @@ -263,7 +263,6 @@ _commandsBase.createCommand({ } catch (err) { logger.error({ err }, "Error in grant command") await context.deleteMessage() - await conversation.halt() } }, }) From e08f2d3b1a37ab0bbca41a63118c5f4cf57c79e5 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 01:58:26 +0100 Subject: [PATCH 11/16] fix: wrong until str in create grant log --- src/modules/tg-logger/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index b5901ef..dca851d 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -400,7 +400,7 @@ export class TgLogger extends Module { n`${b`By:`} ${fmtUser(props.by)}`, props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, n`\n${b`Valid since:`} ${fmtDate(props.since)}`, - n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})`, + n`${b`Duration:`} ${props.duration.raw} (until ${fmtDate(new Date(props.since.getTime() + props.duration.secondsFromNow * 1000))})`, ], { sep: "\n" } ) From 1253441a25f4d8980ec106bfaa33d21f4fd36531 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 02:02:42 +0100 Subject: [PATCH 12/16] fix: grant creation backend call response handling --- src/commands/grants.ts | 44 ++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/src/commands/grants.ts b/src/commands/grants.ts index cef53ea..8b8c03c 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -211,28 +211,34 @@ _commandsBase.createCommand({ const mainMenu = conversation .menu("grants-main") .text("✅ Confirm", async (ctx) => { - await api.tg.grants.create.mutate({ - userId: target.id, - adderId: context.from.id, - reason: args.reason, - since: startDate, - until: endDate(), - }) + 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 ctx.editMessageText( - fmt(({ b, skip }) => [skip`${baseMsg()}`, b`✅ Special Permissions Granted`], { sep: "\n\n" }) - ) + 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, - }) + 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" })) + } - ctx.menu.close() await conversation.halt() }) .row() From 359627ba5ec9a12d4658e264cb4e5d90344638d4 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 02:23:42 +0100 Subject: [PATCH 13/16] docs: update isWhitelisted comment just to not be totally outdated --- src/middlewares/auto-moderation-stack/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index 2191ed4..66b4862 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -88,8 +88,7 @@ export class AutoModerationStack implements MiddlewareObj * - [ ] check if the chat allows specific types of content (?) * * @param ctx The context of the message - * @returns true if the message is exempt and therefore should be ignored by - * the moderation stack, false otherwise + * @returns WT {@link WhitelistType} if there is a whitelisted user, `null` otherwise */ private async isWhitelisted(ctx: ModerationContext): Promise { const { status } = await ctx.getAuthor() From 259ba6bb78acf531d9234dfe359c464c393a2d1b Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Wed, 21 Jan 2026 02:24:22 +0100 Subject: [PATCH 14/16] fix: fail closed in whitelist check --- .../auto-moderation-stack/index.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index 2191ed4..1b2800c 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -3,6 +3,7 @@ import { Composer } from "grammy" import type { Message } from "grammy/types" import ssdeep from "ssdeep.js" import { api } from "@/backend" +import { logger } from "@/logger" import { modules } from "@/modules" import { mute } from "@/modules/moderation" import { redis } from "@/redis" @@ -11,6 +12,7 @@ import { defer } from "@/utils/deferred-middleware" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" import { createFakeMessage, getText } from "@/utils/messages" +import { throttle } from "@/utils/throttle" import type { Context } from "@/utils/types" import { wait } from "@/utils/wait" import { MessageUserStorage } from "../message-user-storage" @@ -26,6 +28,10 @@ type ModerationContext = Filter { + logger.error(e, msg) +}, 1000 * 60) + /** * # Auto-Moderation stack * ## Handles automatic message moderation. @@ -92,15 +98,19 @@ export class AutoModerationStack implements MiddlewareObj * the moderation stack, false otherwise */ private async isWhitelisted(ctx: ModerationContext): Promise { - const { status } = await ctx.getAuthor() - if (status === "creator") return { role: "creator" } - if (status === "administrator") return { role: "admin" } + try { + const { status } = await ctx.getAuthor() + if (status === "creator") return { role: "creator" } + if (status === "administrator") return { role: "admin" } - const isAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) - if (isAdmin) return { role: "admin" } + const isAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) + if (isAdmin) return { role: "admin" } - const grant = await api.tg.grants.checkUser.query({ userId: ctx.from.id }) - if (grant.isGranted) return { role: "user" } + const grant = await api.tg.grants.checkUser.query({ userId: ctx.from.id }) + if (grant.isGranted) return { role: "user" } + } catch (e) { + debouncedError(e, "Error checking whitelist status in auto-moderation") + } return null } From 041ecf5dac38b9a2736b9d9bbf41cae667b0b175 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Wed, 21 Jan 2026 02:25:56 +0100 Subject: [PATCH 15/16] fix: logging convention --- src/middlewares/auto-moderation-stack/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index fd62c73..e5ec670 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -28,8 +28,8 @@ type ModerationContext = Filter { - logger.error(e, msg) +const debouncedError = throttle((error: unknown, msg: string) => { + logger.error({ error }, msg) }, 1000 * 60) /** From 12aff4462a96276c890c5728af29058ae90b3333 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Wed, 21 Jan 2026 02:36:40 +0100 Subject: [PATCH 16/16] fix: duplicate Date instance of conversation.now() --- src/commands/grants.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/commands/grants.ts b/src/commands/grants.ts index 8b8c03c..8097785 100644 --- a/src/commands/grants.ts +++ b/src/commands/grants.ts @@ -160,12 +160,9 @@ _commandsBase.createCommand({ .row() .back("◀️ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) - const convNow = new Date(await conversation.now()) const _startTimeMenu = conversation .menu("grants-start-time", { parent: "grants-main" }) - .text(`Now: ${timeFormat.format(convNow)}`, (ctx) => - changeStartTime(ctx, convNow.getHours(), convNow.getMinutes()) - ) + .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))