From 481c1a727fb1636e082cac3adafd594a5ec501f7 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 00:54:48 +0200 Subject: [PATCH 01/35] feat: voting utility --- src/utils/vote.ts | 99 ++++++++++++++++++++++++++++++++++++++++ tests/vote.test.ts | 110 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 src/utils/vote.ts create mode 100644 tests/vote.test.ts diff --git a/src/utils/vote.ts b/src/utils/vote.ts new file mode 100644 index 0000000..852eef7 --- /dev/null +++ b/src/utils/vote.ts @@ -0,0 +1,99 @@ +import type { User } from "grammy/types" +import { logger } from "@/logger" + +export type Vote = "inFavor" | "against" | "abstained" +export type Outcome = "approved" | "denied" | "waiting" +export type Voter = { + user: Pick + isPresident: boolean + vote?: Vote +} + +/** + * WARNING: This function is specific to Direttivo voting, do NOT use it for generic voting. + * + * This function calculate the voting outcome based on the votes collected so far. + * To determine the outcome, we refer to Article 13.8 of the Statute: + * > Le riunioni del Direttivo sono valide quando è presente la maggioranza assoluta + * > dei componenti. Il Direttivo delibera a maggioranza dei voti dei presenti. + * > In caso di parità prevale il voto del Presidente. + * + * In this context, the “maggioranza assoluta” (absolute majority) + * is always respected because it is an asynchronous vote, + * so it means the absolute majority of votes. + * + * Here is an example to help devs better understand the voting system. + * e.g. Direttivo of 8 + * - 4 inFavor, 4 against. President inFavor => ✅ Approved + * - 3 inFavor, 5 against. President inFavor => ❌ Denied + * - 5 inFavor, 3 against. President against => ✅ Approved + * - 2 inFavor, 2 against, 3 absteined, President absteined => TIE => ❌ Denied + * (this is an unregulated case, for the sake of banall this should + * not ever happen, so we consider it denied anyway) + * Note: the same mechanisms apply to a Direttivo composed of an odd number of members + * + * The rule of thumb is: + * 1) absolute majority of members: + * 8-9 => 5 voters || 6-7 => 4 voters || 4-5 => 3 voters || 3 => 2 voters + * 2) in case of TIE, the President's vote counts twice + * 3) in case of TIE where the President is abstaineded, we consider the votation denied. + * + * This function is unit-tested to ensure correct handling of edge-cases. + */ +export function calculateOutcome(voters: Voter[]): Outcome | null { + if (voters.length < 3 || voters.length > 9) { + logger.error({ length: voters.length }, "[VOTE] recieved a voters array with invalid length (must be 3<=l<=9)") + return null + } + + const membersCount = voters.length + const majority = Math.floor(membersCount / 2) + 1 // absolute majority + const votes = voters.filter((v): v is Voter & { vote: Vote } => v.vote !== undefined) + + const presVote = votes.find((v) => v.isPresident) + if (votes.length === membersCount && !presVote) { + logger.error({ length: voters.length }, "[VOTE] every member voted but no member is flagged as president!") + return null + } + + if (votes.length < majority) return "waiting" // not enough votes + + const results = votes.reduce( + (results, voter) => { + results[voter.vote]++ + return results + }, + { + inFavor: 0, + against: 0, + abstained: 0, + } + ) + + // there are enough votes, but do we have a majority? + if (results.inFavor >= majority) return "approved" // majority voted in-favor + if (results.against >= majority) return "denied" // majority voted against + + // in the following cases we don't have a majority + if (votes.length === membersCount) { + if (!presVote) return null // we already checked above, but TS wants it again + if (results.abstained === membersCount) return "denied" // everyone abstaineded (crazy) + if (results.inFavor > results.against) return "approved" + if (results.against > results.inFavor) return "denied" + + // against === inFavor => TIE => the Pres decides + // abstained === against for the reasons stated in the docs comment + if (presVote.vote === "abstained" || presVote.vote === "against") return "denied" + return "approved" + } + + // some special cases + if (votes.length === membersCount - 1 && presVote && presVote.vote !== "abstained") { + if (results.inFavor > results.against && presVote.vote === "inFavor") return "approved" + if (results.inFavor < results.against && presVote.vote === "against") return "denied" + } + + // we have not reach enoguh votes to determine the outcome + // we wait for the remaining votes + return "waiting" +} diff --git a/tests/vote.test.ts b/tests/vote.test.ts new file mode 100644 index 0000000..ac1a57d --- /dev/null +++ b/tests/vote.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vitest" +import { calculateOutcome, type Outcome, type Vote, type Voter } from "@/utils/vote" + +function makeTest( + pres: Vote | undefined, + inFavor: number, + against: number, + abstained: number, + empty: number +): Outcome | null { + const voters: Voter[] = [{ user: fakeUser, isPresident: true, vote: pres }] + + for (let i = 0; i < inFavor; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: "inFavor", + }) + } + for (let i = 0; i < against; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: "against", + }) + } + for (let i = 0; i < abstained; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: "abstained", + }) + } + for (let i = 0; i < empty; i++) { + voters.push({ + user: fakeUser, + isPresident: false, + vote: undefined, + }) + } + + return calculateOutcome(voters) +} + +const fakeUser: Voter["user"] = { first_name: "First", last_name: "Last", id: 1000000000 } +describe("voting utility", () => { + it("limits breaking", () => { + expect(calculateOutcome([])).toBe(null) + expect(makeTest(undefined, 0, 0, 0, 0)).toBe(null) + expect(makeTest(undefined, 0, 0, 0, 10)).toBe(null) + }) + + it("everyone votes the same", () => { + expect(makeTest("abstained", 0, 0, 6, 0)).toBe("denied") + expect(makeTest("against", 0, 6, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 6, 0, 0, 0)).toBe("approved") + }) + + it("no majority of votes reached", () => { + expect(makeTest(undefined, 2, 3, 0, 3)).toBe("waiting") + expect(makeTest("inFavor", 0, 1, 0, 5)).toBe("waiting") + expect(makeTest(undefined, 1, 2, 0, 4)).toBe("waiting") + expect(makeTest(undefined, 1, 1, 0, 3)).toBe("waiting") + expect(makeTest("abstained", 0, 0, 3, 5)).toBe("waiting") + }) + + it("everyone voted, different combinations", () => { + expect(makeTest("abstained", 4, 2, 0, 0)).toBe("approved") + expect(makeTest("inFavor", 3, 3, 0, 0)).toBe("approved") + expect(makeTest("against", 4, 2, 0, 0)).toBe("approved") + expect(makeTest("against", 2, 4, 0, 0)).toBe("denied") + expect(makeTest("abstained", 2, 4, 0, 0)).toBe("denied") + expect(makeTest("abstained", 1, 5, 0, 0)).toBe("denied") + expect(makeTest("abstained", 0, 6, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 0, 6, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 1, 2, 3, 0)).toBe("approved") + }) + + it("not everyone voted, but still a majority is reached", () => { + expect(makeTest(undefined, 4, 1, 0, 1)).toBe("approved") + expect(makeTest(undefined, 5, 0, 0, 1)).toBe("approved") + expect(makeTest("inFavor", 4, 1, 0, 1)).toBe("approved") + expect(makeTest("abstained", 4, 1, 0, 1)).toBe("approved") + expect(makeTest("inFavor", 3, 0, 2, 1)).toBe("approved") + expect(makeTest(undefined, 4, 2, 0, 0)).toBe("approved") + expect(makeTest("inFavor", 1, 4, 0, 1)).toBe("denied") + expect(makeTest("against", 1, 4, 0, 1)).toBe("denied") + expect(makeTest("inFavor", 1, 4, 0, 1)).toBe("denied") + }) + + it("tie cases", () => { + expect(makeTest("abstained", 3, 3, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 3, 4, 0, 0)).toBe("approved") + expect(makeTest("abstained", 3, 4, 0, 0)).toBe("denied") // not a proper tie + expect(makeTest("against", 3, 2, 1, 0)).toBe("denied") + }) + + it("some tricky cases", () => { + expect(makeTest("inFavor", 2, 3, 0, 1)).toBe("waiting") + expect(makeTest("abstained", 2, 3, 0, 1)).toBe("waiting") + expect(makeTest(undefined, 3, 3, 0, 0)).toBe("waiting") + expect(makeTest("against", 3, 2, 0, 1)).toBe("waiting") + expect(makeTest("inFavor", 2, 2, 0, 1)).toBe("approved") + expect(makeTest("against", 2, 2, 0, 1)).toBe("denied") + expect(makeTest("inFavor", 3, 3, 0, 1)).toBe("approved") + expect(makeTest("inFavor", 0, 2, 0, 0)).toBe("denied") + expect(makeTest("inFavor", 1, 1, 0, 0)).toBe("approved") + expect(makeTest("abstained", 1, 1, 0, 0)).toBe("denied") + }) +}) From 4801d76a64c54b952fb0fd3eff08e68e47d34bf8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 00:55:51 +0200 Subject: [PATCH 02/35] fix: logger level now uses `process.env` to avoid error in tests --- src/logger.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/logger.ts b/src/logger.ts index 721e808..18bc9df 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,8 @@ import pino from "pino" -import { env } from "./env" - export const logger = pino({ - level: env.LOG_LEVEL, + // the reason why we use process.env instead of @/env is that + // we want the logger to be working also in tests where we do not have + // environment variables set. If we used @/env it would throw an error + level: process.env.LOG_LEVEL || "debug", }) From 0e6ba85bcf749258328192e3557dc334728ed4d6 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 00:56:26 +0200 Subject: [PATCH 03/35] fix(fmtUser): reduce user type restrictness --- src/utils/format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/format.ts b/src/utils/format.ts index 5cee82e..6520d63 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -138,7 +138,7 @@ export function fmt(cb: (formatters: Formatters) => string | (string | undefined ) } -export function fmtUser(user: User): string { +export function fmtUser(user: Pick): string { const fullname = user.last_name ? `${user.first_name} ${user.last_name}` : user.first_name return formatters.n`${formatters.link(fullname, `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` } From c442f5fddf011e819fd474d15afa0ea57fe2d48f Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 00:56:58 +0200 Subject: [PATCH 04/35] fix: remove LOG_LEVEL env from `@/env` --- src/env.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/env.ts b/src/env.ts index 2360057..1e592f3 100644 --- a/src/env.ts +++ b/src/env.ts @@ -11,7 +11,6 @@ export const env = createEnv({ REDIS_USERNAME: z.string().min(1).optional(), REDIS_PASSWORD: z.string().min(1).optional(), NODE_ENV: z.enum(["development", "production"]).default("development"), - LOG_LEVEL: z.string().default("DEBUG"), OPENAI_API_KEY: z.string().optional(), }, From 5f905610e654c296e2132a6e748f5fdc5e97a7a0 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 01:53:36 +0200 Subject: [PATCH 05/35] tests: add a vote test --- tests/vote.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/vote.test.ts b/tests/vote.test.ts index ac1a57d..37dbe12 100644 --- a/tests/vote.test.ts +++ b/tests/vote.test.ts @@ -65,6 +65,7 @@ describe("voting utility", () => { }) it("everyone voted, different combinations", () => { + expect(makeTest("abstained", 1, 0, 1, 0)).toBe("approved") expect(makeTest("abstained", 4, 2, 0, 0)).toBe("approved") expect(makeTest("inFavor", 3, 3, 0, 0)).toBe("approved") expect(makeTest("against", 4, 2, 0, 0)).toBe("approved") From 995be8a885e1f5b367cfc59602727865bd3126db Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 01:55:48 +0200 Subject: [PATCH 06/35] feat(MenuGen): add support to update Menu data from callback Useful for long-term menus, like BanAll voting --- src/commands/test/menu.ts | 4 +++- src/lib/menu/index.ts | 22 ++++++++++++++++------ src/lib/tg-logger/report.ts | 6 +++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/commands/test/menu.ts b/src/commands/test/menu.ts index 717ab00..4de52b4 100644 --- a/src/commands/test/menu.ts +++ b/src/commands/test/menu.ts @@ -12,7 +12,7 @@ const generateMenu = MenuGenerator.getInstance().create<{ cb: async ({ ctx, data }) => { await ctx.editMessageText(`${ctx.msg?.text ?? ""}\nBAN`, { reply_markup: ctx.msg?.reply_markup ?? undefined }) logger.info({ data }, "TESTSTESTSTSTE") - return "Deleted + Banned" + return { feedback: "Deleted + Banned" } }, }, ], @@ -21,12 +21,14 @@ const generateMenu = MenuGenerator.getInstance().create<{ text: "TEST 1", cb: () => { logger.info("TEST 1") + return null }, }, { text: "TEST 2", cb: () => { logger.info("TEST 2") + return null }, }, ], diff --git a/src/lib/menu/index.ts b/src/lib/menu/index.ts index 18431d6..a544457 100644 --- a/src/lib/menu/index.ts +++ b/src/lib/menu/index.ts @@ -16,8 +16,10 @@ const CONSTANTS = { } export type CallbackCtx = Filter -// biome-ignore lint/suspicious/noConfusingVoidType: literally a bug in Biome -type Callback = (params: { data: T; ctx: CallbackCtx }) => MaybePromise +type Callback = (params: { + data: T + ctx: CallbackCtx +}) => MaybePromise<{ feedback?: string; newData?: T } | null> class Menu { private dataStorage: RedisFallbackAdapter @@ -63,6 +65,10 @@ class Menu { return keyboard } + async updateData(keyboardId: string, data: T): Promise { + await this.dataStorage.write(keyboardId, data) + } + async call(ctx: CallbackCtx, row: number, col: number, keyboardId: string) { const buttonId = `${row}:${col}` const callback = this.callbacks.get(buttonId) @@ -136,14 +142,18 @@ export class MenuGenerator implements MiddlewareObj { return menu .call(ctx, row, col, keyboardId) - .then((result) => { - return ctx.answerCallbackQuery({ text: result ?? undefined }) + .then(async (result) => { + if (result?.newData) await menu.updateData(keyboardId, result.newData) + return ctx.answerCallbackQuery({ text: result?.feedback }) }) .catch(async (e: unknown) => { logger.error({ e }, "ERROR WHILE CALLING MENU CB") await ctx.editMessageReplyMarkup().catch(() => {}) - const feedback = menu.onExpiredButtonPress && (await menu.onExpiredButtonPress({ data: null, ctx })) - await ctx.answerCallbackQuery({ text: feedback ?? "This button is no longer available", show_alert: true }) + const result = await menu.onExpiredButtonPress?.({ data: null, ctx }) + await ctx.answerCallbackQuery({ + text: result?.feedback ?? "This button is no longer available", + show_alert: true, + }) }) }) } diff --git a/src/lib/tg-logger/report.ts b/src/lib/tg-logger/report.ts index 9d888fe..a222c80 100644 --- a/src/lib/tg-logger/report.ts +++ b/src/lib/tg-logger/report.ts @@ -74,6 +74,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r text: "✅ Ignore", cb: async ({ data, ctx }) => { await editReportMessage(data, ctx, "✅ Ignore") + return null }, }, { @@ -81,6 +82,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r cb: async ({ data, ctx }) => { await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) await editReportMessage(data, ctx, "🗑 Delete") + return null }, }, ], @@ -94,6 +96,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r until_date: Math.floor(Date.now() / 1000) + duration.values.m, }) await editReportMessage(data, ctx, "👢 Kick") + return null }, }, { @@ -102,6 +105,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r await ctx.api.deleteMessage(data.message.chat.id, data.message.message_id) await ctx.api.banChatMember(data.message.chat.id, data.message.from.id) await editReportMessage(data, ctx, "🚫 Ban") + return null }, }, ], @@ -111,7 +115,7 @@ export const reportMenu = MenuGenerator.getInstance().create("r cb: async ({ data, ctx }) => { // TODO: connect ban all when implemented await editReportMessage(data, ctx, "🚨 Start BAN ALL (not implemented yet)") - return "❌ Not implemented yet" + return { feedback: "❌ Not implemented yet" } }, }, ], From fe967b1ff8a235e637dee0bab357de9506a43965 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 01:57:23 +0200 Subject: [PATCH 07/35] feat: ban all - voting management with menu --- src/lib/tg-logger/ban-all.ts | 122 +++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/lib/tg-logger/ban-all.ts diff --git a/src/lib/tg-logger/ban-all.ts b/src/lib/tg-logger/ban-all.ts new file mode 100644 index 0000000..cc64502 --- /dev/null +++ b/src/lib/tg-logger/ban-all.ts @@ -0,0 +1,122 @@ +import type { Context } from "grammy" +import type { User } from "grammy/types" +import { logger } from "@/logger" +import { fmt, fmtUser } from "@/utils/format" +import { calculateOutcome, type Outcome, type Vote, type Voter } from "@/utils/vote" +import { type CallbackCtx, MenuGenerator } from "../menu" + +export type BanAll = { + type: "BAN" | "UNBAN" + target: User + reporter: User + reason: string + outcome: Outcome + voters: Voter[] +} + +const VOTE_EMOJI: Record = { + inFavor: "✅", + against: "❌", + abstained: "🫥", +} + +const OUTCOME_STR: Record = { + waiting: "⏳ Waiting for votes", + approved: "✅ APPROVED", + denied: "❌ DENIED", +} + +/** + * Generate the message text of the BanAll case, based on current voting situation. + * + * @param data - The BanAll data including message and reporter. + * @returns A formatted string of the message text. + */ +export const getBanAllText = (data: BanAll) => + fmt( + ({ n, b, strikethrough }) => [ + data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🟢 UN-BAN ALL 🟢`, + "", + n`${b`🎯 Target:`} ${fmtUser(data.target)}`, + n`${b`📣 Reporter:`} ${fmtUser(data.reporter)}`, + n`${b`📋 Reason:`} ${data.reason}`, + "", + b`${OUTCOME_STR[data.outcome]}`, + "", + b`Voters`, + ...data.voters.map((v) => + data.outcome !== "waiting" && !v.vote + ? strikethrough`➖ ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""}` + : n`${v.vote ? VOTE_EMOJI[v.vote] : "⏳"} ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""}` + ), + ], + { sep: "\n" } + ) + +async function vote( + ctx: CallbackCtx, + data: BanAll, + vote: Vote +): Promise<{ feedback?: string; newData?: BanAll }> { + const voterId = ctx.callbackQuery.from.id + const voter = data.voters.find((v) => v.user.id === voterId) + if (!voter) + return { + feedback: "❌ You cannot vote", + } + if (voter.vote !== undefined) + return { + feedback: "⚠️ You cannot change your vote!", + } + + voter.vote = vote + const outcome = calculateOutcome(data.voters) + logger.debug({ outcome: data.outcome, voters: data.voters }, "[VOTE] new vote, calculating...") + if (outcome === null) { + logger.fatal({ banAll: data }, "ERROR WHILE VOTING FOR BAN_ALL, Outcome is null") + return { + feedback: "There was an error, check logs", + } + } + data.outcome = outcome + + // remove buttons if there is an outcome (not waiting) + const reply_markup = outcome === "waiting" ? ctx.msg?.reply_markup : undefined + + await ctx.editMessageText(getBanAllText(data), { reply_markup }).catch(() => { + // throws if message is not modified - we don't care + }) + + return { + newData: data, + feedback: "✅ Thanks for voting!", + } +} + +/** + * Interactive menu for handling voting. + * + * @param data - {@link BanAll} initial BanAll + */ +export const banAllMenu = MenuGenerator.getInstance().create("ban-all-voting", [ + [ + { + text: VOTE_EMOJI.inFavor, + cb: async ({ ctx, data }) => { + return await vote(ctx, data, "inFavor") + }, + }, + { + text: VOTE_EMOJI.abstained, + cb: async ({ ctx, data }) => { + return await vote(ctx, data, "abstained") + }, + }, + { + text: VOTE_EMOJI.against, + cb: async ({ ctx, data }) => { + return await vote(ctx, data, "against") + }, + }, + ], +]) From 9b29a013e5bd8daa59bb5533772fd8a2786c295e Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 01:58:03 +0200 Subject: [PATCH 08/35] feat: add a banall test command (temporary) --- src/commands/test/banall.ts | 113 ++++++++++++++++++++++++++++++++++++ src/commands/test/index.ts | 1 + src/lib/tg-logger/index.ts | 33 ++--------- 3 files changed, 120 insertions(+), 27 deletions(-) create mode 100644 src/commands/test/banall.ts diff --git a/src/commands/test/banall.ts b/src/commands/test/banall.ts new file mode 100644 index 0000000..70336aa --- /dev/null +++ b/src/commands/test/banall.ts @@ -0,0 +1,113 @@ +import { tgLogger } from "@/bot" +import type { BanAll } from "@/lib/tg-logger/ban-all" +import { _commandsBase } from "../_base" + +_commandsBase + .createCommand({ + trigger: "test_banall", + description: "TEST - PREMA BAN a user from all the Network's groups", + scope: "private", + permissions: { + allowedRoles: ["owner"], + }, + handler: async ({ context }) => { + const banAllTest: BanAll = { + type: "BAN", + outcome: "waiting", + reporter: context.from, + reason: "Testing ban all voting system", + target: { + first_name: "PoliCreator", + last_name: "3", + id: 728441822, // policreator3 - unused + is_bot: false, + username: "policreator3", + }, + voters: [ + { + user: { + first_name: "PoliCreator1", + id: 349275135, + }, + isPresident: true, + vote: undefined, + }, + { + user: { + first_name: "Lorenzo", + last_name: "Corallo", + id: 186407195, + }, + isPresident: false, + vote: undefined, + }, + { + user: { + first_name: "PoliCreator", + last_name: "5", + id: 1699796816, + }, + isPresident: false, + vote: undefined, + }, + ], + } + + await context.deleteMessage() + await tgLogger.banAll(banAllTest) + }, + }) + .createCommand({ + trigger: "test_unbanall", + description: "TEST - UNBAN a user from the network", + scope: "private", + permissions: { + allowedRoles: ["owner"], + }, + handler: async ({ context }) => { + const banAllTest: BanAll = { + type: "UNBAN", + outcome: "waiting", + reporter: context.from, + reason: "Testing ban all voting system", + target: { + first_name: "PoliCreator", + last_name: "3", + id: 728441822, // policreator3 - unused + is_bot: false, + username: "policreator3", + }, + voters: [ + { + user: { + first_name: "PoliCreator1", + id: 349275135, + }, + isPresident: true, + vote: undefined, + }, + { + user: { + first_name: "Lorenzo", + last_name: "Corallo", + id: 186407195, + }, + isPresident: false, + vote: undefined, + }, + { + user: { + first_name: "PoliCreator", + last_name: "5", + id: 1699796816, + }, + isPresident: false, + vote: undefined, + }, + ], + } + + await context.deleteMessage() + await tgLogger.banAll(banAllTest) + }, + }) diff --git a/src/commands/test/index.ts b/src/commands/test/index.ts index 672a9c8..e863767 100644 --- a/src/commands/test/index.ts +++ b/src/commands/test/index.ts @@ -2,3 +2,4 @@ import "./args" import "./db" import "./menu" import "./format" +import "./banall" diff --git a/src/lib/tg-logger/index.ts b/src/lib/tg-logger/index.ts index d05cc50..a8819d1 100644 --- a/src/lib/tg-logger/index.ts +++ b/src/lib/tg-logger/index.ts @@ -3,6 +3,7 @@ import type { Message, User } from "grammy/types" import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" import { fmt, fmtChat, fmtUser } from "@/utils/format" +import { type BanAll, banAllMenu, getBanAllText } from "./ban-all" import { getReportText, type Report, reportMenu } from "./report" import type * as Types from "./types" @@ -133,33 +134,11 @@ export class TgLogger { } } - public async banAll(props: Types.BanAllLog): Promise { - let msg: string - if (props.type === "BAN") { - msg = fmt( - ({ b, n }) => [ - b`🚫 Ban ALL`, - n`${b`Target:`} ${fmtUser(props.target)}`, - n`${b`Admin:`} ${fmtUser(props.from)}`, - props.reason ? n`${b`Reason:`} ${props.reason}` : undefined, - ], - { sep: "\n" } - ) - } else { - msg = fmt( - ({ b, n }) => [ - b`✅ Unban ALL`, - n`${b`Target:`} ${fmtUser(props.target)}`, - n`${b`Admin:`} ${fmtUser(props.from)}`, - ], - { - sep: "\n", - } - ) - } - - await this.log(this.topics.banAll, msg) - return msg + // public async banAll(props: Types.BanAllLog): Promise { + public async banAll(banAll: BanAll): Promise { + const menu = await banAllMenu(banAll) + await this.log(this.topics.banAll, "———————————————") + await this.log(this.topics.banAll, getBanAllText(banAll), { reply_markup: menu }) } public async moderationAction(props: Types.ModerationAction): Promise { From 96115e1fc09cc5789f2f1104adcfda6cf4df03b1 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 27 Sep 2025 17:09:30 +0200 Subject: [PATCH 09/35] feat: use backend addRole and getDirettivo --- package.json | 2 +- pnpm-lock.yaml | 15 +++---- src/commands/_base.ts | 9 ++-- src/commands/role.ts | 22 ++++++---- src/commands/test/banall.ts | 88 +++++++++++++------------------------ src/utils/format.ts | 4 +- src/utils/types.ts | 2 +- src/utils/vote.ts | 2 +- 8 files changed, 59 insertions(+), 85 deletions(-) diff --git a/package.json b/package.json index bc3d7e0..926b15c 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.9.2", + "@polinetwork/backend": "file:../backend/package/dist/", "@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 191fe50..acf8d58 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.9.2 - version: 0.9.2 + specifier: file:../backend/package/dist/ + version: dist@file:../backend/package/dist '@t3-oss/env-core': specifier: ^0.13.4 version: 0.13.4(arktype@2.1.20)(typescript@5.7.3)(zod@4.1.11) @@ -386,10 +386,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@polinetwork/backend@0.9.2': - resolution: {integrity: sha512-WEjmeMblhLZX3Nc0nn9RcK/rb/l1wea6NM69H69pxJI0/KkaPn7YaqSUUoiE2kjKl58Ngp1YmS/WDSk7oZHqOw==} - engines: {node: '>=24.8.0', pnpm: '>=10.17.1'} - '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -751,6 +747,9 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} + dist@file:../backend/package/dist: + resolution: {directory: ../backend/package/dist, type: directory} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1617,8 +1616,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@polinetwork/backend@0.9.2': {} - '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: '@redis/client': 1.6.0 @@ -1901,6 +1898,8 @@ snapshots: diff@4.0.2: {} + dist@file:../backend/package/dist: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 diff --git a/src/commands/_base.ts b/src/commands/_base.ts index be07099..b9c3453 100644 --- a/src/commands/_base.ts +++ b/src/commands/_base.ts @@ -35,12 +35,11 @@ export const _commandsBase = new ManagedCommands({ } } - const { role } = await api.tg.permissions.getRole.query({ userId: ctx.from.id }) - if (role === "user") return false // TODO: maybe we should do this differently + const { roles } = await api.tg.permissions.getRoles.query({ userId: ctx.from.id }) + if (!roles) return false // TODO: maybe we should do this differently - const userRole = role as Role - if (allowedRoles && !allowedRoles.includes(userRole)) return false - if (excludedRoles?.includes(userRole)) return false + if (allowedRoles && !roles.some((r) => allowedRoles.includes(r))) return false + if (roles.some((r) => excludedRoles?.includes(r))) return false return true }, diff --git a/src/commands/role.ts b/src/commands/role.ts index 7abb938..88819ff 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -33,24 +33,24 @@ _commandsBase } try { - const { role } = await api.tg.permissions.getRole.query({ userId }) - await context.reply(fmt(({ b }) => [`Role:`, b`${role}`])) + const { roles } = await api.tg.permissions.getRoles.query({ userId }) + await context.reply(fmt(({ b }) => (roles ? [`Roles:`, b`${roles.join(" ")}`] : "This user has no roles"))) } catch (err) { await context.reply(`There was an error: \n${String(err)}`) } }, }) .createCommand({ - trigger: "setrole", + trigger: "addrole", scope: "private", - description: "Set role of username", + description: "Add role to user", args: [ { key: "username", type: numberOrString, description: "The username or the user id of the user you want to update the role", }, - { key: "role", type: z.enum(["direttivo", "hr", "admin"]) }, + { key: "role", type: z.enum(["owner", "president", "direttivo", "hr", "admin"]) }, ], permissions: { allowedRoles: ["owner", "direttivo"], @@ -65,14 +65,18 @@ _commandsBase } try { - const { role: prev } = await api.tg.permissions.getRole.query({ userId }) - await api.tg.permissions.setRole.query({ userId, adderId: context.from.id, role: args.role }) + const { roles } = await api.tg.permissions.getRoles.query({ userId }) + const { error } = await api.tg.permissions.addRole.mutate({ userId, adderId: context.from.id, role: args.role }) + if (error) { + await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) + return + } await context.reply( fmt( - ({ b, n }) => [ + ({ b, n, u }) => [ b`✅ Role set!`, n`${b`Username:`} ${args.username}`, - n`${b`Role:`} ${prev} -> ${args.role}`, + n`${b`Roles:`} ${u`${args.role}`} ${roles ? roles.join(" ") : ""}`, ], { sep: "\n" } ) diff --git a/src/commands/test/banall.ts b/src/commands/test/banall.ts index 70336aa..a149210 100644 --- a/src/commands/test/banall.ts +++ b/src/commands/test/banall.ts @@ -1,6 +1,8 @@ import { tgLogger } from "@/bot" import type { BanAll } from "@/lib/tg-logger/ban-all" import { _commandsBase } from "../_base" +import { api } from "@/backend" +import { fmt } from "@/utils/format" _commandsBase .createCommand({ @@ -11,6 +13,19 @@ _commandsBase allowedRoles: ["owner"], }, handler: async ({ context }) => { + await context.deleteMessage() + const direttivo = await api.tg.permissions.getDirettivo.query() + if (direttivo.error) { + await context.reply(fmt(({ n }) => n`${direttivo.error}`)) + return + } + + const voters = direttivo.members.map((m) => ({ + user: { id: m.userId }, + isPresident: m.isPresident, + vote: undefined, + })) + const banAllTest: BanAll = { type: "BAN", outcome: "waiting", @@ -23,37 +38,9 @@ _commandsBase is_bot: false, username: "policreator3", }, - voters: [ - { - user: { - first_name: "PoliCreator1", - id: 349275135, - }, - isPresident: true, - vote: undefined, - }, - { - user: { - first_name: "Lorenzo", - last_name: "Corallo", - id: 186407195, - }, - isPresident: false, - vote: undefined, - }, - { - user: { - first_name: "PoliCreator", - last_name: "5", - id: 1699796816, - }, - isPresident: false, - vote: undefined, - }, - ], + voters, } - await context.deleteMessage() await tgLogger.banAll(banAllTest) }, }) @@ -65,6 +52,19 @@ _commandsBase allowedRoles: ["owner"], }, handler: async ({ context }) => { + await context.deleteMessage() + const direttivo = await api.tg.permissions.getDirettivo.query() + if (direttivo.error) { + await context.reply(fmt(({ n }) => n`${direttivo.error}`)) + return + } + + const voters = direttivo.members.map((m) => ({ + user: { id: m.userId }, + isPresident: m.isPresident, + vote: undefined, + })) + const banAllTest: BanAll = { type: "UNBAN", outcome: "waiting", @@ -77,37 +77,9 @@ _commandsBase is_bot: false, username: "policreator3", }, - voters: [ - { - user: { - first_name: "PoliCreator1", - id: 349275135, - }, - isPresident: true, - vote: undefined, - }, - { - user: { - first_name: "Lorenzo", - last_name: "Corallo", - id: 186407195, - }, - isPresident: false, - vote: undefined, - }, - { - user: { - first_name: "PoliCreator", - last_name: "5", - id: 1699796816, - }, - isPresident: false, - vote: undefined, - }, - ], + voters, } - await context.deleteMessage() await tgLogger.banAll(banAllTest) }, }) diff --git a/src/utils/format.ts b/src/utils/format.ts index 6520d63..414c190 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -138,9 +138,9 @@ export function fmt(cb: (formatters: Formatters) => string | (string | undefined ) } -export function fmtUser(user: Pick): string { +export function fmtUser(user: Partial> & { id: number }): string { const fullname = user.last_name ? `${user.first_name} ${user.last_name}` : user.first_name - return formatters.n`${formatters.link(fullname, `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` + return formatters.n`${formatters.link(fullname ?? user.id.toString(), `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` } export function fmtChat(chat: Chat, inviteLink?: string): string { diff --git a/src/utils/types.ts b/src/utils/types.ts index 07c5304..ea77e0a 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -15,4 +15,4 @@ export type ContextWith

> = Exclude = T | Promise export type Context = ManagedCommandsFlavor -export type Role = ApiInput["tg"]["permissions"]["setRole"]["role"] +export type Role = ApiInput["tg"]["permissions"]["addRole"]["role"] diff --git a/src/utils/vote.ts b/src/utils/vote.ts index 852eef7..4238495 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -4,7 +4,7 @@ import { logger } from "@/logger" export type Vote = "inFavor" | "against" | "abstained" export type Outcome = "approved" | "denied" | "waiting" export type Voter = { - user: Pick + user: Partial> & { id: number } isPresident: boolean vote?: Vote } From adce5c9ebb0f83bb429a6dc8903086d9790bac65 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Mon, 29 Sep 2025 23:18:34 +0200 Subject: [PATCH 10/35] feat: update types from backend, add delrole command --- src/commands/role.ts | 77 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 11 deletions(-) diff --git a/src/commands/role.ts b/src/commands/role.ts index 88819ff..e955341 100644 --- a/src/commands/role.ts +++ b/src/commands/role.ts @@ -13,9 +13,9 @@ const numberOrString = z.string().transform((s) => { _commandsBase .createCommand({ - trigger: "getrole", + trigger: "getroles", scope: "private", - description: "Get role of userid", + description: "Get roles of an user", args: [ { key: "username", @@ -34,7 +34,9 @@ _commandsBase try { const { roles } = await api.tg.permissions.getRoles.query({ userId }) - await context.reply(fmt(({ b }) => (roles ? [`Roles:`, b`${roles.join(" ")}`] : "This user has no roles"))) + await context.reply( + fmt(({ b }) => (roles?.length ? [`Roles:`, b`${roles.join(" ")}`] : "This user has no roles")) + ) } catch (err) { await context.reply(`There was an error: \n${String(err)}`) } @@ -65,20 +67,73 @@ _commandsBase } try { - const { roles } = await api.tg.permissions.getRoles.query({ userId }) - const { error } = await api.tg.permissions.addRole.mutate({ userId, adderId: context.from.id, role: args.role }) + const { roles, error } = await api.tg.permissions.addRole.mutate({ + userId, + adderId: context.from.id, + role: args.role, + }) + if (error) { await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) return } + + await context.reply( + fmt( + ({ b, n }) => [b`✅ Role added!`, n`${b`Username:`} ${args.username}`, n`${b`Updated roles:`} ${roles}`], + { + sep: "\n", + } + ) + ) + await context.deleteMessage() + } catch (err) { + await context.reply(`There was an error: \n${String(err)}`) + } + }, + }) + .createCommand({ + trigger: "delrole", + scope: "private", + description: "Remove role from an user", + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to remove the role from", + }, + { key: "role", type: z.enum(["owner", "president", "direttivo", "hr", "admin"]) }, + ], + permissions: { + allowedRoles: ["owner", "direttivo"], + }, + handler: async ({ context, args }) => { + const userId: number | null = + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + try { + const { roles, error } = await api.tg.permissions.removeRole.mutate({ + userId, + removerId: context.from.id, + role: args.role, + }) + + if (error) { + await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) + return + } + await context.reply( fmt( - ({ b, n, u }) => [ - b`✅ Role set!`, - n`${b`Username:`} ${args.username}`, - n`${b`Roles:`} ${u`${args.role}`} ${roles ? roles.join(" ") : ""}`, - ], - { sep: "\n" } + ({ b, n }) => [b`✅ Role removed!`, n`${b`Username:`} ${args.username}`, n`${b`Updated roles:`} ${roles}`], + { + sep: "\n", + } ) ) await context.deleteMessage() From 190426d876bfc3754dfb6040eb8dbd82f95dcce2 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Mon, 29 Sep 2025 23:30:24 +0200 Subject: [PATCH 11/35] build: bump @polinetwork/backend to 0.11.0 --- package.json | 2 +- pnpm-lock.yaml | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 926b15c..1562328 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": "file:../backend/package/dist/", + "@polinetwork/backend": "^0.11.0", "@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 acf8d58..1469fa0 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: file:../backend/package/dist/ - version: dist@file:../backend/package/dist + specifier: ^0.11.0 + version: 0.11.0 '@t3-oss/env-core': specifier: ^0.13.4 version: 0.13.4(arktype@2.1.20)(typescript@5.7.3)(zod@4.1.11) @@ -386,6 +386,10 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@polinetwork/backend@0.11.0': + resolution: {integrity: sha512-KKUMDow4KPOOoOIMwbCC9SKUYZb3ZD4EFfnxCGHi8ehPOTiP+tfli2dLhF/AcX/M5+g8f00vgmiCZL+W9+ATqA==} + engines: {node: '>=24.8.0', pnpm: '>=10.17.1'} + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -747,9 +751,6 @@ packages: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} - dist@file:../backend/package/dist: - resolution: {directory: ../backend/package/dist, type: directory} - dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1616,6 +1617,8 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@polinetwork/backend@0.11.0': {} + '@redis/bloom@1.2.0(@redis/client@1.6.0)': dependencies: '@redis/client': 1.6.0 @@ -1898,8 +1901,6 @@ snapshots: diff@4.0.2: {} - dist@file:../backend/package/dist: {} - dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 From 2d63a5d64cb4f6cac60b8bf4bbc37696e8f8dab8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Mon, 29 Sep 2025 23:44:23 +0200 Subject: [PATCH 12/35] fix: check if there is no president --- src/commands/test/banall.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/src/commands/test/banall.ts b/src/commands/test/banall.ts index a149210..01e1e3a 100644 --- a/src/commands/test/banall.ts +++ b/src/commands/test/banall.ts @@ -1,8 +1,8 @@ +import { api } from "@/backend" import { tgLogger } from "@/bot" import type { BanAll } from "@/lib/tg-logger/ban-all" -import { _commandsBase } from "../_base" -import { api } from "@/backend" import { fmt } from "@/utils/format" +import { _commandsBase } from "../_base" _commandsBase .createCommand({ @@ -26,6 +26,15 @@ _commandsBase vote: undefined, })) + if (!voters.some((v) => v.isPresident)) { + await context.reply( + fmt(({ n, b }) => [b`No member is President!`, n`${b`Members:`} ${voters.map((v) => v.user.id).join(" ")}`], { + sep: "\n", + }) + ) + return + } + const banAllTest: BanAll = { type: "BAN", outcome: "waiting", @@ -65,6 +74,15 @@ _commandsBase vote: undefined, })) + if (!voters.some((v) => v.isPresident)) { + await context.reply( + fmt(({ n, b }) => [b`No member is President!`, n`${b`Members:`} ${voters.map((v) => v.user.id).join(" ")}`], { + sep: "\n", + }) + ) + return + } + const banAllTest: BanAll = { type: "UNBAN", outcome: "waiting", From 0f4afabe9672286edf526b79f1d3407a1e109f72 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 01:12:02 +0200 Subject: [PATCH 13/35] feat: module coordinator (#52) * feat: module coordinator * chore: folder structure * fix: don't know how variables work * fix: websocket lifecycle * feat: BanAll message update on progress * feat: unicode progress bar!!!! * fix(group-mgmt): join+admin correct handling * feat: ban-all --------- Co-authored-by: Lorenzo Corallo --- package.json | 4 +- pnpm-lock.yaml | 200 ++++++++++++++++ src/bot.ts | 26 +-- src/commands/ban.ts | 2 +- src/commands/del.ts | 5 +- src/commands/kick.ts | 2 +- src/commands/mute.ts | 2 +- src/commands/report.ts | 5 +- src/commands/test/banall.ts | 18 +- src/lib/modules/index.ts | 131 +++++++++++ .../auto-moderation-stack/index.ts | 8 +- src/middlewares/bot-membership-handler.ts | 32 ++- src/middlewares/ui-actions-logger.ts | 14 +- src/modules/index.ts | 27 +++ src/modules/moderation/ban-all.ts | 213 ++++++++++++++++++ src/{lib => modules}/moderation/ban.ts | 12 +- src/{lib => modules}/moderation/index.ts | 0 src/{lib => modules}/moderation/kick.ts | 6 +- src/{lib => modules}/moderation/mute.ts | 8 +- src/{lib => modules}/tg-logger/ban-all.ts | 65 +++++- src/{lib => modules}/tg-logger/index.ts | 40 ++-- src/{lib => modules}/tg-logger/report.ts | 2 +- src/{lib => modules}/tg-logger/types.ts | 0 src/utils/progress.ts | 38 ++++ src/utils/throttle.ts | 32 +++ src/utils/types.ts | 8 +- src/utils/wait.ts | 32 +++ src/websocket.ts | 18 +- tests/throttle.test.ts | 53 +++++ 29 files changed, 916 insertions(+), 87 deletions(-) create mode 100644 src/lib/modules/index.ts create mode 100644 src/modules/index.ts create mode 100644 src/modules/moderation/ban-all.ts rename src/{lib => modules}/moderation/ban.ts (85%) rename src/{lib => modules}/moderation/index.ts (100%) rename src/{lib => modules}/moderation/kick.ts (88%) rename src/{lib => modules}/moderation/mute.ts (91%) rename src/{lib => modules}/tg-logger/ban-all.ts (61%) rename src/{lib => modules}/tg-logger/index.ts (91%) rename src/{lib => modules}/tg-logger/report.ts (98%) rename src/{lib => modules}/tg-logger/types.ts (100%) create mode 100644 src/utils/progress.ts create mode 100644 src/utils/throttle.ts create mode 100644 tests/throttle.test.ts diff --git a/package.json b/package.json index b815015..c70b95d 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", "@types/ssdeep.js": "^0.0.2", + "bullmq": "^5.59.0", "croner": "^9.0.0", "grammy": "^1.37.0", "nanoid": "^5.1.5", @@ -54,7 +55,7 @@ "superjson": "^2.2.2", "zod": "^4.1.11" }, - "packageManager": "pnpm@10.6.5+sha512.cdf928fca20832cd59ec53826492b7dc25dc524d4370b6b4adbf65803d32efaa6c1c88147c0ae4e8d579a6c9eec715757b50d4fa35eea179d868eada4ed043af", + "packageManager": "pnpm@10.17.1+sha512.17c560fca4867ae9473a3899ad84a88334914f379be46d455cbf92e5cf4b39d34985d452d2583baf19967fa76cb5c17bc9e245529d0b98745721aa7200ecaf7a", "engines": { "npm": ">=10.9.2", "node": ">=22.14.0" @@ -62,6 +63,7 @@ "pnpm": { "onlyBuiltDependencies": [ "esbuild", + "msgpackr-extract", "unrs-resolver" ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a43ef02..2313d50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@types/ssdeep.js': specifier: ^0.0.2 version: 0.0.2 + bullmq: + specifier: ^5.59.0 + version: 5.59.0 croner: specifier: ^9.0.0 version: 9.0.0 @@ -360,6 +363,9 @@ packages: '@grammyjs/types@3.21.0': resolution: {integrity: sha512-IMj0EpmglPCICuyfGRx4ENKPSuzS2xMSoPgSPzHC6FtnWKDEmJLBP/GbPv/h3TAeb27txqxm/BUld+gbJk6ccQ==} + '@ioredis/commands@1.4.0': + resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -385,6 +391,36 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -666,6 +702,9 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + bullmq@5.59.0: + resolution: {integrity: sha512-RmqUIvNKWQ5bTBnMo4ttCNqWs+IzTHfkRbPS95r8Ba2uCZQKe/xXbZbIiwx5FAMhckab03slIKKAYHto/M223Q==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -725,6 +764,10 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + croner@9.0.0: resolution: {integrity: sha512-onMB0OkDjkXunhdW9htFjEhqrD54+M94i6ackoUkjHKbRnXdyEyKRelp4nJ1kAz32+s27jP1FsebpJCVl0BsvA==} engines: {node: '>=18.0'} @@ -762,6 +805,14 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + + detect-libc@2.1.1: + resolution: {integrity: sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==} + engines: {node: '>=8'} + diff@4.0.2: resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} engines: {node: '>=0.3.1'} @@ -923,6 +974,10 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + ioredis@5.8.0: + resolution: {integrity: sha512-AUXbKn9gvo9hHKvk6LbZJQSKn/qIfkWXrnsyL9Yrf+oeXmla9Nmf6XEumOddyhM8neynpK5oAV6r9r99KBuwzA==} + engines: {node: '>=12.22.0'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -952,6 +1007,12 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -961,6 +1022,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} @@ -993,6 +1058,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -1010,6 +1082,9 @@ packages: resolution: {integrity: sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==} engines: {node: '>=18'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} engines: {node: '>=10.5.0'} @@ -1024,6 +1099,10 @@ packages: encoding: optional: true + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -1137,6 +1216,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redis@4.7.0: resolution: {integrity: sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==} @@ -1162,6 +1249,11 @@ packages: secure-json-parse@4.0.0: resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1207,6 +1299,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -1302,6 +1397,9 @@ packages: '@swc/wasm': optional: true + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsup@8.4.0: resolution: {integrity: sha512-b+eZbPCjz10fRryaAA7C8xlIHnf8VnsaRqydheLIqwG/Mcpfk8Z5zp3HayX7GaTygkigHl5cBUs+IhcySiIexQ==} engines: {node: '>=18'} @@ -1337,6 +1435,10 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -1629,6 +1731,8 @@ snapshots: '@grammyjs/types@3.21.0': {} + '@ioredis/commands@1.4.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1660,6 +1764,24 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -1879,6 +2001,18 @@ snapshots: dependencies: balanced-match: 1.0.2 + bullmq@5.59.0: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.8.0 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.2 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + bundle-require@5.1.0(esbuild@0.25.1): dependencies: esbuild: 0.25.1 @@ -1929,6 +2063,10 @@ snapshots: create-require@1.1.1: {} + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + croner@9.0.0: {} cross-spawn@7.0.6: @@ -1951,6 +2089,11 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + + detect-libc@2.1.1: + optional: true + diff@4.0.2: {} dunder-proto@1.0.1: @@ -2138,6 +2281,20 @@ snapshots: dependencies: ms: 2.1.3 + ioredis@5.8.0: + dependencies: + '@ioredis/commands': 1.4.0 + cluster-key-slot: 1.1.2 + debug: 4.4.0 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + is-fullwidth-code-point@3.0.0: {} is-what@4.1.16: {} @@ -2158,12 +2315,18 @@ snapshots: load-tsconfig@0.2.5: {} + lodash.defaults@4.2.0: {} + + lodash.isarguments@3.1.0: {} + lodash.sortby@4.7.0: {} loupe@3.1.3: {} lru-cache@10.4.3: {} + luxon@3.7.2: {} + magic-string@0.30.17: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -2188,6 +2351,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -2202,12 +2381,19 @@ snapshots: optionalDependencies: '@rollup/rollup-linux-x64-gnu': 4.37.0 + node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.1 + optional: true + object-assign@4.1.1: {} on-exit-leak-free@2.1.2: {} @@ -2315,6 +2501,12 @@ snapshots: real-require@0.2.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redis@4.7.0: dependencies: '@redis/bloom': 1.2.0(@redis/client@1.6.0) @@ -2360,6 +2552,8 @@ snapshots: secure-json-parse@4.0.0: {} + semver@7.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2404,6 +2598,8 @@ snapshots: stackback@0.0.2: {} + standard-as-callback@2.1.0: {} + std-env@3.9.0: {} string-width@4.2.3: @@ -2502,6 +2698,8 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tslib@2.8.1: {} + tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.7.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.1) @@ -2542,6 +2740,8 @@ snapshots: undici-types@6.20.0: {} + uuid@11.1.0: {} + v8-compile-cache-lib@3.0.1: {} vite-node@3.1.1(@types/node@22.13.1)(tsx@4.19.3): diff --git a/src/bot.ts b/src/bot.ts index 7cc92e1..bb375c0 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -8,7 +8,6 @@ import { apiTestQuery } from "./backend" import { commands } from "./commands" import { env } from "./env" import { MenuGenerator } from "./lib/menu" -import { TgLogger } from "./lib/tg-logger" import { logger } from "./logger" import { AutoModerationStack } from "./middlewares/auto-moderation-stack" import { BotMembershipHandler } from "./middlewares/bot-membership-handler" @@ -16,10 +15,10 @@ import { checkUsername } from "./middlewares/check-username" import { messageLink } from "./middlewares/message-link" import { MessageStorage } from "./middlewares/message-storage" import { UIActionsLogger } from "./middlewares/ui-actions-logger" +import { modules, sharedDataInit } from "./modules" import { redis } from "./redis" import { setTelegramId } from "./utils/telegram-id" -import type { Context } from "./utils/types" -import { WebSocketClient } from "./websocket" +import type { Context, ModuleShared } from "./utils/types" const TEST_CHAT_ID = -1002669533277 const ALLOWED_UPDATES: ReadonlyArray> = [ @@ -62,16 +61,16 @@ bot.use( }) ) -export const tgLogger = new TgLogger(bot, -1002685849173, { - banAll: 13, - exceptions: 3, - autoModeration: 7, - adminActions: 5, - actionRequired: 10, - groupManagement: 33, - deletedMessages: 130, +bot.init().then(() => { + const sharedData: ModuleShared = { + api: bot.api, + botInfo: bot.botInfo, + } + sharedDataInit.resolve(sharedData) }) +const tgLogger = modules.get("tgLogger") + bot.use(MenuGenerator.getInstance()) bot.use(commands) bot.use(new BotMembershipHandler()) @@ -107,8 +106,6 @@ bot.catch(async (err) => { logger.error(e) }) -new WebSocketClient(bot) - const runner = run(bot, { runner: { fetch: { @@ -126,7 +123,8 @@ async function terminate(signal: NodeJS.Signals) { const p1 = MessageStorage.getInstance().sync() const p2 = redis.quit() const p3 = runner.isRunning() && runner.stop() - await Promise.all([p1, p2, p3]) + const p4 = modules.stop() + await Promise.all([p1, p2, p3, p4]) logger.info("Bot stopped!") process.exit(0) } diff --git a/src/commands/ban.ts b/src/commands/ban.ts index f61ef85..f336d28 100644 --- a/src/commands/ban.ts +++ b/src/commands/ban.ts @@ -1,5 +1,5 @@ -import { ban, unban } from "@/lib/moderation" import { logger } from "@/logger" +import { ban, unban } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" diff --git a/src/commands/del.ts b/src/commands/del.ts index ef038d9..db13e1f 100644 --- a/src/commands/del.ts +++ b/src/commands/del.ts @@ -1,7 +1,6 @@ -import { tgLogger } from "@/bot" import { logger } from "@/logger" +import { modules } from "@/modules" import { getText } from "@/utils/messages" - import { _commandsBase } from "./_base" _commandsBase.createCommand({ @@ -22,7 +21,7 @@ _commandsBase.createCommand({ sender: repliedTo.from?.username, }) - await tgLogger.delete([repliedTo], "Command /del", context.from) // actual message to delete + await modules.get("tgLogger").delete([repliedTo], "Command /del", context.from) // actual message to delete await context.deleteMessage() // /del message }, }) diff --git a/src/commands/kick.ts b/src/commands/kick.ts index e577adc..f23dcce 100644 --- a/src/commands/kick.ts +++ b/src/commands/kick.ts @@ -1,5 +1,5 @@ -import { kick } from "@/lib/moderation" import { logger } from "@/logger" +import { kick } from "@/modules/moderation" import { wait } from "@/utils/wait" import { _commandsBase } from "./_base" diff --git a/src/commands/mute.ts b/src/commands/mute.ts index ce93bdf..c8791f2 100644 --- a/src/commands/mute.ts +++ b/src/commands/mute.ts @@ -1,5 +1,5 @@ -import { mute, unmute } from "@/lib/moderation" import { logger } from "@/logger" +import { mute, unmute } from "@/modules/moderation" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import { getTelegramId } from "@/utils/telegram-id" diff --git a/src/commands/report.ts b/src/commands/report.ts index 8a998e7..7df8a9d 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,6 +1,5 @@ -import { tgLogger } from "@/bot" import { logger } from "@/logger" - +import { modules } from "@/modules" import { _commandsBase } from "./_base" _commandsBase.createCommand({ @@ -15,6 +14,6 @@ _commandsBase.createCommand({ return } - await tgLogger.report(repliedTo, context.from) + await modules.get("tgLogger").report(repliedTo, context.from) }, }) diff --git a/src/commands/test/banall.ts b/src/commands/test/banall.ts index 01e1e3a..08c4e8d 100644 --- a/src/commands/test/banall.ts +++ b/src/commands/test/banall.ts @@ -1,6 +1,6 @@ import { api } from "@/backend" -import { tgLogger } from "@/bot" -import type { BanAll } from "@/lib/tg-logger/ban-all" +import { modules } from "@/modules" +import type { BanAll } from "@/modules/tg-logger/ban-all" import { fmt } from "@/utils/format" import { _commandsBase } from "../_base" @@ -48,9 +48,14 @@ _commandsBase username: "policreator3", }, voters, + state: { + successCount: 0, + failedCount: 0, + jobCount: 0, + }, } - await tgLogger.banAll(banAllTest) + await modules.get("tgLogger").banAll(banAllTest) }, }) .createCommand({ @@ -96,8 +101,13 @@ _commandsBase username: "policreator3", }, voters, + state: { + failedCount: 0, + successCount: 0, + jobCount: 0, + }, } - await tgLogger.banAll(banAllTest) + await modules.get("tgLogger").banAll(banAllTest) }, }) diff --git a/src/lib/modules/index.ts b/src/lib/modules/index.ts new file mode 100644 index 0000000..6ad6734 --- /dev/null +++ b/src/lib/modules/index.ts @@ -0,0 +1,131 @@ +import type { MaybePromise } from "@/utils/types" +import { Awaiter } from "@/utils/wait" + +const SHARED_GETTER = Symbol("__internal_shared_getter") + +type WithGetter = { + [SHARED_GETTER]?: () => Readonly +} + +/** + * @deprecated ## VERY PRIVATE FUNCTION, IF EXPORTED I'LL LITERALLY START CRYING SO DON'T + * + * Get the shared value getter for a module instance. + * + * _comments as visibility modifiers: ✅_ + * @param self The module instance + * @returns A function that returns the shared value, or null if not available + */ +function magicGetter(self: Module): WithGetter { + return self as unknown as WithGetter // dont tell typescript! +} + +/** + * Base class for modules that can share immutable data via a ModuleCoordinator. + * The shared data is accessible via the `shared` getter. + * + * This abstract class provides overridable lifecycle methods `start` and `stop` + * for initialization and cleanup when used with a `ModuleCoordinator`. + */ +export abstract class Module { + /** + * The concrete getter is stored under a symbol property that is NOT exposed. + * It's `private` so subclasses cannot directly touch it. We still access it + * via a symbol to allow ModuleCoordinator (in same module/file) to bind it. + * biome-ignore lint/correctness/noUnusedPrivateClassMembers: it's a kind of magic + */ + private [SHARED_GETTER]?: () => Readonly + + /** + * Protected accessor for the shared, immutable data. If a module tries to use + * it before the coordinator has bound it, we throw an error. + */ + protected get shared(): Readonly { + const getter = magicGetter(this)[SHARED_GETTER] + if (!getter) { + throw new Error("Module not bound to a ModuleCoordinator or coordinator hasn't started yet.") + } + return getter() + } + + /** + * Called upon initialization, here the shared value is guaranteed to be bound. + */ + public start?(): void | Promise + /** + * Called when the coordinator is stopped, for cleanup. + */ + public stop?(): void | Promise +} + +/** + * Coordinator which owns a Readonly and binds it to all modules. + * The data is frozen (immutable) and isolated per-coordinator instance. + */ +export class ModuleCoordinator>> { + private sharedValue?: Readonly + private started = false + private starting = new Awaiter() + + constructor( + private readonly modules: ModuleMap, + sharedValue: () => MaybePromise + ) { + void this.init(sharedValue) + } + + private async init(sharedValue: () => MaybePromise) { + const resolved = await sharedValue() + this.sharedValue = Object.freeze(resolved) // make it immutable + + // Bind the internal getter to each module. Because SHARED_GETTER is a symbol + // private to this file, external code won't know how to access it. + for (const m of Object.values(this.modules)) { + // `as any` is required because symbols on classes are not part of the + // public Module shape. This is internal wiring only. + magicGetter(m)[SHARED_GETTER] = () => resolved + } + await this.start() + this.starting.resolve() + } + + public async ready(): Promise { + await this.starting + } + + /** Returns the shared value owned by the coordinator. */ + public get shared(): Readonly { + if (!this.sharedValue) { + throw new Error("ModuleCoordinator hasn't been initialized yet.") + } + return this.sharedValue + } + + public get(module: K): ModuleMap[K] { + return this.modules[module] + } + + private async start(): Promise { + if (this.started) return + this.started = true + await Promise.all( + Object.values(this.modules) + .map((m) => m.start?.()) + .filter(Boolean) + ) + } + + /** + * Stops all modules for a graceful shutdown. + * @returns A promise that resolves when all modules have been stopped. + */ + public async stop(): Promise { + if (!this.started) return + await Promise.all( + Object.values(this.modules) + .map((m) => m.stop?.()) + .filter(Boolean) + ) + this.started = false + } +} diff --git a/src/middlewares/auto-moderation-stack/index.ts b/src/middlewares/auto-moderation-stack/index.ts index 2a825e2..96a7b4b 100644 --- a/src/middlewares/auto-moderation-stack/index.ts +++ b/src/middlewares/auto-moderation-stack/index.ts @@ -2,8 +2,8 @@ import type { Filter, MiddlewareObj } from "grammy" import { Composer } from "grammy" import type { Message } from "grammy/types" import ssdeep from "ssdeep.js" -import { tgLogger } from "@/bot" -import { mute } from "@/lib/moderation" +import { modules } from "@/modules" +import { mute } from "@/modules/moderation" import { redis } from "@/redis" import { groupMessagesByChat, RestrictPermissions } from "@/utils/chat" import { defer } from "@/utils/deferred-middleware" @@ -145,7 +145,7 @@ export class AutoModerationStack implements MiddlewareObj await msg.delete() } else { // no flagged category is above the threshold, still log it for manual review - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "SILENT", from: ctx.me, chat: ctx.chat, @@ -226,7 +226,7 @@ export class AutoModerationStack implements MiddlewareObj ) ) - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "MULTI_CHAT_SPAM", from: ctx.me, chat: ctx.chat, diff --git a/src/middlewares/bot-membership-handler.ts b/src/middlewares/bot-membership-handler.ts index a148183..8230b9f 100644 --- a/src/middlewares/bot-membership-handler.ts +++ b/src/middlewares/bot-membership-handler.ts @@ -1,8 +1,8 @@ import { Composer, type Filter, InlineKeyboard, type MiddlewareObj } from "grammy" import { api } from "@/backend" -import { tgLogger } from "@/bot" import { GroupManagement } from "@/lib/group-management" import { logger } from "@/logger" +import { modules } from "@/modules" import type { Context } from "@/utils/types" type ChatType = "group" | "supergroup" | "private" | "channel" @@ -41,8 +41,8 @@ export class BotMembershipHandler implements MiddlewareObj if (this.isJoin(ctx)) { // joined event - await this.checkAdderPermission(ctx) - return next() + // go next, if adder has no permission + if (!(await this.checkAdderPermission(ctx))) return next() } if (newStatus === "administrator") { @@ -73,10 +73,12 @@ export class BotMembershipHandler implements MiddlewareObj if (!allowed) { const left = await ctx.leaveChat().catch(() => false) if (left) { - await tgLogger.groupManagement({ type: "LEAVE", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from }) + await modules + .get("tgLogger") + .groupManagement({ type: "LEAVE", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from }) logger.info({ chat: ctx.myChatMember.chat, from: ctx.myChatMember.from }, `[BCE] Left unauthorized group`) } else { - await tgLogger.groupManagement({ + await modules.get("tgLogger").groupManagement({ type: "LEAVE_FAIL", chat: ctx.myChatMember.chat, addedBy: ctx.myChatMember.from, @@ -95,7 +97,7 @@ export class BotMembershipHandler implements MiddlewareObj const res = await GroupManagement.delete(chat) await res.match( async () => { - await tgLogger.groupManagement({ type: "DELETE", chat }) + await modules.get("tgLogger").groupManagement({ type: "DELETE", chat }) logger.info({ chat }, `[BCE] Deleted a group`) }, (e) => { @@ -107,16 +109,26 @@ export class BotMembershipHandler implements MiddlewareObj private async createGroup(ctx: MemberContext): Promise { const chat = await ctx.getChat() const res = await GroupManagement.create(chat) + const logChat = { + id: chat.id, + title: chat.title, + is_forum: chat.is_forum, + type: chat.type, + invite_link: chat.invite_link, + } + await res.match( async (g) => { - await tgLogger.groupManagement({ type: "CREATE", chat, inviteLink: g.link, addedBy: ctx.from }) - logger.info({ chat }, `[BCE] Created a new group`) + await modules.get("tgLogger").groupManagement({ type: "CREATE", chat, inviteLink: g.link, addedBy: ctx.from }) + logger.info({ chat: logChat }, `[BCE] Created a new group`) }, async (e) => { const ik = new InlineKeyboard() if (chat.invite_link) ik.url("Join Group", chat.invite_link) - await tgLogger.groupManagement({ type: "CREATE_FAIL", chat, inviteLink: chat.invite_link, reason: e }) - logger.error({ chat }, `[BCE] Cannot create group into DB. Reason: ${e}`) + await modules + .get("tgLogger") + .groupManagement({ type: "CREATE_FAIL", chat, inviteLink: chat.invite_link, reason: e }) + logger.error({ chat: logChat }, `[BCE] Cannot create group into DB. Reason: ${e}`) } ) } diff --git a/src/middlewares/ui-actions-logger.ts b/src/middlewares/ui-actions-logger.ts index 6f171c6..c973c1f 100644 --- a/src/middlewares/ui-actions-logger.ts +++ b/src/middlewares/ui-actions-logger.ts @@ -1,5 +1,5 @@ import { Composer, type MiddlewareObj } from "grammy" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import { duration } from "@/utils/duration" import type { Context } from "@/utils/types" @@ -30,7 +30,7 @@ export class UIActionsLogger implements MiddlewareObj { if (prev === "member" && curr === "left") return // skip left event if (prev === "kicked" && curr === "left") { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "UNBAN", from: admin, target, @@ -40,7 +40,7 @@ export class UIActionsLogger implements MiddlewareObj { } if (prev === "member" && curr === "kicked") { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "BAN", from: admin, target, @@ -50,7 +50,7 @@ export class UIActionsLogger implements MiddlewareObj { } if (prev === "member" && curr === "restricted" && !new_chat_member.can_send_messages) { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "MUTE", duration: duration.fromUntilDate(new_chat_member.until_date), from: admin, @@ -63,7 +63,7 @@ export class UIActionsLogger implements MiddlewareObj { if (prev === "restricted" && curr === "restricted") { if (old_chat_member.can_send_messages && !new_chat_member.can_send_messages) { // mute - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "MUTE", duration: duration.fromUntilDate(new_chat_member.until_date), from: admin, @@ -71,7 +71,7 @@ export class UIActionsLogger implements MiddlewareObj { chat, }) } else if (!old_chat_member.can_send_messages && new_chat_member.can_send_messages) { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from: admin, target, @@ -82,7 +82,7 @@ export class UIActionsLogger implements MiddlewareObj { } if (prev === "restricted" && curr === "member") { - await tgLogger.moderationAction({ + await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from: admin, target, diff --git a/src/modules/index.ts b/src/modules/index.ts new file mode 100644 index 0000000..ab24a97 --- /dev/null +++ b/src/modules/index.ts @@ -0,0 +1,27 @@ +import { ModuleCoordinator } from "@/lib/modules" +import type { ModuleShared } from "@/utils/types" +import { Awaiter } from "@/utils/wait" +import { WebSocketClient } from "@/websocket" +import { BanAllQueue } from "./moderation/ban-all" +import { TgLogger } from "./tg-logger" + +export const sharedDataInit = new Awaiter() + +export const modules = new ModuleCoordinator( + { + tgLogger: new TgLogger(-1002685849173, { + banAll: 13, + exceptions: 3, + autoModeration: 7, + adminActions: 5, + actionRequired: 10, + groupManagement: 33, + deletedMessages: 130, + }), + webSocket: new WebSocketClient(), + banAll: new BanAllQueue(), + }, + async () => { + return await sharedDataInit + } +) diff --git a/src/modules/moderation/ban-all.ts b/src/modules/moderation/ban-all.ts new file mode 100644 index 0000000..fc66a51 --- /dev/null +++ b/src/modules/moderation/ban-all.ts @@ -0,0 +1,213 @@ +import { type ConnectionOptions, type FlowJob, FlowProducer, type Job, Queue, Worker } from "bullmq" +import { api } from "@/backend" +import { env } from "@/env" +import { Module } from "@/lib/modules" +import { logger } from "@/logger" +import { throttle } from "@/utils/throttle" +import type { ModuleShared } from "@/utils/types" +import { modules } from ".." +import { type BanAll, type BanAllState, isBanAllState } from "../tg-logger/ban-all" + +const CONFIG = { + ORCHESTRATOR_QUEUE: "[ban_all.orchestrator]", + EXECUTOR_QUEUE: "[ban_all.exec]", +} + +type BanJobData = { + chatId: number + targetId: number +} + +type BanJobCommand = "ban" | "unban" +type BanAllCommand = `${BanJobCommand}_all` + +type BanJob = Job +type JobForFlow = J extends FlowJob + ? J extends { name: infer N extends string; data: infer D } + ? Job + : never + : never + +type WorkerFor = J extends Job + ? Worker + : J extends FlowJob + ? Worker + : never +interface BanFlowJob extends FlowJob { + name: BanJobCommand + queueName: typeof CONFIG.EXECUTOR_QUEUE + data: BanJobData + children?: undefined +} +interface BanAllFlowJob extends FlowJob { + name: BanAllCommand + queueName: typeof CONFIG.ORCHESTRATOR_QUEUE + data: { + banAll: BanAll + messageId: number + } + children: BanFlowJob[] +} + +const connection: ConnectionOptions = { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + username: env.REDIS_USERNAME, + password: env.REDIS_PASSWORD, +} + +export class BanAllQueue extends Module { + private executor: WorkerFor = new Worker( + CONFIG.EXECUTOR_QUEUE, + async (job) => { + switch (job.name) { + case "ban": { + const success = await this.shared.api.banChatMember(job.data.chatId, job.data.targetId, { + revoke_messages: true, + }) + logger.debug({ chatId: job.data.chatId, targetId: job.data.targetId, success }, "[BanAllQueue] ban result") + if (!success) { + throw new Error("Failed to ban user") + } + return + } + case "unban": { + const success = await this.shared.api.unbanChatMember(job.data.chatId, job.data.targetId) + if (!success) { + throw new Error("Failed to unban user") + } + logger.debug({ chatId: job.data.chatId, targetId: job.data.targetId, success }, "[BanAllQueue] unban result") + return + } + default: + throw new Error("Unknown job command") + } + }, + { connection, concurrency: 3 } + ) + + private orchestrator: WorkerFor = new Worker( + CONFIG.ORCHESTRATOR_QUEUE, + async (job) => { + const { failed, ignored, processed } = await job.getDependenciesCount() + logger.info( + `[BanAllQueue] Finished executing ${job.name} job for target ${job.data.banAll.target.id} in ${processed} chats (ignored: ${ignored}, failed: ${failed})` + ) + }, + { connection } + ) + + private execQueue = new Queue(CONFIG.EXECUTOR_QUEUE, { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { + type: "exponential", + delay: 1000, // start with 1 second + }, + removeOnComplete: { + age: 60 * 60, // keep for 1 hour + count: 1000, // keep only the last 1000 + }, + removeOnFail: { + age: 24 * 60 * 60, // keep for 24 hours + count: 1000, // keep only the last 1000 + }, + }, + }) + + private orchestrateQueue = new Queue>(CONFIG.ORCHESTRATOR_QUEUE, { connection }) + + private flowProducer = new FlowProducer({ connection }) + + public async progress(targetId: number) { + const jobs = await this.orchestrateQueue.getJobs([]) + const job = jobs.find((j) => j.data.banAll.target.id === targetId) + if (!job) return null + const { failed, ignored, processed } = await job.getDependenciesCount() + return { failed, ignored, processed } + } + + override async start() { + // set the listener to update the parent job progress + this.executor.on("completed", async (job) => { + const parentID = job.parent?.id + if (!parentID) return + const parent = await this.orchestrateQueue.getJob(parentID) + if (!parent) return + const rawNumbers = await parent.getDependenciesCount({ + processed: true, + failed: true, + ignored: true, + unprocessed: true, + }) + const { failed, ignored, processed, unprocessed } = { + failed: 0, + ignored: 0, + processed: 0, + unprocessed: 0, + ...rawNumbers, + } + + const completed = processed - (failed + ignored) + const total = processed + unprocessed + await parent.updateProgress({ + successCount: completed, + jobCount: total, + failedCount: failed, + } satisfies BanAllState) + }) + + const updateMessage = throttle((banAll: BanAll, messageId: number) => { + logger.debug("[BanAllQueue] Updating ban all progress message") + void modules + .get("tgLogger") + .banAllProgress(banAll, messageId) + .catch(() => { + logger.warn("[BanAllQueue] Failed to update ban all progress message") + }) + }, 5000) + + this.orchestrateQueue.on("progress", async (job, progress) => { + if (!isBanAllState(progress)) return + const banAll = { ...job.data.banAll, state: progress } + updateMessage(banAll, job.data.messageId) + await job.updateData({ ...job.data, banAll }) + }) + } + + public async initiateBanAll(banAll: BanAll, messageId: number) { + if (banAll.outcome !== "approved") { + throw new Error("Cannot initiate ban all for a non-approved BanAll") + } + + const allGroups = await api.tg.groups.getAll.query() + const chats = allGroups.map((g) => g.telegramId) + const banType = banAll.type === "BAN" ? "ban" : "unban" + + const job = await this.flowProducer.add({ + name: `${banType}_all`, + queueName: CONFIG.ORCHESTRATOR_QUEUE, + data: { banAll, messageId }, + children: chats.map((chat) => ({ + name: banType, + queueName: CONFIG.EXECUTOR_QUEUE, + data: { + chatId: chat, + targetId: banAll.target.id, + }, + })), + } satisfies BanAllFlowJob) + return job + } + + override async stop() { + await Promise.all([ + this.executor.close(), + this.orchestrator.close(), + this.execQueue.close(), + this.orchestrateQueue.close(), + this.flowProducer.close(), + ]) + } +} diff --git a/src/lib/moderation/ban.ts b/src/modules/moderation/ban.ts similarity index 85% rename from src/lib/moderation/ban.ts rename to src/modules/moderation/ban.ts index b811344..e5e84b7 100644 --- a/src/lib/moderation/ban.ts +++ b/src/modules/moderation/ban.ts @@ -2,7 +2,7 @@ import type { Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import type { z } from "zod" import { api } from "@/backend" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import type { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import type { ContextWith } from "@/utils/types" @@ -33,7 +33,11 @@ export async function ban({ ctx, target, from, reason, duration, message }: BanP reason, type: "ban", }) - return ok(await tgLogger.moderationAction({ action: "BAN", from, message, target, duration, reason, chat: ctx.chat })) + return ok( + await modules + .get("tgLogger") + .moderationAction({ action: "BAN", from, message, target, duration, reason, chat: ctx.chat }) + ) } interface UnbanProps { @@ -51,5 +55,7 @@ export async function unban({ ctx, targetId, from }: UnbanProps): Promise b`@${from.username} this user is not banned in this chat`)) await ctx.unbanChatMember(target.user.id) - return ok(await tgLogger.moderationAction({ action: "UNBAN", from: from, target: target.user, chat: ctx.chat })) + return ok( + await modules.get("tgLogger").moderationAction({ action: "UNBAN", from: from, target: target.user, chat: ctx.chat }) + ) } diff --git a/src/lib/moderation/index.ts b/src/modules/moderation/index.ts similarity index 100% rename from src/lib/moderation/index.ts rename to src/modules/moderation/index.ts diff --git a/src/lib/moderation/kick.ts b/src/modules/moderation/kick.ts similarity index 88% rename from src/lib/moderation/kick.ts rename to src/modules/moderation/kick.ts index 988f303..39822d5 100644 --- a/src/lib/moderation/kick.ts +++ b/src/modules/moderation/kick.ts @@ -1,7 +1,7 @@ import type { Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import { api } from "@/backend" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import { duration } from "@/utils/duration" import { fmt } from "@/utils/format" import type { ContextWith } from "@/utils/types" @@ -32,5 +32,7 @@ export async function kick({ ctx, target, from, reason, message }: KickProps): P reason, type: "kick", }) - return ok(await tgLogger.moderationAction({ action: "KICK", from, target, reason, message, chat: ctx.chat })) + return ok( + await modules.get("tgLogger").moderationAction({ action: "KICK", from, target, reason, message, chat: ctx.chat }) + ) } diff --git a/src/lib/moderation/mute.ts b/src/modules/moderation/mute.ts similarity index 91% rename from src/lib/moderation/mute.ts rename to src/modules/moderation/mute.ts index 6e16d42..f2ecd21 100644 --- a/src/lib/moderation/mute.ts +++ b/src/modules/moderation/mute.ts @@ -2,7 +2,7 @@ import type { Message, User } from "grammy/types" import { err, ok, type Result } from "neverthrow" import type { z } from "zod" import { api } from "@/backend" -import { tgLogger } from "@/bot" +import { modules } from "@/modules" import { RestrictPermissions } from "@/utils/chat" import type { duration } from "@/utils/duration" import { fmt, fmtUser } from "@/utils/format" @@ -47,7 +47,7 @@ export async function mute({ type: "mute", }) - const res = await tgLogger.moderationAction({ + const res = await modules.get("tgLogger").moderationAction({ action: "MUTE", chat: ctx.chat, from, @@ -77,5 +77,7 @@ export async function unmute({ ctx, targetId, from }: UnmuteProps): Promise b`@${from.username} this user is not muted`)) await ctx.restrictChatMember(target.user.id, RestrictPermissions.unmute) - return ok(await tgLogger.moderationAction({ action: "UNMUTE", from, target: target.user, chat: ctx.chat })) + return ok( + await modules.get("tgLogger").moderationAction({ action: "UNMUTE", from, target: target.user, chat: ctx.chat }) + ) } diff --git a/src/lib/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts similarity index 61% rename from src/lib/tg-logger/ban-all.ts rename to src/modules/tg-logger/ban-all.ts index cc64502..0e19783 100644 --- a/src/lib/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -1,9 +1,30 @@ import type { Context } from "grammy" import type { User } from "grammy/types" +import { type CallbackCtx, MenuGenerator } from "@/lib/menu" import { logger } from "@/logger" import { fmt, fmtUser } from "@/utils/format" +import { unicodeProgressBar } from "@/utils/progress" import { calculateOutcome, type Outcome, type Vote, type Voter } from "@/utils/vote" -import { type CallbackCtx, MenuGenerator } from "../menu" +import { modules } from ".." + +export type BanAllState = { + jobCount: number + successCount: number + failedCount: number +} + +export function isBanAllState(obj: unknown): obj is BanAllState { + return !!( + obj && + typeof obj === "object" && + "jobCount" in obj && + "successCount" in obj && + "failedCount" in obj && + typeof obj.jobCount === "number" && + typeof obj.successCount === "number" && + typeof obj.failedCount === "number" + ) +} export type BanAll = { type: "BAN" | "UNBAN" @@ -12,6 +33,7 @@ export type BanAll = { reason: string outcome: Outcome voters: Voter[] + state: BanAllState } const VOTE_EMOJI: Record = { @@ -26,6 +48,24 @@ const OUTCOME_STR: Record = { denied: "❌ DENIED", } +export const getProgressText = (state: BanAll["state"]): string => { + if (state.jobCount === 0) return fmt(({ i }) => i`\nFetching groups...`) + + const progress = (state.successCount + state.failedCount) / state.jobCount + const percent = (progress * 100).toFixed(1) + + return fmt( + ({ n, b }) => [ + b`\nProgress - ${state.jobCount} groups`, + n`\t🟢 ${state.successCount}`, + n`\t🔴 ${state.failedCount}`, + n`\t⏸️ ${state.jobCount - state.successCount - state.failedCount}`, + n`${unicodeProgressBar(progress, 20)} ${percent}%`, + ], + { sep: "\n" } + ) +} + /** * Generate the message text of the BanAll case, based on current voting situation. * @@ -34,20 +74,21 @@ const OUTCOME_STR: Record = { */ export const getBanAllText = (data: BanAll) => fmt( - ({ n, b, strikethrough }) => [ - data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🟢 UN-BAN ALL 🟢`, + ({ n, b, skip, strikethrough }) => [ + data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🟢 UN - BAN ALL 🟢`, "", - n`${b`🎯 Target:`} ${fmtUser(data.target)}`, - n`${b`📣 Reporter:`} ${fmtUser(data.reporter)}`, - n`${b`📋 Reason:`} ${data.reason}`, + n`${b`🎯 Target:`} ${fmtUser(data.target)} `, + n`${b`📣 Reporter:`} ${fmtUser(data.reporter)} `, + n`${b`📋 Reason:`} ${data.reason} `, "", - b`${OUTCOME_STR[data.outcome]}`, + b`${OUTCOME_STR[data.outcome]} `, + data.outcome === "approved" ? skip`${getProgressText(data.state)}` : undefined, "", b`Voters`, ...data.voters.map((v) => data.outcome !== "waiting" && !v.vote - ? strikethrough`➖ ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""}` - : n`${v.vote ? VOTE_EMOJI[v.vote] : "⏳"} ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""}` + ? strikethrough`➖ ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""} ` + : n`${v.vote ? VOTE_EMOJI[v.vote] : "⏳"} ${fmtUser(v.user)} ${v.isPresident ? b`PRES` : ""} ` ), ], { sep: "\n" } @@ -80,6 +121,12 @@ async function vote( } data.outcome = outcome + const messageId = ctx.callbackQuery.message?.message_id + + if (outcome === "approved" && messageId) { + await modules.get("banAll").initiateBanAll(data, messageId) + } + // remove buttons if there is an outcome (not waiting) const reply_markup = outcome === "waiting" ? ctx.msg?.reply_markup : undefined diff --git a/src/lib/tg-logger/index.ts b/src/modules/tg-logger/index.ts similarity index 91% rename from src/lib/tg-logger/index.ts rename to src/modules/tg-logger/index.ts index a8819d1..d716f19 100644 --- a/src/lib/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -1,8 +1,10 @@ -import { type Bot, type Context, GrammyError, InlineKeyboard } from "grammy" +import { GrammyError, InlineKeyboard } from "grammy" import type { Message, User } from "grammy/types" +import { Module } from "@/lib/modules" import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" import { fmt, fmtChat, fmtUser } from "@/utils/format" +import type { ModuleShared } from "@/utils/types" import { type BanAll, banAllMenu, getBanAllText } from "./ban-all" import { getReportText, type Report, reportMenu } from "./report" import type * as Types from "./types" @@ -17,19 +19,20 @@ type Topics = { groupManagement: number } -export class TgLogger { +export class TgLogger extends Module { constructor( - private bot: Bot, private groupId: number, private topics: Topics - ) {} + ) { + super() + } private async log( topicId: number, fmtString: string, - opts?: Parameters[2] + opts?: Parameters[2] ): Promise { - return await this.bot.api + return await this.shared.api .sendMessage(this.groupId, fmtString, { message_thread_id: topicId, disable_notification: true, @@ -46,7 +49,7 @@ export class TgLogger { } private async forward(topicId: number, chatId: number, messageIds: number[]): Promise { - await this.bot.api + await this.shared.api .forwardMessages(this.groupId, chatId, messageIds, { message_thread_id: topicId, disable_notification: true, @@ -72,7 +75,7 @@ export class TgLogger { public async report(message: Message, reporter: User): Promise { if (message.from === undefined) return false // should be impossible - const { invite_link } = await this.bot.api.getChat(message.chat.id) + const { invite_link } = await this.shared.api.getChat(message.chat.id) const report: Report = { message, reporter } as Report const reportText = getReportText(report, invite_link) @@ -91,7 +94,7 @@ export class TgLogger { async delete( messages: Message[], reason: string, - deleter: User = this.bot.botInfo + deleter: User = this.shared.botInfo ): Promise { if (!messages.length) return null const sendersMap = new Map() @@ -113,7 +116,7 @@ export class TgLogger { ? n`${b`Senders:`} \n - ${senders.map(fmtUser).join("\n - ")}` : n`${b`Sender:`} ${fmtUser(senders[0])}`, - deleter.id === this.bot.botInfo.id ? i`Automatic deletion by BOT` : n`${b`Deleter:`} ${fmtUser(deleter)}`, + 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, @@ -125,7 +128,7 @@ export class TgLogger { for (const [chatId, mIds] of groupMessagesByChat(messages)) { await this.forward(this.topics.deletedMessages, chatId, mIds) - await this.bot.api.deleteMessages(chatId, mIds) + await this.shared.api.deleteMessages(chatId, mIds) } return { @@ -141,13 +144,20 @@ export class TgLogger { await this.log(this.topics.banAll, getBanAllText(banAll), { reply_markup: menu }) } + public async banAllProgress(banAll: BanAll, messageId: number): Promise { + await this.shared.api.editMessageText(this.groupId, messageId, getBanAllText(banAll), { + reply_markup: undefined, + link_preview_options: { is_disabled: true }, + }) + } + public async moderationAction(props: Types.ModerationAction): Promise { - const isAutoModeration = props.from.id === this.bot.botInfo.id + 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.bot.api.getChat(props.chat.id) + 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) { @@ -172,11 +182,11 @@ export class TgLogger { const groupByChat = groupMessagesByChat(props.messages) others.push(fmt(({ b }) => b`\nChats involved:`)) for (const [chatId, mIds] of groupByChat) { - const chat = await this.bot.api.getChat(chatId) + 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.bot.botInfo) + deleteRes = await this.delete(props.messages, delReason, this.shared.botInfo) break } diff --git a/src/lib/tg-logger/report.ts b/src/modules/tg-logger/report.ts similarity index 98% rename from src/lib/tg-logger/report.ts rename to src/modules/tg-logger/report.ts index a222c80..a22c3fa 100644 --- a/src/lib/tg-logger/report.ts +++ b/src/modules/tg-logger/report.ts @@ -1,8 +1,8 @@ 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 { type CallbackCtx, MenuGenerator } from "../menu" export type Report = { message: Message & { from: User } diff --git a/src/lib/tg-logger/types.ts b/src/modules/tg-logger/types.ts similarity index 100% rename from src/lib/tg-logger/types.ts rename to src/modules/tg-logger/types.ts diff --git a/src/utils/progress.ts b/src/utils/progress.ts new file mode 100644 index 0000000..2ac6ade --- /dev/null +++ b/src/utils/progress.ts @@ -0,0 +1,38 @@ +/** + * Clamps a number between a minimum and maximum value. + * @param num The number to clamp. + * @param min The minimum value. + * @param max The maximum value. + * @returns The clamped value. + */ +export function clamp(num: number, min: number, max: number) { + return Math.min(Math.max(num, min), max) +} + +/** + * Generates a unicode progress bar string. + * @param progress A number between 0 and 1 representing the progress. + * @param size The length of the progress bar (default is 10). + * @returns A string representing the progress bar. + * + * @example + * ```ts + * console.log(unicodeProgressBar(0.1)) // "█░░░░░░░░░" + * console.log(unicodeProgressBar(0.25)) // "███░░░░░░" + * console.log(unicodeProgressBar(0.5)) // "█████░░░░░" + * console.log(unicodeProgressBar(0.75)) // "███████▒░░" + * console.log(unicodeProgressBar(0.9)) // "█████████▓" + * console.log(unicodeProgressBar(1)) // "██████████" + * ``` + */ +export function unicodeProgressBar(progress: number, size = 10) { + const shades = ["░", "▒", "▓", "█"] // 0, 1/3, 2/3, 1 + const clamped = clamp(progress, 0, 1) + const filledBars = Math.floor(clamped * size) + const partialBarIndex = Math.floor((clamped * size - filledBars) * shades.length) + if (filledBars < size && partialBarIndex > 0) { + return "█".repeat(filledBars) + shades[partialBarIndex] + "░".repeat(size - filledBars - 1) + } else { + return "█".repeat(filledBars) + "░".repeat(size - filledBars) + } +} diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts new file mode 100644 index 0000000..07d43f9 --- /dev/null +++ b/src/utils/throttle.ts @@ -0,0 +1,32 @@ +/** + * Throttles a function to limit the rate at which it can be called. + * + * The function will get called at most once every `limit` milliseconds. + * If the function is called again before the `limit` has passed, + * the call will be ignored and the last result will be returned. + * + * @param func The function to throttle + * @param limit The time limit in milliseconds + * @returns A throttled version of the function + */ +export function throttle(func: (...args: A) => void, limit: number): (...args: A) => void { + let timeout: NodeJS.Timeout | null = null + let again = false + + return (...args: A): void => { + if (timeout === null) { + // first call + const handler = () => { + if (again) { + // if called again during the timeout, schedule another call + timeout = setTimeout(handler, limit) + func(...args) + } else timeout = null // if not called again, clear the timeout + again = false // reset the again flag + } + + timeout = setTimeout(handler, limit) + func(...args) + } else again = true + } +} diff --git a/src/utils/types.ts b/src/utils/types.ts index ea77e0a..0cc4327 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,4 +1,5 @@ -import type { Context as TContext } from "grammy" +import type { Api, Context as TContext } from "grammy" +import type { UserFromGetMe } from "grammy/types" import type { ApiInput } from "@/backend" import type { ManagedCommandsFlavor } from "@/lib/managed-commands" @@ -16,3 +17,8 @@ export type MaybePromise = T | Promise export type Context = ManagedCommandsFlavor export type Role = ApiInput["tg"]["permissions"]["addRole"]["role"] + +export type ModuleShared = { + api: Api + botInfo: UserFromGetMe +} diff --git a/src/utils/wait.ts b/src/utils/wait.ts index 476c3e4..172a3de 100644 --- a/src/utils/wait.ts +++ b/src/utils/wait.ts @@ -15,3 +15,35 @@ export function wait(time_ms: number): Promise { }, time_ms) }) } + +/** + * A utility class that implements PromiseLike and allows manual resolution of the promise. + * This is useful when you need to await a value that will be provided later, outside of the + * current execution context. + */ +export class Awaiter implements PromiseLike { + private promise: Promise + + // biome-ignore lint/suspicious/noThenProperty: Literally needed to implement PromiseLike + then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined, + // biome-ignore lint/suspicious/noExplicitAny: This is needed for the PromiseLike implementation + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined + ): PromiseLike { + return this.promise.then(onfulfilled, onrejected) + } + + private promiseResolve: (value: T) => void = () => { + throw new Error("Promise not initialized. How did you even get here?") + } + + constructor() { + this.promise = new Promise((res) => { + this.promiseResolve = res + }) + } + + public resolve(value: T): void { + this.promiseResolve(value) + } +} diff --git a/src/websocket.ts b/src/websocket.ts index 27ec86f..eb7838a 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -1,25 +1,30 @@ import { type TelegramSocket, WS_PATH } from "@polinetwork/backend" -import type { Bot, Context } from "grammy" import { io } from "socket.io-client" import { env } from "./env" +import { Module } from "./lib/modules" import { logger } from "./logger" import { duration } from "./utils/duration" +import type { ModuleShared } from "./utils/types" /** * WebSocket to handle from-backend communication * * @param bot - The telegram bot instance */ -export class WebSocketClient { +export class WebSocketClient extends Module { private io: TelegramSocket - constructor(private bot: Bot) { + constructor() { + super() this.io = io(`http://${env.BACKEND_URL}`, { path: WS_PATH, query: { type: "telegram" } }) + } + + override async start() { this.io.on("connect", () => logger.info("[WS] connected")) this.io.on("connect_error", (error) => logger.info({ error }, "[WS] error while connecting")) this.io.on("ban", async ({ chatId, userId, durationInSeconds }, cb) => { - const error = await this.bot.api + const error = await this.shared.api .banChatMember(chatId, userId, { until_date: durationInSeconds ? duration.zod.parse(`${durationInSeconds}s`).timestamp_s : undefined, }) @@ -35,4 +40,9 @@ export class WebSocketClient { } }) } + + override async stop() { + this.io.close() + logger.info("[WS] disconnected") + } } diff --git a/tests/throttle.test.ts b/tests/throttle.test.ts new file mode 100644 index 0000000..a1f470f --- /dev/null +++ b/tests/throttle.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest" +import { throttle } from "@/utils/throttle" +import { wait } from "@/utils/wait" + +async function callNTimes(n: number, ms: number, fn: () => void) { + for (let i = 0; i < n; i++) { + fn() + await wait(ms) + } +} + +const testobj = { + foo() { + return 42 + }, +} + +describe("throttle function", () => { + it("test 1", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 100 + const throttled = throttle(() => testobj.foo(), limitms) + await callNTimes(11, 10, throttled) + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(3) + }) + it("test 2", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 50 + const throttled = throttle(() => testobj.foo(), limitms) + await callNTimes(3, 100, throttled) + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(3) + }) + it("test 3", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 500 + const throttled = throttle(() => testobj.foo(), limitms) + for (let i = 0; i < 50; i++) { + throttled() + } + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(2) + }) + it("test 4", async () => { + const spy = vi.spyOn(testobj, "foo") + const limitms = 10 + const throttled = throttle(() => testobj.foo(), limitms) + throttled() + await wait(limitms + 20) + expect(spy).toHaveBeenCalledTimes(1) + }) +}) From ddced4a1ba84f451c454394c8b643744b4054c38 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 01:15:58 +0200 Subject: [PATCH 14/35] fix: lint --- src/utils/throttle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts index 07d43f9..03f0c38 100644 --- a/src/utils/throttle.ts +++ b/src/utils/throttle.ts @@ -11,7 +11,7 @@ */ export function throttle(func: (...args: A) => void, limit: number): (...args: A) => void { let timeout: NodeJS.Timeout | null = null - let again = false + let again: boolean = false return (...args: A): void => { if (timeout === null) { From 032ad8a78f17ff37e0b491fa563f93731051e38b Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:05:17 +0200 Subject: [PATCH 15/35] feat: test command banall --- package.json | 2 +- src/commands/test/banall.ts | 159 +++++++++++---------------------- src/modules/tg-logger/index.ts | 71 ++++++++++++++- 3 files changed, 120 insertions(+), 112 deletions(-) diff --git a/package.json b/package.json index 24e0792..2e5ab7c 100644 --- a/package.json +++ b/package.json @@ -67,4 +67,4 @@ "unrs-resolver" ] } -} \ No newline at end of file +} diff --git a/src/commands/test/banall.ts b/src/commands/test/banall.ts index 08c4e8d..47936bd 100644 --- a/src/commands/test/banall.ts +++ b/src/commands/test/banall.ts @@ -1,113 +1,56 @@ +import type { User } from "grammy/types" +import z from "zod" import { api } from "@/backend" import { modules } from "@/modules" -import type { BanAll } from "@/modules/tg-logger/ban-all" -import { fmt } from "@/utils/format" +import { getTelegramId } from "@/utils/telegram-id" import { _commandsBase } from "../_base" -_commandsBase - .createCommand({ - trigger: "test_banall", - description: "TEST - PREMA BAN a user from all the Network's groups", - scope: "private", - permissions: { - allowedRoles: ["owner"], +const numberOrString = z.string().transform((s) => { + const n = Number(s) + if (!Number.isNaN(n) && s.trim() !== "") return n + return s +}) + +_commandsBase.createCommand({ + trigger: "test_banall", + description: "TEST - PREMA BAN a user from all the Network's groups", + scope: "private", + permissions: { + allowedRoles: ["owner"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to update the role", }, - handler: async ({ context }) => { - await context.deleteMessage() - const direttivo = await api.tg.permissions.getDirettivo.query() - if (direttivo.error) { - await context.reply(fmt(({ n }) => n`${direttivo.error}`)) - return - } - - const voters = direttivo.members.map((m) => ({ - user: { id: m.userId }, - isPresident: m.isPresident, - vote: undefined, - })) - - if (!voters.some((v) => v.isPresident)) { - await context.reply( - fmt(({ n, b }) => [b`No member is President!`, n`${b`Members:`} ${voters.map((v) => v.user.id).join(" ")}`], { - sep: "\n", - }) - ) - return - } - - const banAllTest: BanAll = { - type: "BAN", - outcome: "waiting", - reporter: context.from, - reason: "Testing ban all voting system", - target: { - first_name: "PoliCreator", - last_name: "3", - id: 728441822, // policreator3 - unused - is_bot: false, - username: "policreator3", - }, - voters, - state: { - successCount: 0, - failedCount: 0, - jobCount: 0, - }, - } - - await modules.get("tgLogger").banAll(banAllTest) - }, - }) - .createCommand({ - trigger: "test_unbanall", - description: "TEST - UNBAN a user from the network", - scope: "private", - permissions: { - allowedRoles: ["owner"], - }, - handler: async ({ context }) => { - await context.deleteMessage() - const direttivo = await api.tg.permissions.getDirettivo.query() - if (direttivo.error) { - await context.reply(fmt(({ n }) => n`${direttivo.error}`)) - return - } - - const voters = direttivo.members.map((m) => ({ - user: { id: m.userId }, - isPresident: m.isPresident, - vote: undefined, - })) - - if (!voters.some((v) => v.isPresident)) { - await context.reply( - fmt(({ n, b }) => [b`No member is President!`, n`${b`Members:`} ${voters.map((v) => v.user.id).join(" ")}`], { - sep: "\n", - }) - ) - return - } - - const banAllTest: BanAll = { - type: "UNBAN", - outcome: "waiting", - reporter: context.from, - reason: "Testing ban all voting system", - target: { - first_name: "PoliCreator", - last_name: "3", - id: 728441822, // policreator3 - unused - is_bot: false, - username: "policreator3", - }, - voters, - state: { - failedCount: 0, - successCount: 0, - jobCount: 0, - }, - } - - await modules.get("tgLogger").banAll(banAllTest) - }, - }) + ], + handler: async ({ args, context }) => { + await context.deleteMessage() + + const userId: number | null = + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + const dbUser = await api.tg.users.get.query({ userId }) + if (!dbUser || dbUser.error) { + await context.reply("This user is not in our cache, we cannot proceed.") + return + } + + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } + + await modules.get("tgLogger").banAll(target, context.from, "BAN", "Testing") + }, +}) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index d716f19..8850997 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -1,5 +1,6 @@ import { GrammyError, InlineKeyboard } from "grammy" import type { Message, User } from "grammy/types" +import { api } from "@/backend" import { Module } from "@/lib/modules" import { logger } from "@/logger" import { groupMessagesByChat, stripChatId } from "@/utils/chat" @@ -137,11 +138,75 @@ export class TgLogger extends Module { } } - // public async banAll(props: Types.BanAllLog): Promise { - public async banAll(banAll: BanAll): Promise { + public async banAll(target: User, reporter: User, type: "BAN" | "UNBAN", reason: string): Promise { + const direttivo = await api.tg.permissions.getDirettivo.query() + + switch (direttivo.error) { + case "EMPTY": + return fmt(({ n }) => n`Error: Direttivo is not set`) + + case "NOT_ENOUGH_MEMBERS": + return fmt(({ n }) => n`Error: Direttivo has not enough members!`) + + case "TOO_MANY_MEMBERS": + return fmt(({ n }) => n`Error: Direttivo has too many members!`) + + case "INTERNAL_SERVER_ERROR": + return fmt(({ n }) => n`Error: there was an internal error while fetching members of Direttivo.`) + + case null: + break + } + + const voters = direttivo.members.map((m) => ({ + user: m.user + ? { + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } + : { id: m.userId }, + isPresident: m.isPresident, + vote: undefined, + })) + + if (!voters.some((v) => v.isPresident)) + return fmt( + ({ n, b }) => [b`Error: No member is President!`, n`${b`Members:`} ${voters.map((v) => v.user.id).join(" ")}`], + { + sep: "\n", + } + ) + + const banAll: BanAll = { + type, + outcome: "waiting", + reporter: reporter, + reason, + target, + voters, + state: { + successCount: 0, + failedCount: 0, + jobCount: 0, + }, + } + const menu = await banAllMenu(banAll) await this.log(this.topics.banAll, "———————————————") - await this.log(this.topics.banAll, getBanAllText(banAll), { reply_markup: menu }) + const msg = await this.log(this.topics.banAll, getBanAllText(banAll), { reply_markup: menu }) + return fmt( + ({ n, b, link }) => [ + b`${type} All requested!`, + msg + ? n`Check ${link("here", `https://t.me/c/${this.groupId}/${this.topics.banAll}/${msg.message_id}`)}` + : undefined, + ], + { sep: "\n" } + ) } public async banAllProgress(banAll: BanAll, messageId: number): Promise { From b21da7c326223009157a75b28eff75843254a409 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 02:11:26 +0200 Subject: [PATCH 16/35] fix: throttle arg caching --- src/utils/throttle.ts | 4 +++- tests/throttle.test.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts index 03f0c38..4cea632 100644 --- a/src/utils/throttle.ts +++ b/src/utils/throttle.ts @@ -11,16 +11,18 @@ */ export function throttle(func: (...args: A) => void, limit: number): (...args: A) => void { let timeout: NodeJS.Timeout | null = null + let lastArgs: A let again: boolean = false return (...args: A): void => { + lastArgs = args if (timeout === null) { // first call const handler = () => { if (again) { // if called again during the timeout, schedule another call timeout = setTimeout(handler, limit) - func(...args) + func(...lastArgs) } else timeout = null // if not called again, clear the timeout again = false // reset the again flag } diff --git a/tests/throttle.test.ts b/tests/throttle.test.ts index a1f470f..e6b5536 100644 --- a/tests/throttle.test.ts +++ b/tests/throttle.test.ts @@ -10,8 +10,8 @@ async function callNTimes(n: number, ms: number, fn: () => void) { } const testobj = { - foo() { - return 42 + foo(i: number = 0) { + return 42 + i }, } @@ -35,12 +35,14 @@ describe("throttle function", () => { it("test 3", async () => { const spy = vi.spyOn(testobj, "foo") const limitms = 500 - const throttled = throttle(() => testobj.foo(), limitms) + const throttled = throttle((i: number) => testobj.foo(i), limitms) for (let i = 0; i < 50; i++) { - throttled() + throttled(i) } await wait(limitms + 20) expect(spy).toHaveBeenCalledTimes(2) + expect(spy).toHaveBeenNthCalledWith(1, 0) + expect(spy).toHaveBeenLastCalledWith(49) }) it("test 4", async () => { const spy = vi.spyOn(testobj, "foo") From 3076cb9cf4ce792718e0efe977456746600b91e8 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:13:56 +0200 Subject: [PATCH 17/35] feat: real commands btw --- src/commands/banall.ts | 114 +++++++++++++++++++++++++++++++ src/commands/test/banall.ts | 56 --------------- src/commands/test/index.ts | 1 - src/modules/tg-logger/ban-all.ts | 8 +-- src/modules/tg-logger/index.ts | 2 +- 5 files changed, 119 insertions(+), 62 deletions(-) create mode 100644 src/commands/banall.ts delete mode 100644 src/commands/test/banall.ts diff --git a/src/commands/banall.ts b/src/commands/banall.ts new file mode 100644 index 0000000..9423360 --- /dev/null +++ b/src/commands/banall.ts @@ -0,0 +1,114 @@ +import type { User } from "grammy/types" +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 { _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 + .createCommand({ + trigger: "ban_all", + description: "PREMA BAN a user from all the Network's groups", + scope: "private", + permissions: { + allowedRoles: ["owner", "direttivo"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to update the role", + }, + { + key: "reason", + type: z.string(), + description: "The reason why you ban the user", + }, + ], + handler: async ({ args, context }) => { + await context.deleteMessage() + + const userId: number | null = + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + const dbUser = await api.tg.users.get.query({ userId }) + const { roles } = await api.tg.permissions.getRoles.query({ userId }) + if (roles?.some((r) => BYPASS_ROLES.includes(r))) { + await context.reply("This user has special roles so cannot be banned.") + return + } + + if (!dbUser || dbUser.error) { + await context.reply("This user is not in our cache, we cannot proceed.") + return + } + + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } + + await modules.get("tgLogger").banAll(target, context.from, "BAN", args.reason) + }, + }) + .createCommand({ + trigger: "unban_all", + description: "UNBAN a user from all the Network's groups", + scope: "private", + permissions: { + allowedRoles: ["owner", "direttivo"], + }, + args: [ + { + key: "username", + type: numberOrString, + description: "The username or the user id of the user you want to update the role", + }, + ], + handler: async ({ args, context }) => { + await context.deleteMessage() + + const userId: number | null = + typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username + + if (userId === null) { + await context.reply("Not a valid userId or username not in our cache") + return + } + + const dbUser = await api.tg.users.get.query({ userId }) + if (!dbUser || dbUser.error) { + await context.reply("This user is not in our cache, we cannot proceed.") + return + } + + const target: User = { + id: userId, + first_name: dbUser.user.firstName, + last_name: dbUser.user.lastName, + username: dbUser.user.username, + is_bot: dbUser.user.isBot, + language_code: dbUser.user.langCode, + } + + await modules.get("tgLogger").banAll(target, context.from, "UNBAN") + }, + }) diff --git a/src/commands/test/banall.ts b/src/commands/test/banall.ts deleted file mode 100644 index 47936bd..0000000 --- a/src/commands/test/banall.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { User } from "grammy/types" -import z from "zod" -import { api } from "@/backend" -import { modules } from "@/modules" -import { getTelegramId } from "@/utils/telegram-id" -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: "test_banall", - description: "TEST - PREMA BAN a user from all the Network's groups", - scope: "private", - permissions: { - allowedRoles: ["owner"], - }, - args: [ - { - key: "username", - type: numberOrString, - description: "The username or the user id of the user you want to update the role", - }, - ], - handler: async ({ args, context }) => { - await context.deleteMessage() - - const userId: number | null = - typeof args.username === "string" ? await getTelegramId(args.username.replaceAll("@", "")) : args.username - - if (userId === null) { - await context.reply("Not a valid userId or username not in our cache") - return - } - - const dbUser = await api.tg.users.get.query({ userId }) - if (!dbUser || dbUser.error) { - await context.reply("This user is not in our cache, we cannot proceed.") - return - } - - const target: User = { - id: userId, - first_name: dbUser.user.firstName, - last_name: dbUser.user.lastName, - username: dbUser.user.username, - is_bot: dbUser.user.isBot, - language_code: dbUser.user.langCode, - } - - await modules.get("tgLogger").banAll(target, context.from, "BAN", "Testing") - }, -}) diff --git a/src/commands/test/index.ts b/src/commands/test/index.ts index e863767..672a9c8 100644 --- a/src/commands/test/index.ts +++ b/src/commands/test/index.ts @@ -2,4 +2,3 @@ import "./args" import "./db" import "./menu" import "./format" -import "./banall" diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts index 0e19783..4346d37 100644 --- a/src/modules/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -30,7 +30,7 @@ export type BanAll = { type: "BAN" | "UNBAN" target: User reporter: User - reason: string + reason?: string outcome: Outcome voters: Voter[] state: BanAllState @@ -74,12 +74,12 @@ export const getProgressText = (state: BanAll["state"]): string => { */ export const getBanAllText = (data: BanAll) => fmt( - ({ n, b, skip, strikethrough }) => [ - data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🟢 UN - BAN ALL 🟢`, + ({ n, b, skip, strikethrough, i }) => [ + data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🟢 UN-BAN ALL 🟢`, "", n`${b`🎯 Target:`} ${fmtUser(data.target)} `, n`${b`📣 Reporter:`} ${fmtUser(data.reporter)} `, - n`${b`📋 Reason:`} ${data.reason} `, + data.type === "BAN" ? n`${b`📋 Reason:`} ${data.reason ? data.reason : i`N/A`}` : undefined, "", b`${OUTCOME_STR[data.outcome]} `, data.outcome === "approved" ? skip`${getProgressText(data.state)}` : undefined, diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 8850997..5e46699 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -138,7 +138,7 @@ export class TgLogger extends Module { } } - public async banAll(target: User, reporter: User, type: "BAN" | "UNBAN", reason: string): Promise { + public async banAll(target: User, reporter: User, type: "BAN" | "UNBAN", reason?: string): Promise { const direttivo = await api.tg.permissions.getDirettivo.query() switch (direttivo.error) { From 4c963046ab170ec346ccf6e4d1381010c522567d Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:14:34 +0200 Subject: [PATCH 18/35] chore: biome --- src/modules/tg-logger/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 5e46699..855cf2e 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -161,13 +161,13 @@ export class TgLogger extends Module { const voters = direttivo.members.map((m) => ({ user: m.user ? { - id: m.userId, - first_name: m.user.firstName, - last_name: m.user.lastName, - username: m.user.username, - is_bot: m.user.isBot, - language_code: m.user.langCode, - } + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } : { id: m.userId }, isPresident: m.isPresident, vote: undefined, From 2d44561b6f684301daf7f2c4750bbc61461af534 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 02:36:54 +0200 Subject: [PATCH 19/35] fix: minor aesthetic in progress message --- src/modules/moderation/ban-all.ts | 3 ++- src/utils/progress.ts | 21 +++------------------ 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/src/modules/moderation/ban-all.ts b/src/modules/moderation/ban-all.ts index fc66a51..9b66c33 100644 --- a/src/modules/moderation/ban-all.ts +++ b/src/modules/moderation/ban-all.ts @@ -11,6 +11,7 @@ import { type BanAll, type BanAllState, isBanAllState } from "../tg-logger/ban-a const CONFIG = { ORCHESTRATOR_QUEUE: "[ban_all.orchestrator]", EXECUTOR_QUEUE: "[ban_all.exec]", + UPDATE_MESSAGE_THROTTLE_MS: 5000, } type BanJobData = { @@ -166,7 +167,7 @@ export class BanAllQueue extends Module { .catch(() => { logger.warn("[BanAllQueue] Failed to update ban all progress message") }) - }, 5000) + }, CONFIG.UPDATE_MESSAGE_THROTTLE_MS) this.orchestrateQueue.on("progress", async (job, progress) => { if (!isBanAllState(progress)) return diff --git a/src/utils/progress.ts b/src/utils/progress.ts index 2ac6ade..77da2bf 100644 --- a/src/utils/progress.ts +++ b/src/utils/progress.ts @@ -14,25 +14,10 @@ export function clamp(num: number, min: number, max: number) { * @param progress A number between 0 and 1 representing the progress. * @param size The length of the progress bar (default is 10). * @returns A string representing the progress bar. - * - * @example - * ```ts - * console.log(unicodeProgressBar(0.1)) // "█░░░░░░░░░" - * console.log(unicodeProgressBar(0.25)) // "███░░░░░░" - * console.log(unicodeProgressBar(0.5)) // "█████░░░░░" - * console.log(unicodeProgressBar(0.75)) // "███████▒░░" - * console.log(unicodeProgressBar(0.9)) // "█████████▓" - * console.log(unicodeProgressBar(1)) // "██████████" - * ``` */ export function unicodeProgressBar(progress: number, size = 10) { - const shades = ["░", "▒", "▓", "█"] // 0, 1/3, 2/3, 1 const clamped = clamp(progress, 0, 1) - const filledBars = Math.floor(clamped * size) - const partialBarIndex = Math.floor((clamped * size - filledBars) * shades.length) - if (filledBars < size && partialBarIndex > 0) { - return "█".repeat(filledBars) + shades[partialBarIndex] + "░".repeat(size - filledBars - 1) - } else { - return "█".repeat(filledBars) + "░".repeat(size - filledBars) - } + const filledBars = Math.round(clamped * size) + const emptyBars = size - filledBars + return `「${"▰".repeat(filledBars)}${"▱".repeat(emptyBars)}」` } From ef53c5ae436b9125836161ee14b6ac39c389555e Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 02:37:41 +0200 Subject: [PATCH 20/35] fix: spelling errors Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/vote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/vote.ts b/src/utils/vote.ts index 4238495..9fbf36f 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -93,7 +93,7 @@ export function calculateOutcome(voters: Voter[]): Outcome | null { if (results.inFavor < results.against && presVote.vote === "against") return "denied" } - // we have not reach enoguh votes to determine the outcome + // we have not reached enough votes to determine the outcome // we wait for the remaining votes return "waiting" } From c7acfbd85724a7175571f5640a71db152861f32a Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 02:37:53 +0200 Subject: [PATCH 21/35] fix: spelling errors 2 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/vote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/vote.ts b/src/utils/vote.ts index 9fbf36f..6bd20f8 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -42,7 +42,7 @@ export type Voter = { */ export function calculateOutcome(voters: Voter[]): Outcome | null { if (voters.length < 3 || voters.length > 9) { - logger.error({ length: voters.length }, "[VOTE] recieved a voters array with invalid length (must be 3<=l<=9)") + logger.error({ length: voters.length }, "[VOTE] received a voters array with invalid length (must be 3<=l<=9)") return null } From 097925fc0098d717743fe6dbfcb393623fb89bda Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:38:24 +0200 Subject: [PATCH 22/35] fix: banall commands not imported --- src/commands/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/commands/index.ts b/src/commands/index.ts index 09ce95a..602da23 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,6 +2,7 @@ import "./test" import "./mute" import "./audit" import "./ban" +import "./banall" import "./kick" import "./del" import "./search" From 26f9c1d42d7dd17e19683968c83975600b3b400d Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:38:31 +0200 Subject: [PATCH 23/35] fix: better progress text layout --- src/modules/tg-logger/ban-all.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts index 4346d37..b9d0a25 100644 --- a/src/modules/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -13,6 +13,8 @@ export type BanAllState = { failedCount: number } +const spaces = (n: number) => new Array(n).fill(" ").join("") + export function isBanAllState(obj: unknown): obj is BanAllState { return !!( obj && @@ -53,13 +55,14 @@ export const getProgressText = (state: BanAll["state"]): string => { const progress = (state.successCount + state.failedCount) / state.jobCount const percent = (progress * 100).toFixed(1) + const barLength = 20 + const stateEmoji = `🟢 ${state.successCount}${spaces(10)}🔴 ${state.failedCount}${spaces(10)}⏸️ ${state.jobCount - state.successCount - state.failedCount}` + const stateEmojiSpaces = spaces(barLength * 2 + 3 - stateEmoji.length) return fmt( - ({ n, b }) => [ - b`\nProgress - ${state.jobCount} groups`, - n`\t🟢 ${state.successCount}`, - n`\t🔴 ${state.failedCount}`, - n`\t⏸️ ${state.jobCount - state.successCount - state.failedCount}`, + ({ n, b, i }) => [ + b`\nProgress`, + n`${stateEmoji} ${stateEmojiSpaces} ${i`${`${state.jobCount} groups`}`}`, n`${unicodeProgressBar(progress, 20)} ${percent}%`, ], { sep: "\n" } From 0d5eadc7a0cd827e8ff0790fc2aff7fa52b732e3 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 02:40:39 +0200 Subject: [PATCH 24/35] fix: tu ma Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/format.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/format.ts b/src/utils/format.ts index 414c190..fd0ce17 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -140,7 +140,7 @@ export function fmt(cb: (formatters: Formatters) => string | (string | undefined export function fmtUser(user: Partial> & { id: number }): string { const fullname = user.last_name ? `${user.first_name} ${user.last_name}` : user.first_name - return formatters.n`${formatters.link(fullname ?? user.id.toString(), `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` + return formatters.n`${formatters.link(fullname ?? "[no-name]", `tg://user?id=${user.id}`)} [${formatters.code`${user.id}`}]` } export function fmtChat(chat: Chat, inviteLink?: string): string { From d623be9fc92745a6502d1ea0570052b410d02dca Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:46:29 +0200 Subject: [PATCH 25/35] fix: words typos --- src/utils/vote.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/utils/vote.ts b/src/utils/vote.ts index 6bd20f8..6aa7940 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -27,7 +27,7 @@ export type Voter = { * - 4 inFavor, 4 against. President inFavor => ✅ Approved * - 3 inFavor, 5 against. President inFavor => ❌ Denied * - 5 inFavor, 3 against. President against => ✅ Approved - * - 2 inFavor, 2 against, 3 absteined, President absteined => TIE => ❌ Denied + * - 2 inFavor, 2 against, 3 abstained, President absteined => TIE => ❌ Denied * (this is an unregulated case, for the sake of banall this should * not ever happen, so we consider it denied anyway) * Note: the same mechanisms apply to a Direttivo composed of an odd number of members @@ -36,7 +36,7 @@ export type Voter = { * 1) absolute majority of members: * 8-9 => 5 voters || 6-7 => 4 voters || 4-5 => 3 voters || 3 => 2 voters * 2) in case of TIE, the President's vote counts twice - * 3) in case of TIE where the President is abstaineded, we consider the votation denied. + * 3) in case of TIE where the President is abstained, we consider the voting denied. * * This function is unit-tested to ensure correct handling of edge-cases. */ @@ -77,7 +77,7 @@ export function calculateOutcome(voters: Voter[]): Outcome | null { // in the following cases we don't have a majority if (votes.length === membersCount) { if (!presVote) return null // we already checked above, but TS wants it again - if (results.abstained === membersCount) return "denied" // everyone abstaineded (crazy) + if (results.abstained === membersCount) return "denied" // everyone abstained (crazy) if (results.inFavor > results.against) return "approved" if (results.against > results.inFavor) return "denied" From d9cabf330452426038cd8896d74f497397536115 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 02:48:16 +0200 Subject: [PATCH 26/35] fix: better sorry than safe --- src/modules/tg-logger/ban-all.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts index b9d0a25..477bfd3 100644 --- a/src/modules/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -124,10 +124,14 @@ async function vote( } data.outcome = outcome - const messageId = ctx.callbackQuery.message?.message_id - - if (outcome === "approved" && messageId) { - await modules.get("banAll").initiateBanAll(data, messageId) + if (outcome === "approved") { + if (ctx.msgId) await modules.get("banAll").initiateBanAll(data, ctx.msgId) + else { + logger.error( + { callbackQuery: ctx.callbackQuery }, + "Message ID is undefined, cannot initiate ban all. How did this happen?" + ) + } } // remove buttons if there is an outcome (not waiting) From 5c96e1077d790aca74e6a62d988ff4008663a68b Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 02:53:54 +0200 Subject: [PATCH 27/35] fix: add try-catch for banall initialization --- src/modules/tg-logger/ban-all.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts index 477bfd3..4744eb9 100644 --- a/src/modules/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -125,12 +125,18 @@ async function vote( data.outcome = outcome if (outcome === "approved") { - if (ctx.msgId) await modules.get("banAll").initiateBanAll(data, ctx.msgId) - else { - logger.error( - { callbackQuery: ctx.callbackQuery }, - "Message ID is undefined, cannot initiate ban all. How did this happen?" - ) + try { + if (ctx.msgId) await modules.get("banAll").initiateBanAll(data, ctx.msgId) + else { + logger.error( + { callbackQuery: ctx.callbackQuery }, + "Message ID is undefined, cannot initiate ban all. How did this happen?" + ) + } + } catch (error) { + await modules + .get("tgLogger") + .exception({ error, type: "UNKNOWN" }, "There was an error while initializing BanAll queue, check logs") } } From edd9ff82f5bd1c9b8e2afadf6b8b14f7e477dfc5 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 03:04:15 +0200 Subject: [PATCH 28/35] feat: implement BanAll start from user report --- src/modules/tg-logger/index.ts | 14 +++++++------- src/modules/tg-logger/report.ts | 14 +++++++++++--- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 855cf2e..5e46699 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -161,13 +161,13 @@ export class TgLogger extends Module { const voters = direttivo.members.map((m) => ({ user: m.user ? { - id: m.userId, - first_name: m.user.firstName, - last_name: m.user.lastName, - username: m.user.username, - is_bot: m.user.isBot, - language_code: m.user.langCode, - } + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } : { id: m.userId }, isPresident: m.isPresident, vote: undefined, diff --git a/src/modules/tg-logger/report.ts b/src/modules/tg-logger/report.ts index a22c3fa..5142ac8 100644 --- a/src/modules/tg-logger/report.ts +++ b/src/modules/tg-logger/report.ts @@ -3,6 +3,7 @@ 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 ".." export type Report = { message: Message & { from: User } @@ -113,9 +114,16 @@ export const reportMenu = MenuGenerator.getInstance().create("r { text: "🚨 Start BAN ALL 🚨", cb: async ({ data, ctx }) => { - // TODO: connect ban all when implemented - await editReportMessage(data, ctx, "🚨 Start BAN ALL (not implemented yet)") - return { feedback: "❌ Not implemented yet" } + modules + .get("tgLogger") + .banAll( + data.message.from, + ctx.from, + "BAN", + `Started after report by ${data.reporter.username ?? data.reporter.id}` + ) + await editReportMessage(data, ctx, "🚨 Start BAN ALL") + return null }, }, ], From 79810ecf99c76ebf6a22781085bae08506fa9196 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 03:05:43 +0200 Subject: [PATCH 29/35] chore:biome --- src/modules/tg-logger/index.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 5e46699..855cf2e 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -161,13 +161,13 @@ export class TgLogger extends Module { const voters = direttivo.members.map((m) => ({ user: m.user ? { - id: m.userId, - first_name: m.user.firstName, - last_name: m.user.lastName, - username: m.user.username, - is_bot: m.user.isBot, - language_code: m.user.langCode, - } + id: m.userId, + first_name: m.user.firstName, + last_name: m.user.lastName, + username: m.user.username, + is_bot: m.user.isBot, + language_code: m.user.langCode, + } : { id: m.userId }, isPresident: m.isPresident, vote: undefined, From a86fcdfb58600c3aaac9041d10cdafecbbd0eae6 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 03:22:10 +0200 Subject: [PATCH 30/35] docs: banallqueue documentation --- src/modules/moderation/ban-all.ts | 159 +++++++++++++++++++----------- 1 file changed, 103 insertions(+), 56 deletions(-) diff --git a/src/modules/moderation/ban-all.ts b/src/modules/moderation/ban-all.ts index 9b66c33..171f80f 100644 --- a/src/modules/moderation/ban-all.ts +++ b/src/modules/moderation/ban-all.ts @@ -8,48 +8,62 @@ import type { ModuleShared } from "@/utils/types" import { modules } from ".." import { type BanAll, type BanAllState, isBanAllState } from "../tg-logger/ban-all" +/** + * Utility type that get the Worker type for a Job + */ +type WorkerFor = J extends Job ? Worker : never + +/** + * Utility type that get the Job type for a FlowJob + */ +type JobForFlow = J extends FlowJob + ? J extends { name: infer N extends string; data: infer D } + ? Job + : never + : never + +/** Configuration for the BanAll queue system */ const CONFIG = { ORCHESTRATOR_QUEUE: "[ban_all.orchestrator]", EXECUTOR_QUEUE: "[ban_all.exec]", UPDATE_MESSAGE_THROTTLE_MS: 5000, } +/** Possible commands for ban jobs */ +type BanJobCommand = "ban" | "unban" +/** Possible commands for ban all jobs, each child will have the equivalent command */ +type BanAllCommand = `${BanJobCommand}_all` + +/** Data for a single ban job */ type BanJobData = { chatId: number targetId: number } -type BanJobCommand = "ban" | "unban" -type BanAllCommand = `${BanJobCommand}_all` - -type BanJob = Job -type JobForFlow = J extends FlowJob - ? J extends { name: infer N extends string; data: infer D } - ? Job - : never - : never - -type WorkerFor = J extends Job - ? Worker - : J extends FlowJob - ? Worker - : never -interface BanFlowJob extends FlowJob { +/** Flow description for a single ban job */ +interface BanFlow extends FlowJob { name: BanJobCommand queueName: typeof CONFIG.EXECUTOR_QUEUE data: BanJobData children?: undefined } -interface BanAllFlowJob extends FlowJob { +/** Flow description for a ban all job */ +interface BanAllFlow extends FlowJob { name: BanAllCommand queueName: typeof CONFIG.ORCHESTRATOR_QUEUE data: { - banAll: BanAll - messageId: number + banAll: BanAll // entire BanAll data, to re-render the message with progress + messageId: number // message ID to update the progress message } - children: BanFlowJob[] + children: BanFlow[] } +/** Job type for a single ban job */ +type BanJob = JobForFlow +/** Job type for a ban all job, only executed when all child jobs are completed (every ban executed) */ +type BanAllJob = JobForFlow + +// redis connection options const connection: ConnectionOptions = { host: env.REDIS_HOST, port: env.REDIS_PORT, @@ -57,7 +71,26 @@ const connection: ConnectionOptions = { password: env.REDIS_PASSWORD, } +/** + * # BanAll Queue + * + * ### A queue system to handle `/ban_all` commands. + * + * Each command is a job in the orchestrator queue, which spawns a child job for + * each PoliNetwork group in the executor queue. + * + * - [X] **Completely persistent**: all jobs are stored in Redis + * - [X] **Resilient to crashes**: if the bot crashes or is restarted, + * both jobs and side-effects will continue from where they left off + * - [X] **Atomicity**: `ban_all`s are guaranteed to only be marked as completed + * when all bans are executed + */ export class BanAllQueue extends Module { + /** + * Worker that executes the actual ban/unban commands + * + * Has no context about the ban all, just executes the commands it receives + */ private executor: WorkerFor = new Worker( CONFIG.EXECUTOR_QUEUE, async (job) => { @@ -87,7 +120,13 @@ export class BanAllQueue extends Module { { connection, concurrency: 3 } ) - private orchestrator: WorkerFor = new Worker( + /** + * Worker that orchestrates the ban all jobs + * + * Listens for completed child jobs and updates the parent job progress + * When all child jobs are completed, the parent job is marked as completed + */ + private orchestrator: WorkerFor = new Worker( CONFIG.ORCHESTRATOR_QUEUE, async (job) => { const { failed, ignored, processed } = await job.getDependenciesCount() @@ -98,6 +137,9 @@ export class BanAllQueue extends Module { { connection } ) + /** + * Queue used to add new ban jobs, each ban_all command will dispatch a batch in this queue + */ private execQueue = new Queue(CONFIG.EXECUTOR_QUEUE, { connection, defaultJobOptions: { @@ -117,21 +159,44 @@ export class BanAllQueue extends Module { }, }) - private orchestrateQueue = new Queue>(CONFIG.ORCHESTRATOR_QUEUE, { connection }) + /** queue for the orchestrator, each ban_all command is a job in this queue */ + private orchestrateQueue = new Queue(CONFIG.ORCHESTRATOR_QUEUE, { connection }) + /** Flow producer to create parent/child job batch in a single ban_all command */ private flowProducer = new FlowProducer({ connection }) - public async progress(targetId: number) { - const jobs = await this.orchestrateQueue.getJobs([]) - const job = jobs.find((j) => j.data.banAll.target.id === targetId) - if (!job) return null - const { failed, ignored, processed } = await job.getDependenciesCount() - return { failed, ignored, processed } + public async initiateBanAll(banAll: BanAll, messageId: number) { + if (banAll.outcome !== "approved") { + throw new Error("Cannot initiate ban all for a non-approved BanAll") + } + + const allGroups = await api.tg.groups.getAll.query() + const chats = allGroups.map((g) => g.telegramId) + const banType = banAll.type === "BAN" ? "ban" : "unban" + + const job = await this.flowProducer.add({ + name: `${banType}_all`, + queueName: CONFIG.ORCHESTRATOR_QUEUE, + data: { banAll, messageId }, + children: chats.map((chat) => ({ + name: banType, + queueName: CONFIG.EXECUTOR_QUEUE, + data: { + chatId: chat, + targetId: banAll.target.id, + }, + })), + } satisfies BanAllFlow) + return job } + /** + * Register event listeners when the module is loaded + */ override async start() { // set the listener to update the parent job progress this.executor.on("completed", async (job) => { + // this listener recomputes the progress for the parent job every time a child job is completed const parentID = job.parent?.id if (!parentID) return const parent = await this.orchestrateQueue.getJob(parentID) @@ -142,6 +207,7 @@ export class BanAllQueue extends Module { ignored: true, unprocessed: true, }) + // get child counts const { failed, ignored, processed, unprocessed } = { failed: 0, ignored: 0, @@ -150,15 +216,16 @@ export class BanAllQueue extends Module { ...rawNumbers, } - const completed = processed - (failed + ignored) + const successCount = processed - (failed + ignored) const total = processed + unprocessed await parent.updateProgress({ - successCount: completed, jobCount: total, + successCount, failedCount: failed, } satisfies BanAllState) }) + // throttled call to update the message, to avoid spamming Telegram API const updateMessage = throttle((banAll: BanAll, messageId: number) => { logger.debug("[BanAllQueue] Updating ban all progress message") void modules @@ -170,38 +237,18 @@ export class BanAllQueue extends Module { }, CONFIG.UPDATE_MESSAGE_THROTTLE_MS) this.orchestrateQueue.on("progress", async (job, progress) => { + // on progress of a ban_all job (in the orchestrator queue), + // update the message with the new progress (throttled) if (!isBanAllState(progress)) return const banAll = { ...job.data.banAll, state: progress } updateMessage(banAll, job.data.messageId) - await job.updateData({ ...job.data, banAll }) + await job.updateData({ ...job.data, banAll }) // update data just to be sure }) } - public async initiateBanAll(banAll: BanAll, messageId: number) { - if (banAll.outcome !== "approved") { - throw new Error("Cannot initiate ban all for a non-approved BanAll") - } - - const allGroups = await api.tg.groups.getAll.query() - const chats = allGroups.map((g) => g.telegramId) - const banType = banAll.type === "BAN" ? "ban" : "unban" - - const job = await this.flowProducer.add({ - name: `${banType}_all`, - queueName: CONFIG.ORCHESTRATOR_QUEUE, - data: { banAll, messageId }, - children: chats.map((chat) => ({ - name: banType, - queueName: CONFIG.EXECUTOR_QUEUE, - data: { - chatId: chat, - targetId: banAll.target.id, - }, - })), - } satisfies BanAllFlowJob) - return job - } - + /** + * Gracefully close all the queues and workers + */ override async stop() { await Promise.all([ this.executor.close(), From 03cc5187b5fb7935b9c17ee205d34d1269ae6367 Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 03:21:37 +0200 Subject: [PATCH 31/35] fix: the banall text --- src/modules/tg-logger/ban-all.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts index 4744eb9..5fb3d9b 100644 --- a/src/modules/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -55,15 +55,14 @@ export const getProgressText = (state: BanAll["state"]): string => { const progress = (state.successCount + state.failedCount) / state.jobCount const percent = (progress * 100).toFixed(1) - const barLength = 20 + const barLength = 18 const stateEmoji = `🟢 ${state.successCount}${spaces(10)}🔴 ${state.failedCount}${spaces(10)}⏸️ ${state.jobCount - state.successCount - state.failedCount}` - const stateEmojiSpaces = spaces(barLength * 2 + 3 - stateEmoji.length) return fmt( ({ n, b, i }) => [ - b`\nProgress`, - n`${stateEmoji} ${stateEmojiSpaces} ${i`${`${state.jobCount} groups`}`}`, - n`${unicodeProgressBar(progress, 20)} ${percent}%`, + n`\n${b`Progress`} ${i`(${state.jobCount} groups)`}`, + n`${unicodeProgressBar(progress, barLength)} ${percent}% `, + n`${stateEmoji}`, ], { sep: "\n" } ) @@ -78,7 +77,7 @@ export const getProgressText = (state: BanAll["state"]): string => { export const getBanAllText = (data: BanAll) => fmt( ({ n, b, skip, strikethrough, i }) => [ - data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🟢 UN-BAN ALL 🟢`, + data.type === "BAN" ? b`🚨 BAN ALL 🚨` : b`🕊 UN-BAN ALL 🕊`, "", n`${b`🎯 Target:`} ${fmtUser(data.target)} `, n`${b`📣 Reporter:`} ${fmtUser(data.reporter)} `, From 1f392214555532c77958e9a90582b737bdfed63e Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo <66379281+lorenzocorallo@users.noreply.github.com> Date: Thu, 2 Oct 2025 03:23:56 +0200 Subject: [PATCH 32/35] fix: use string repeat Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/tg-logger/ban-all.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/tg-logger/ban-all.ts b/src/modules/tg-logger/ban-all.ts index 5fb3d9b..1df190b 100644 --- a/src/modules/tg-logger/ban-all.ts +++ b/src/modules/tg-logger/ban-all.ts @@ -13,7 +13,7 @@ export type BanAllState = { failedCount: number } -const spaces = (n: number) => new Array(n).fill(" ").join("") +const spaces = (n: number) => " ".repeat(n) export function isBanAllState(obj: unknown): obj is BanAllState { return !!( From 3e224d1c1e099b01470817125a13f2a6842f6a89 Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 03:26:40 +0200 Subject: [PATCH 33/35] fix: docs: fix: docs: fix: docs: --- src/utils/throttle.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/throttle.ts b/src/utils/throttle.ts index 4cea632..99a45a5 100644 --- a/src/utils/throttle.ts +++ b/src/utils/throttle.ts @@ -3,7 +3,8 @@ * * The function will get called at most once every `limit` milliseconds. * If the function is called again before the `limit` has passed, - * the call will be ignored and the last result will be returned. + * the call will be ignored, but a new call will be scheduled at the end of the + * limit period, with the last arguments provided. * * @param func The function to throttle * @param limit The time limit in milliseconds From 43cdb57eb46f75523e427881db28b22a7aac295b Mon Sep 17 00:00:00 2001 From: Tommaso Morganti Date: Thu, 2 Oct 2025 03:27:23 +0200 Subject: [PATCH 34/35] fix: spellinggggggg Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/vote.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/vote.ts b/src/utils/vote.ts index 6aa7940..4e20c46 100644 --- a/src/utils/vote.ts +++ b/src/utils/vote.ts @@ -12,7 +12,7 @@ export type Voter = { /** * WARNING: This function is specific to Direttivo voting, do NOT use it for generic voting. * - * This function calculate the voting outcome based on the votes collected so far. + * This function calculates the voting outcome based on the votes collected so far. * To determine the outcome, we refer to Article 13.8 of the Statute: * > Le riunioni del Direttivo sono valide quando è presente la maggioranza assoluta * > dei componenti. Il Direttivo delibera a maggioranza dei voti dei presenti. From fccecf81b6c44878a2d0d7120447087bfbd27cde Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Thu, 2 Oct 2025 03:27:10 +0200 Subject: [PATCH 35/35] docs: update TODO.md --- TODO.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 5e73f7f..706decf 100644 --- a/TODO.md +++ b/TODO.md @@ -15,9 +15,9 @@ - [x] delete group from backend when the bot leaves a group - [x] search - [ ] advanced moderation - - [ ] ban_all - - [ ] unban_all - - [x] audit log + - [x] ban_all + - [x] unban_all + - [ ] audit log (implemented, need to audit every mod action) - [x] /report to allow user to report (@admin is not implemented) - [x] track ban, mute and kick done via telegram UI (not by command) - [ ] send in-chat action log (deprived of chat ids and stuff)