diff --git a/TODO.md b/TODO.md index 927a400..fdd5df3 100644 --- a/TODO.md +++ b/TODO.md @@ -27,7 +27,7 @@ - [x] check harmful messages - [x] check spam across different groups (mute + del) - [ ] exception to send our whatsapp links? - - [ ] do not delete Direttivo's allowed messages + - [x] do not delete Direttivo's allowed messages (/grant command) - [x] check if user has username - [ ] group-specific moderation (eg. #cerco #vendo in polihouse) see [here](https://github.com/PoliNetworkOrg/PoliNetworkBot_CSharp/blob/03c7434f06323ffdec301cb105d1d3b2c1ed4a95/PoliNetworkBot_CSharp/Code/Bots/Moderation/Blacklist/Blacklist.cs#L84) - [x] role management diff --git a/biome.jsonc b/biome.jsonc index 56b3583..4ac8973 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.2.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": false, "clientKind": "git", "useIgnoreFile": false }, "files": { "ignoreUnknown": false }, "formatter": { diff --git a/package.json b/package.json index 2e5ab7c..9d47b72 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,13 @@ "test": "vitest", "typecheck": "tsc --noEmit", "check": "biome check", - "check:fix": "biome check --write" + "check:fix": "biome check --write --unsafe" }, "keywords": [], "author": "", "license": "ISC", "devDependencies": { - "@biomejs/biome": "2.2.4", + "@biomejs/biome": "2.3.11", "@trpc/server": "11.5.1", "@types/node": "^22.13.1", "globals": "^15.14.0", @@ -37,7 +37,7 @@ "@grammyjs/menu": "^1.3.1", "@grammyjs/parse-mode": "^1.11.1", "@grammyjs/runner": "^2.0.3", - "@polinetwork/backend": "^0.12.0", + "@polinetwork/backend": "^0.13.1", "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", "@types/ssdeep.js": "^0.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fa61b2..2c37046 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,8 +27,8 @@ importers: specifier: ^2.0.3 version: 2.0.3(grammy@1.37.0) '@polinetwork/backend': - specifier: ^0.12.0 - version: 0.12.0 + specifier: ^0.13.1 + version: 0.13.1 '@t3-oss/env-core': specifier: ^0.13.4 version: 0.13.4(arktype@2.1.20)(typescript@5.7.3)(zod@4.1.11) @@ -79,8 +79,8 @@ importers: version: 4.1.11 devDependencies: '@biomejs/biome': - specifier: 2.2.4 - version: 2.2.4 + specifier: 2.3.11 + version: 2.3.11 '@trpc/server': specifier: 11.5.1 version: 11.5.1(typescript@5.7.3) @@ -117,55 +117,55 @@ packages: '@ark/util@0.46.0': resolution: {integrity: sha512-JPy/NGWn/lvf1WmGCPw2VGpBg5utZraE84I7wli18EDF3p3zc/e9WolT35tINeZO3l7C77SjqRJeAUoT0CvMRg==} - '@biomejs/biome@2.2.4': - resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} + '@biomejs/biome@2.3.11': + resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.2.4': - resolution: {integrity: sha512-RJe2uiyaloN4hne4d2+qVj3d3gFJFbmrr5PYtkkjei1O9c+BjGXgpUPVbi8Pl8syumhzJjFsSIYkcLt2VlVLMA==} + '@biomejs/cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.2.4': - resolution: {integrity: sha512-cFsdB4ePanVWfTnPVaUX+yr8qV8ifxjBKMkZwN7gKb20qXPxd/PmwqUH8mY5wnM9+U0QwM76CxFyBRJhC9tQwg==} + '@biomejs/cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.2.4': - resolution: {integrity: sha512-7TNPkMQEWfjvJDaZRSkDCPT/2r5ESFPKx+TEev+I2BXDGIjfCZk2+b88FOhnJNHtksbOZv8ZWnxrA5gyTYhSsQ==} + '@biomejs/cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.2.4': - resolution: {integrity: sha512-M/Iz48p4NAzMXOuH+tsn5BvG/Jb07KOMTdSVwJpicmhN309BeEyRyQX+n1XDF0JVSlu28+hiTQ2L4rZPvu7nMw==} + '@biomejs/cli-linux-arm64@2.3.11': + resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.2.4': - resolution: {integrity: sha512-m41nFDS0ksXK2gwXL6W6yZTYPMH0LughqbsxInSKetoH6morVj43szqKx79Iudkp8WRT5SxSh7qVb8KCUiewGg==} + '@biomejs/cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.2.4': - resolution: {integrity: sha512-orr3nnf2Dpb2ssl6aihQtvcKtLySLta4E2UcXdp7+RTa7mfJjBgIsbS0B9GC8gVu0hjOu021aU8b3/I1tn+pVQ==} + '@biomejs/cli-linux-x64@2.3.11': + resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.2.4': - resolution: {integrity: sha512-NXnfTeKHDFUWfxAefa57DiGmu9VyKi0cDqFpdI+1hJWQjGJhJutHPX0b5m+eXvTKOaf+brU+P0JrQAZMb5yYaQ==} + '@biomejs/cli-win32-arm64@2.3.11': + resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.2.4': - resolution: {integrity: sha512-3Y4V4zVRarVh/B/eSHczR4LYoSVyv3Dfuvm3cWs5w/HScccS0+Wt/lHOcDTRYeHjQmMYVC3rIRWqyN2EI52+zg==} + '@biomejs/cli-win32-x64@2.3.11': + resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -425,8 +425,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@polinetwork/backend@0.12.0': - resolution: {integrity: sha512-W4SN1/aoth3Uk77su9UJ7hR1z3VVT72amuLQp/rjNBqP7ca8+XJuAMhlYM4awxyh0fKXlDfQEGwYr0y356ESSw==} + '@polinetwork/backend@0.13.1': + resolution: {integrity: sha512-wx7JTxM9GAYFY4ESriJcFq4gT2DwFGW3Lqaa1R++0qI81xpxaB6Da5be2Z5TBDKmKepOD3DFmGaBJfsygZdsjA==} engines: {node: '>=24.8.0', pnpm: '>=10.17.1'} '@redis/bloom@1.2.0': @@ -1586,39 +1586,39 @@ snapshots: '@ark/util@0.46.0': {} - '@biomejs/biome@2.2.4': + '@biomejs/biome@2.3.11': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.2.4 - '@biomejs/cli-darwin-x64': 2.2.4 - '@biomejs/cli-linux-arm64': 2.2.4 - '@biomejs/cli-linux-arm64-musl': 2.2.4 - '@biomejs/cli-linux-x64': 2.2.4 - '@biomejs/cli-linux-x64-musl': 2.2.4 - '@biomejs/cli-win32-arm64': 2.2.4 - '@biomejs/cli-win32-x64': 2.2.4 + '@biomejs/cli-darwin-arm64': 2.3.11 + '@biomejs/cli-darwin-x64': 2.3.11 + '@biomejs/cli-linux-arm64': 2.3.11 + '@biomejs/cli-linux-arm64-musl': 2.3.11 + '@biomejs/cli-linux-x64': 2.3.11 + '@biomejs/cli-linux-x64-musl': 2.3.11 + '@biomejs/cli-win32-arm64': 2.3.11 + '@biomejs/cli-win32-x64': 2.3.11 - '@biomejs/cli-darwin-arm64@2.2.4': + '@biomejs/cli-darwin-arm64@2.3.11': optional: true - '@biomejs/cli-darwin-x64@2.2.4': + '@biomejs/cli-darwin-x64@2.3.11': optional: true - '@biomejs/cli-linux-arm64-musl@2.2.4': + '@biomejs/cli-linux-arm64-musl@2.3.11': optional: true - '@biomejs/cli-linux-arm64@2.2.4': + '@biomejs/cli-linux-arm64@2.3.11': optional: true - '@biomejs/cli-linux-x64-musl@2.2.4': + '@biomejs/cli-linux-x64-musl@2.3.11': optional: true - '@biomejs/cli-linux-x64@2.2.4': + '@biomejs/cli-linux-x64@2.3.11': optional: true - '@biomejs/cli-win32-arm64@2.2.4': + '@biomejs/cli-win32-arm64@2.3.11': optional: true - '@biomejs/cli-win32-x64@2.2.4': + '@biomejs/cli-win32-x64@2.3.11': optional: true '@cspotcode/source-map-support@0.8.1': @@ -1785,7 +1785,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@polinetwork/backend@0.12.0': {} + '@polinetwork/backend@0.13.1': {} '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: diff --git a/src/commands/banall.ts b/src/commands/banall.ts index 9423360..286caea 100644 --- a/src/commands/banall.ts +++ b/src/commands/banall.ts @@ -3,15 +3,9 @@ import z from "zod" import { api } from "@/backend" import { modules } from "@/modules" import { getTelegramId } from "@/utils/telegram-id" -import type { Role } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { _commandsBase } from "./_base" -const numberOrString = z.string().transform((s) => { - const n = Number(s) - if (!Number.isNaN(n) && s.trim() !== "") return n - return s -}) - const BYPASS_ROLES: Role[] = ["president", "owner", "direttivo"] _commandsBase @@ -26,7 +20,7 @@ _commandsBase { key: "username", type: numberOrString, - description: "The username or the user id of the user you want to update the role", + description: "The username or the user id of the user you want to ban from all groups", }, { key: "reason", diff --git a/src/commands/grants.ts b/src/commands/grants.ts new file mode 100644 index 0000000..8097785 --- /dev/null +++ b/src/commands/grants.ts @@ -0,0 +1,271 @@ +import type { ConversationMenuContext } from "@grammyjs/conversations" +import type { User } from "grammy/types" +import z from "zod" +import { api } from "@/backend" +import type { ConversationContext } from "@/lib/managed-commands" +import { logger } from "@/logger" +import { modules } from "@/modules" +import { duration } from "@/utils/duration" +import { fmt, fmtUser } from "@/utils/format" +import { getTelegramId } from "@/utils/telegram-id" +import { numberOrString } from "@/utils/types" +import { wait } from "@/utils/wait" +import { _commandsBase } from "./_base" + +const dateFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", +}) + +const timeFormat = new Intl.DateTimeFormat(undefined, { + timeStyle: "short", + hour12: false, +}) + +const datetimeFormat = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + hour12: false, +}) + +const getDateWithDelta = (date: Date, deltaDay: number) => { + const newDate = new Date(date.getTime()) + newDate.setDate(newDate.getDate() + deltaDay) + return newDate +} + +const mainMsg = (user: User, startTime: Date, endTime: Date, duration: string, reason?: string) => + fmt( + ({ n, b, u }) => [ + b`🔐 Grant Special Permissions`, + n`${b`Target:`} ${fmtUser(user)}`, + n`${b`Start Time:`} ${datetimeFormat.format(startTime)}`, + n`${b`End Time:`} ${datetimeFormat.format(endTime)} (${duration})`, + reason ? n`${b`Reason:`} ${reason}` : undefined, + endTime.getTime() < Date.now() + ? b`\n${u`INVALID:`} END datetime is in the past, change start date or duration.` + : undefined, + ], + { sep: "\n" } + ) + +const askDurationMsg = fmt(({ n, b }) => [b`How long should the special grant last?`, n`${duration.formatDesc}`], { + sep: "\n", +}) + +_commandsBase.createCommand({ + trigger: "grant", + description: "Grant special permissions to a user allowing them to bypass the Auto-Moderation stack", + scope: "private", + permissions: { + allowedRoles: ["direttivo"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to grant special permissions to", + }, + { + key: "reason", + type: z.string(), + description: "The reason why you are granting special permissions to the user", + optional: true, + }, + ], + handler: async ({ args, context, conversation }) => { + try { + const userId: number | null = await conversation.external(async () => + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + ) + + if (userId === null) { + await context.reply(fmt(({ n }) => n`Not a valid userId or username not in our cache`)) + return + } + + const dbUser = await conversation.external(() => api.tg.users.get.query({ userId })) + if (!dbUser || dbUser.error) { + await context.reply(fmt(({ n }) => n`This user is not in our cache, we cannot proceed.`)) + return + } + + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } + + const today = new Date(await conversation.now()) + const startDate = new Date(await conversation.now()) + let grantDuration = duration.zod.parse("2h") + const endDate = () => new Date(startDate.getTime() + grantDuration.secondsFromNow * 1000) + const baseMsg = () => mainMsg(target, startDate, endDate(), grantDuration.raw, args.reason) + + async function changeDuration(ctx: ConversationMenuContext>, durationStr: string) { + grantDuration = duration.zod.parse(durationStr) + ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ctx.menu.nav("grants-main") + } + + async function changeStartDate(ctx: ConversationMenuContext>, delta: number) { + startDate.setDate(today.getDate() + delta) + ctx.editMessageText( + fmt(({ skip, b }) => [skip`${baseMsg()}`, b`🕓 Changing start TIME`], { sep: "\n\n" }), + { reply_markup: ctx.msg?.reply_markup } + ) + ctx.menu.nav("grants-start-time") + } + + async function changeStartTime( + ctx: ConversationMenuContext>, + hour: number, + minutes: number + ) { + // TODO: check timezone match between bot and user + startDate.setHours(hour) + startDate.setMinutes(minutes) + ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ctx.menu.nav("grants-main") + } + + const backToMain = conversation + .menu("grants-back-to-main", { parent: "grants-main" }) + .back("◀️ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + + const durationMenu = conversation + .menu("grants-duration", { parent: "grants-main" }) + .text("30m", (ctx) => changeDuration(ctx, "30m")) + .text("2h", (ctx) => changeDuration(ctx, "2h")) + .text("6h", (ctx) => changeDuration(ctx, "6h")) + .text("1d", (ctx) => changeDuration(ctx, "1d")) + .row() + .text("✍️ Custom", async (ctx) => { + ctx.menu.nav("grants-back-to-main") + await ctx.editMessageText( + fmt(({ skip }) => [skip`${baseMsg()}`, skip`${askDurationMsg}`], { sep: "\n\n" }), + { reply_markup: backToMain } + ) + let text: string + do { + const res = await conversation.waitFor(":text") + res.deleteMessage() + text = res.msg.text + } while (!duration.zod.safeParse(text).success) + + await changeDuration(ctx, text) + }) + .row() + .back("◀️ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + + const _startTimeMenu = conversation + .menu("grants-start-time", { parent: "grants-main" }) + .text(`Now: ${timeFormat.format(today)}`, (ctx) => changeStartTime(ctx, today.getHours(), today.getMinutes())) + .row() + .text("8:00", (ctx) => changeStartTime(ctx, 8, 0)) + .text("9:00", (ctx) => changeStartTime(ctx, 9, 0)) + .text("10:00", (ctx) => changeStartTime(ctx, 10, 0)) + .text("11:00", (ctx) => changeStartTime(ctx, 11, 0)) + .text("12:00", (ctx) => changeStartTime(ctx, 12, 0)) + .row() + .text("13:00", (ctx) => changeStartTime(ctx, 13, 0)) + .text("14:00", (ctx) => changeStartTime(ctx, 14, 0)) + .text("15:00", (ctx) => changeStartTime(ctx, 15, 0)) + .text("16:00", (ctx) => changeStartTime(ctx, 16, 0)) + .text("17:00", (ctx) => changeStartTime(ctx, 17, 0)) + .row() + .text("18:00", (ctx) => changeStartTime(ctx, 18, 0)) + .text("19:00", (ctx) => changeStartTime(ctx, 19, 0)) + .text("20:00", (ctx) => changeStartTime(ctx, 20, 0)) + .text("21:00", (ctx) => changeStartTime(ctx, 21, 0)) + .text("22:00", (ctx) => changeStartTime(ctx, 22, 0)) + .row() + .back( + () => `⚪️ Keep current time ${timeFormat.format(startDate)}`, + (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup }) + ) + + const startDateMenu = conversation + .menu("grants-start-date", { parent: "grants-main" }) + .text( + () => `Today ${dateFormat.format(today)}`, + (ctx) => changeStartDate(ctx, 0) + ) + .row() + .text(dateFormat.format(getDateWithDelta(today, 1)), (ctx) => changeStartDate(ctx, 1)) + .text(dateFormat.format(getDateWithDelta(today, 2)), (ctx) => changeStartDate(ctx, 2)) + .text(dateFormat.format(getDateWithDelta(today, 3)), (ctx) => changeStartDate(ctx, 3)) + .row() + .text(dateFormat.format(getDateWithDelta(today, 4)), (ctx) => changeStartDate(ctx, 4)) + .text(dateFormat.format(getDateWithDelta(today, 5)), (ctx) => changeStartDate(ctx, 5)) + .text(dateFormat.format(getDateWithDelta(today, 6)), (ctx) => changeStartDate(ctx, 6)) + .text(dateFormat.format(getDateWithDelta(today, 7)), (ctx) => changeStartDate(ctx, 7)) + .row() + .back("◀️ Back", (ctx) => ctx.editMessageText(baseMsg(), { reply_markup: ctx.msg?.reply_markup })) + + const mainMenu = conversation + .menu("grants-main") + .text("✅ Confirm", async (ctx) => { + ctx.menu.close() + const { success, error } = await api.tg.grants.create + .mutate({ + userId: target.id, + adderId: context.from.id, + reason: args.reason, + since: startDate, + until: endDate(), + }) + .catch(() => ({ success: false, error: "API_CALL_ERROR" })) + + if (success) { + await ctx.editMessageText( + fmt(({ b, skip }) => [skip`${baseMsg()}`, b`✅ Special Permissions Granted`], { sep: "\n\n" }) + ) + + await modules.get("tgLogger").grants({ + action: "CREATE", + target, + by: context.from, + since: startDate, + reason: args.reason, + duration: grantDuration, + }) + } else { + await ctx.editMessageText(fmt(({ b, skip }) => [skip`${baseMsg()}`, b`⁉️ Error: ${error}`], { sep: "\n\n" })) + } + + await conversation.halt() + }) + .row() + .submenu("📆 Change Start Date", startDateMenu, (ctx) => + ctx.editMessageText( + fmt(({ skip, b }) => [skip`${baseMsg()}`, b`📆 Changing start DATE`], { sep: "\n\n" }), + { reply_markup: ctx.msg?.reply_markup } + ) + ) + .submenu("⏱️ Change Duration", durationMenu, (ctx) => + ctx.editMessageText( + fmt(({ skip, b }) => [skip`${baseMsg()}`, b`⏱️ Changing grant DURATION`], { sep: "\n\n" }), + { reply_markup: ctx.msg?.reply_markup } + ) + ) + .row() + .text("❌ Cancel", async (ctx) => { + await ctx.editMessageText(fmt(({ b, skip }) => [skip`${baseMsg()}`, b`❌ Grant Cancelled`], { sep: "\n\n" })) + ctx.menu.close() + await wait(5000) + await ctx.deleteMessage().catch(() => {}) + await conversation.halt() + }) + + const msg = await context.reply(baseMsg(), { reply_markup: mainMenu }) + await conversation.waitUntil(() => false, { maxMilliseconds: 60 * 60 * 1000 }) + await msg.delete() + } catch (err) { + logger.error({ err }, "Error in grant command") + await context.deleteMessage() + } + }, +}) diff --git a/src/commands/index.ts b/src/commands/index.ts index 602da23..e5bd179 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -10,5 +10,6 @@ import "./role" import "./userid" import "./link-admin-dashboard" import "./report" +import "./grants" export { _commandsBase as commands } from "./_base" diff --git a/src/commands/role.ts b/src/commands/role.ts index e955341..6cfe399 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -2,15 +2,9 @@ import { z } from "zod" import { api } from "@/backend" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" -import type { Role } from "@/utils/types" +import { numberOrString, type Role } from "@/utils/types" import { _commandsBase } from "./_base" -const numberOrString = z.string().transform((s) => { - const n = Number(s) - if (!Number.isNaN(n) && s.trim() !== "") return n - return s -}) - _commandsBase .createCommand({ trigger: "getroles", diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index daf2a78..e5ec670 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -2,6 +2,8 @@ import type { Filter, MiddlewareObj } from "grammy" import { Composer } from "grammy" import type { Message } from "grammy/types" import ssdeep from "ssdeep.js" +import { api } from "@/backend" +import { logger } from "@/logger" import { modules } from "@/modules" import { mute } from "@/modules/moderation" import { redis } from "@/redis" @@ -10,6 +12,7 @@ import { defer } from "@/utils/deferred-middleware" import { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" import { createFakeMessage, getText } from "@/utils/messages" +import { throttle } from "@/utils/throttle" import type { Context } from "@/utils/types" import { wait } from "@/utils/wait" import { MessageUserStorage } from "../message-user-storage" @@ -17,6 +20,18 @@ import { AIModeration } from "./ai-moderation" import { MULTI_CHAT_SPAM, NON_LATIN } from "./constants" import { checkForAllowedLinks } from "./functions" +export type WhitelistType = { + role: "creator" | "admin" | "user" +} + +type ModerationContext = Filter & { + whitelisted?: WhitelistType +} + +const debouncedError = throttle((error: unknown, msg: string) => { + logger.error({ error }, msg) +}, 1000 * 60) + /** * # Auto-Moderation stack * ## Handles automatic message moderation. @@ -33,14 +48,24 @@ import { checkForAllowedLinks } from "./functions" */ export class AutoModerationStack implements MiddlewareObj { // the composer that holds all middlewares - private composer = new Composer() + private composer = new Composer>() // AI moderation instance private aiModeration: AIModeration = new AIModeration() constructor() { this.composer + .on(["message", "edited_message"]) .fork() // fork the processing, this stack executes in parallel to the rest of the bot - .filter(async (ctx) => !(await this.isWhitelisted(ctx))) // skip if the message is whitelisted + .filter(async (ctx) => { + if (ctx.from.id === ctx.me.id) return false // skip messages from the bot itself + const whitelistType = await this.isWhitelisted(ctx) + if (whitelistType) { + // creators can skip moderation entirely + if (whitelistType.role === "creator") return false + ctx.whitelisted = whitelistType + } + return true + }) // skip if the message is whitelisted // register all middlewares .on( ["message::url", "message::text_link", "edited_message::url", "edited_message::text_link"], @@ -64,16 +89,29 @@ export class AutoModerationStack implements MiddlewareObj * Checks if the message should be ignored by the moderation stack. * * TODO: implement a proper whitelist system - * - [ ] check if the user is privileged (admin, mod, etc) - * - [ ] check if the message is explicitly allowed by Direttivo (e.g. via a command) + * - [x] check if the user is privileged (admin, mod, etc) + * - [x] check if the message is explicitly allowed by Direttivo (e.g. via a command) * - [ ] check if the chat allows specific types of content (?) * * @param ctx The context of the message - * @returns true if the message is exempt and therefore should be ignored by - * the moderation stack, false otherwise + * @returns WT {@link WhitelistType} if there is a whitelisted user, `null` otherwise */ - private async isWhitelisted(_ctx: C): Promise { - return false + private async isWhitelisted(ctx: ModerationContext): Promise { + try { + const { status } = await ctx.getAuthor() + if (status === "creator") return { role: "creator" } + if (status === "administrator") return { role: "admin" } + + const isAdmin = await api.tg.permissions.checkGroup.query({ userId: ctx.from.id, groupId: ctx.chatId }) + if (isAdmin) return { role: "admin" } + + const grant = await api.tg.grants.checkUser.query({ userId: ctx.from.id }) + if (grant.isGranted) return { role: "user" } + } catch (e) { + debouncedError(e, "Error checking whitelist status in auto-moderation") + } + + return null } /** @@ -81,43 +119,60 @@ export class AutoModerationStack implements MiddlewareObj * If a link is not allowed, mutes the user for 1 minute and deletes the message. */ private async linkHandler( - ctx: Filter + ctx: Filter< + ModerationContext, + "message::url" | "message::text_link" | "edited_message::url" | "edited_message::text_link" + > ) { - // check both messages sent and edited - const message = ctx.message ?? ctx.editedMessage + const message = ctx.msg // extract all links from the message, might be inside entities, or inside the message text body const links = ctx .entities("text_link") .map((e) => e.url) .concat([getText(message).text]) + const allowed = await checkForAllowedLinks(links) - if (!allowed) { - await mute({ - ctx, - from: ctx.me, - target: ctx.from, - reason: "Shared link not allowed", - duration: duration.zod.parse("1m"), // 1 minute - message, - }) - const msg = await ctx.reply( - fmt(({ b }) => [ - b`${fmtUser(ctx.from)}`, - "The link you shared is not allowed.", - "Please refrain from sharing links that could be considered spam", - ]) - ) - await wait(5000) - await msg.delete() + if (allowed) return + + if (ctx.whitelisted) { + // no mod action + if (ctx.whitelisted.role === "user") { + // log the grant usage + await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, + message, + }) + } return } + + await mute({ + ctx, + from: ctx.me, + target: ctx.from, + reason: "Shared link not allowed", + duration: duration.zod.parse("1m"), // 1 minute + message, + }) + const msg = await ctx.reply( + fmt(({ b }) => [ + b`${fmtUser(ctx.from)}`, + "The link you shared is not allowed.", + "Please refrain from sharing links that could be considered spam", + ]) + ) + await wait(5000) + await msg.delete() + return } /** * Checks messages for harmful content using AI moderation. * If harmful content is detected, mutes the user and deletes the message. */ - private async harmfulContentHandler(ctx: Filter) { + private async harmfulContentHandler(ctx: Filter, "message">) { const message = ctx.message const flaggedCategories = await this.aiModeration.checkForHarmfulContent(ctx) @@ -125,24 +180,35 @@ export class AutoModerationStack implements MiddlewareObj const reasons = flaggedCategories.map((cat) => ` - ${cat.category} (${(cat.score * 100).toFixed(1)}%)`).join("\n") if (flaggedCategories.some((cat) => cat.aboveThreshold)) { - // above threshold, mute user and delete the message - await mute({ - ctx, - from: ctx.me, - target: ctx.from, - reason: `Automatic moderation detected harmful content\n${reasons}`, - duration: duration.zod.parse("1d"), // 1 day - message, - }) + if (ctx.whitelisted) { + // log the action but do not mute + if (ctx.whitelisted.role === "user") + await modules.get("tgLogger").grants({ + action: "USAGE", + from: ctx.from, + chat: ctx.chat, + message, + }) + } else { + // above threshold, mute user and delete the message + await mute({ + ctx, + from: ctx.me, + target: ctx.from, + reason: `Automatic moderation detected harmful content\n${reasons}`, + duration: duration.zod.parse("1d"), // 1 day + message, + }) - const msg = await ctx.reply( - fmt(({ i, b }) => [ - b`⚠️ Message from ${fmtUser(ctx.from)} was deleted automatically due to harmful content.`, - i`If you think this is a mistake, please contact the group administrators.`, - ]) - ) - await wait(5000) - await msg.delete() + const msg = await ctx.reply( + fmt(({ i, b }) => [ + b`⚠️ Message from ${fmtUser(ctx.from)} was deleted automatically due to harmful content.`, + i`If you think this is a mistake, please contact the group administrators.`, + ]) + ) + await wait(5000) + await msg.delete() + } } else { // no flagged category is above the threshold, still log it for manual review await modules.get("tgLogger").moderationAction({ @@ -161,7 +227,7 @@ export class AutoModerationStack implements MiddlewareObj * Handles messages containing a high percentage of non-latin characters to avoid most spam bots. * If the percentage of non-latin characters is too high, mutes the user for 10 minutes and deletes the message. */ - private async nonLatinHandler(ctx: Filter) { + private async nonLatinHandler(ctx: Filter, "message:text" | "message:caption">) { const text = ctx.message.caption ?? ctx.message.text const match = text.match(NON_LATIN.REGEX) @@ -187,8 +253,8 @@ export class AutoModerationStack implements MiddlewareObj /** * Handles messages sent to multiple chats with similar content. */ - private async multichatSpamHandler(ctx: Filter) { - if (ctx.from.is_bot) return + private async multichatSpamHandler(ctx: Filter, "message:text" | "message:media">) { + if (ctx.from.is_bot || ctx.whitelisted) return const { text } = getText(ctx.message) if (text === null) return if (text.length < MULTI_CHAT_SPAM.LENGTH_THR) return // skip because too short @@ -203,7 +269,11 @@ export class AutoModerationStack implements MiddlewareObj const similarMessages: Message[] = await Promise.all( range .map((r) => r.split("|")) - .map(([hash, chatId, messageId]) => ({ hash, chatId: Number(chatId), messageId: Number(messageId) })) + .map(([hash, chatId, messageId]) => ({ + hash, + chatId: Number(chatId), + messageId: Number(messageId), + })) .filter((v) => ssdeep.similarity(v.hash, hash) > MULTI_CHAT_SPAM.SIMILARITY_THR) .map(async (v) => { const msg = await MessageUserStorage.getInstance().get(v.chatId, v.messageId) @@ -239,6 +309,6 @@ export class AutoModerationStack implements MiddlewareObj } middleware() { - return this.composer.middleware() + return (this.composer as MiddlewareObj).middleware() } } diff --git a/src/modules/index.ts b/src/modules/index.ts index ab24a97..ce77656 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -17,6 +17,7 @@ export const modules = new ModuleCoordinator( actionRequired: 10, groupManagement: 33, deletedMessages: 130, + grants: 402, }), webSocket: new WebSocketClient(), banAll: new BanAllQueue(), diff --git a/src/modules/tg-logger/grants.ts b/src/modules/tg-logger/grants.ts new file mode 100644 index 0000000..5780b61 --- /dev/null +++ b/src/modules/tg-logger/grants.ts @@ -0,0 +1,115 @@ +import type { Context } from "grammy" +import type { Message, User } from "grammy/types" +import { type ApiOutput, api } from "@/backend" +import { type CallbackCtx, MenuGenerator } from "@/lib/menu" +import { logger } from "@/logger" +import { modules } from ".." + +type GrantedMessage = { + message: Message + chatId: number + target: User + deleted: boolean + interrupted: boolean +} + +async function handleInterrupt(ctx: CallbackCtx, target: User) { + const res = await api.tg.grants.interrupt.mutate({ interruptedById: ctx.from.id, userId: target.id }) + logger.debug({ res }, "handleInterrupt function in grants menu") + if (!res.success) { + return { error: res.error } + } + + await modules.get("tgLogger").grants({ action: "INTERRUPT", by: ctx.from, target: target }) + return { error: null } +} + +type Error = ApiOutput["tg"]["grants"]["interrupt"]["error"] | "CANNOT_DELETE" | null +const getFeedback = (error: Error): string | null => { + switch (error) { + case null: + return null + case "NOT_FOUND": + return "☑️ Grant already expired or interrupted" + case "UNAUTHORIZED": + return "❌ You don't have enough permissions" + case "INTERNAL_SERVER_ERROR": + return "⁉️ Backend error, please check logs" + case "CANNOT_DELETE": + return "⁉️ Cannot delete, maybe message already deleted" + } +} + +async function handleDelete(ctx: CallbackCtx, data: GrantedMessage): Promise<{ error: Error }> { + const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) + if (!roles?.includes("direttivo")) return { error: "UNAUTHORIZED" } + + const res = await modules + .get("tgLogger") + .delete([data.message], "[GRANT] Manual deletion of message sent by granted user", ctx.from) + + if (!res?.count) { + return { + error: "CANNOT_DELETE", + } + } + + return { error: null } +} + +/** + * Interactive menu for interacting with granted message. + * + * @param data - {@link GrantedMessage} grant info + */ +export const grantMessageMenu = MenuGenerator.getInstance().create("grants-message", [ + [ + { + text: "🗑", + cb: async ({ ctx, data }) => { + if (data.deleted) return { feedback: "☑️ Message already deleted" } + const { error } = await handleDelete(ctx, data) + if (!error && data.interrupted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + return { + feedback: getFeedback(error) ?? "✅ Message deleted", + newData: !error ? { ...data, deleted: true } : undefined, + } + }, + }, + { + text: "🛑", + cb: async ({ ctx, data }) => { + if (data.interrupted) return { feedback: "☑️ Grant already interrupted" } + const { error } = await handleInterrupt(ctx, data.target) + const noError = !error || error === "NOT_FOUND" + if (noError && data.deleted) await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + return { + feedback: getFeedback(error) ?? "✅ Grant Interrupted", + newData: noError ? { ...data, interrupted: true } : undefined, + } + }, + }, + ], +]) + +/** + * Interactive menu for interacting with newly created grant. + * + * @param data - {@link User} granted grammy's User + */ +export const grantCreatedMenu = MenuGenerator.getInstance().create("grants-create", [ + [ + { + text: "🛑 Interrupt", + cb: async ({ ctx, data }) => { + const { error } = await handleInterrupt(ctx, data) + logger.info({ error }, "handleInterrupt error output in created menu") + if (!error || error === "NOT_FOUND") + await ctx.editMessageReplyMarkup({ reply_markup: undefined }).catch(() => {}) + return { + feedback: getFeedback(error) ?? "✅ Grant Interrupted", + } + }, + }, + ], +]) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 855cf2e..dca851d 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -4,9 +4,10 @@ import { api } from "@/backend" import { Module } from "@/lib/modules" import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" -import { fmt, fmtChat, fmtUser } from "@/utils/format" +import { fmt, fmtChat, fmtDate, fmtUser } from "@/utils/format" import type { ModuleShared } from "@/utils/types" import { type BanAll, banAllMenu, getBanAllText } from "./ban-all" +import { grantCreatedMenu, grantMessageMenu } from "./grants" import { getReportText, type Report, reportMenu } from "./report" import type * as Types from "./types" @@ -18,6 +19,7 @@ type Topics = { adminActions: number exceptions: number groupManagement: number + grants: number } export class TgLogger extends Module { @@ -281,7 +283,7 @@ export class TgLogger extends Module { ? n`${b`Duration:`} ${props.duration.raw} (until ${props.duration.dateStr})` : undefined, - "reason" in props && props.reason ? fmt(({ n, b }) => n`${b`Reason:`} ${props.reason}`) : undefined, + "reason" in props && props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, /// per-action specific info, like MULTI_CHAT ...others.map((o) => skip`${o}`), @@ -320,11 +322,10 @@ export class TgLogger extends Module { case "LEAVE_FAIL": msg = fmt( - ({ b, n, i }) => [ - b`‼ Cannot Left`, + ({ b, n }) => [ + b`‼ Leave failed`, n`${b`Group:`} ${fmtChat(props.chat)}`, n`${b`Added by:`} ${fmtUser(props.addedBy)}`, - n`${i`This user does not have enough permissions to add the bot`}`, ], { sep: "\n", @@ -366,6 +367,64 @@ export class TgLogger extends Module { return msg } + public async grants(props: Types.GrantLog): Promise { + let msg: string + switch (props.action) { + case "USAGE": { + const { invite_link } = await this.shared.api.getChat(props.chat.id) + msg = fmt( + ({ n, b }) => [ + b`💬 Spam-message detected`, + n`${b`From:`} ${fmtUser(props.from)}`, + n`${b`Chat:`} ${fmtChat(props.chat, invite_link)}`, + ], + { sep: "\n" } + ) + const usageMenu = await grantMessageMenu({ + target: props.from, + interrupted: false, + deleted: false, + chatId: props.chat.id, + message: props.message, + }) + await this.log(this.topics.grants, msg, { reply_markup: usageMenu, disable_notification: false }) + await this.forward(this.topics.grants, props.chat.id, [props.message.message_id]) + return msg + } + + case "CREATE": { + msg = fmt( + ({ n, b }) => [ + b`✳ New Grant`, + n`${b`Target:`} ${fmtUser(props.target)}`, + n`${b`By:`} ${fmtUser(props.by)}`, + props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, + n`\n${b`Valid since:`} ${fmtDate(props.since)}`, + n`${b`Duration:`} ${props.duration.raw} (until ${fmtDate(new Date(props.since.getTime() + props.duration.secondsFromNow * 1000))})`, + ], + { sep: "\n" } + ) + + const createMenu = await grantCreatedMenu(props.target) + await this.log(this.topics.grants, msg, { reply_markup: createMenu, disable_notification: false }) + return msg + } + + case "INTERRUPT": + msg = fmt( + ({ n, b }) => [ + b`🛑 Grant Interruption`, + n`${b`Target:`} ${fmtUser(props.target)}`, + n`${b`By:`} ${fmtUser(props.by)}`, + ], + { sep: "\n" } + ) + + await this.log(this.topics.grants, msg, { reply_markup: undefined, disable_notification: false }) + return msg + } + } + public async exception(props: Types.ExceptionLog, context?: string): Promise { const contextFmt = context ? fmt(({ n, b }) => n`\n${b`Context:`} ${context}`) : undefined let msg: string = "" diff --git a/src/modules/tg-logger/types.ts b/src/modules/tg-logger/types.ts index a85e502..3ce20fc 100644 --- a/src/modules/tg-logger/types.ts +++ b/src/modules/tg-logger/types.ts @@ -91,3 +91,25 @@ export type DeleteResult = { count: number link: string } + +export type GrantLog = {} & ( + | { + action: "USAGE" + from: User + message: Message + chat: Chat + } + | { + action: "CREATE" + target: User + by: User + since: Date + duration: Duration + reason?: string + } + | { + action: "INTERRUPT" + target: User + by: User + } +) diff --git a/src/utils/types.ts b/src/utils/types.ts index 0cc4327..0703c63 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,5 +1,6 @@ import type { Api, Context as TContext } from "grammy" import type { UserFromGetMe } from "grammy/types" +import z from "zod" import type { ApiInput } from "@/backend" import type { ManagedCommandsFlavor } from "@/lib/managed-commands" @@ -22,3 +23,9 @@ export type ModuleShared = { api: Api botInfo: UserFromGetMe } + +export const numberOrString = z.string().transform((s) => { + const n = Number(s) + if (!Number.isNaN(n) && s.trim() !== "") return n + return s +})