From 55abc949ebce4cd3a7a2eeda1f66533021c96e95 Mon Sep 17 00:00:00 2001 From: Ita_simo <118686320+Itasimo@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:15:31 +0200 Subject: [PATCH 1/2] feat: Add /warn /unwarn and /warns commands Add /warn to warn a user, /unwarn to remove a warn given the warnId and /warns to get the warn history of a user Add docker-compose.yml to run the bot with a local database Add cross-env to package.json to set environment variables in a cross-platform way Refactor src/modules/moderation/index.ts to help with readability --- docker-compose.yml | 32 ++ package.json | 7 +- pnpm-lock.yaml | 22 +- src/commands/moderation/index.ts | 3 +- src/commands/moderation/warn.ts | 165 ++++++++++ src/modules/moderation/index.ts | 503 +++++++++++++++++++------------ src/modules/tg-logger/index.ts | 3 + src/utils/users.ts | 51 +++- 8 files changed, 568 insertions(+), 218 deletions(-) create mode 100644 docker-compose.yml create mode 100644 src/commands/moderation/warn.ts diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9040876 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + redis: + image: redis:7-alpine + container_name: redis_db + restart: always + command: redis-server --requirepass "Radmin" + ports: + - "6379:6379" + volumes: + - redis_data:/data + + influxdb: + image: influxdb:2.7-alpine + container_name: influx_db + restart: always + ports: + - "8086:8086" + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=Iadmin + - DOCKER_INFLUXDB_INIT_PASSWORD=Iadmin1234567 + - DOCKER_INFLUXDB_INIT_ORG=polinetwork + - DOCKER_INFLUXDB_INIT_BUCKET=telegram_bot + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=tu_token_secreto_de_32_caracteres_o_mas + volumes: + - influxdb_data:/var/lib/influxdb2 + +volumes: + redis_data: + influxdb_data: \ No newline at end of file diff --git a/package.json b/package.json index 927844f..f4b9491 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,9 @@ "main": "./dist/bot.js", "type": "module", "scripts": { - "start": "NODE_ENV=production node dist/bot.js", + "start": "cross-env NODE_ENV=production node dist/bot.js", "build": "tsup", - "dev:raw": "NODE_ENV=development tsx watch --clear-screen=false --env-file=.env src/bot.ts", + "dev:raw": "cross-env NODE_ENV=development tsx watch --clear-screen=false --env-file=.env src/bot.ts", "dev": "pnpm run dev:raw | pino-pretty", "test": "vitest run", "typecheck": "tsgo --noEmit", @@ -23,6 +23,7 @@ "@trpc/server": "11.5.1", "@types/node": "^24.12.2", "@typescript/native-preview": "7.0.0-dev.20260421.2", + "cross-env": "^7.0.3", "pino-pretty": "^13.0.0", "ts-node": "^10.9.2", "tsup": "^8.4.0", @@ -38,7 +39,7 @@ "@grammyjs/parse-mode": "^1.11.1", "@grammyjs/runner": "^2.0.3", "@influxdata/influxdb-client": "^1.35.0", - "@polinetwork/backend": "^0.15.18", + "@polinetwork/backend": "file:../backend/package", "@socket.io/devalue-parser": "^0.1.0", "@t3-oss/env-core": "^0.13.4", "@trpc/client": "^11.5.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7930fe..c83dd53 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^1.35.0 version: 1.35.0 '@polinetwork/backend': - specifier: ^0.15.18 - version: 0.15.18 + specifier: file:../backend/package + version: file:../backend/package '@socket.io/devalue-parser': specifier: ^0.1.0 version: 0.1.0 @@ -93,6 +93,9 @@ importers: '@typescript/native-preview': specifier: 7.0.0-dev.20260421.2 version: 7.0.0-dev.20260421.2 + cross-env: + specifier: ^7.0.3 + version: 7.0.3 pino-pretty: specifier: ^13.0.0 version: 13.0.0 @@ -437,8 +440,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@polinetwork/backend@0.15.18': - resolution: {integrity: sha512-gT4TRMWIFNhwQi2mOx1OiH9HTP9BoorZHSvTti8vTCpk0qgKlL39ntAHQvFtDq1RbJ7rvS+DRd8fRBZkLVtOOw==} + '@polinetwork/backend@file:../backend/package': + resolution: {directory: ../backend/package, type: directory} '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} @@ -843,6 +846,11 @@ packages: resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} engines: {node: '>=18.0'} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1762,7 +1770,7 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@polinetwork/backend@0.15.18': {} + '@polinetwork/backend@file:../backend/package': {} '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: @@ -2083,6 +2091,10 @@ snapshots: croner@10.0.1: {} + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/src/commands/moderation/index.ts b/src/commands/moderation/index.ts index 82cae60..91e7ad4 100644 --- a/src/commands/moderation/index.ts +++ b/src/commands/moderation/index.ts @@ -5,5 +5,6 @@ import { banAll } from "./banall" import { del } from "./del" import { kick } from "./kick" import { mute } from "./mute" +import { warn } from "./warn" -export const moderation = new CommandsCollection("Moderation").withCollection(ban, banAll, del, kick, mute) +export const moderation = new CommandsCollection("Moderation").withCollection(ban, banAll, del, kick, mute, warn) diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts new file mode 100644 index 0000000..6506163 --- /dev/null +++ b/src/commands/moderation/warn.ts @@ -0,0 +1,165 @@ +import { CommandsCollection } from "@/lib/managed-commands" +import { logger } from "@/logger" +import { fmt } from "@/utils/format" +import { ephemeral } from "@/utils/messages" +import { numberOrString, type Role } from "@/utils/types" +import { getOverloadUser, resolveUser } from "@/utils/users" +import { api } from "@/backend" + +export const warn = new CommandsCollection("Warning") + .createCommand({ + trigger: "warn", + args: [ + { + key: "reasonOrUser", + optional: true, + description: + "If the message is a reply, this argument is the reason. Otherwise, it's the username or user id of the user to warn", + type: numberOrString, + }, + { + key: "reason", + optional: true, + description: "Optional reason to warn the user", + }, + ], + description: "Warn a user from a group", + scope: "both", + reply: "optional", + permissions: { + allowedRoles: ["owner", "direttivo"], + excludedRoles: ["creator"], + allowGroupAdmins: true, + }, + handler: async ({ args, context, repliedTo }) => { + const userOverload = await getOverloadUser(context, repliedTo, args.reasonOrUser, args.reason) + if (userOverload.isErr()) { + void ephemeral( + context.reply( + repliedTo + ? fmt(({ n }) => n`There was an error`) + : fmt(({ n }) => n`Target user not found, please try replying to their message`) + ) + ) + logger.error({ args, repliedTo }, `WARN: ${userOverload.error}`) + return + } + + const { user, reason } = userOverload.value + + try { + const { error } = await api.tg.warnings.create.mutate({ + targetId: user.id, + adminId: context.from.id, + groupId: context.chat.id, + reason + }) + + if (error) { + await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) + return + } + } catch (error) { + logger.error({ error }, "Failed to warn user") + await context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)) + return + } + + // Modified to contextually match warning logic + await context.reply( + fmt(({ b, n, i }) => [ + b`⚠️ User has been warned!`, + n`${b`Target:`} ${user.username ? `@${user.username}` : user.first_name}`, + ...(reason ? [n`${b`Reason:`} ${i`${reason}`}`] : []) + ], { + sep: "\n", + }) + ) + } + }) +.createCommand({ + trigger: "unwarn", + args: [{ key: "warnId", type: "number", description: "Warning ID to remove (use /warns to get the ID)" }], + description: "Remove a warning by its ID", + scope: "both", + permissions: { + allowedRoles: ["owner", "direttivo"], + excludedRoles: ["creator"], + allowGroupAdmins: true, + }, + handler: async ({ args, context }) => { + const warnId = Number(args.warnId) + + try { + await api.tg.warnings.deleteById.mutate({ + id: warnId + }) + + await context.reply( + fmt(({ b, n }) => [ + b`✅ Warning removed!`, + n`Warning with ID ${b`#${warnId}`} has been lifted.` + ], { + sep: "\n", + }) + ) + } catch (error) { + logger.error({ warnId, error }, "Failed to delete warning") + await context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)) + } + }, + }) + .createCommand({ + trigger: "warns", + description: "Get the warnings of a user", + scope: "both", + args: [{ key: "username", type: numberOrString, description: "Username (or user id) to get the warnings of" }], + permissions: { + allowedRoles: ["owner", "direttivo"], + excludedRoles: ["creator"], + allowGroupAdmins: true, + }, + handler: async ({ args, context }) => { + + const user = await resolveUser(args.username, context) + if (!user) return + + try { + const warns = await api.tg.warnings.getByTarget.query({ + targetId: user.id, + }) + + if (warns.length === 0) { + return void await context.reply(`${user.username || user.first_name} has clean records (0 warnings).`) + } + await context.reply( + fmt(({ b, i }) => [ + b`⚠️ Warning history for @${user.username || user.first_name}:`, + `Total Warnings: ${warns.length}`, + "", + ...warns.map((warn, index) => { + const date = new Date(warn.createdAt).toLocaleDateString() + const status = warn.isExpired ? "🟢 [Expired]" : warn.deletedAt ? "🗑️ [Removed]" : "🔴 [Active]" + const groupName = warn.group?.title || `Group ID: ${warn.groupId}` + const adminName = warn.admin?.firstName ? `@${warn.admin.username || warn.admin.firstName}` : `ID: ${warn.adminId}` + const reason = warn.reason ? i`${warn.reason}` : "No reason provided" + + return [ + `${index + 1}. ${status} — ${date}`, + ` 👥 ${b`Group:`} ${groupName}`, + ` 👮‍♂️ ${b`Admin:`} ${adminName}`, + ` 📝 ${b`Reason:`} ${reason}`, + "" + ].join("\n") + }) + ], { + sep: "\n", + }) + ) + } catch (error) { + logger.error({ error }, "Failed to fetch warnings") + await context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)) + } + }, +}) + diff --git a/src/modules/moderation/index.ts b/src/modules/moderation/index.ts index 078c35c..1527808 100644 --- a/src/modules/moderation/index.ts +++ b/src/modules/moderation/index.ts @@ -10,45 +10,17 @@ import { fmt, fmtUser } from "@/utils/format" import { modules } from ".." import type { ModerationAction, ModerationError, ModerationErrorCode, PreDeleteResult } from "./types" -function deduceModerationAction(oldMember: ChatMember, newMember: ChatMember): ModerationAction["action"] | null { - const prev = oldMember.status - const curr = newMember.status - - if (prev === "left" && curr === "member") return null // join event - if (prev === "member" && curr === "left") return null // left event - - if (prev === "kicked" && curr === "left") return "UNBAN" - if (prev === "member" && curr === "kicked") return "BAN" - if (prev === "member" && curr === "restricted" && !newMember.can_send_messages) return "MUTE" - if (prev === "restricted" && curr === "member") return "UNMUTE" - - if (prev === "restricted" && curr === "restricted") { - if (oldMember.can_send_messages && !newMember.can_send_messages) { - return "MUTE" - } else if (!oldMember.can_send_messages && newMember.can_send_messages) { - return "UNMUTE" - } - } - - return null -} - -const MAP_ACTIONS: Record< - Exclude | "BAN_ALL" | "UNBAN_ALL", - ApiInput["tg"]["auditLog"]["create"]["type"] -> = { - MUTE: "mute", - BAN: "ban", - KICK: "kick", - UNBAN: "unban", - UNMUTE: "unmute", - BAN_ALL: "ban_all", - UNBAN_ALL: "unban_all", -} - class ModerationClass implements MiddlewareObj { private composer = new Composer() private static instance: ModerationClass | null = null + + + + /** + * Get the singleton moderation middleware instance. + * + * @returns The shared ModerationClass instance. + */ static getInstance(): ModerationClass { if (!ModerationClass.instance) { ModerationClass.instance = new ModerationClass() @@ -56,6 +28,11 @@ class ModerationClass implements MiddlewareObj { return ModerationClass.instance as unknown as ModerationClass } + /** + * Expose the moderation middleware for use by the bot. + * + * @returns The composed middleware object. + */ middleware() { return this.composer.middleware() } @@ -88,39 +65,128 @@ class ModerationClass implements MiddlewareObj { }) } - private getModerationError(p: ModerationAction, code: ModerationErrorCode): ModerationError { - // biome-ignore lint/nursery/noUnnecessaryConditions: lying - switch (code) { - case "CANNOT_MOD_BOT": - return { - code, - fmtError: fmt(({ b }) => b`@${p.from.username} you cannot moderate the bot!`), - strError: "You cannot moderate the bot", - } - case "CANNOT_MOD_YOURSELF": - return { - code, - fmtError: fmt(({ b }) => b`@${p.from.username} you cannot moderate yourself (smh)`), - strError: "You cannot moderate yourself", - } - case "CANNOT_MOD_GROUPADMIN": - return { - code, - fmtError: fmt( - ({ b }) => b`@${p.from.username} the user ${fmtUser(p.target)} is a group admin and cannot be moderated` - ), - strError: "You cannot moderate a group admin", - } - case "PERFORM_ERROR": - return { - code, - fmtError: fmt(() => "TG: Cannot perform the moderation action"), - strError: "There was an error performing the moderation action", - } - } + // ========================================================================= + // PUBLIC MODERATION API (Interface Methods) + // ========================================================================= + + /** + * Ban a user from the chat. + * + * @param target The user to ban. + * @param chat The chat where the ban occurs. + * @param moderator The moderator performing the ban. + * @param duration Optional ban duration. + * @param messagesToDelete Optional messages to delete before banning. + * @param reason Optional reason for the ban. + * @returns Result indicating success or a moderation error. + */ + public async ban( + target: User, + chat: Chat, + moderator: User, + duration: Duration | null, + messagesToDelete?: Message[], + reason?: string + ): Promise> { + return await this.moderate( + { action: "BAN", from: moderator, target, chat, duration: duration ?? undefined, reason }, + messagesToDelete + ) } + public async unban(target: User, chat: Chat, moderator: User): Promise> { + return await this.moderate({ action: "UNBAN", from: moderator, target, chat }) + } + + public async mute( + target: User, + chat: Chat, + moderator: User, + duration: Duration | null, + messagesToDelete?: Message[], + reason?: string + ): Promise> { + return await this.moderate( + { action: "MUTE", from: moderator, target, chat, duration: duration ?? undefined, reason }, + messagesToDelete + ) + } + + public async unmute(target: User, chat: Chat, moderator: User): Promise> { + return await this.moderate({ action: "UNMUTE", from: moderator, target, chat }) + } + + public async kick( + target: User, + chat: Chat, + moderator: User, + messagesToDelete?: Message[], + reason?: string + ): Promise> { + return await this.moderate({ action: "KICK", from: moderator, target, chat, reason }, messagesToDelete) + } + + public async multiChatSpam( + target: User, + messagesToDelete: Message[], + duration: Duration + ): Promise> { + if (messagesToDelete.length === 0) + throw new Error("[Moderation:multiChatSpam] passed an empty messagesToDelete array") + + return await this.moderate( + { + action: "MULTI_CHAT_SPAM", + from: modules.shared.botInfo, + target, + messages: messagesToDelete, + duration, + chat: messagesToDelete[0].chat, + }, + messagesToDelete + ) + } + + // ========================================================================= + // CORE ORCHESTRATION PIPELINE + // ========================================================================= + + /** + * Run a moderation action, including validation, optional message deletion, execution, and post-processing. + * + * @param p The moderation action to apply. + * @param messagesToDelete Optional messages to delete before the action. + * @returns Ok when the moderation completed, or an error when it failed. + */ + private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { + const check = await this.checkTargetValid(p) + if (check.isErr()) return err(this.getModerationError(p, check.error)) + + const preDeleteRes = + messagesToDelete !== undefined + ? await this.deleteMessages( + messagesToDelete, + p.from, + `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` + ) + : ok(null) + + const performOk = await this.perform(p) + if (!performOk) return err(this.getModerationError(p, "PERFORM_ERROR")) // TODO: make the perform output a Result + + await this.post(p, preDeleteRes.unwrapOr(null)) + + return ok() + } + + /** + * Validate that a moderation target can be moderated. + * + * @param p The moderation action to validate. + * @returns Ok when valid, or an error code when invalid. + */ private async checkTargetValid(p: ModerationAction): Promise> { + if (p.target.id === p.from.id) return err("CANNOT_MOD_YOURSELF") if (p.target.id === modules.shared.botInfo.id) return err("CANNOT_MOD_BOT") @@ -130,23 +196,63 @@ class ModerationClass implements MiddlewareObj { return ok() } - private async audit(p: ModerationAction) { - if (p.action === "SILENT" || p.action === "MULTI_CHAT_SPAM") return + /** + * Delete the provided messages and log the deletion in the moderation logger. + * + * @param messages The messages to delete. + * @param executor The user who requested the deletion. + * @param reason The reason for deleting the messages. + * @returns A result containing the pre-delete metadata or an error code. + */ + public async deleteMessages( + messages: Message[], + executor: User, + reason: string + ): Promise> { + if (messages.length === 0) return ok(null) - await api.tg.auditLog.create.mutate({ - adminId: p.from.id, - groupId: p.chat.id, - targetId: p.target.id, - type: MAP_ACTIONS[p.action], - until: "duration" in p && p.duration ? p.duration.date : null, - reason: "reason" in p ? p.reason : undefined, - }) + const tgLogger = modules.get("tgLogger") + const preRes = await tgLogger.preDelete(messages, reason, executor) + if (preRes === null || preRes.count === 0) return err("NOT_FOUND") + + let delCount = 0 + for (const [chatId, mIds] of groupMessagesByChat(messages)) { + const delOk = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) + if (delOk) delCount += mIds.length + } + + if (delCount === 0) { + logger.error( + { initialMessages: messages, executor, forwardedCount: preRes.count, deletedCount: 0 }, + "[Moderation:deleteMessages] no message(s) could be deleted" + ) + void modules.shared.api.deleteMessages(tgLogger.groupId, preRes.logMessageIds) + return err("DELETE_ERROR") + } + + if (delCount / preRes.count < 0.2) { + logger.warn( + { + initialMessages: messages, + executor, + forwardedCount: preRes.count, + deletedCount: delCount, + deletedPercentage: (delCount / preRes.count).toFixed(3), + }, + "[Moderation:deleteMessages] delete count is much lower than forwarded count" + ) + } + + return ok(preRes) } /** * Mass deletes the last 100 messages of a user in a specific chat, on best effort basis. * * Used when banning a user to delete all their messages in the chat + * + * @param userId The ID of the user whose messages should be deleted. + * @param chatId The chat ID where the messages are located. */ public async deleteAllLastMessages(userId: number, chatId: number): Promise { await MessageUserStorage.getInstance() @@ -172,17 +278,29 @@ class ModerationClass implements MiddlewareObj { ) } - private async perform(p: ModerationAction) { - switch (p.action) { - case "SILENT": - return true - case "KICK": - return modules.shared.api + private async kickAction(p: ModerationAction) { + return modules.shared.api .banChatMember(p.chat.id, p.target.id, { until_date: Date.now() / 1000 + duration.values.m, revoke_messages: true, }) .catch(() => false) + } + + /** + * Perform the actual moderation action against Telegram. + * + * @param p The moderation action to execute. + * @returns True when the action succeeded, false otherwise. + */ + private async perform(p: ModerationAction) { + switch (p.action) { + case "SILENT": + return true + + case "KICK": + return await this.kickAction(p) + case "BAN": { const [success] = await Promise.all([ modules.shared.api @@ -195,16 +313,19 @@ class ModerationClass implements MiddlewareObj { case "UNBAN": return modules.shared.api.unbanChatMember(p.chat.id, p.target.id, { only_if_banned: true }).catch(() => false) + case "MUTE": return modules.shared.api .restrictChatMember(p.chat.id, p.target.id, RestrictPermissions.mute, { until_date: p.duration?.timestamp_s, }) .catch(() => false) + case "UNMUTE": return modules.shared.api .restrictChatMember(p.chat.id, p.target.id, RestrictPermissions.unmute) .catch(() => false) + case "MULTI_CHAT_SPAM": return Promise.all( groupMessagesByChat(p.messages) @@ -220,6 +341,12 @@ class ModerationClass implements MiddlewareObj { } } + /** + * Dispatch moderation notifications and audit logging after an action. + * + * @param p The moderation action that was executed. + * @param preDeleteRes The result of any pre-delete operation, or null. + */ private async post(p: ModerationAction, preDeleteRes: PreDeleteResult | null) { // TODO: handle errors? await Promise.allSettled([ @@ -231,134 +358,112 @@ class ModerationClass implements MiddlewareObj { ]) } - public async deleteMessages( - messages: Message[], - executor: User, - reason: string - ): Promise> { - if (messages.length === 0) return ok(null) - - const tgLogger = modules.get("tgLogger") - const preRes = await tgLogger.preDelete(messages, reason, executor) - if (preRes === null || preRes.count === 0) return err("NOT_FOUND") - - let delCount = 0 - for (const [chatId, mIds] of groupMessagesByChat(messages)) { - const delOk = await modules.shared.api.deleteMessages(chatId, mIds).catch(() => false) - if (delOk) delCount += mIds.length - } - - if (delCount === 0) { - logger.error( - { initialMessages: messages, executor, forwardedCount: preRes.count, deletedCount: 0 }, - "[Moderation:deleteMessages] no message(s) could be deleted" - ) - void modules.shared.api.deleteMessages(tgLogger.groupId, preRes.logMessageIds) - return err("DELETE_ERROR") - } - - if (delCount / preRes.count < 0.2) { - logger.warn( - { - initialMessages: messages, - executor, - forwardedCount: preRes.count, - deletedCount: delCount, - deletedPercentage: (delCount / preRes.count).toFixed(3), - }, - "[Moderation:deleteMessages] delete count is much lower than forwarded count" - ) - } - - return ok(preRes) - } - - private async moderate(p: ModerationAction, messagesToDelete?: Message[]): Promise> { - const check = await this.checkTargetValid(p) - if (check.isErr()) return err(this.getModerationError(p, check.error)) - - const preDeleteRes = - messagesToDelete !== undefined - ? await this.deleteMessages( - messagesToDelete, - p.from, - `${p.action}${"reason" in p && p.reason ? ` -- ${p.reason}` : ""}` - ) - : ok(null) - - const performOk = await this.perform(p) - if (!performOk) return err(this.getModerationError(p, "PERFORM_ERROR")) // TODO: make the perform output a Result + /** + * Record the moderation action in the audit log when appropriate. + * + * @param p The moderation action to audit. + */ + private async audit(p: ModerationAction) { + if (p.action === "SILENT" || p.action === "MULTI_CHAT_SPAM") return - await this.post(p, preDeleteRes.unwrapOr(null)) - return ok() + await api.tg.auditLog.create.mutate({ + adminId: p.from.id, + groupId: p.chat.id, + targetId: p.target.id, + type: MAP_ACTIONS[p.action], + until: "duration" in p && p.duration ? p.duration.date : null, + reason: "reason" in p ? p.reason : undefined, + }) } - public async ban( - target: User, - chat: Chat, - moderator: User, - duration: Duration | null, - messagesToDelete?: Message[], - reason?: string - ): Promise> { - return await this.moderate( - { action: "BAN", from: moderator, target, chat, duration: duration ?? undefined, reason }, - messagesToDelete - ) + /** + * Convert a moderation error code into a user-facing moderation error payload. + * + * @param p The moderation action context. + * @param code The error code to map. + * @returns The error object containing formatted and string messages. + */ + private getModerationError(p: ModerationAction, code: ModerationErrorCode): ModerationError { + // biome-ignore lint/nursery/noUnnecessaryConditions: lying + switch (code) { + case "CANNOT_MOD_BOT": + return { + code, + fmtError: fmt(({ b }) => b`@${p.from.username} you cannot moderate the bot!`), + strError: "You cannot moderate the bot", + } + case "CANNOT_MOD_YOURSELF": + return { + code, + fmtError: fmt(({ b }) => b`@${p.from.username} you cannot moderate yourself (smh)`), + strError: "You cannot moderate yourself", + } + case "CANNOT_MOD_GROUPADMIN": + return { + code, + fmtError: fmt( + ({ b }) => b`@${p.from.username} the user ${fmtUser(p.target)} is a group admin and cannot be moderated` + ), + strError: "You cannot moderate a group admin", + } + case "PERFORM_ERROR": + return { + code, + fmtError: fmt(() => "TG: Cannot perform the moderation action"), + strError: "There was an error performing the moderation action", + } + } } +} - public async unban(target: User, chat: Chat, moderator: User): Promise> { - return await this.moderate({ action: "UNBAN", from: moderator, target, chat }) - } +// ========================================================================= +// STATIC HELPERS & EXPORTS (Pushed to bottom to keep main logic accessible) +// ========================================================================= + +/** + * Deduce a moderation action from a Telegram chat member update. + * + * @param oldMember The previous state of the chat member. + * @param newMember The new state of the chat member. + * @returns The inferred moderation action or null when no action should be logged. + */ +function deduceModerationAction(oldMember: ChatMember, newMember: ChatMember): ModerationAction["action"] | null { + const prev = oldMember.status + const curr = newMember.status - public async mute( - target: User, - chat: Chat, - moderator: User, - duration: Duration | null, - messagesToDelete?: Message[], - reason?: string - ): Promise> { - return await this.moderate( - { action: "MUTE", from: moderator, target, chat, duration: duration ?? undefined, reason }, - messagesToDelete - ) - } + if (prev === "left" && curr === "member") return null // join event + if (prev === "member" && curr === "left") return null // left event - public async unmute(target: User, chat: Chat, moderator: User): Promise> { - return await this.moderate({ action: "UNMUTE", from: moderator, target, chat }) - } + if (prev === "kicked" && curr === "left") return "UNBAN" + if (prev === "member" && curr === "kicked") return "BAN" + if (prev === "member" && curr === "restricted" && !newMember.can_send_messages) return "MUTE" + if (prev === "restricted" && curr === "member") return "UNMUTE" - public async kick( - target: User, - chat: Chat, - moderator: User, - messagesToDelete?: Message[], - reason?: string - ): Promise> { - return await this.moderate({ action: "KICK", from: moderator, target, chat, reason }, messagesToDelete) + if (prev === "restricted" && curr === "restricted") { + if (oldMember.can_send_messages && !newMember.can_send_messages) { + return "MUTE" + } else if (!oldMember.can_send_messages && newMember.can_send_messages) { + return "UNMUTE" + } } - public async multiChatSpam( - target: User, - messagesToDelete: Message[], - duration: Duration - ): Promise> { - if (messagesToDelete.length === 0) - throw new Error("[Moderation:multiChatSpam] passed an empty messagesToDelete array") + return null +} - return await this.moderate( - { - action: "MULTI_CHAT_SPAM", - from: modules.shared.botInfo, - target, - messages: messagesToDelete, - duration, - chat: messagesToDelete[0].chat, - }, - messagesToDelete - ) - } +/** + * A mapping of moderation actions to their corresponding API input types. + */ +const MAP_ACTIONS: Record< + Exclude | "BAN_ALL" | "UNBAN_ALL", + ApiInput["tg"]["auditLog"]["create"]["type"] +> = { + MUTE: "mute", + BAN: "ban", + KICK: "kick", + UNBAN: "unban", + UNMUTE: "unmute", + BAN_ALL: "ban_all", + UNBAN_ALL: "unban_all", } export const Moderation = ModerationClass.getInstance() diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index e5b76e2..0be8844 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -37,6 +37,9 @@ const MOD_ACTION_TITLE = (props: ModerationAction) => UNBAN: fmt(({ b }) => b`✅ Unban`), UNMUTE: fmt(({ b }) => b`🎤 Unmute`), SILENT: fmt(({ b }) => b`🔶 Possible Harmful Content Detection`), + WARN: fmt(({ b }) => b`⚠️ Warn`), + UNWARN: fmt(({ b }) => b`♻️ Unwarn`), + WARNS: fmt(({ b }) => b`📋 Warnings`), })[props.action] export class TgLogger extends Module { diff --git a/src/utils/users.ts b/src/utils/users.ts index edfe1a9..d175a6d 100644 --- a/src/utils/users.ts +++ b/src/utils/users.ts @@ -3,6 +3,9 @@ import type { Message, User } from "grammy/types" import { Err, Ok, type Result } from "neverthrow" import { MessageUserStorage } from "@/middlewares/message-user-storage" import { getTelegramId } from "./telegram-id" +import { logger } from "@/logger" +import { fmt } from "@/utils/format" +import { ephemeral } from "@/utils/messages" export async function getUser(userId: number, ctx: C | null): Promise { // TODO: check if this works correctly @@ -37,22 +40,50 @@ export async function getOverloadUser( ): Promise> { if (repliedTo) { if (!repliedTo.from) { - // error - return new Err("[getOverloadUser] no repliedTo.from field (the msg was sent in a channel)") + return new Err("[getOverloadUser] no repliedTo.from field") } return new Ok({ user: repliedTo.from, reason: [firstArg, secondArg].filter(Boolean).join(" ") }) } - if (!firstArg) return new Err("[getOverloadUser] No firstArg passed (without repliedTo)") - - const userId = typeof firstArg === "number" ? firstArg : await getTelegramId(firstArg).catch(() => null) - if (!userId) return new Err("[getOverloadUser] Cannot retrieve the userId from arg or redis") - - const user = await getUser(userId, context).catch(() => null) - if (!user) return new Err("[getOverloadUser] Cannot retrieve the User from chatMember or storage") + const user = await resolveUser(firstArg, context) + if (!user) { + return new Err("SILENT_ERROR") + } return new Ok({ user, - reason: secondArg, + reason: secondArg as string | undefined, }) } + + +export async function resolveUser( + usernameOrId: string | number | undefined, + c: Context +): Promise { + if (!usernameOrId) return null + + const userId: number | null = + typeof usernameOrId === "string" + ? await getTelegramId(usernameOrId.replaceAll("@", "")).catch(() => null) + : typeof usernameOrId === "number" + ? usernameOrId + : null + + if (!userId) { + logger.debug(`warns: no userId for username/id ${usernameOrId}`) + const msg = await c.reply(fmt(({ b }) => b`@${c.from?.username} user not found`)) + void ephemeral(msg) + return null + } + + const user = await getUser(userId, c).catch(() => null) + if (!user) { + const msg = await c.reply(fmt(({ n }) => n`Error: cannot find this user`)) + logger.error({ userId }, "WARNS: cannot retrieve the user") + void ephemeral(msg) + return null + } + + return user +} \ No newline at end of file From 5fa985df13b97bcdc5a3673cc20335b9e6b7ce62 Mon Sep 17 00:00:00 2001 From: Ita_simo <118686320+Itasimo@users.noreply.github.com> Date: Sat, 27 Jun 2026 18:18:35 +0200 Subject: [PATCH 2/2] feat/fix: implement /warn command and decouple /ban_all logging Features: - Finished implementing the `/warn` command with automatic warning-based moderation. - Added the `tgnumber` argument type to enforce strict numeric inputs instead of `numberOrString`. - Added the `ephemeralInGroup` function to send temporary messages in groups while keeping them permanent in private chats. Fixes: - Fixed a bug where `/ban_all` crashed if the bot lacked access to the logging group. Decoupled the logic so that if the initial message fails, progress updates are skipped but command execution continues normally. --- src/commands/moderation/warn.ts | 188 ++++++++++++++++++++++-------- src/modules/moderation/ban-all.ts | 18 ++- src/modules/tg-logger/index.ts | 48 +++++--- src/utils/types.ts | 20 ++++ 4 files changed, 211 insertions(+), 63 deletions(-) diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index 6506163..34f0aa5 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -1,11 +1,53 @@ import { CommandsCollection } from "@/lib/managed-commands" import { logger } from "@/logger" +import { modules } from "@/modules" +import { Moderation } from "@/modules/moderation" import { fmt } from "@/utils/format" import { ephemeral } from "@/utils/messages" -import { numberOrString, type Role } from "@/utils/types" +import { numberOrString, tgnumber, type MaybePromise, type PartialMessage, type Role } from "@/utils/types" import { getOverloadUser, resolveUser } from "@/utils/users" import { api } from "@/backend" + +export type Warning = { + id: number; + targetId: number; + adminId: number; + groupId: number; + reason: string | null; + isExpired: boolean; + deletedAt: Date | null; + createdAt: Date; + group: { + title: string; + inviteLink: string | null; + } | null; + admin: { + id: number; + firstName: string; + lastName?: string; + username?: string; + isBot: boolean; + langCode?: string; + } | null; + target: { + id: number; + firstName: string; + lastName?: string; + username?: string; + isBot: boolean; + langCode?: string; + } | null; +}; + +async function ephemeralInGroup(ctx: { chat: { type: string } }, message: MaybePromise) { + if (ctx.chat.type === "private") { + await message + } else { + void ephemeral(message) + } +} + export const warn = new CommandsCollection("Warning") .createCommand({ trigger: "warn", @@ -25,22 +67,19 @@ export const warn = new CommandsCollection("Warning") ], description: "Warn a user from a group", scope: "both", - reply: "optional", permissions: { allowedRoles: ["owner", "direttivo"], excludedRoles: ["creator"], allowGroupAdmins: true, }, handler: async ({ args, context, repliedTo }) => { - const userOverload = await getOverloadUser(context, repliedTo, args.reasonOrUser, args.reason) + const userOverload = await getOverloadUser(context, repliedTo ?? null, args.reasonOrUser, args.reason) if (userOverload.isErr()) { - void ephemeral( - context.reply( - repliedTo - ? fmt(({ n }) => n`There was an error`) - : fmt(({ n }) => n`Target user not found, please try replying to their message`) - ) - ) + await ephemeralInGroup(context, context.reply( + repliedTo + ? fmt(({ n }) => n`There was an error`) + : fmt(({ n }) => n`Target user not found, please try replying to their message`) + )) logger.error({ args, repliedTo }, `WARN: ${userOverload.error}`) return } @@ -56,30 +95,61 @@ export const warn = new CommandsCollection("Warning") }) if (error) { - await context.reply(fmt(({ n }) => n`There was an error: ${error}`)) + await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`There was an error: ${error}`))) return } } catch (error) { logger.error({ error }, "Failed to warn user") - await context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)) + await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`))) return } - // Modified to contextually match warning logic - await context.reply( + const extraLines: string[] = [] + + if (context.chat.type === "group" || context.chat.type === "supergroup") { + try { + const [groupCount] = await api.tg.warnings.getActiveCountInGroup.query({ + targetId: user.id, + groupId: context.chat.id, + }) + if (groupCount.count >= 3) { + const kickRes = await Moderation.kick(user, context.chat, context.from, [], "Auto-kick: 3 warnings in this group") + if (kickRes.isOk()) { + extraLines.push("👢 Auto-kicked (3 warnings in this group)") + } + } + } catch (error) { + logger.error({ error, targetId: user.id, groupId: context.chat.id }, "Auto-kick check failed after warning") + } + } + + try { + const [totalCount] = await api.tg.warnings.getTotalActiveCount.query({ + targetId: user.id, + }) + if (totalCount.count >= 4) { + await modules.get("tgLogger").banAll(user, context.from, "BAN", "Auto-ban all: 4 total warnings") + extraLines.push("🚫 Auto-ban all initiated (4 total warnings)") + } + } catch (error) { + logger.error({ error, targetId: user.id }, "Auto-ban all check failed after warning") + } + + await ephemeralInGroup(context, context.reply( fmt(({ b, n, i }) => [ - b`⚠️ User has been warned!`, + b`⚠️ User has been warned!`, n`${b`Target:`} ${user.username ? `@${user.username}` : user.first_name}`, - ...(reason ? [n`${b`Reason:`} ${i`${reason}`}`] : []) + ...(reason ? [n`${b`Reason:`} ${i`${reason}`}`] : []), + ...(extraLines.length > 0 ? ["", ...extraLines.map(l => n`${l}`)] : []), ], { sep: "\n", }) - ) + )) } }) .createCommand({ trigger: "unwarn", - args: [{ key: "warnId", type: "number", description: "Warning ID to remove (use /warns to get the ID)" }], + args: [{ key: "warnId", type: tgnumber, description: "Warning ID to remove (use /warns to get the ID)" }], description: "Remove a warning by its ID", scope: "both", permissions: { @@ -90,34 +160,45 @@ export const warn = new CommandsCollection("Warning") handler: async ({ args, context }) => { const warnId = Number(args.warnId) + if (Number.isNaN(warnId)) { + await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`Invalid Warning ID. Use /warns to get the correct numeric ID.`))) + return + } + try { - await api.tg.warnings.deleteById.mutate({ + const res = await api.tg.warnings.deleteById.mutate({ id: warnId }) - await context.reply( + if (res?.deleted === false) { + await ephemeralInGroup(context, context.reply( + fmt(({ n }) => n`That warning was already removed, expired or doesn't exist.`) + )) + return + } + + await ephemeralInGroup(context, context.reply( fmt(({ b, n }) => [ b`✅ Warning removed!`, n`Warning with ID ${b`#${warnId}`} has been lifted.` ], { sep: "\n", }) - ) + )) } catch (error) { logger.error({ warnId, error }, "Failed to delete warning") - await context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`)) + await ephemeralInGroup(context, context.reply(fmt(({ n }) => n`There was an error: ${String(error)}`))) } }, }) .createCommand({ trigger: "warns", description: "Get the warnings of a user", - scope: "both", + scope: "private", args: [{ key: "username", type: numberOrString, description: "Username (or user id) to get the warnings of" }], permissions: { allowedRoles: ["owner", "direttivo"], excludedRoles: ["creator"], - allowGroupAdmins: true, }, handler: async ({ args, context }) => { @@ -125,34 +206,49 @@ export const warn = new CommandsCollection("Warning") if (!user) return try { - const warns = await api.tg.warnings.getByTarget.query({ + const warns: Warning[] = await api.tg.warnings.getByTarget.query({ targetId: user.id, }) if (warns.length === 0) { - return void await context.reply(`${user.username || user.first_name} has clean records (0 warnings).`) + return void await context.reply( + fmt(({ n }) => n`${user.username || user.first_name} has clean records (0 warnings).`) + ) } + await context.reply( - fmt(({ b, i }) => [ - b`⚠️ Warning history for @${user.username || user.first_name}:`, - `Total Warnings: ${warns.length}`, - "", - ...warns.map((warn, index) => { - const date = new Date(warn.createdAt).toLocaleDateString() - const status = warn.isExpired ? "🟢 [Expired]" : warn.deletedAt ? "🗑️ [Removed]" : "🔴 [Active]" - const groupName = warn.group?.title || `Group ID: ${warn.groupId}` - const adminName = warn.admin?.firstName ? `@${warn.admin.username || warn.admin.firstName}` : `ID: ${warn.adminId}` - const reason = warn.reason ? i`${warn.reason}` : "No reason provided" - - return [ - `${index + 1}. ${status} — ${date}`, - ` 👥 ${b`Group:`} ${groupName}`, - ` 👮‍♂️ ${b`Admin:`} ${adminName}`, - ` 📝 ${b`Reason:`} ${reason}`, - "" - ].join("\n") - }) - ], { + fmt(({ b, n, i, code }) => { + const elements = [ + b`⚠️ Warning history for @${user.username || user.first_name}:`, + `Total Warnings: ${warns.length}`, + "", + ]; + + warns.forEach((warn, index) => { + const date = new Date(warn.createdAt).toLocaleDateString(); + const status = warn.isExpired ? "🟢 [Expired]" : warn.deletedAt ? "🗑️ [Removed]" : "🔴 [Active]"; + + const warningId = warn.id + const groupName = warn.group?.title || `Group ID: ${warn.groupId}`; + const adminName = warn.admin?.firstName + ? `@${warn.admin.username || warn.admin.firstName}` + : `ID: ${warn.adminId}`; + + elements.push( + `${index + 1}. ${status} - ${date}`, + n` 🆔 ${b`Warning ID:`} ${code`${warningId}`}`, + n` 👥 ${b`Group:`} ${groupName}`, + n` 👮‍♂️ ${b`Admin:`} ${adminName}`, + n` 📝 ${b`Reason:`} ${warn.reason ? i`${warn.reason}` : "No reason provided"}` + ) + + if (index < warns.length - 1) { + elements.push(""); + } + }); + + return elements; + }, { sep: "\n", }) ) diff --git a/src/modules/moderation/ban-all.ts b/src/modules/moderation/ban-all.ts index ee55b7b..22bf6ba 100644 --- a/src/modules/moderation/ban-all.ts +++ b/src/modules/moderation/ban-all.ts @@ -54,7 +54,7 @@ interface BanAllFlow extends FlowJob { queueName: typeof CONFIG.ORCHESTRATOR_QUEUE data: { banAll: BanAll // entire BanAll data, to re-render the message with progress - messageId: number // message ID to update the progress message + messageId?: number // message ID to update the progress message (undefined if logger unavailable) } children: BanFlow[] } @@ -170,7 +170,19 @@ export class BanAllQueue extends Module { /** Flow producer to create parent/child job batch in a single ban_all command */ private flowProducer = new FlowProducer({ connection }) - public async initiateBanAll(banAll: BanAll, messageId: number) { + /** + * Creates a BullMQ flow job that spawns one child job per non-hidden group. + * + * Children execute the actual `banChatMember` / `unbanChatMember` calls in + * the executor queue. The parent orchestrator job tracks overall progress + * and emits progress events that update the logger message. + * + * @param banAll - The ban_all descriptor (target, reporter, type, state) + * @param messageId - Telegram message ID of the progress post in the logger + * group. `undefined` when the logger is unreachable — + * progress updates are skipped in that case. + */ + public async initiateBanAll(banAll: BanAll, messageId?: number) { const allGroups = await api.tg.groups.getAll.query() const chats = allGroups.filter((g) => !g.hide).map((g) => g.telegramId) const banType = banAll.type === "BAN" ? "ban" : "unban" @@ -258,6 +270,8 @@ export class BanAllQueue extends Module { // on progress of a ban_all job (in the orchestrator queue), // update the message with the new progress (throttled) if (!isBanAllState(progress)) return + // No logger message to update - skip progress tracking + if (job.data.messageId == null) return const banAll = { ...job.data.banAll, state: progress } updateMessage(banAll, job.data.messageId) await job.updateData({ ...job.data, banAll }) // update data just to be sure diff --git a/src/modules/tg-logger/index.ts b/src/modules/tg-logger/index.ts index 0be8844..5154428 100644 --- a/src/modules/tg-logger/index.ts +++ b/src/modules/tg-logger/index.ts @@ -175,6 +175,20 @@ export class TgLogger extends Module { } } + /** + * Initiates a ban_all or unban_all procedure. + * + * Posts the initial progress message to the logger group, then delegates to the + * BanAllQueue to execute the individual bans. If the logger group is unreachable + * (e.g. the bot was removed), the bans still proceed — progress tracking is + * simply skipped. + * + * @param target - User object or numeric ID to ban/unban + * @param reporter - The moderator who issued the command + * @param type - "BAN" or "UNBAN" + * @param reason - Optional reason shown in the log message + * @returns A user-facing result string, or null on unexpected errors + */ public async banAll( target: User | number, reporter: User, @@ -193,33 +207,37 @@ export class TgLogger extends Module { }, } + // Try to post the initial progress message - this can fail if the bot is no + // longer a member of the logger group. We still proceed with the bans; + // only progress-tracking edits are lost. const msg = await this.log(this.topics.banAll, getBanAllText(banAll)) + const messageId = msg?.message_id - if (!msg?.message_id) { - logger.error("[banall] There was an error when initiating banall, no msg.msgId") - return fmt( - ({ n, b }) => [ - b`${type} All ERROR!`, - n`Cannot log the message in tgLogger, therefore cannot start the procedure`, - n`This should be inspected as it should not never happen`, - ], - { sep: "\n" } - ) + if (!messageId) { + logger.warn("[banall] Cannot log initial message in tgLogger, proceeding without progress tracking") } - await modules.get("banAll").initiateBanAll(banAll, msg.message_id) + await modules.get("banAll").initiateBanAll(banAll, messageId) return fmt( ({ n, b, link }) => [ b`${type} All started!`, - msg - ? n`Check ${link("here", `https://t.me/c/${this.groupId}/${this.topics.banAll}/${msg.message_id}`)}` - : undefined, + messageId + ? n`Check ${link("here", `https://t.me/c/${this.groupId}/${this.topics.banAll}/${messageId}`)}` + : n`Progress tracking unavailable (logger group not accessible)`, ], { sep: "\n" } ) } - public async banAllProgress(banAll: BanAll, messageId: number): Promise { + /** + * Edits the ban_all progress message in the logger group with updated state. + * + * No-op when `messageId` is not set (logger group was unavailable when the + * procedure started). + */ + public async banAllProgress(banAll: BanAll, messageId?: number): Promise { + // No message to edit - the initial log failed so we skip progress updates + if (messageId == null) return await this.shared.api.editMessageText(this.groupId, messageId, getBanAllText(banAll), { reply_markup: undefined, link_preview_options: { is_disabled: true }, diff --git a/src/utils/types.ts b/src/utils/types.ts index b6ccd85..f37bd5c 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -26,6 +26,26 @@ export type ModuleShared = { botInfo: UserFromGetMe } +/** + * Validates and transforms a string into a type-safe number. + * + * Since the Telegram API sends all payload data and command arguments as strings, + * this schema serves as a strict gatekeeper. It ensures the string contains a valid + * numeric value safely rejecting text, whitespace, and empty strings—before + * casting it to a native JavaScript number. + * + * @example + * tgnumber.parse("42"); // Returns: 42 + * tgnumber.parse("abc"); // Throws: ZodError (Invalid numeric string) + * tgnumber.parse(" "); // Throws: ZodError (Prevents whitespace converting to 0) + */ +export const tgnumber = z + .string() + .refine((s) => !Number.isNaN(Number(s)) && s.trim() !== "", { + message: "Must be a valid numeric string", + }) + .transform(Number); + export const numberOrString = z.string().transform((s) => { const n = Number(s) if (!Number.isNaN(n) && s.trim() !== "") return n