diff --git a/drizzle/0003_wakeful_doctor_spectrum.sql b/drizzle/0003_wakeful_doctor_spectrum.sql new file mode 100644 index 0000000..0718cbd --- /dev/null +++ b/drizzle/0003_wakeful_doctor_spectrum.sql @@ -0,0 +1,8 @@ +CREATE TABLE "banned_users" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" bigint NOT NULL, + "reason" text, + "banned_by" bigint NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "banned_users_user_id_unique" UNIQUE("user_id") +); diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..ac6889e --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,281 @@ +{ + "id": "e566bacf-bce1-4a35-b020-5f9724aa4e99", + "prevId": "92655a53-6225-4b92-87d3-8df7ba9778e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.banned_users": { + "name": "banned_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned_by": { + "name": "banned_by", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "banned_users_user_id_unique": { + "name": "banned_users_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_configs": { + "name": "chat_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "role_manage_permission": { + "name": "role_manage_permission", + "type": "role_manage_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all_admins'" + }, + "role_mention_permission": { + "name": "role_mention_permission", + "type": "role_mention_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'everyone'" + }, + "role_admin_permission": { + "name": "role_admin_permission", + "type": "role_admin_permission", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'all_admins'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_configs_chat_id_unique": { + "name": "chat_configs_chat_id_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.role_members": { + "name": "role_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "role_id": { + "name": "role_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "role_members_role_id_roles_id_fk": { + "name": "role_members_role_id_roles_id_fk", + "tableFrom": "role_members", + "tableTo": "roles", + "columnsFrom": [ + "role_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.roles": { + "name": "roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "varchar(32)[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.role_admin_permission": { + "name": "role_admin_permission", + "schema": "public", + "values": [ + "everyone", + "all_admins", + "only_owner" + ] + }, + "public.role_manage_permission": { + "name": "role_manage_permission", + "schema": "public", + "values": [ + "everyone", + "all_admins", + "admin_can_promote_members", + "admin_can_change_info", + "admin_can_manage_chat", + "only_owner" + ] + }, + "public.role_mention_permission": { + "name": "role_mention_permission", + "schema": "public", + "values": [ + "everyone", + "all_admins", + "only_owner" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 63dba7c..45c2c85 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1765468835132, "tag": "0002_bored_stephen_strange", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1771342325809, + "tag": "0003_wakeful_doctor_spectrum", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/bot/commands/admin/role-add.ts b/src/bot/commands/admin/role-add.ts index 63682c1..1682659 100644 --- a/src/bot/commands/admin/role-add.ts +++ b/src/bot/commands/admin/role-add.ts @@ -2,6 +2,7 @@ import type { BotType } from '@/bot/index.js' import { bold, code, format, italic } from 'gramio' import { RESERVED_NAMES, ROLE_NAME_REGEX } from '@/bot/utilities/constants.js' import { updateChatCommands } from '@/bot/utilities/commands.js' +import { BanlistService } from '@/shared/services/banlist.js' import { PermissionService } from '@/shared/services/permission.js' import { RoleService } from '@/shared/services/role.js' @@ -11,6 +12,11 @@ export default (bot: BotType) => { return ctx.reply('This command can only be used in groups or supergroups') } + const banned = await BanlistService.isBanned(ctx.from.id) + if (banned) { + return ctx.reply('you are globally banned from creating roles') + } + const canManage = await PermissionService.canManageRoles(ctx.chat.id, ctx.from.id) if (!canManage) { return ctx.reply('You don\'t have permission to manage roles') diff --git a/src/bot/commands/global/gban.ts b/src/bot/commands/global/gban.ts new file mode 100644 index 0000000..7d4fae5 --- /dev/null +++ b/src/bot/commands/global/gban.ts @@ -0,0 +1,36 @@ +import type { BotType } from '@/bot/index.js' +import { bold, code, format } from 'gramio' +import env from '@/shared/env.js' +import { BanlistService } from '@/shared/services/banlist.js' + +export default (bot: BotType) => { + bot.command('gban', async (ctx) => { + if (!env.globalAdmins.includes(ctx.from.id)) { + return + } + + const args = (ctx.args ?? '').trim().split(/\s+/) + const targetId = Number(args[0]) + const reason = args.slice(1).join(' ') || undefined + + if (!targetId || Number.isNaN(targetId)) { + return ctx.reply(format`usage: ${code('/gban [reason]')}`) + } + + if (env.globalAdmins.includes(targetId)) { + return ctx.reply('can\'t ban a global admin') + } + + const existing = await BanlistService.isBanned(targetId) + if (existing) { + return ctx.reply('user is already banned') + } + + const banned = await BanlistService.ban(targetId, ctx.from.id, reason).catch(() => null) + if (!banned) { + return ctx.reply('failed to ban user') + } + + return ctx.reply(format`${bold('user globally banned')}\nid: ${code(String(targetId))}${reason ? `\nreason: ${reason}` : ''}`) + }) +} diff --git a/src/bot/commands/global/gbanlist.ts b/src/bot/commands/global/gbanlist.ts new file mode 100644 index 0000000..11c138f --- /dev/null +++ b/src/bot/commands/global/gbanlist.ts @@ -0,0 +1,23 @@ +import type { BotType } from '@/bot/index.js' +import env from '@/shared/env.js' +import { BanlistService } from '@/shared/services/banlist.js' + +export default (bot: BotType) => { + bot.command('gbanlist', async (ctx) => { + if (!env.globalAdmins.includes(ctx.from.id)) { + return + } + + const banned = await BanlistService.list() + + if (!banned.length) { + return ctx.reply('banlist is empty') + } + + const lines = banned.map((b, i) => + `${i + 1}. ${b.userId}${b.reason ? ` — ${b.reason}` : ''}` + ) + + return ctx.reply(`globally banned users:\n${lines.join('\n')}`) + }) +} diff --git a/src/bot/commands/global/gunban.ts b/src/bot/commands/global/gunban.ts new file mode 100644 index 0000000..c6fa142 --- /dev/null +++ b/src/bot/commands/global/gunban.ts @@ -0,0 +1,25 @@ +import type { BotType } from '@/bot/index.js' +import { code, format } from 'gramio' +import env from '@/shared/env.js' +import { BanlistService } from '@/shared/services/banlist.js' + +export default (bot: BotType) => { + bot.command('gunban', async (ctx) => { + if (!env.globalAdmins.includes(ctx.from.id)) { + return + } + + const targetId = Number((ctx.args ?? '').trim()) + + if (!targetId || Number.isNaN(targetId)) { + return ctx.reply(format`usage: ${code('/gunban ')}`) + } + + const unbanned = await BanlistService.unban(targetId) + if (!unbanned) { + return ctx.reply('user is not banned') + } + + return ctx.reply(`user ${targetId} unbanned`) + }) +} diff --git a/src/bot/utilities/constants.ts b/src/bot/utilities/constants.ts index 196ab29..d50b29a 100644 --- a/src/bot/utilities/constants.ts +++ b/src/bot/utilities/constants.ts @@ -1,2 +1,2 @@ export const ROLE_NAME_REGEX = /^[a-z]{4,32}$/i -export const RESERVED_NAMES = ['roleadd', 'roledel', 'join', 'leave', 'myroles', 'roles', 'config', 'reload'] +export const RESERVED_NAMES = ['roleadd', 'roledel', 'join', 'leave', 'myroles', 'roles', 'config', 'reload', 'gban', 'gunban', 'gbanlist'] diff --git a/src/shared/database/schema.ts b/src/shared/database/schema.ts index 9f514dd..bb5b35f 100644 --- a/src/shared/database/schema.ts +++ b/src/shared/database/schema.ts @@ -1,4 +1,4 @@ -import { bigint, pgEnum, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core' +import { bigint, pgEnum, pgTable, text, timestamp, uuid, varchar } from 'drizzle-orm/pg-core' // Enums for permission levels export const roleManagePermissionEnum = pgEnum('role_manage_permission', [ @@ -40,6 +40,15 @@ export const roleMembersSchema = pgTable('role_members', { createdAt: timestamp('created_at').notNull().defaultNow(), }) +export const bannedUsersSchema = pgTable('banned_users', { + id: uuid('id').primaryKey().defaultRandom(), + userId: bigint('user_id', { mode: 'number' }).notNull().unique(), + reason: text('reason'), + bannedBy: bigint('banned_by', { mode: 'number' }).notNull(), + + createdAt: timestamp('created_at').notNull().defaultNow(), +}) + export const chatConfigSchema = pgTable('chat_configs', { id: uuid('id').primaryKey().defaultRandom(), chatId: bigint('chat_id', { mode: 'number' }).notNull().unique(), diff --git a/src/shared/env.ts b/src/shared/env.ts index 1f80ec3..b6cc674 100644 --- a/src/shared/env.ts +++ b/src/shared/env.ts @@ -9,4 +9,5 @@ export default { mode: env.get('NODE_ENV').default('production').asString(), databaseUrl: env.get('DATABASE_URL').required().asString(), botToken: env.get('BOT_TOKEN').required().asString(), + globalAdmins: env.get('GLOBAL_ADMINS').default('').asString().split(',').filter(Boolean).map(Number), } diff --git a/src/shared/services/banlist.ts b/src/shared/services/banlist.ts new file mode 100644 index 0000000..f274e3b --- /dev/null +++ b/src/shared/services/banlist.ts @@ -0,0 +1,36 @@ +import { eq } from 'drizzle-orm' +import database from '../database/index.js' +import { bannedUsersSchema } from '../database/schema.js' + +export class BanlistService { + static isBanned(userId: number) { + return database + .select() + .from(bannedUsersSchema) + .where(eq(bannedUsersSchema.userId, userId)) + .then(result => result[0] ?? null) + } + + static ban(userId: number, bannedBy: number, reason?: string) { + return database + .insert(bannedUsersSchema) + .values({ userId, bannedBy, reason }) + .returning() + .then(result => result[0] ?? null) + } + + static unban(userId: number) { + return database + .delete(bannedUsersSchema) + .where(eq(bannedUsersSchema.userId, userId)) + .returning() + .then(result => result[0] ?? null) + } + + static list() { + return database + .select() + .from(bannedUsersSchema) + .orderBy(bannedUsersSchema.createdAt) + } +}