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 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/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/commands/ban.ts b/src/commands/ban.ts index f336d28..d51fe40 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,10 +1,11 @@ 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" +import { numberOrString } from "@/utils/types" +import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" - import { _commandsBase } from "./_base" _commandsBase @@ -25,22 +26,10 @@ _commandsBase return } - const res = await 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.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) .createCommand({ @@ -68,28 +57,22 @@ _commandsBase return } - const res = await 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.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) .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: { @@ -98,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`)) @@ -107,12 +92,18 @@ _commandsBase return } - const res = await unban({ ctx: context, from: context.from, targetId: userId }) - if (res.isErr()) { - const msg = await context.reply(res.error) + 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") await wait(5000) await msg.delete() return } + + const res = await Moderation.unban(user, context.chat, context.from) + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) diff --git a/src/commands/del.ts b/src/commands/del.ts index db13e1f..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,7 +23,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 + 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() }, }) diff --git a/src/commands/kick.ts b/src/commands/kick.ts index f23dcce..66b3689 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,20 +21,9 @@ _commandsBase.createCommand({ return } - const res = await 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.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) diff --git a/src/commands/mute.ts b/src/commands/mute.ts index c8791f2..09db062 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,10 +1,11 @@ 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" +import { numberOrString } from "@/utils/types" +import { getUser } from "@/utils/users" import { wait } from "@/utils/wait" - import { _commandsBase } from "./_base" _commandsBase @@ -33,21 +34,17 @@ _commandsBase return } - const res = await 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.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) .createCommand({ @@ -67,25 +64,15 @@ _commandsBase return } - const res = await 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.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) .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: { @@ -94,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`)) @@ -103,12 +91,18 @@ _commandsBase return } - const res = await unmute({ ctx: context, from: context.from, targetId: userId }) - if (res.isErr()) { - const msg = await context.reply(res.error) + 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") await wait(5000) await msg.delete() return } + + const res = await Moderation.unmute(user, context.chat, context.from) + const msg = await context.reply(res.isErr() ? res.error.fmtError : "OK") + await wait(5000) + await msg.delete() }, }) diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index e5ec670..e3bde46 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -5,9 +5,8 @@ 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" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -148,20 +147,23 @@ export class AutoModerationStack implements MiddlewareObj return } - await 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.fmtError ) 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 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"), + [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.fmtError ) await wait(5000) await msg.delete() @@ -216,7 +220,6 @@ export class AutoModerationStack implements MiddlewareObj from: ctx.me, chat: ctx.chat, target: ctx.from, - message, reason: `Message flagged for moderation: \n${reasons}`, }) } @@ -239,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 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, - }) + const res = await Moderation.mute( + ctx.from, + ctx.chat, + ctx.me, + 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" + ) + } } } @@ -286,25 +295,12 @@ export class AutoModerationStack implements MiddlewareObj similarMessages.push(ctx.message) const muteDuration = duration.zod.parse(MULTI_CHAT_SPAM.MUTE_DURATION) - await Promise.allSettled( - groupMessagesByChat(similarMessages) - .keys() - .map((chatId) => - ctx.api.restrictChatMember(chatId, ctx.from.id, RestrictPermissions.mute, { - until_date: muteDuration.timestamp_s, - }) - ) - ) - await modules.get("tgLogger").moderationAction({ - action: "MULTI_CHAT_SPAM", - from: ctx.me, - chat: ctx.chat, - message: ctx.message, - messages: similarMessages, - duration: muteDuration, - target: ctx.from, - }) + const res = await Moderation.multiChatSpam(ctx.from, similarMessages, muteDuration) + + if (res.isErr()) { + logger.error({ error: res.error }, "Cannot execute moderation action for MULTI_CHAT_SPAM") + } } } diff --git a/src/middlewares/group-specific-actions.ts b/src/middlewares/group-specific-actions.ts index 6a65706..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,7 +66,16 @@ 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) + const res = await Moderation.deleteMessages( + [ctx.message], + ctx.me, + `User did not follow group rules:\n${check.error}` + ) + + 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}`], { sep: "\n", 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/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/ban.ts b/src/modules/moderation/ban.ts deleted file mode 100644 index e5e84b7..0000000 --- a/src/modules/moderation/ban.ts +++ /dev/null @@ -1,61 +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", - }) - return ok( - await modules - .get("tgLogger") - .moderationAction({ action: "BAN", from, message, target, duration, reason, chat: ctx.chat }) - ) -} - -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 e337fd8..a2819bd 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -1,3 +1,330 @@ -export * from "./ban" -export * from "./kick" -export * from "./mute" +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" +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, ModerationError, ModerationErrorCode, PreDeleteResult } from "./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"] +> = { + MUTE: "mute", + BAN: "ban", + KICK: "kick", + UNBAN: "unban", + UNMUTE: "unmute", + BAN_ALL: "ban_all", + MUTE_ALL: "mute_all", +} + +// TODO: missing in-channel user feedback (eg. has been muted by ...) +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 as unknown as ModerationClass + } + + middleware() { + return this.composer.middleware() + } + + 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 + 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 performing 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("CANNOT_MOD_GROUPADMIN") + + 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.target.id, RestrictPermissions.mute, { + until_date: p.duration.timestamp_s, + }) + .catch(() => false) + ) + ).then((res) => res.every((r) => r)) + } + } + + 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, + reason: string + ): Promise> { + if (messages.length === 0) return ok(null) + + 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 + for (const [chatId, mIds] of groupMessagesByChat(messages)) { + const delOk = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) + if (delOk) delCount += mIds.length + } + + if (delCount === 0) { + logger.error( + { 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) + return err("DELETE_ERROR") + } + + if (delCount / preRes.count < 0.2) { + logger.warn( + { + initialMessages: messages, + executor, + forwardedCount: 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> { + const check = await this.checkTargetValid(p) + if (check.isErr()) return err(this.getModerationError(p, check.error)) + + const preDeleteRes = + messagesToDelete !== undefined + ? await this.deleteMessages( + messagesToDelete, + p.from, + `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` + ) + : 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 this.post(p, preDeleteRes.unwrapOr(null)) + 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 + ): Promise> { + if (messagesToDelete.length === 0) + throw new Error("[Moderation:multiChatSpam] passed an empty messagesToDelete array") + + 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 39822d5..0000000 --- a/src/modules/moderation/kick.ts +++ /dev/null @@ -1,38 +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", - }) - return ok( - await modules.get("tgLogger").moderationAction({ action: "KICK", from, target, reason, message, chat: ctx.chat }) - ) -} diff --git a/src/modules/moderation/mute.ts b/src/modules/moderation/mute.ts deleted file mode 100644 index f2ecd21..0000000 --- a/src/modules/moderation/mute.ts +++ /dev/null @@ -1,83 +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 res = await modules.get("tgLogger").moderationAction({ - action: "MUTE", - chat: ctx.chat, - from, - target, - duration, - reason, - message, - }) - - 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/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/grants.ts b/src/modules/tg-logger/grants.ts index 5780b61..d027d4d 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 @@ -24,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: @@ -35,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" } } @@ -44,13 +47,15 @@ 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 - .get("tgLogger") - .delete([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" + ) - if (!res?.count) { + 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 dca851d..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,9 +23,20 @@ type Topics = { grants: number } +const MOD_ACTION_TITLE = (props: 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, + public readonly groupId: number, private topics: Topics ) { super() @@ -51,29 +63,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 { @@ -94,34 +114,24 @@ export class TgLogger extends Module { return true } - async delete( + // NOTE: this does not delete the messages + // TODO: better return type + 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" } @@ -129,13 +139,22 @@ export class TgLogger extends Module { ) if (!sent) return null + const forwardedIds: number[] = [] for (const [chatId, mIds] of groupMessagesByChat(messages)) { - await this.forward(this.topics.deletedMessages, chatId, mIds) - await this.shared.api.deleteMessages(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, + logMessageIds: [sent.message_id, ...forwardedIds], + count: forwardedIds.length, link: `https://t.me/c/${stripChatId(this.groupId)}/${this.topics.deletedMessages}/${sent.message_id}`, } } @@ -218,63 +237,27 @@ 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 - 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)}`, + !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, @@ -291,7 +274,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/report.ts b/src/modules/tg-logger/report.ts index 5142ac8..faab03d 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,15 @@ 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) + 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,11 +99,18 @@ 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, - }) + 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 }, @@ -103,8 +118,19 @@ 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) + const res = await Moderation.ban( + data.message.from, + data.message.chat, + ctx.from, + null, + [data.message], + "[REPORT] resolved with ban" + ) + if (res.isErr()) + return { + feedback: `❌ ${res.error.strError}`, + } + await editReportMessage(data, ctx, "🚫 Ban") return null }, diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index 3ce20fc..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 - message?: Message -} & ( - | { - 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,11 +56,6 @@ export type GroupManagement = { } ) -export type DeleteResult = { - count: number - link: string -} - export type GrantLog = {} & ( | { action: "USAGE" diff --git a/src/utils/duration.ts b/src/utils/duration.ts index 491858d..70a1b9d 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 @@ -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 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, +}) diff --git a/src/utils/users.ts b/src/utils/users.ts new file mode 100644 index 0000000..62f8bc5 --- /dev/null +++ b/src/utils/users.ts @@ -0,0 +1,9 @@ +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 | 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) +}