feat: global banlist for role creation

GLOBAL_ADMINS env controls who can /gban, /gunban, /gbanlist.
Banned users blocked from /roleadd across all chats.
This commit is contained in:
devilreef 2026-02-17 21:38:38 +06:00
parent 85c7410ffb
commit d00295b291
11 changed files with 434 additions and 2 deletions

View file

@ -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")
);

View file

@ -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": {}
}
}

View file

@ -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
}
]
}

View file

@ -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')

View file

@ -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 <user_id> [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}` : ''}`)
})
}

View file

@ -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')}`)
})
}

View file

@ -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 <user_id>')}`)
}
const unbanned = await BanlistService.unban(targetId)
if (!unbanned) {
return ctx.reply('user is not banned')
}
return ctx.reply(`user ${targetId} unbanned`)
})
}

View file

@ -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']

View file

@ -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(),

View file

@ -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),
}

View file

@ -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)
}
}