From 2d31faadeb5674e5d84385c76b5aa0af253b1733 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 29 Jan 2026 15:58:29 +0100 Subject: [PATCH 01/22] chore: just for fun --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1fbdb9a..ec0c450 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,4 @@ Our new telegram bot. ### Maybe useful references - [How to send private messages](https://github.com/PoliNetworkOrg/PoliNetworkBot_CSharp/blob/03c7434f06323ffdec301cb105d1d3b2c1ed4a95/PoliNetworkBot_CSharp/Code/Utils/SendMessage.cs#L90) + From 02a2a38b1387a5319da2e9d7afbdcc4718067fcf Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 29 Jan 2026 17:44:15 +0100 Subject: [PATCH 02/22] refactor: move delete perform outside of tgLogger --- README.md | 1 - src/commands/del.ts | 7 +- .../auto-moderation-stack/index.ts | 18 ++-- src/middlewares/group-specific-actions.ts | 6 +- src/modules/moderation/ban.ts | 23 +++-- src/modules/moderation/kick.ts | 20 ++++- src/modules/moderation/mute.ts | 8 +- src/modules/tg-logger/grants.ts | 7 +- src/modules/tg-logger/index.ts | 89 ++++++------------- src/modules/tg-logger/types.ts | 4 +- 10 files changed, 93 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index ec0c450..1fbdb9a 100644 --- a/README.md +++ b/README.md @@ -23,4 +23,3 @@ Our new telegram bot. ### Maybe useful references - [How to send private messages](https://github.com/PoliNetworkOrg/PoliNetworkBot_CSharp/blob/03c7434f06323ffdec301cb105d1d3b2c1ed4a95/PoliNetworkBot_CSharp/Code/Utils/SendMessage.cs#L90) - diff --git a/src/commands/del.ts b/src/commands/del.ts index db13e1f..35011b6 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -21,7 +21,10 @@ _commandsBase.createCommand({ sender: repliedTo.from?.username, }) - await modules.get("tgLogger").delete([repliedTo], "Command /del", context.from) // actual message to delete - await context.deleteMessage() // /del message + await modules.get("tgLogger").preDelete([repliedTo], "Command /del", context.from) // actual message to delete + await Promise.all([ + context.deleteMessages([repliedTo.message_id]), + context.deleteMessage(), // /del message + ]) }, }) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index e5ec670..c11cd7d 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -216,7 +216,6 @@ export class AutoModerationStack implements MiddlewareObj from: ctx.me, chat: ctx.chat, target: ctx.from, - message, reason: `Message flagged for moderation: \n${reasons}`, }) } @@ -286,21 +285,26 @@ export class AutoModerationStack implements MiddlewareObj similarMessages.push(ctx.message) const muteDuration = duration.zod.parse(MULTI_CHAT_SPAM.MUTE_DURATION) + const tgLogger = modules.get("tgLogger") + const preDeleteRes = await tgLogger.preDelete(similarMessages, "MultiChatSpam") + + // this one delete all similar messages and mutes the sender in all involved chat for `muteDuration` await Promise.allSettled( groupMessagesByChat(similarMessages) - .keys() - .map((chatId) => + .entries() + .flatMap(([chatId, mIds]) => [ + ctx.api.deleteMessages(chatId, mIds), ctx.api.restrictChatMember(chatId, ctx.from.id, RestrictPermissions.mute, { until_date: muteDuration.timestamp_s, - }) - ) + }), + ]) ) - await modules.get("tgLogger").moderationAction({ + await tgLogger.moderationAction({ action: "MULTI_CHAT_SPAM", from: ctx.me, chat: ctx.chat, - message: ctx.message, + preDeleteRes: preDeleteRes, messages: similarMessages, duration: muteDuration, target: ctx.from, diff --git a/src/middlewares/group-specific-actions.ts b/src/middlewares/group-specific-actions.ts index 6a65706..a5b1246 100644 --- a/src/middlewares/group-specific-actions.ts +++ b/src/middlewares/group-specific-actions.ts @@ -66,7 +66,11 @@ export class GroupSpecificActions implements MiddlewareObj if (check.isOk()) return next() - await modules.get("tgLogger").delete([ctx.message], `User did not follow group rules:\n${check.error}`, ctx.me) + await modules + .get("tgLogger") + .preDelete([ctx.message], `User did not follow group rules:\n${check.error}`, ctx.me) + await ctx.deleteMessage() + const reply = await ctx.reply( fmt(({ b, n }) => [b`${fmtUser(ctx.from)} you sent an invalid message`, b`Reason:`, n`${check.error}`], { sep: "\n", diff --git a/src/modules/moderation/ban.ts b/src/modules/moderation/ban.ts index e5e84b7..14827be 100644 --- a/src/modules/moderation/ban.ts +++ b/src/modules/moderation/ban.ts @@ -9,7 +9,7 @@ import type { ContextWith } from "@/utils/types" interface BanProps { ctx: ContextWith<"chat"> - message?: Message + message: Message from: User target: User reason?: string @@ -33,11 +33,22 @@ export async function ban({ ctx, target, from, reason, duration, message }: BanP reason, type: "ban", }) - return ok( - await modules - .get("tgLogger") - .moderationAction({ action: "BAN", from, message, target, duration, reason, chat: ctx.chat }) - ) + + const tgLogger = modules.get("tgLogger") + const preDeleteRes = await tgLogger.preDelete([message], reason ?? "Ban", from) + await ctx.deleteMessages([message.message_id]) + + const res = await tgLogger.moderationAction({ + action: "BAN", + from, + preDeleteRes, + target, + duration, + reason, + chat: ctx.chat, + }) + + return ok(res) } interface UnbanProps { diff --git a/src/modules/moderation/kick.ts b/src/modules/moderation/kick.ts index 39822d5..b8c383c 100644 --- a/src/modules/moderation/kick.ts +++ b/src/modules/moderation/kick.ts @@ -10,7 +10,7 @@ interface KickProps { ctx: ContextWith<"chat"> from: User target: User - message?: Message + message: Message reason?: string } @@ -32,7 +32,19 @@ export async function kick({ ctx, target, from, reason, message }: KickProps): P reason, type: "kick", }) - return ok( - await modules.get("tgLogger").moderationAction({ action: "KICK", from, target, reason, message, chat: ctx.chat }) - ) + + const tgLogger = modules.get("tgLogger") + const preDeleteRes = await tgLogger.preDelete([message], reason ?? "Kick", from) + await ctx.deleteMessages([message.message_id]) + + const res = await tgLogger.moderationAction({ + action: "KICK", + from, + target, + reason, + preDeleteRes, + chat: ctx.chat, + }) + + return ok(res) } diff --git a/src/modules/moderation/mute.ts b/src/modules/moderation/mute.ts index f2ecd21..7601bb0 100644 --- a/src/modules/moderation/mute.ts +++ b/src/modules/moderation/mute.ts @@ -47,14 +47,18 @@ export async function mute({ type: "mute", }) - const res = await modules.get("tgLogger").moderationAction({ + const tgLogger = modules.get("tgLogger") + const preDeleteRes = await tgLogger.preDelete([message], reason ?? "Mute", from) + await ctx.deleteMessages([message.message_id]) + + const res = await tgLogger.moderationAction({ action: "MUTE", chat: ctx.chat, from, target, duration, + preDeleteRes, reason, - message, }) return ok(res) diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts index 5780b61..0dbd6c4 100644 --- a/src/modules/tg-logger/grants.ts +++ b/src/modules/tg-logger/grants.ts @@ -44,11 +44,12 @@ async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Pr const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) if (!roles?.includes("direttivo")) return { error: "UNAUTHORIZED" } - const res = await modules + await modules .get("tgLogger") - .delete([data.message], "[GRANT] Manual deletion of message sent by granted user", ctx.from) + .preDelete([data.message], "[GRANT] Manual deletion of message sent by granted user", ctx.from) - if (!res?.count) { + const ok = await ctx.api.deleteMessages(data.message.chat.id, [data.message.message_id]).catch(() => false) + if (!ok) { return { error: "CANNOT_DELETE", } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index dca851d..d5a7625 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -22,6 +22,17 @@ type Topics = { grants: number } +const MOD_ACTION_TITLE = (props: Types.ModerationAction) => + ({ + MUTE: fmt(({ b }) => b`🤫 ${"duration" in props && props.duration ? "Temp" : "PERMA"} Mute`), + KICK: fmt(({ b }) => b`👢 Kick`), + BAN: fmt(({ b }) => b`🚫 ${"duration" in props && props.duration ? "Temp" : "PERMA"} Ban`), + MULTI_CHAT_SPAM: fmt(({ b }) => [b`📑 Multi Chat Spam (MuteDel)`]), + UNBAN: fmt(({ b }) => b`✅ Unban`), + UNMUTE: fmt(({ b }) => b`🎤 Unmute`), + SILENT: fmt(({ b }) => b`🔶 Possible Harmful Content Detection`), + })[props.action] + export class TgLogger extends Module { constructor( private groupId: number, @@ -94,44 +105,33 @@ export class TgLogger extends Module { return true } - async delete( + // NOTE: this does not delete the messages + async preDelete( messages: Message[], reason: string, deleter: User = this.shared.botInfo - ): Promise { + ): Promise { if (!messages.length) return null - const sendersMap = new Map() - messages - .map((m) => m.from) - .filter((m): m is User => m !== undefined) - .forEach((u) => { - if (!sendersMap.has(u.id)) sendersMap.set(u.id, u) - }) - const senders = Array.from(sendersMap.values()) - if (!senders.length) return null + const sender = messages[0].from const sent = await this.log( this.topics.deletedMessages, fmt( ({ n, b, i, code }) => [ b`🗑 Delete`, - senders.length > 1 - ? n`${b`Senders:`} \n - ${senders.map(fmtUser).join("\n - ")}` - : n`${b`Sender:`} ${fmtUser(senders[0])}`, - + sender ? n`${b`Sender:`} ${fmtUser(sender)}` : undefined, deleter.id === this.shared.botInfo.id ? i`Automatic deletion by BOT` : n`${b`Deleter:`} ${fmtUser(deleter)}`, n`${b`Count:`} ${code`${messages.length}`}`, - reason ? n`${b`Reason:`} ${reason}` : undefined, ], { sep: "\n" } ) ) + if (!sent) return null for (const [chatId, mIds] of groupMessagesByChat(messages)) { await this.forward(this.topics.deletedMessages, chatId, mIds) - await this.shared.api.deleteMessages(chatId, mIds) } return { @@ -221,58 +221,21 @@ export class TgLogger extends Module { public async moderationAction(props: Types.ModerationAction): Promise { const isAutoModeration = props.from.id === this.shared.botInfo.id - let title: string const others: string[] = [] - let deleteRes: Types.DeleteResult | null = null const { invite_link } = await this.shared.api.getChat(props.chat.id) - const delReason = `${props.action}${"reason" in props && props.reason ? ` -- ${props.reason}` : ""}` - switch (props.action) { - case "MUTE": - title = fmt(({ b }) => b`🤫 ${props.duration ? "Temp" : "PERMA"} Mute`) - if (props.message) deleteRes = await this.delete([props.message], delReason, props.from) - break - - case "KICK": - title = fmt(({ b }) => b`👢 Kick`) - if (props.message) deleteRes = await this.delete([props.message], delReason, props.from) - break - - case "BAN": - title = fmt(({ b }) => b`🚫 ${props.duration ? "Temp" : "PERMA"} Ban`) - if (props.message) deleteRes = await this.delete([props.message], delReason, props.from) - break - - case "MULTI_CHAT_SPAM": { - title = fmt(({ b }) => [b`📑 Multi Chat Spam (MuteDel)`]) - - const groupByChat = groupMessagesByChat(props.messages) - others.push(fmt(({ b }) => b`\nChats involved:`)) - for (const [chatId, mIds] of groupByChat) { - const chat = await this.shared.api.getChat(chatId) - others.push(fmt(({ n, i }) => n`${fmtChat(chat, chat.invite_link)} \n${i`Messages: ${mIds.length}`}`)) - } - - deleteRes = await this.delete(props.messages, delReason, this.shared.botInfo) - break + if (props.action === "MULTI_CHAT_SPAM") { + const groupByChat = groupMessagesByChat(props.messages) + others.push(fmt(({ b }) => b`\nChats involved:`)) + for (const [chatId, mIds] of groupByChat) { + const chat = await this.shared.api.getChat(chatId) + others.push(fmt(({ n, i }) => n`${fmtChat(chat, chat.invite_link)} \n${i`Messages: ${mIds.length}`}`)) } - - case "UNBAN": - title = fmt(({ b }) => b`✅ Unban`) - break - - case "UNMUTE": - title = fmt(({ b }) => b`🎤 Unmute`) - break - - case "SILENT": - title = fmt(({ b }) => b`🔶 Possible Harmful Content Detection`) - break } const mainMsg = fmt( ({ n, b, skip }) => [ - skip`${title}`, + skip`${MOD_ACTION_TITLE(props)}`, n`${b`Target:`} ${fmtUser(props.target)}`, @@ -291,7 +254,9 @@ export class TgLogger extends Module { { sep: "\n" } ) - const reply_markup = deleteRes ? new InlineKeyboard().url("See Deleted Message", deleteRes.link) : undefined + const reply_markup = props.preDeleteRes + ? new InlineKeyboard().url("See Deleted Message", props.preDeleteRes.link) + : undefined await this.log(isAutoModeration ? this.topics.autoModeration : this.topics.adminActions, mainMsg, { reply_markup }) return mainMsg } diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index 3ce20fc..69c5490 100644 --- a/src/modules/tg-logger/types.ts +++ b/src/modules/tg-logger/types.ts @@ -40,7 +40,7 @@ export type ModerationAction = { from: User target: User chat: Chat - message?: Message + preDeleteRes?: PreDeleteResult | null } & ( | { action: "BAN" | "MUTE" @@ -87,7 +87,7 @@ export type GroupManagement = { } ) -export type DeleteResult = { +export type PreDeleteResult = { count: number link: string } From 3c82dfbb3016ff5f2d98ba00ea53b897ca4fe96c Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 29 Jan 2026 17:47:44 +0100 Subject: [PATCH 03/22] refactor: move moderation functions inside a Moderation obj --- src/commands/ban.ts | 8 ++++---- src/commands/kick.ts | 4 ++-- src/commands/mute.ts | 8 ++++---- src/middlewares/auto-moderation-stack/index.ts | 8 ++++---- src/modules/moderation/index.ts | 14 +++++++++++--- 5 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index f336d28..5b450b0 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,5 +1,5 @@ import { logger } from "@/logger" -import { ban, unban } from "@/modules/moderation" +import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" @@ -25,7 +25,7 @@ _commandsBase return } - const res = await ban({ + const res = await Moderation.ban({ ctx: context, target: repliedTo.from, from: context.from, @@ -68,7 +68,7 @@ _commandsBase return } - const res = await ban({ + const res = await Moderation.ban({ ctx: context, target: repliedTo.from, from: context.from, @@ -107,7 +107,7 @@ _commandsBase return } - const res = await unban({ ctx: context, from: context.from, targetId: userId }) + const res = await Moderation.unban({ ctx: context, from: context.from, targetId: userId }) if (res.isErr()) { const msg = await context.reply(res.error) await wait(5000) diff --git a/src/commands/kick.ts b/src/commands/kick.ts index f23dcce..35d860c 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -1,5 +1,5 @@ import { logger } from "@/logger" -import { kick } from "@/modules/moderation" +import { Moderation } from "@/modules/moderation" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" @@ -21,7 +21,7 @@ _commandsBase.createCommand({ return } - const res = await kick({ + const res = await Moderation.kick({ ctx: context, target: repliedTo.from, from: context.from, diff --git a/src/commands/mute.ts b/src/commands/mute.ts index c8791f2..bca13e3 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,5 +1,5 @@ import { logger } from "@/logger" -import { mute, unmute } from "@/modules/moderation" +import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" @@ -33,7 +33,7 @@ _commandsBase return } - const res = await mute({ + const res = await Moderation.mute({ ctx: context, target: repliedTo.from, message: repliedTo, @@ -67,7 +67,7 @@ _commandsBase return } - const res = await mute({ + const res = await Moderation.mute({ ctx: context, target: repliedTo.from, message: repliedTo, @@ -103,7 +103,7 @@ _commandsBase return } - const res = await unmute({ ctx: context, from: context.from, targetId: userId }) + const res = await Moderation.unmute({ ctx: context, from: context.from, targetId: userId }) if (res.isErr()) { const msg = await context.reply(res.error) await wait(5000) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index c11cd7d..c90c37d 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -5,7 +5,7 @@ import ssdeep from "ssdeep.js" import { api } from "@/backend" import { logger } from "@/logger" import { modules } from "@/modules" -import { mute } from "@/modules/moderation" +import { Moderation } from "@/modules/moderation" import { redis } from "@/redis" import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { defer } from "@/utils/deferred-middleware" @@ -148,7 +148,7 @@ export class AutoModerationStack implements MiddlewareObj return } - await mute({ + await Moderation.mute({ ctx, from: ctx.me, target: ctx.from, @@ -191,7 +191,7 @@ export class AutoModerationStack implements MiddlewareObj }) } else { // above threshold, mute user and delete the message - await mute({ + await Moderation.mute({ ctx, from: ctx.me, target: ctx.from, @@ -238,7 +238,7 @@ export class AutoModerationStack implements MiddlewareObj // longer messages can have more non-latin characters, but less in percentage if (match && (match.length - NON_LATIN.LENGTH_THR) / text.length > NON_LATIN.PERCENTAGE_THR) { // just delete the message and mute the user for 10 minutes - await mute({ + await Moderation.mute({ ctx, message: ctx.message, target: ctx.from, diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index e337fd8..e2e31f3 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -1,3 +1,11 @@ -export * from "./ban" -export * from "./kick" -export * from "./mute" +import { ban, unban } from "./ban" +import { kick } from "./kick" +import { mute, unmute } from "./mute" + +export const Moderation = { + ban, + unban, + mute, + unmute, + kick, +} From 6abc4d2d1ddce3a9312a74950375ff830c4b889b Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 29 Jan 2026 19:57:00 +0100 Subject: [PATCH 04/22] refactor: moderation class --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/commands/ban.ts | 63 +++--- src/commands/kick.ts | 19 +- src/commands/mute.ts | 59 +++-- .../auto-moderation-stack/index.ts | 98 ++++---- src/modules/moderation/ban.ts | 72 ------ src/modules/moderation/index.ts | 213 +++++++++++++++++- src/modules/moderation/kick.ts | 50 ---- src/modules/moderation/mute.ts | 87 ------- src/utils/duration.ts | 2 +- src/utils/types.ts | 15 +- 12 files changed, 321 insertions(+), 369 deletions(-) delete mode 100644 src/modules/moderation/ban.ts delete mode 100644 src/modules/moderation/kick.ts delete mode 100644 src/modules/moderation/mute.ts diff --git a/package.json b/package.json index 3bb7d7c..45eeb0f 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.15.2", + "@polinetwork/backend": "^0.15.3", "@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 3ce15c7..9216892 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.15.2 - version: 0.15.2 + specifier: ^0.15.3 + version: 0.15.3 '@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.15.2': - resolution: {integrity: sha512-R7UT727EnHdFma3EysACcXRn7RRgeKQhK5jvXgVqXU68C2hbx9TvdtD5S88W6wq0/QxiPjTplQ8m9HmSaIk+NA==} + '@polinetwork/backend@0.15.3': + resolution: {integrity: sha512-W63S2omBKMQnoEWKtrHBPywJaQf2I39ZubHTBIivseEfPMcC3M3HUDlTOiGcg/TpMIIuGj9lp6dmRGtH4YhFHw==} '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} @@ -1784,7 +1784,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@polinetwork/backend@0.15.2': {} + '@polinetwork/backend@0.15.3': {} '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 5b450b0..bac4123 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,10 +1,11 @@ +import { api } from "@/backend" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" +import { toGrammyUser } from "@/utils/types" import { wait } from "@/utils/wait" - import { _commandsBase } from "./_base" _commandsBase @@ -25,22 +26,10 @@ _commandsBase return } - const res = await Moderation.ban({ - ctx: context, - target: repliedTo.from, - from: context.from, - message: repliedTo, - reason: args.reason, - }) - - if (res.isErr()) { - const msg = await context.reply(res.error) - await wait(5000) - await msg.delete() - return - } - - await context.reply(res.value) + const res = await Moderation.ban(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) .createCommand({ @@ -68,23 +57,17 @@ _commandsBase return } - const res = await Moderation.ban({ - ctx: context, - target: repliedTo.from, - from: context.from, - message: repliedTo, - duration: args.duration, - reason: args.reason, - }) - - if (res.isErr()) { - const msg = await context.reply(res.error) - await wait(5000) - await msg.delete() - return - } - - await context.reply(res.value) + const res = await Moderation.ban( + repliedTo.from, + context.chat, + context.from, + args.duration, + [repliedTo], + args.reason + ) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) .createCommand({ @@ -107,12 +90,18 @@ _commandsBase return } - const res = await Moderation.unban({ ctx: context, from: context.from, targetId: userId }) - if (res.isErr()) { - const msg = await context.reply(res.error) + const { user, error } = await api.tg.users.get.query({ userId }) + if (!user) { + const msg = await context.reply("Error: cannot find this user") + logger.error({ error }, "UNBAN: error while retrieving the user") await wait(5000) await msg.delete() return } + + const res = await Moderation.unban(toGrammyUser(user), context.chat, context.from) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) diff --git a/src/commands/kick.ts b/src/commands/kick.ts index 35d860c..01c4e54 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -21,20 +21,9 @@ _commandsBase.createCommand({ return } - const res = await Moderation.kick({ - ctx: context, - target: repliedTo.from, - from: context.from, - message: repliedTo, - reason: args.reason, - }) - if (res.isErr()) { - const msg = await context.reply(res.error) - await wait(5000) - await msg.delete() - return - } - - await context.reply(res.value) + const res = await Moderation.kick(repliedTo.from, context.chat, context.from, [repliedTo], args.reason) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) diff --git a/src/commands/mute.ts b/src/commands/mute.ts index bca13e3..d535a77 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,10 +1,11 @@ +import { api } from "@/backend" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" +import { toGrammyUser } from "@/utils/types" import { wait } from "@/utils/wait" - import { _commandsBase } from "./_base" _commandsBase @@ -33,21 +34,17 @@ _commandsBase return } - const res = await Moderation.mute({ - ctx: context, - target: repliedTo.from, - message: repliedTo, - from: context.from, - duration: args.duration, - reason: args.reason, - }) - - if (res.isErr()) { - const msg = await context.reply(res.error) - await wait(5000) - await msg.delete() - return - } + const res = await Moderation.mute( + repliedTo.from, + context.chat, + context.from, + args.duration, + [repliedTo], + args.reason + ) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) .createCommand({ @@ -67,20 +64,10 @@ _commandsBase return } - const res = await Moderation.mute({ - ctx: context, - target: repliedTo.from, - message: repliedTo, - from: context.from, - reason: args.reason, - }) - - if (res.isErr()) { - const msg = await context.reply(res.error) - await wait(5000) - await msg.delete() - return - } + const res = await Moderation.mute(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) .createCommand({ @@ -103,12 +90,18 @@ _commandsBase return } - const res = await Moderation.unmute({ ctx: context, from: context.from, targetId: userId }) - if (res.isErr()) { - const msg = await context.reply(res.error) + const { user, error } = await api.tg.users.get.query({ userId }) + if (!user) { + const msg = await context.reply("Error: cannot find this user") + logger.error({ error }, "UNMUTE: error while retrieving the user") await wait(5000) await msg.delete() return } + + const res = await Moderation.unmute(toGrammyUser(user), context.chat, context.from) + const msg = await context.reply(res.isErr() ? res.error : "OK") + await wait(5000) + await msg.delete() }, }) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index c90c37d..7ad0d76 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -7,7 +7,6 @@ import { logger } from "@/logger" import { modules } from "@/modules" import { Moderation } from "@/modules/moderation" import { redis } from "@/redis" -import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { defer } from "@/utils/deferred-middleware" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -148,20 +147,23 @@ export class AutoModerationStack implements MiddlewareObj return } - await Moderation.mute({ - ctx, - from: ctx.me, - target: ctx.from, - reason: "Shared link not allowed", - duration: duration.zod.parse("1m"), // 1 minute - message, - }) + const res = await Moderation.mute( + ctx.from, + ctx.chat, + ctx.me, + duration.zod.parse("1m"), // 1 minute + [message], + "Shared link not allowed" + ) + 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", - ]) + res.isOk() + ? fmt(({ b }) => [ + b`${fmtUser(ctx.from)}`, + "The link you shared is not allowed.", + "Please refrain from sharing links that could be considered spam", + ]) + : res.error ) await wait(5000) await msg.delete() @@ -191,20 +193,22 @@ export class AutoModerationStack implements MiddlewareObj }) } else { // above threshold, mute user and delete the message - await Moderation.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 res = await Moderation.mute( + ctx.from, + ctx.chat, + ctx.me, + duration.zod.parse("1d"), // 1 minute + [message], + `Automatic moderation detected harmful content\n${reasons}` + ) 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.`, - ]) + res.isOk() + ? 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.`, + ]) + : res.error ) await wait(5000) await msg.delete() @@ -238,14 +242,14 @@ export class AutoModerationStack implements MiddlewareObj // longer messages can have more non-latin characters, but less in percentage if (match && (match.length - NON_LATIN.LENGTH_THR) / text.length > NON_LATIN.PERCENTAGE_THR) { // just delete the message and mute the user for 10 minutes - await Moderation.mute({ - ctx, - message: ctx.message, - target: ctx.from, - reason: "Message contains non-latin characters", - duration: duration.zod.parse(NON_LATIN.MUTE_DURATION), - from: ctx.me, - }) + await Moderation.mute( + ctx.from, + ctx.chat, + ctx.me, + duration.zod.parse(NON_LATIN.MUTE_DURATION), // 1 minute + [ctx.message], + "Message contains non-latin characters" + ) } } @@ -285,30 +289,12 @@ export class AutoModerationStack implements MiddlewareObj similarMessages.push(ctx.message) const muteDuration = duration.zod.parse(MULTI_CHAT_SPAM.MUTE_DURATION) - const tgLogger = modules.get("tgLogger") - const preDeleteRes = await tgLogger.preDelete(similarMessages, "MultiChatSpam") - // this one delete all similar messages and mutes the sender in all involved chat for `muteDuration` - await Promise.allSettled( - groupMessagesByChat(similarMessages) - .entries() - .flatMap(([chatId, mIds]) => [ - ctx.api.deleteMessages(chatId, mIds), - ctx.api.restrictChatMember(chatId, ctx.from.id, RestrictPermissions.mute, { - until_date: muteDuration.timestamp_s, - }), - ]) - ) + const res = await Moderation.multiChatSpam(ctx.from, similarMessages, muteDuration) - await tgLogger.moderationAction({ - action: "MULTI_CHAT_SPAM", - from: ctx.me, - chat: ctx.chat, - preDeleteRes: preDeleteRes, - messages: similarMessages, - duration: muteDuration, - target: ctx.from, - }) + if (res.isErr()) { + logger.error({ error: res.error }, "Cannot execute moderation action for MULTI_CHAT_SPAM") + } } } diff --git a/src/modules/moderation/ban.ts b/src/modules/moderation/ban.ts deleted file mode 100644 index 14827be..0000000 --- a/src/modules/moderation/ban.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { Message, User } from "grammy/types" -import { err, ok, type Result } from "neverthrow" -import type { z } from "zod" -import { api } from "@/backend" -import { modules } from "@/modules" -import type { duration } from "@/utils/duration" -import { fmt } from "@/utils/format" -import type { ContextWith } from "@/utils/types" - -interface BanProps { - ctx: ContextWith<"chat"> - message: Message - from: User - target: User - reason?: string - duration?: z.output -} - -export async function ban({ ctx, target, from, reason, duration, message }: BanProps): Promise> { - if (target.id === from.id) return err(fmt(({ b }) => b`@${from.username} you cannot ban youself (smh)`)) - if (target.id === ctx.me.id) return err(fmt(({ b }) => b`@${from.username} you cannot ban the bot!`)) - - const chatMember = await ctx.getChatMember(target.id).catch(() => null) - if (chatMember?.status === "administrator" || chatMember?.status === "creator") - return err(fmt(({ b }) => b`@${from.username} the user @${target.username} cannot be banned (admin)`)) - - await ctx.banChatMember(target.id, { until_date: duration?.timestamp_s }) - void api.tg.auditLog.create.mutate({ - targetId: target.id, - adminId: from.id, - groupId: ctx.chat.id, - until: null, - reason, - type: "ban", - }) - - const tgLogger = modules.get("tgLogger") - const preDeleteRes = await tgLogger.preDelete([message], reason ?? "Ban", from) - await ctx.deleteMessages([message.message_id]) - - const res = await tgLogger.moderationAction({ - action: "BAN", - from, - preDeleteRes, - target, - duration, - reason, - chat: ctx.chat, - }) - - return ok(res) -} - -interface UnbanProps { - ctx: ContextWith<"chat"> - from: User - targetId: number -} - -export async function unban({ ctx, targetId, from }: UnbanProps): Promise> { - if (targetId === from.id) return err(fmt(({ b }) => b`@${from.username} you cannot unban youself (smh)`)) - if (targetId === ctx.me.id) return err(fmt(({ b }) => b`@${from.username} you cannot unban the bot!`)) - - const target = await ctx.getChatMember(targetId).catch(() => null) - if (!target || target.status !== "kicked") - return err(fmt(({ b }) => b`@${from.username} this user is not banned in this chat`)) - - await ctx.unbanChatMember(target.user.id) - return ok( - await modules.get("tgLogger").moderationAction({ action: "UNBAN", from: from, target: target.user, chat: ctx.chat }) - ) -} diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index e2e31f3..1332bc6 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -1,11 +1,204 @@ -import { ban, unban } from "./ban" -import { kick } from "./kick" -import { mute, unmute } from "./mute" - -export const Moderation = { - ban, - unban, - mute, - unmute, - kick, +import type { Chat, Message, User } from "grammy/types" +import { err, ok, type Result } from "neverthrow" +import { type ApiInput, api } from "@/backend" +import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" +import { type Duration, duration } from "@/utils/duration" +import { fmt, fmtUser } from "@/utils/format" +import { modules } from ".." +import type { ModerationAction, PreDeleteResult } from "../tg-logger/types" + +const MAP_ACTIONS: Record< + Exclude | "BAN_ALL" | "MUTE_ALL", + ApiInput["tg"]["auditLog"]["create"]["type"] +> = { + MUTE: "mute", + BAN: "ban", + KICK: "kick", + UNBAN: "unban", + UNMUTE: "unmute", + BAN_ALL: "ban_all", + MUTE_ALL: "mute_all", +} + +class ModerationClass { + private static instance: ModerationClass | null = null + static getInstance(): ModerationClass { + if (!ModerationClass.instance) { + ModerationClass.instance = new ModerationClass() + } + return ModerationClass.instance + } + + private constructor() {} + + private async checkTargetValid(p: ModerationAction): Promise> { + if (p.target.id === p.from.id) return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate youself (smh)`)) + if (p.target.id === modules.shared.botInfo.id) + return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate the bot!`)) + + const chatMember = await modules.shared.api.getChatMember(p.chat.id, p.target.id).catch(() => null) + if (chatMember?.status === "administrator" || chatMember?.status === "creator") + return err( + fmt(({ b }) => b`@${p.from.username} the user ${fmtUser(p.target)} is a group admin and cannot be moderated`) + ) + + return ok() + } + + private async audit(p: ModerationAction) { + if (p.action === "SILENT" || p.action === "MULTI_CHAT_SPAM") return + + await api.tg.auditLog.create.mutate({ + adminId: p.from.id, + groupId: p.chat.id, + targetId: p.target.id, + type: MAP_ACTIONS[p.action], + until: "duration" in p && p.duration ? p.duration.date : null, + reason: "reason" in p ? p.reason : undefined, + }) + } + + private async perform(p: ModerationAction) { + switch (p.action) { + case "SILENT": + return true + case "KICK": + return modules.shared.api + .banChatMember(p.chat.id, p.target.id, { + until_date: Date.now() / 1000 + duration.values.m, + }) + .catch(() => false) + case "BAN": + return modules.shared.api + .banChatMember(p.chat.id, p.target.id, { + until_date: p.duration?.timestamp_s, + }) + .catch(() => false) + case "UNBAN": + return modules.shared.api.unbanChatMember(p.chat.id, p.target.id).catch(() => false) + case "MUTE": + return modules.shared.api + .restrictChatMember(p.chat.id, p.target.id, RestrictPermissions.mute, { + until_date: p.duration?.timestamp_s, + }) + .catch(() => false) + case "UNMUTE": + return modules.shared.api + .restrictChatMember(p.chat.id, p.target.id, RestrictPermissions.unmute) + .catch(() => false) + case "MULTI_CHAT_SPAM": + return Promise.all( + groupMessagesByChat(p.messages) + .keys() + .map((chatId) => + modules.shared.api + .restrictChatMember(chatId, p.from.id, RestrictPermissions.mute, { + until_date: p.duration.timestamp_s, + }) + .catch(() => false) + ) + ).then((res) => res.every((r) => r)) + } + } + + public async deleteMessages(messages: Message[], executor: User, reason: string): Promise { + if (messages.length === 0) return null + const preRes = await modules.get("tgLogger").preDelete(messages, reason, executor) + + for (const [chatId, mIds] of groupMessagesByChat(messages)) { + await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) + // TODO: check if delete goes wrong, delete logs + } + + return preRes + } + + private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { + const check = await this.checkTargetValid(p) + if (check.isErr()) return check + + const preDeleteRes = + messagesToDelete !== undefined + ? await this.deleteMessages( + messagesToDelete, + p.from, + `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` + ) + : null + + const performOk = await this.perform(p) + if (!performOk) return err("TG: Cannot perform the moderation action") // TODO: make the perform output a Result + + await modules.get("tgLogger").moderationAction({ + ...p, + preDeleteRes, + }) + + await this.audit(p) + return ok() + } + + public async ban( + target: User, + chat: Chat, + moderator: User, + duration: Duration | null, + messagesToDelete?: Message[], + reason?: string + ): Promise> { + return await this.moderate( + { action: "BAN", from: moderator, target, chat, duration: duration ?? undefined, reason }, + messagesToDelete + ) + } + + public async unban(target: User, chat: Chat, moderator: User): Promise> { + return await this.moderate({ action: "UNBAN", from: moderator, target, chat }) + } + + public async mute( + target: User, + chat: Chat, + moderator: User, + duration: Duration | null, + messagesToDelete?: Message[], + reason?: string + ): Promise> { + return await this.moderate( + { action: "MUTE", from: moderator, target, chat, duration: duration ?? undefined, reason }, + messagesToDelete + ) + } + + public async unmute(target: User, chat: Chat, moderator: User): Promise> { + return await this.moderate({ action: "UNMUTE", from: moderator, target, chat }) + } + + public async kick( + target: User, + chat: Chat, + moderator: User, + messagesToDelete?: Message[], + reason?: string + ): Promise> { + return await this.moderate({ action: "KICK", from: moderator, target, chat, reason }, messagesToDelete) + } + + public async multiChatSpam(target: User, messagesToDelete: Message[], duration: Duration) { + if (messagesToDelete.length === 0) return err("Sei stupido") + + return await this.moderate( + { + action: "MULTI_CHAT_SPAM", + from: modules.shared.botInfo, + target, + messages: messagesToDelete, + duration, + chat: messagesToDelete[0].chat, + }, + messagesToDelete + ) + } } + +export const Moderation = ModerationClass.getInstance() diff --git a/src/modules/moderation/kick.ts b/src/modules/moderation/kick.ts deleted file mode 100644 index b8c383c..0000000 --- a/src/modules/moderation/kick.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Message, User } from "grammy/types" -import { err, ok, type Result } from "neverthrow" -import { api } from "@/backend" -import { modules } from "@/modules" -import { duration } from "@/utils/duration" -import { fmt } from "@/utils/format" -import type { ContextWith } from "@/utils/types" - -interface KickProps { - ctx: ContextWith<"chat"> - from: User - target: User - message: Message - reason?: string -} - -export async function kick({ ctx, target, from, reason, message }: KickProps): Promise> { - if (target.id === from.id) return err(fmt(({ b }) => b`@${from.username} you cannot kick youself (smh)`)) - if (target.id === ctx.me.id) return err(fmt(({ b }) => b`@${from.username} you cannot kick the bot!`)) - - const chatMember = await ctx.getChatMember(target.id).catch(() => null) - if (chatMember?.status === "administrator" || chatMember?.status === "creator") - return err(fmt(({ b }) => b`@${from.username} the user @${target.username} cannot be kicked (admin)`)) - - const until_date = Math.floor(Date.now() / 1000) + duration.values.m - await ctx.banChatMember(target.id, { until_date }) - void api.tg.auditLog.create.mutate({ - targetId: target.id, - adminId: from.id, - groupId: ctx.chat.id, - until: null, - reason, - type: "kick", - }) - - const tgLogger = modules.get("tgLogger") - const preDeleteRes = await tgLogger.preDelete([message], reason ?? "Kick", from) - await ctx.deleteMessages([message.message_id]) - - const res = await tgLogger.moderationAction({ - action: "KICK", - from, - target, - reason, - preDeleteRes, - chat: ctx.chat, - }) - - return ok(res) -} diff --git a/src/modules/moderation/mute.ts b/src/modules/moderation/mute.ts deleted file mode 100644 index 7601bb0..0000000 --- a/src/modules/moderation/mute.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { Message, User } from "grammy/types" -import { err, ok, type Result } from "neverthrow" -import type { z } from "zod" -import { api } from "@/backend" -import { modules } from "@/modules" -import { RestrictPermissions } from "@/utils/chat" -import type { duration } from "@/utils/duration" -import { fmt, fmtUser } from "@/utils/format" -import type { ContextWith } from "@/utils/types" - -interface MuteProps { - /** The context within which the mute was dispatched, will be used to identify the chat mute */ - ctx: ContextWith<"chat"> - /** Message upon which mute is called, will be deleted */ - message: Message - /** The user that dispatched the mute command */ - from: User - /** The user that is gonna be muted */ - target: User - reason?: string - /** duration parsed with utility zod type {@link duration} */ - duration?: z.output -} - -export async function mute({ - ctx, - from, - target, - reason, - duration, - message, -}: MuteProps): Promise> { - if (target.id === from.id) return err(fmt(({ b }) => b`@${from.username} you cannot mute youself (smh)`)) - if (target.id === ctx.me.id) return err(fmt(({ b }) => b`@${from.username} you cannot mute the bot!`)) - - const chatMember = await ctx.getChatMember(target.id).catch(() => null) - if (chatMember?.status === "administrator" || chatMember?.status === "creator") - return err(fmt(({ b }) => b`@${from.username} the user ${fmtUser(target)} cannot be muted`)) - - await ctx.restrictChatMember(target.id, RestrictPermissions.mute, { until_date: duration?.timestamp_s }) - void api.tg.auditLog.create.mutate({ - targetId: target.id, - adminId: from.id, - groupId: ctx.chat.id, - until: duration?.date ?? null, - reason, - type: "mute", - }) - - const tgLogger = modules.get("tgLogger") - const preDeleteRes = await tgLogger.preDelete([message], reason ?? "Mute", from) - await ctx.deleteMessages([message.message_id]) - - const res = await tgLogger.moderationAction({ - action: "MUTE", - chat: ctx.chat, - from, - target, - duration, - preDeleteRes, - reason, - }) - - return ok(res) -} - -interface UnmuteProps { - ctx: ContextWith<"chat"> - from: User - targetId: number -} - -export async function unmute({ ctx, targetId, from }: UnmuteProps): Promise> { - if (targetId === from.id) return err(fmt(({ b }) => b`@${from.username} you cannot unmute youself (smh)`)) - if (targetId === ctx.me.id) return err(fmt(({ b }) => b`@${from.username} you cannot unmute the bot!`)) - - const target = await ctx.getChatMember(targetId).catch(() => null) - if (!target) return err(fmt(({ b }) => b`@${from.username} this user is not in this chat`)) - - if (target.status !== "restricted" || target.can_send_messages) - return err(fmt(({ b }) => b`@${from.username} this user is not muted`)) - - await ctx.restrictChatMember(target.user.id, RestrictPermissions.unmute) - return ok( - await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from, target: target.user, chat: ctx.chat }) - ) -} diff --git a/src/utils/duration.ts b/src/utils/duration.ts index 491858d..0d768da 100644 --- a/src/utils/duration.ts +++ b/src/utils/duration.ts @@ -6,7 +6,7 @@ const DURATIONS = ["m", "h", "d", "w"] as const type DurationUnit = (typeof DURATIONS)[number] const durationRegex = new RegExp(`(\\d+)[${DURATIONS.join("")}]`) -type Duration = { +export type Duration = { raw: string date: Date timestamp_s: number diff --git a/src/utils/types.ts b/src/utils/types.ts index 0703c63..53b0903 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,7 +1,7 @@ import type { Api, Context as TContext } from "grammy" -import type { UserFromGetMe } from "grammy/types" +import type { User, UserFromGetMe } from "grammy/types" import z from "zod" -import type { ApiInput } from "@/backend" +import type { ApiInput, ApiOutput } from "@/backend" import type { ManagedCommandsFlavor } from "@/lib/managed-commands" export type OptionalPropertyOf = Exclude< @@ -29,3 +29,14 @@ export const numberOrString = z.string().transform((s) => { if (!Number.isNaN(n) && s.trim() !== "") return n return s }) + +export const toGrammyUser = (apiUser: Exclude): User => ({ + id: apiUser.id, + is_bot: apiUser.isBot, + first_name: apiUser.firstName, + last_name: apiUser.lastName, + username: apiUser.username, + language_code: apiUser.langCode, + is_premium: undefined, + added_to_attachment_menu: undefined, +}) From e6ae507377895a696203e5aa261313d51c3a8ec0 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 29 Jan 2026 20:22:40 +0100 Subject: [PATCH 05/22] feat: getUser function, use Moderation in tgLogger menus --- src/commands/ban.ts | 11 +++++------ src/commands/mute.ts | 11 +++++------ src/modules/moderation/index.ts | 19 +++++++++++++------ src/modules/tg-logger/grants.ts | 12 +++++++----- src/modules/tg-logger/report.ts | 26 +++++++++++++++++--------- src/utils/users.ts | 19 +++++++++++++++++++ 6 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 src/utils/users.ts diff --git a/src/commands/ban.ts b/src/commands/ban.ts index bac4123..1e2148a 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,10 +1,9 @@ -import { api } from "@/backend" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import { toGrammyUser } from "@/utils/types" +import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" @@ -90,16 +89,16 @@ _commandsBase return } - const { user, error } = await api.tg.users.get.query({ userId }) - if (!user) { + const user = await getUser(userId) + if (user.isErr()) { const msg = await context.reply("Error: cannot find this user") - logger.error({ error }, "UNBAN: error while retrieving the user") + logger.error({ error: user.error }, "UNBAN: error while retrieving the user") await wait(5000) await msg.delete() return } - const res = await Moderation.unban(toGrammyUser(user), context.chat, context.from) + const res = await Moderation.unban(user.value, context.chat, context.from) const msg = await context.reply(res.isErr() ? res.error : "OK") await wait(5000) await msg.delete() diff --git a/src/commands/mute.ts b/src/commands/mute.ts index d535a77..7ffa349 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,10 +1,9 @@ -import { api } from "@/backend" import { logger } from "@/logger" import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import { toGrammyUser } from "@/utils/types" +import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" @@ -90,16 +89,16 @@ _commandsBase return } - const { user, error } = await api.tg.users.get.query({ userId }) - if (!user) { + const user = await getUser(userId) + if (user.isErr()) { const msg = await context.reply("Error: cannot find this user") - logger.error({ error }, "UNMUTE: error while retrieving the user") + logger.error({ error: user.error }, "UNMUTE: error while retrieving the user") await wait(5000) await msg.delete() return } - const res = await Moderation.unmute(toGrammyUser(user), context.chat, context.from) + const res = await Moderation.unmute(user.value, context.chat, context.from) const msg = await context.reply(res.isErr() ? res.error : "OK") await wait(5000) await msg.delete() diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 1332bc6..4e256be 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -101,16 +101,23 @@ class ModerationClass { } } - public async deleteMessages(messages: Message[], executor: User, reason: string): Promise { - if (messages.length === 0) return null + public async deleteMessages( + messages: Message[], + executor: User, + reason: string + ): Promise> { + if (messages.length === 0) return ok(null) const preRes = await modules.get("tgLogger").preDelete(messages, reason, executor) for (const [chatId, mIds] of groupMessagesByChat(messages)) { - await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) - // TODO: check if delete goes wrong, delete logs + const res = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) + if (!res) { + // TODO: delete preRes messages + return err("DELETE_ERROR") + } } - return preRes + return ok(preRes) } private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { @@ -131,7 +138,7 @@ class ModerationClass { await modules.get("tgLogger").moderationAction({ ...p, - preDeleteRes, + preDeleteRes: preDeleteRes?.unwrapOr(null), }) await this.audit(p) diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts index 0dbd6c4..082772f 100644 --- a/src/modules/tg-logger/grants.ts +++ b/src/modules/tg-logger/grants.ts @@ -4,6 +4,7 @@ import { type ApiOutput, api } from "@/backend" import { type CallbackCtx, MenuGenerator } from "@/lib/menu" import { logger } from "@/logger" import { modules } from ".." +import { Moderation } from "../moderation" type GrantedMessage = { message: Message @@ -44,12 +45,13 @@ async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Pr const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) if (!roles?.includes("direttivo")) return { error: "UNAUTHORIZED" } - await modules - .get("tgLogger") - .preDelete([data.message], "[GRANT] Manual deletion of message sent by granted user", ctx.from) + const res = await Moderation.deleteMessages( + [data.message], + ctx.from, + "[GRANT] Manual deletion of message sent by granted user" + ) - const ok = await ctx.api.deleteMessages(data.message.chat.id, [data.message.message_id]).catch(() => false) - if (!ok) { + if (res.isErr()) { return { error: "CANNOT_DELETE", } diff --git a/src/modules/tg-logger/report.ts b/src/modules/tg-logger/report.ts index 5142ac8..bc91372 100644 --- a/src/modules/tg-logger/report.ts +++ b/src/modules/tg-logger/report.ts @@ -1,9 +1,9 @@ import type { Context } from "grammy" import type { Message, User } from "grammy/types" import { type CallbackCtx, MenuGenerator } from "@/lib/menu" -import { duration } from "@/utils/duration" import { fmt, fmtChat, fmtDate, fmtUser } from "@/utils/format" import { modules } from ".." +import { Moderation } from "../moderation" export type Report = { message: Message & { from: User } @@ -81,7 +81,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "🗑 Del", cb: async ({ data, ctx }) => { - await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) + await Moderation.deleteMessages([data.message], ctx.from, "[REPORT] resolved with delete") await editReportMessage(data, ctx, "🗑 Delete") return null }, @@ -91,11 +91,13 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "👢 Kick", cb: async ({ data, ctx }) => { - await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) - await ctx.api.banChatMember(data.message.chat.id, data.message.from.id, { - // kick = ban for 1 minute, kick is not a thing in Telegram - until_date: Math.floor(Date.now() / 1000) + duration.values.m, - }) + await Moderation.kick( + data.message.from, + data.message.chat, + ctx.from, + [data.message], + "[REPORT] resolved with kick" + ) await editReportMessage(data, ctx, "👢 Kick") return null }, @@ -103,8 +105,14 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "🚫 Ban", cb: async ({ data, ctx }) => { - await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) - await ctx.api.banChatMember(data.message.chat.id, data.message.from.id) + await Moderation.ban( + data.message.from, + data.message.chat, + ctx.from, + null, + [data.message], + "[REPORT] resolved with ban" + ) await editReportMessage(data, ctx, "🚫 Ban") return null }, diff --git a/src/utils/users.ts b/src/utils/users.ts new file mode 100644 index 0000000..28e51fe --- /dev/null +++ b/src/utils/users.ts @@ -0,0 +1,19 @@ +import type { Context } from "grammy" +import type { User } from "grammy/types" +import { err, ok, type Result } from "neverthrow" +import { api } from "@/backend" +import { toGrammyUser } from "./types" + +export async function getUser(userId: number, ctx?: C): Promise> { + const chatUser = await ctx + ?.getChatMember(userId) + .then((r) => r.user) + .catch(() => null) + + if (chatUser) return ok(chatUser) + + const { user, error } = await api.tg.users.get.query({ userId }) + if (user) return ok(toGrammyUser(user)) + + return err(error) +} From 25e8442e468307b9e0a16128d75847df3f0623f2 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 29 Jan 2026 22:25:43 +0100 Subject: [PATCH 06/22] fix: some revision --- src/middlewares/auto-moderation-stack/index.ts | 12 +++++++++--- src/middlewares/group-specific-actions.ts | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index 7ad0d76..cd8f248 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -197,7 +197,7 @@ export class AutoModerationStack implements MiddlewareObj ctx.from, ctx.chat, ctx.me, - duration.zod.parse("1d"), // 1 minute + duration.zod.parse("1d"), [message], `Automatic moderation detected harmful content\n${reasons}` ) @@ -242,14 +242,20 @@ export class AutoModerationStack implements MiddlewareObj // longer messages can have more non-latin characters, but less in percentage if (match && (match.length - NON_LATIN.LENGTH_THR) / text.length > NON_LATIN.PERCENTAGE_THR) { // just delete the message and mute the user for 10 minutes - await Moderation.mute( + const res = await Moderation.mute( ctx.from, ctx.chat, ctx.me, - duration.zod.parse(NON_LATIN.MUTE_DURATION), // 1 minute + duration.zod.parse(NON_LATIN.MUTE_DURATION), [ctx.message], "Message contains non-latin characters" ) + if (res.isErr()) { + logger.error( + { from: ctx.from, chat: ctx.chat, messageId: ctx.message.message_id }, + "AUTOMOD: nonLatinHandler - Cannot mute" + ) + } } } diff --git a/src/middlewares/group-specific-actions.ts b/src/middlewares/group-specific-actions.ts index a5b1246..20a93da 100644 --- a/src/middlewares/group-specific-actions.ts +++ b/src/middlewares/group-specific-actions.ts @@ -69,6 +69,7 @@ export class GroupSpecificActions implements MiddlewareObj await modules .get("tgLogger") .preDelete([ctx.message], `User did not follow group rules:\n${check.error}`, ctx.me) + await ctx.deleteMessage() const reply = await ctx.reply( From 4754d7a0fec6bb6db319908b163fb2f73118d7b4 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <66379281+lorenzocorallo@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:01:32 +0100 Subject: [PATCH 07/22] fix: typo and wrong field in multiChatSpam Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/moderation/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 4e256be..16ece5d 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -32,7 +32,7 @@ class ModerationClass { private constructor() {} private async checkTargetValid(p: ModerationAction): Promise> { - if (p.target.id === p.from.id) return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate youself (smh)`)) + if (p.target.id === p.from.id) return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate yourself (smh)`)) if (p.target.id === modules.shared.botInfo.id) return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate the bot!`)) @@ -92,7 +92,7 @@ class ModerationClass { .keys() .map((chatId) => modules.shared.api - .restrictChatMember(chatId, p.from.id, RestrictPermissions.mute, { + .restrictChatMember(chatId, p.target.id, RestrictPermissions.mute, { until_date: p.duration.timestamp_s, }) .catch(() => false) From 94edd3e51075959aad36e0edb62efb1a0abaf559 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 00:03:08 +0100 Subject: [PATCH 08/22] fix: handle API call error in getUser --- src/utils/users.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/utils/users.ts b/src/utils/users.ts index 28e51fe..ff59f24 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -2,6 +2,7 @@ import type { Context } from "grammy" import type { User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import { api } from "@/backend" +import { logger } from "@/logger" import { toGrammyUser } from "./types" export async function getUser(userId: number, ctx?: C): Promise> { @@ -12,8 +13,13 @@ export async function getUser(userId: number, ctx?: C): Promi if (chatUser) return ok(chatUser) - const { user, error } = await api.tg.users.get.query({ userId }) - if (user) return ok(toGrammyUser(user)) + try { + const { user, error } = await api.tg.users.get.query({ userId }) + if (user) return ok(toGrammyUser(user)) - return err(error) + return err(error) + } catch (error) { + logger.error({ error }, "getUser: error while calling the API") + return err("INTERNAL_SERVER_ERROR") + } } From f10da09899ef24dae795615592dfe1ac7cc86aa4 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 00:12:35 +0100 Subject: [PATCH 09/22] feat: support retrieving user from memoryStorage In the rare case when we need to get an user by userId, the context cannot retrieve it and the user is yet to be synced to the backend, we can retrieve it from the temporary map. --- src/middlewares/message-user-storage.ts | 17 +++++++++++++++++ src/utils/users.ts | 19 +++---------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/middlewares/message-user-storage.ts b/src/middlewares/message-user-storage.ts index 030d72a..7aafc84 100644 --- a/src/middlewares/message-user-storage.ts +++ b/src/middlewares/message-user-storage.ts @@ -4,6 +4,7 @@ import type { User } from "grammy/types" import { type ApiInput, api } from "@/backend" import { logger } from "@/logger" import { padChatId } from "@/utils/chat" +import { toGrammyUser } from "@/utils/types" export type Message = Parameters[0]["messages"][0] type DBUsers = ApiInput["tg"]["users"]["add"]["users"] @@ -80,6 +81,22 @@ export class MessageUserStorage implements MiddlewareObj { this.memoryStorage = [] } + public async getStoredUser(userId: number): Promise { + const fromMemory = this.userStorage.get(userId) + if (fromMemory) return fromMemory + + try { + const fromBackend = await api.tg.users.get.query({ userId }) + if (fromBackend.user) return toGrammyUser(fromBackend.user) + + if (fromBackend.error !== "NOT_FOUND") + logger.error({ error: fromBackend.error }, "userStorage: error from API while retrieving user from backend") + } catch (error) { + logger.error({ error }, "userStorage: error while calling API for retrieving user from backend") + } + return null + } + private async syncUsers(): Promise { if (this.userStorage.size === 0) return const users: DBUsers = this.userStorage diff --git a/src/utils/users.ts b/src/utils/users.ts index ff59f24..e6a3659 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -1,25 +1,12 @@ import type { Context } from "grammy" import type { User } from "grammy/types" -import { err, ok, type Result } from "neverthrow" -import { api } from "@/backend" -import { logger } from "@/logger" -import { toGrammyUser } from "./types" +import { MessageUserStorage } from "@/middlewares/message-user-storage" -export async function getUser(userId: number, ctx?: C): Promise> { +export async function getUser(userId: number, ctx?: C): Promise { const chatUser = await ctx ?.getChatMember(userId) .then((r) => r.user) .catch(() => null) - if (chatUser) return ok(chatUser) - - try { - const { user, error } = await api.tg.users.get.query({ userId }) - if (user) return ok(toGrammyUser(user)) - - return err(error) - } catch (error) { - logger.error({ error }, "getUser: error while calling the API") - return err("INTERNAL_SERVER_ERROR") - } + return chatUser ?? MessageUserStorage.getInstance().getStoredUser(userId) } From 97610045cda42f735dc25de37ca48ce813b779bc Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 00:16:31 +0100 Subject: [PATCH 10/22] fix: new getUser return type --- src/commands/ban.ts | 6 +++--- src/commands/mute.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 1e2148a..f8f69f9 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -90,15 +90,15 @@ _commandsBase } const user = await getUser(userId) - if (user.isErr()) { + if (!user) { const msg = await context.reply("Error: cannot find this user") - logger.error({ error: user.error }, "UNBAN: error while retrieving the user") + logger.error({ userId }, "UNBAN: cannot retrieve the user") await wait(5000) await msg.delete() return } - const res = await Moderation.unban(user.value, context.chat, context.from) + const res = await Moderation.unban(user, context.chat, context.from) const msg = await context.reply(res.isErr() ? res.error : "OK") await wait(5000) await msg.delete() diff --git a/src/commands/mute.ts b/src/commands/mute.ts index 7ffa349..f9ba70e 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -90,15 +90,15 @@ _commandsBase } const user = await getUser(userId) - if (user.isErr()) { + if (!user) { const msg = await context.reply("Error: cannot find this user") - logger.error({ error: user.error }, "UNMUTE: error while retrieving the user") + logger.error({ userId }, "UNMUTE: cannot retrieve the user") await wait(5000) await msg.delete() return } - const res = await Moderation.unmute(user.value, context.chat, context.from) + const res = await Moderation.unmute(user, context.chat, context.from) const msg = await context.reply(res.isErr() ? res.error : "OK") await wait(5000) await msg.delete() From e356233b1ae9d34b484118a1f53d9d4100fb5635 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 01:31:23 +0100 Subject: [PATCH 11/22] feat: handle preDel abort, better Moderation errors --- src/commands/ban.ts | 6 +- src/commands/kick.ts | 2 +- src/commands/mute.ts | 6 +- .../auto-moderation-stack/index.ts | 4 +- src/modules/moderation/index.ts | 106 ++++++++++++++---- src/modules/tg-logger/grants.ts | 10 +- src/modules/tg-logger/index.ts | 61 ++++++---- src/modules/tg-logger/report.ts | 24 +++- 8 files changed, 157 insertions(+), 62 deletions(-) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index f8f69f9..566d419 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -26,7 +26,7 @@ _commandsBase } const res = await Moderation.ban(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, @@ -64,7 +64,7 @@ _commandsBase [repliedTo], args.reason ) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, @@ -99,7 +99,7 @@ _commandsBase } const res = await Moderation.unban(user, context.chat, context.from) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, diff --git a/src/commands/kick.ts b/src/commands/kick.ts index 01c4e54..66b3689 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -22,7 +22,7 @@ _commandsBase.createCommand({ } const res = await Moderation.kick(repliedTo.from, context.chat, context.from, [repliedTo], args.reason) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, diff --git a/src/commands/mute.ts b/src/commands/mute.ts index f9ba70e..d3a9077 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -41,7 +41,7 @@ _commandsBase [repliedTo], args.reason ) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, @@ -64,7 +64,7 @@ _commandsBase } const res = await Moderation.mute(repliedTo.from, context.chat, context.from, null, [repliedTo], args.reason) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, @@ -99,7 +99,7 @@ _commandsBase } const res = await Moderation.unmute(user, context.chat, context.from) - const msg = await context.reply(res.isErr() ? res.error : "OK") + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") await wait(5000) await msg.delete() }, diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index cd8f248..e3bde46 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -163,7 +163,7 @@ export class AutoModerationStack implements MiddlewareObj "The link you shared is not allowed.", "Please refrain from sharing links that could be considered spam", ]) - : res.error + : res.error.fmtError ) await wait(5000) await msg.delete() @@ -208,7 +208,7 @@ export class AutoModerationStack implements MiddlewareObj 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.`, ]) - : res.error + : res.error.fmtError ) await wait(5000) await msg.delete() diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 16ece5d..4ec6e3b 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -1,6 +1,7 @@ import type { Chat, Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import { type ApiInput, api } from "@/backend" +import { logger } from "@/logger" import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { type Duration, duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -20,6 +21,9 @@ const MAP_ACTIONS: Record< MUTE_ALL: "mute_all", } +type ModerationErrorCode = "CANNOT_MOD_YOURSELF" | "CANNOT_MOD_BOT" | "CANNOT_MOD_GROUPADMIN" | "PERFORM_ERROR" +type ModerationError = { code: ModerationErrorCode; fmtError: string; strError: string } + class ModerationClass { private static instance: ModerationClass | null = null static getInstance(): ModerationClass { @@ -31,16 +35,44 @@ class ModerationClass { private constructor() {} - private async checkTargetValid(p: ModerationAction): Promise> { - if (p.target.id === p.from.id) return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate yourself (smh)`)) - if (p.target.id === modules.shared.botInfo.id) - return err(fmt(({ b }) => b`@${p.from.username} you cannot moderate the bot!`)) + private getModerationError(p: ModerationAction, code: ModerationErrorCode): ModerationError { + // biome-ignore lint/nursery/noUnnecessaryConditions: lying + switch (code) { + case "CANNOT_MOD_BOT": + return { + code, + fmtError: fmt(({ b }) => b`@${p.from.username} you cannot moderate the bot!`), + strError: "You cannot moderate the bot", + } + case "CANNOT_MOD_YOURSELF": + return { + code, + fmtError: fmt(({ b }) => b`@${p.from.username} you cannot moderate yourself (smh)`), + strError: "You cannot moderate yourself", + } + case "CANNOT_MOD_GROUPADMIN": + return { + code, + fmtError: fmt( + ({ b }) => b`@${p.from.username} the user ${fmtUser(p.target)} is a group admin and cannot be moderated` + ), + strError: "You cannot moderate a group admin", + } + case "PERFORM_ERROR": + return { + code, + fmtError: fmt(() => "TG: Cannot perform the moderation action"), + strError: "There was an error perfoming the moderation action", + } + } + } + + private async checkTargetValid(p: ModerationAction): Promise> { + if (p.target.id === p.from.id) return err("CANNOT_MOD_YOURSELF") + if (p.target.id === modules.shared.botInfo.id) return err("CANNOT_MOD_BOT") const chatMember = await modules.shared.api.getChatMember(p.chat.id, p.target.id).catch(() => null) - if (chatMember?.status === "administrator" || chatMember?.status === "creator") - return err( - fmt(({ b }) => b`@${p.from.username} the user ${fmtUser(p.target)} is a group admin and cannot be moderated`) - ) + if (chatMember?.status === "administrator" || chatMember?.status === "creator") return err("CANNOT_MOD_GROUPADMIN") return ok() } @@ -105,24 +137,45 @@ class ModerationClass { messages: Message[], executor: User, reason: string - ): Promise> { + ): Promise> { if (messages.length === 0) return ok(null) + const preRes = await modules.get("tgLogger").preDelete(messages, reason, executor) + if (preRes === null || preRes.count === 0) return err("NOT_FOUND") + let delCount = 0 for (const [chatId, mIds] of groupMessagesByChat(messages)) { - const res = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) - if (!res) { - // TODO: delete preRes messages - return err("DELETE_ERROR") - } + const ok = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) + if (ok) delCount += mIds.length + } + + if (delCount === 0) { + logger.error( + { initialMessages: messages, executor, forwaredCount: preRes.count, deletedCount: 0 }, + "[Moderation:deleteMessages] no message(s) could be deleted" + ) + return err("DELETE_ERROR") + } + + if (delCount / preRes.count < 0.2) { + logger.warn( + { + initialMessages: messages, + executor, + forwaredCount: preRes.count, + deletedCount: delCount, + deletedPercentage: (delCount / preRes.count).toFixed(3), + }, + "[Moderation:deleteMessages] delete count is much lower than forwarded count" + ) } return ok(preRes) } - private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { + private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { const check = await this.checkTargetValid(p) - if (check.isErr()) return check + if (check.isErr()) return err(this.getModerationError(p, check.error)) const preDeleteRes = messagesToDelete !== undefined @@ -134,7 +187,7 @@ class ModerationClass { : null const performOk = await this.perform(p) - if (!performOk) return err("TG: Cannot perform the moderation action") // TODO: make the perform output a Result + if (!performOk) return err(this.getModerationError(p, "PERFORM_ERROR")) // TODO: make the perform output a Result await modules.get("tgLogger").moderationAction({ ...p, @@ -152,14 +205,14 @@ class ModerationClass { duration: Duration | null, messagesToDelete?: Message[], reason?: string - ): Promise> { + ): Promise> { return await this.moderate( { action: "BAN", from: moderator, target, chat, duration: duration ?? undefined, reason }, messagesToDelete ) } - public async unban(target: User, chat: Chat, moderator: User): Promise> { + public async unban(target: User, chat: Chat, moderator: User): Promise> { return await this.moderate({ action: "UNBAN", from: moderator, target, chat }) } @@ -170,14 +223,14 @@ class ModerationClass { duration: Duration | null, messagesToDelete?: Message[], reason?: string - ): Promise> { + ): Promise> { return await this.moderate( { action: "MUTE", from: moderator, target, chat, duration: duration ?? undefined, reason }, messagesToDelete ) } - public async unmute(target: User, chat: Chat, moderator: User): Promise> { + public async unmute(target: User, chat: Chat, moderator: User): Promise> { return await this.moderate({ action: "UNMUTE", from: moderator, target, chat }) } @@ -187,12 +240,17 @@ class ModerationClass { moderator: User, messagesToDelete?: Message[], reason?: string - ): Promise> { + ): Promise> { return await this.moderate({ action: "KICK", from: moderator, target, chat, reason }, messagesToDelete) } - public async multiChatSpam(target: User, messagesToDelete: Message[], duration: Duration) { - if (messagesToDelete.length === 0) return err("Sei stupido") + public async multiChatSpam( + target: User, + messagesToDelete: Message[], + duration: Duration + ): Promise> { + if (messagesToDelete.length === 0) + throw new Error("[Moderation:multiChatSpam] passed an empty messagesToDelete array") return await this.moderate( { diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts index 082772f..d027d4d 100644 --- a/src/modules/tg-logger/grants.ts +++ b/src/modules/tg-logger/grants.ts @@ -25,7 +25,7 @@ async function handleInterrupt(ctx: CallbackCtx, target: User) { return { error: null } } -type Error = ApiOutput["tg"]["grants"]["interrupt"]["error"] | "CANNOT_DELETE" | null +type Error = ApiOutput["tg"]["grants"]["interrupt"]["error"] | "DELETE_ERROR" | "DELETE_NOT_FOUND" | null const getFeedback = (error: Error): string | null => { switch (error) { case null: @@ -36,8 +36,10 @@ const getFeedback = (error: Error): string | null => { 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" + case "DELETE_NOT_FOUND": + return "☑️ Message already deleted or is unreachable" + case "DELETE_ERROR": + return "⁉️ Cannot delete message, please check logs" } } @@ -53,7 +55,7 @@ async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Pr if (res.isErr()) { return { - error: "CANNOT_DELETE", + error: res.error === "NOT_FOUND" ? "DELETE_NOT_FOUND" : "DELETE_ERROR", } } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index d5a7625..deeb7c0 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -62,29 +62,37 @@ export class TgLogger extends Module { }) } - private async forward(topicId: number, chatId: number, messageIds: number[]): Promise { - await this.shared.api - .forwardMessages(this.groupId, chatId, messageIds, { + private async forward(topicId: number, chatId: number, messageIds: number[]): Promise { + if (messageIds.length === 0) return [] + + try { + const res = await this.shared.api.forwardMessages(this.groupId, chatId, messageIds, { message_thread_id: topicId, disable_notification: true, }) - .catch(async (e: unknown) => { - if (e instanceof GrammyError) { - if (e.description === "Bad Request: message to forward not found") { - await this.log( - topicId, - fmt(({ b, i }) => [b`Could not forward the message`, i`It probably was deleted before forwarding`], { - sep: "\n", - }) - ) - } else { - await this.exception({ type: "BOT_ERROR", error: e }, "TgLogger.forward") - logger.error({ e }, "[TgLogger:forward] There was an error while trying to forward a message") - } - } else if (e instanceof Error) { - await this.exception({ type: "GENERIC", error: e }, "TgLogger.forward") + return res.map((r) => r.message_id) + } catch (e) { + if (e instanceof GrammyError) { + if ( + e.description.includes("message to forward not found") || + e.description.includes("there are no messages to forward") + ) { + logger.warn({ e }, "[TgLogger:forward] Message(s) to forward not found") + // await this.log( + // topicId, + // fmt(({ b, i }) => [b`Could not forward the message`, i`It probably was deleted before forwarding`], { + // sep: "\n", + // }) + // ) + } else { + await this.exception({ type: "BOT_ERROR", error: e }, "TgLogger.forward") + logger.error({ e }, "[TgLogger:forward] There was an error while trying to forward a message") } - }) + } else if (e instanceof Error) { + await this.exception({ type: "GENERIC", error: e }, "TgLogger.forward") + } + } + return [] } public async report(message: Message, reporter: User): Promise { @@ -106,6 +114,7 @@ export class TgLogger extends Module { } // NOTE: this does not delete the messages + // TODO: better return type async preDelete( messages: Message[], reason: string, @@ -127,15 +136,23 @@ export class TgLogger extends Module { { sep: "\n" } ) ) - if (!sent) return null + const forwardedIds: number[] = [] for (const [chatId, mIds] of groupMessagesByChat(messages)) { - await this.forward(this.topics.deletedMessages, chatId, mIds) + if (mIds.length === 0) continue + forwardedIds.push(...(await this.forward(this.topics.deletedMessages, chatId, mIds))) + } + + logger.debug({ forwardedIds }, "preDel") + + if (forwardedIds.length === 0) { + void this.shared.api.deleteMessage(this.groupId, sent.message_id).catch(() => {}) + return null } return { - count: messages.length, + count: forwardedIds.length, link: `https://t.me/c/${stripChatId(this.groupId)}/${this.topics.deletedMessages}/${sent.message_id}`, } } diff --git a/src/modules/tg-logger/report.ts b/src/modules/tg-logger/report.ts index bc91372..faab03d 100644 --- a/src/modules/tg-logger/report.ts +++ b/src/modules/tg-logger/report.ts @@ -81,7 +81,15 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "🗑 Del", cb: async ({ data, ctx }) => { - await Moderation.deleteMessages([data.message], ctx.from, "[REPORT] resolved with delete") + const res = await Moderation.deleteMessages([data.message], ctx.from, "[REPORT] resolved with delete") + if (res.isErr()) + return { + feedback: + res.error === "DELETE_ERROR" + ? "❌ There was an error deleting the message(s)" + : "☑️ Message(s) already deleted or unreachable", + } + await editReportMessage(data, ctx, "🗑 Delete") return null }, @@ -91,13 +99,18 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "👢 Kick", cb: async ({ data, ctx }) => { - await Moderation.kick( + const res = await Moderation.kick( data.message.from, data.message.chat, ctx.from, [data.message], "[REPORT] resolved with kick" ) + if (res.isErr()) + return { + feedback: `❌ ${res.error.strError}`, + } + await editReportMessage(data, ctx, "👢 Kick") return null }, @@ -105,7 +118,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "🚫 Ban", cb: async ({ data, ctx }) => { - await Moderation.ban( + const res = await Moderation.ban( data.message.from, data.message.chat, ctx.from, @@ -113,6 +126,11 @@ export const reportMenu = MenuGenerator.getInstance().create("r [data.message], "[REPORT] resolved with ban" ) + if (res.isErr()) + return { + feedback: `❌ ${res.error.strError}`, + } + await editReportMessage(data, ctx, "🚫 Ban") return null }, From 2845b5d61774c80d0cb423d748617b8dc01466a9 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 01:39:12 +0100 Subject: [PATCH 12/22] fix: missing moderator in moderationAction log --- src/modules/moderation/index.ts | 1 + src/modules/tg-logger/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 4ec6e3b..3e17c45 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -24,6 +24,7 @@ const MAP_ACTIONS: Record< type ModerationErrorCode = "CANNOT_MOD_YOURSELF" | "CANNOT_MOD_BOT" | "CANNOT_MOD_GROUPADMIN" | "PERFORM_ERROR" type ModerationError = { code: ModerationErrorCode; fmtError: string; strError: string } +// TODO: missing in-channel user feedback (eg. has been muted by ...) class ModerationClass { private static instance: ModerationClass | null = null static getInstance(): ModerationClass { diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index deeb7c0..2390b23 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -255,6 +255,7 @@ export class TgLogger extends Module { skip`${MOD_ACTION_TITLE(props)}`, n`${b`Target:`} ${fmtUser(props.target)}`, + !isAutoModeration ? n`${b`Moderator:`} ${fmtUser(props.from)}` : undefined, // for MULTI_CHAT we have specific per-chat info props.action !== "MULTI_CHAT_SPAM" ? `${b`Group:`} ${fmtChat(props.chat, invite_link)}` : undefined, From 3cad9b541818a0a3fd5f7607aa4f68696d78d024 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 01:49:01 +0100 Subject: [PATCH 13/22] fix: command /del not using the new Moderation method --- src/commands/del.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/commands/del.ts b/src/commands/del.ts index 35011b6..fb73bda 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -1,6 +1,7 @@ import { logger } from "@/logger" -import { modules } from "@/modules" +import { Moderation } from "@/modules/moderation" import { getText } from "@/utils/messages" +import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" _commandsBase.createCommand({ @@ -13,6 +14,7 @@ _commandsBase.createCommand({ description: "Deletes the replied to message", reply: "required", handler: async ({ repliedTo, context }) => { + await context.deleteMessage() const { text, type } = getText(repliedTo) logger.info({ action: "delete_message", @@ -21,10 +23,10 @@ _commandsBase.createCommand({ sender: repliedTo.from?.username, }) - await modules.get("tgLogger").preDelete([repliedTo], "Command /del", context.from) // actual message to delete - await Promise.all([ - context.deleteMessages([repliedTo.message_id]), - context.deleteMessage(), // /del message - ]) + const res = await Moderation.deleteMessages([repliedTo], context.from, "Command /del") + // TODO: better error and ok response + const msg = await context.reply(res.isErr() ? "Cannot delete the message" : "OK") + await wait(5000) + await msg.delete() }, }) From 9d8005756dfaf3efaafe0181ab67b13b4f3b1df6 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 01:49:24 +0100 Subject: [PATCH 14/22] feat: delete preLogs when forwarded but not deleted message(s) --- src/modules/moderation/index.ts | 4 +++- src/modules/tg-logger/index.ts | 3 ++- src/modules/tg-logger/types.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 3e17c45..a7bcfb9 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -141,7 +141,8 @@ class ModerationClass { ): Promise> { if (messages.length === 0) return ok(null) - const preRes = await modules.get("tgLogger").preDelete(messages, reason, executor) + const tgLogger = modules.get("tgLogger") + const preRes = await tgLogger.preDelete(messages, reason, executor) if (preRes === null || preRes.count === 0) return err("NOT_FOUND") let delCount = 0 @@ -155,6 +156,7 @@ class ModerationClass { { initialMessages: messages, executor, forwaredCount: preRes.count, deletedCount: 0 }, "[Moderation:deleteMessages] no message(s) could be deleted" ) + void modules.shared.api.deleteMessages(tgLogger.groupId, preRes.logMessageIds) return err("DELETE_ERROR") } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 2390b23..5eb3b04 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -35,7 +35,7 @@ const MOD_ACTION_TITLE = (props: Types.ModerationAction) => export class TgLogger extends Module { constructor( - private groupId: number, + public readonly groupId: number, private topics: Topics ) { super() @@ -152,6 +152,7 @@ export class TgLogger extends Module { } return { + logMessageIds: [sent.message_id, ...forwardedIds], count: forwardedIds.length, link: `https://t.me/c/${stripChatId(this.groupId)}/${this.topics.deletedMessages}/${sent.message_id}`, } diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index 69c5490..69a074a 100644 --- a/src/modules/tg-logger/types.ts +++ b/src/modules/tg-logger/types.ts @@ -89,6 +89,7 @@ export type GroupManagement = { export type PreDeleteResult = { count: number + logMessageIds: number[] link: string } From 44b31fb4ade51da17404e094576a9336158f3085 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 01:55:05 +0100 Subject: [PATCH 15/22] fix: username arg with custom type, pass ctx to getUser --- src/commands/ban.ts | 9 ++++++--- src/commands/mute.ts | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/commands/ban.ts b/src/commands/ban.ts index 566d419..d51fe40 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -3,6 +3,7 @@ import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" +import { numberOrString } from "@/utils/types" import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" @@ -71,7 +72,7 @@ _commandsBase }) .createCommand({ trigger: "unban", - args: [{ key: "username", optional: false, description: "Username (or user id) to unban" }], + args: [{ key: "username", type: numberOrString, description: "Username (or user id) to unban" }], description: "Unban a user from a group", scope: "group", permissions: { @@ -80,7 +81,9 @@ _commandsBase }, handler: async ({ args, context }) => { await context.deleteMessage() - const userId = args.username.startsWith("@") ? await getTelegramId(args.username) : parseInt(args.username, 10) + 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`)) @@ -89,7 +92,7 @@ _commandsBase return } - const user = await getUser(userId) + const user = await getUser(userId, context) if (!user) { const msg = await context.reply("Error: cannot find this user") logger.error({ userId }, "UNBAN: cannot retrieve the user") diff --git a/src/commands/mute.ts b/src/commands/mute.ts index d3a9077..09db062 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -3,6 +3,7 @@ import { Moderation } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" +import { numberOrString } from "@/utils/types" import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" @@ -71,7 +72,7 @@ _commandsBase }) .createCommand({ trigger: "unmute", - args: [{ key: "username", optional: false, description: "Username (or user id) to unmute" }], + args: [{ key: "username", type: numberOrString, description: "Username (or user id) to unmute" }], description: "Unmute a user from a group", scope: "group", permissions: { @@ -80,7 +81,8 @@ _commandsBase }, handler: async ({ args, context }) => { await context.deleteMessage() - const userId = args.username.startsWith("@") ? await getTelegramId(args.username) : parseInt(args.username, 10) + 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`)) @@ -89,7 +91,7 @@ _commandsBase return } - const user = await getUser(userId) + const user = await getUser(userId, context) if (!user) { const msg = await context.reply("Error: cannot find this user") logger.error({ userId }, "UNMUTE: cannot retrieve the user") From 14928d2de2582c203e6b85bb9f21e664c7e2e078 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 02:02:36 +0100 Subject: [PATCH 16/22] fix: getUser ctx undefined propagation in optional chaining --- src/utils/users.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/utils/users.ts b/src/utils/users.ts index e6a3659..62f8bc5 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -2,11 +2,8 @@ import type { Context } from "grammy" import type { User } from "grammy/types" import { MessageUserStorage } from "@/middlewares/message-user-storage" -export async function getUser(userId: number, ctx?: C): Promise { - const chatUser = await ctx - ?.getChatMember(userId) - .then((r) => r.user) - .catch(() => null) - - return chatUser ?? MessageUserStorage.getInstance().getStoredUser(userId) +export async function getUser(userId: number, ctx: C | null): Promise { + // TODO: check if this works correctly + const chatUser = ctx ? await ctx.getChatMember(userId).catch(() => null) : null + return chatUser?.user ?? MessageUserStorage.getInstance().getStoredUser(userId) } From bfe9a2336f2ff0e76b2cf60ea1550ec825a76f65 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <66379281+lorenzocorallo@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:05:20 +0100 Subject: [PATCH 17/22] fix: typo Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/modules/moderation/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index a7bcfb9..5dbd0c1 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -63,7 +63,7 @@ class ModerationClass { return { code, fmtError: fmt(() => "TG: Cannot perform the moderation action"), - strError: "There was an error perfoming the moderation action", + strError: "There was an error performing the moderation action", } } } From 4ea2b98f8706be9f87825f30d37db1f258061647 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Fri, 30 Jan 2026 02:18:32 +0100 Subject: [PATCH 18/22] fix: typos --- src/modules/moderation/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 5dbd0c1..ba116a9 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -147,13 +147,13 @@ class ModerationClass { let delCount = 0 for (const [chatId, mIds] of groupMessagesByChat(messages)) { - const ok = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) - if (ok) delCount += mIds.length + const delOk = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) + if (delOk) delCount += mIds.length } if (delCount === 0) { logger.error( - { initialMessages: messages, executor, forwaredCount: preRes.count, deletedCount: 0 }, + { initialMessages: messages, executor, forwardedCount: preRes.count, deletedCount: 0 }, "[Moderation:deleteMessages] no message(s) could be deleted" ) void modules.shared.api.deleteMessages(tgLogger.groupId, preRes.logMessageIds) @@ -165,7 +165,7 @@ class ModerationClass { { initialMessages: messages, executor, - forwaredCount: preRes.count, + forwardedCount: preRes.count, deletedCount: delCount, deletedPercentage: (delCount / preRes.count).toFixed(3), }, From db11e3d50fc2240845604d8414eea46e52b37241 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 30 Jan 2026 02:46:09 +0100 Subject: [PATCH 19/22] refactor: moved UI actions middleware in new moderation stack --- src/bot.ts | 4 +- src/middlewares/ui-actions-logger.ts | 99 ---------------------------- src/modules/moderation/index.ts | 87 ++++++++++++++++++++---- 3 files changed, 76 insertions(+), 114 deletions(-) delete mode 100644 src/middlewares/ui-actions-logger.ts diff --git a/src/bot.ts b/src/bot.ts index ef6df2c..0527610 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -15,8 +15,8 @@ import { checkUsername } from "./middlewares/check-username" import { GroupSpecificActions } from "./middlewares/group-specific-actions" import { messageLink } from "./middlewares/message-link" import { MessageUserStorage } from "./middlewares/message-user-storage" -import { UIActionsLogger } from "./middlewares/ui-actions-logger" import { modules, sharedDataInit } from "./modules" +import { Moderation } from "./modules/moderation" import { redis } from "./redis" import { once } from "./utils/once" import { setTelegramId } from "./utils/telegram-id" @@ -78,7 +78,7 @@ bot.use(commands) bot.use(new BotMembershipHandler()) bot.use(new AutoModerationStack()) bot.use(new GroupSpecificActions()) -bot.use(new UIActionsLogger()) +bot.use(Moderation) bot.on("message", async (ctx, next) => { const { username, id } = ctx.message.from diff --git a/src/middlewares/ui-actions-logger.ts b/src/middlewares/ui-actions-logger.ts deleted file mode 100644 index c973c1f..0000000 --- a/src/middlewares/ui-actions-logger.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { Composer, type MiddlewareObj } from "grammy" -import { modules } from "@/modules" -import { duration } from "@/utils/duration" -import type { Context } from "@/utils/types" - -/** - * Middleware to track administrative actions performed via Telegram UI (not via bot commands). - * Supported actions: ban/unban, mute/unmute - * - * LIMITATIONS: (TO CHECK) - * - Telegram Bot API doesn't provide deletion events. - * - Duration/reason detection: UI actions don't include duration or reason - * information in the chat_member updates. - * Note: For message deletion detection, consider enabling admin permissions - * for the bot or implementing a separate system. - */ - -export class UIActionsLogger implements MiddlewareObj { - private composer = new Composer() - - constructor() { - this.composer.on("chat_member", async (ctx) => { - const { chat, from: admin, new_chat_member, old_chat_member } = ctx.chatMember - if (admin.id === ctx.me.id) return - - const prev = old_chat_member.status - const curr = new_chat_member.status - const target = new_chat_member.user - if (prev === "left" && curr === "member") return // skip join event - if (prev === "member" && curr === "left") return // skip left event - - if (prev === "kicked" && curr === "left") { - await modules.get("tgLogger").moderationAction({ - action: "UNBAN", - from: admin, - target, - chat, - }) - return - } - - if (prev === "member" && curr === "kicked") { - await modules.get("tgLogger").moderationAction({ - action: "BAN", - from: admin, - target, - chat, - }) - return - } - - if (prev === "member" && curr === "restricted" && !new_chat_member.can_send_messages) { - await modules.get("tgLogger").moderationAction({ - action: "MUTE", - duration: duration.fromUntilDate(new_chat_member.until_date), - from: admin, - target, - chat, - }) - return - } - - if (prev === "restricted" && curr === "restricted") { - if (old_chat_member.can_send_messages && !new_chat_member.can_send_messages) { - // mute - await modules.get("tgLogger").moderationAction({ - action: "MUTE", - duration: duration.fromUntilDate(new_chat_member.until_date), - from: admin, - target, - chat, - }) - } else if (!old_chat_member.can_send_messages && new_chat_member.can_send_messages) { - await modules.get("tgLogger").moderationAction({ - action: "UNMUTE", - from: admin, - target, - chat, - }) - } - return - } - - if (prev === "restricted" && curr === "member") { - await modules.get("tgLogger").moderationAction({ - action: "UNMUTE", - from: admin, - target, - chat, - }) - return - } - }) - } - - middleware() { - return this.composer.middleware() - } -} diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index ba116a9..76c39d6 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -1,4 +1,5 @@ -import type { Chat, Message, User } from "grammy/types" +import { Composer, type Context, type MiddlewareObj } from "grammy" +import type { Chat, ChatMember, Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import { type ApiInput, api } from "@/backend" import { logger } from "@/logger" @@ -8,6 +9,29 @@ import { fmt, fmtUser } from "@/utils/format" import { modules } from ".." import type { ModerationAction, PreDeleteResult } from "../tg-logger/types" +function deduceModerationAction(oldMember: ChatMember, newMember: ChatMember): ModerationAction["action"] | null { + const prev = oldMember.status + const curr = newMember.status + + if (prev === "left" && curr === "member") return null // join event + if (prev === "member" && curr === "left") return null // left event + + if (prev === "kicked" && curr === "left") return "UNBAN" + if (prev === "member" && curr === "kicked") return "BAN" + if (prev === "member" && curr === "restricted" && !newMember.can_send_messages) return "MUTE" + if (prev === "restricted" && curr === "member") return "UNMUTE" + + if (prev === "restricted" && curr === "restricted") { + if (oldMember.can_send_messages && !newMember.can_send_messages) { + return "MUTE" + } else if (!oldMember.can_send_messages && newMember.can_send_messages) { + return "UNMUTE" + } + } + + return null +} + const MAP_ACTIONS: Record< Exclude | "BAN_ALL" | "MUTE_ALL", ApiInput["tg"]["auditLog"]["create"]["type"] @@ -25,16 +49,47 @@ type ModerationErrorCode = "CANNOT_MOD_YOURSELF" | "CANNOT_MOD_BOT" | "CANNOT_MO type ModerationError = { code: ModerationErrorCode; fmtError: string; strError: string } // TODO: missing in-channel user feedback (eg. has been muted by ...) -class ModerationClass { - private static instance: ModerationClass | null = null - static getInstance(): ModerationClass { +class ModerationClass implements MiddlewareObj { + private composer = new Composer() + private static instance: ModerationClass | null = null + static getInstance(): ModerationClass { if (!ModerationClass.instance) { ModerationClass.instance = new ModerationClass() } - return ModerationClass.instance + return ModerationClass.instance as unknown as ModerationClass + } + + middleware() { + return this.composer.middleware() } - private constructor() {} + private constructor() { + this.composer.on("chat_member", async (ctx) => { + const { chat, from: admin, new_chat_member, old_chat_member } = ctx.chatMember + if (admin.id === ctx.me.id) return + + const actionType = deduceModerationAction(old_chat_member, new_chat_member) + if (!actionType) return + + const moderationAction = { + action: actionType, + from: admin, + target: new_chat_member.user, + chat, + reason: "Manual action via Telegram UI", + } as ModerationAction + + if ( + (moderationAction.action === "BAN" || moderationAction.action === "MUTE") && + "until_date" in new_chat_member && + new_chat_member.until_date + ) { + moderationAction.duration = duration.fromUntilDate(new_chat_member.until_date) + } + + await this.post(moderationAction, null) + }) + } private getModerationError(p: ModerationAction, code: ModerationErrorCode): ModerationError { // biome-ignore lint/nursery/noUnnecessaryConditions: lying @@ -134,6 +189,17 @@ class ModerationClass { } } + private async post(p: ModerationAction, preDeleteRes: PreDeleteResult | null) { + // TODO: handle errors? + await Promise.allSettled([ + modules.get("tgLogger").moderationAction({ + ...p, + preDeleteRes: preDeleteRes, + }), + this.audit(p), + ]) + } + public async deleteMessages( messages: Message[], executor: User, @@ -187,17 +253,12 @@ class ModerationClass { p.from, `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` ) - : null + : ok(null) const performOk = await this.perform(p) if (!performOk) return err(this.getModerationError(p, "PERFORM_ERROR")) // TODO: make the perform output a Result - await modules.get("tgLogger").moderationAction({ - ...p, - preDeleteRes: preDeleteRes?.unwrapOr(null), - }) - - await this.audit(p) + await this.post(p, preDeleteRes.unwrapOr(null)) return ok() } From 1badc83acdb1d81f0d93c274d3ffc2fdef6562bb Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 30 Jan 2026 02:50:56 +0100 Subject: [PATCH 20/22] docs: todoooooooo --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index d1f2528..b2f4b01 100644 --- a/TODO.md +++ b/TODO.md @@ -20,7 +20,7 @@ - [x] /report to allow user to report (@admin is not implemented) - [x] track ban, mute and kick done via telegram UI (not by command) - [ ] controlled moderation flow (see #42) - - [ ] audit log (implemented, need to audit every mod action) + - [x] audit log (implemented, need to audit every mod action) - [ ] send in-chat action log (deprived of chat ids and stuff) - [x] automatic moderation - [x] delete non-latin alphabet characters From 6a51733656c734ceeae1531deae917fb27d36c5d Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 30 Jan 2026 03:06:25 +0100 Subject: [PATCH 21/22] fix: use moderation stack in `group-specific-actions` --- src/middlewares/group-specific-actions.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/middlewares/group-specific-actions.ts b/src/middlewares/group-specific-actions.ts index 20a93da..862895b 100644 --- a/src/middlewares/group-specific-actions.ts +++ b/src/middlewares/group-specific-actions.ts @@ -2,7 +2,7 @@ import { Composer, type Filter, type MiddlewareObj } from "grammy" import { err, ok, type Result } from "neverthrow" import { api } from "@/backend" import { logger } from "@/logger" -import { modules } from "@/modules" +import { Moderation } from "@/modules/moderation" import { fmt, fmtUser } from "@/utils/format" import type { Context } from "@/utils/types" import { wait } from "@/utils/wait" @@ -66,11 +66,15 @@ export class GroupSpecificActions implements MiddlewareObj if (check.isOk()) return next() - await modules - .get("tgLogger") - .preDelete([ctx.message], `User did not follow group rules:\n${check.error}`, ctx.me) + const res = await Moderation.deleteMessages( + [ctx.message], + ctx.me, + `User did not follow group rules:\n${check.error}` + ) - await ctx.deleteMessage() + if (res.isErr()) { + logger.error({ error: res.error }, "Failed to delete message in GroupSpecificActions middleware") + } const reply = await ctx.reply( fmt(({ b, n }) => [b`${fmtUser(ctx.from)} you sent an invalid message`, b`Reason:`, n`${check.error}`], { From d18fed1cf347b97debc2a75346f568110365321a Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Fri, 30 Jan 2026 03:25:00 +0100 Subject: [PATCH 22/22] refactor: types --- src/modules/moderation/index.ts | 5 +---- src/modules/moderation/types.ts | 40 +++++++++++++++++++++++++++++++++ src/modules/tg-logger/index.ts | 7 +++--- src/modules/tg-logger/types.ts | 39 +------------------------------- src/utils/duration.ts | 10 +++++++++ 5 files changed, 56 insertions(+), 45 deletions(-) create mode 100644 src/modules/moderation/types.ts diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 76c39d6..a2819bd 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -7,7 +7,7 @@ import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { type Duration, duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" import { modules } from ".." -import type { ModerationAction, PreDeleteResult } from "../tg-logger/types" +import type { ModerationAction, ModerationError, ModerationErrorCode, PreDeleteResult } from "./types" function deduceModerationAction(oldMember: ChatMember, newMember: ChatMember): ModerationAction["action"] | null { const prev = oldMember.status @@ -45,9 +45,6 @@ const MAP_ACTIONS: Record< MUTE_ALL: "mute_all", } -type ModerationErrorCode = "CANNOT_MOD_YOURSELF" | "CANNOT_MOD_BOT" | "CANNOT_MOD_GROUPADMIN" | "PERFORM_ERROR" -type ModerationError = { code: ModerationErrorCode; fmtError: string; strError: string } - // TODO: missing in-channel user feedback (eg. has been muted by ...) class ModerationClass implements MiddlewareObj { private composer = new Composer() diff --git a/src/modules/moderation/types.ts b/src/modules/moderation/types.ts new file mode 100644 index 0000000..0816170 --- /dev/null +++ b/src/modules/moderation/types.ts @@ -0,0 +1,40 @@ +import type { Chat, Message, User } from "grammy/types" +import type { Duration } from "@/utils/duration" + +export type PreDeleteResult = { + count: number + logMessageIds: number[] + link: string +} + +export type ModerationAction = { + from: User + target: User + chat: Chat + preDeleteRes?: PreDeleteResult | null +} & ( + | { + action: "BAN" | "MUTE" + duration?: Duration + reason?: string + } + | { + action: "KICK" + reason?: string + } + | { + action: "UNBAN" | "UNMUTE" + } + | { + action: "MULTI_CHAT_SPAM" + duration: Duration + messages: Message[] + } + | { + action: "SILENT" + reason?: string + } +) + +export type ModerationErrorCode = "CANNOT_MOD_YOURSELF" | "CANNOT_MOD_BOT" | "CANNOT_MOD_GROUPADMIN" | "PERFORM_ERROR" +export type ModerationError = { code: ModerationErrorCode; fmtError: string; strError: string } diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 5eb3b04..1d72c3e 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -6,6 +6,7 @@ import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" import { fmt, fmtChat, fmtDate, fmtUser } from "@/utils/format" import type { ModuleShared } from "@/utils/types" +import type { ModerationAction, PreDeleteResult } from "../moderation/types" import { type BanAll, banAllMenu, getBanAllText } from "./ban-all" import { grantCreatedMenu, grantMessageMenu } from "./grants" import { getReportText, type Report, reportMenu } from "./report" @@ -22,7 +23,7 @@ type Topics = { grants: number } -const MOD_ACTION_TITLE = (props: Types.ModerationAction) => +const MOD_ACTION_TITLE = (props: ModerationAction) => ({ MUTE: fmt(({ b }) => b`🤫 ${"duration" in props && props.duration ? "Temp" : "PERMA"} Mute`), KICK: fmt(({ b }) => b`👢 Kick`), @@ -119,7 +120,7 @@ export class TgLogger extends Module { messages: Message[], reason: string, deleter: User = this.shared.botInfo - ): Promise { + ): Promise { if (!messages.length) return null const sender = messages[0].from @@ -236,7 +237,7 @@ export class TgLogger extends Module { }) } - public async moderationAction(props: Types.ModerationAction): Promise { + public async moderationAction(props: ModerationAction): Promise { const isAutoModeration = props.from.id === this.shared.botInfo.id const others: string[] = [] diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index 69a074a..55c87d0 100644 --- a/src/modules/tg-logger/types.ts +++ b/src/modules/tg-logger/types.ts @@ -1,9 +1,7 @@ import type { GrammyError, HttpError } from "grammy" import type { Chat, Message, User } from "grammy/types" -import type { z } from "zod" -import type { duration } from "@/utils/duration" +import type { Duration } from "@/utils/duration" -type Duration = z.output export type BanAllLog = { target: User from: User @@ -36,35 +34,6 @@ export type ExceptionLog = error: unknown } -export type ModerationAction = { - from: User - target: User - chat: Chat - preDeleteRes?: PreDeleteResult | null -} & ( - | { - action: "BAN" | "MUTE" - duration?: Duration - reason?: string - } - | { - action: "KICK" - reason?: string - } - | { - action: "UNBAN" | "UNMUTE" - } - | { - action: "MULTI_CHAT_SPAM" - duration: Duration - messages: Message[] - } - | { - action: "SILENT" - reason?: string - } -) - export type GroupManagement = { chat: Chat } & ( @@ -87,12 +56,6 @@ export type GroupManagement = { } ) -export type PreDeleteResult = { - count: number - logMessageIds: number[] - link: string -} - export type GrantLog = {} & ( | { action: "USAGE" diff --git a/src/utils/duration.ts b/src/utils/duration.ts index 0d768da..70a1b9d 100644 --- a/src/utils/duration.ts +++ b/src/utils/duration.ts @@ -54,4 +54,14 @@ export const duration = { dateStr: fmtDate(date), } }, + fromSeconds(seconds: number): Duration { + const date = new Date(Date.now() + seconds * 1000) + return { + raw: "custom", + secondsFromNow: seconds, + date, + timestamp_s: Math.floor(date.getTime() / 1000), + dateStr: fmtDate(date), + } + }, } as const