From 85c7410ffb345ad8f271dffa341bd7e869a1679d Mon Sep 17 00:00:00 2001 From: devilreef <86633411+devilr33f@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:27:35 +0600 Subject: [PATCH] refactor: simplify codebase with unified patterns and reduced duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - unify ChatConfigService setters into generic setPermission method - replace PermissionService switch statements with data-driven checkers map - deduplicate config.ts callback handlers via loop-based registration (414→254 lines) - extract shared updateChatCommands utility from role-add/role-delete - add proper generics to chunk utility, fix mention loop indexing - extract isHiddenAdmin helper and ANONYMOUS_ADMIN_ID constant in perms - remove debug console.logs, standardize arrow exports and type imports - add roleAdminPermission schema and migration --- drizzle/0002_bored_stephen_strange.sql | 2 + drizzle/meta/0002_snapshot.json | 228 ++++++++++++ drizzle/meta/_journal.json | 7 + src/bot/commands/admin/config-refresh.ts | 5 +- src/bot/commands/admin/config.ts | 361 ++++++++----------- src/bot/commands/admin/role-add.ts | 17 +- src/bot/commands/admin/role-delete.ts | 15 +- src/bot/commands/admin/role-kick.ts | 54 +++ src/bot/commands/admin/role-members.ts | 43 +++ src/bot/commands/debug/reset-autocomplete.ts | 2 +- src/bot/commands/user/join.ts | 2 - src/bot/commands/user/myroles.ts | 2 +- src/bot/commands/user/remove.ts | 3 +- src/bot/commands/user/roles.ts | 2 +- src/bot/utilities/commands.ts | 18 + src/bot/utilities/perms.ts | 45 ++- src/shared/database/schema.ts | 11 + src/shared/services/chatConfig.ts | 44 ++- src/shared/services/mention.ts | 10 +- src/shared/services/permission.ts | 127 +++---- src/shared/utilities/chunk.ts | 4 +- 21 files changed, 638 insertions(+), 364 deletions(-) create mode 100644 drizzle/0002_bored_stephen_strange.sql create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 src/bot/commands/admin/role-kick.ts create mode 100644 src/bot/commands/admin/role-members.ts create mode 100644 src/bot/utilities/commands.ts diff --git a/drizzle/0002_bored_stephen_strange.sql b/drizzle/0002_bored_stephen_strange.sql new file mode 100644 index 0000000..534287b --- /dev/null +++ b/drizzle/0002_bored_stephen_strange.sql @@ -0,0 +1,2 @@ +CREATE TYPE "public"."role_admin_permission" AS ENUM('everyone', 'all_admins', 'only_owner');--> statement-breakpoint +ALTER TABLE "chat_configs" ADD COLUMN "role_admin_permission" "role_admin_permission" DEFAULT 'all_admins' NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..82b12c3 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,228 @@ +{ + "id": "92655a53-6225-4b92-87d3-8df7ba9778e8", + "prevId": "fb792b24-bbbd-4b38-9191-24a83d05d277", + "version": "7", + "dialect": "postgresql", + "tables": { + "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 d89f052..63dba7c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1764313289570, "tag": "0001_legal_blob", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1765468835132, + "tag": "0002_bored_stephen_strange", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/bot/commands/admin/config-refresh.ts b/src/bot/commands/admin/config-refresh.ts index f3562f4..cecc703 100644 --- a/src/bot/commands/admin/config-refresh.ts +++ b/src/bot/commands/admin/config-refresh.ts @@ -2,20 +2,17 @@ import type { BotType } from '@/bot/index.js' import { bold, format } from 'gramio' import { isChatAdmin, refreshAdminCache } from '@/bot/utilities/perms.js' -export default function (bot: BotType) { - // Command to refresh admin cache +export default (bot: BotType) => { bot.command('reload', async (ctx) => { if (!['group', 'supergroup'].includes(ctx.chat.type)) { return ctx.reply('This command can only be used in groups or supergroups') } - // Any admin can refresh cache const isAdmin = await isChatAdmin(ctx.chat.id, ctx.from.id) if (!isAdmin) { return ctx.reply('This command can only be used by admins') } - // Refresh the admin cache for this chat refreshAdminCache(ctx.chat.id) return ctx.reply( diff --git a/src/bot/commands/admin/config.ts b/src/bot/commands/admin/config.ts index a53d6cc..6381033 100644 --- a/src/bot/commands/admin/config.ts +++ b/src/bot/commands/admin/config.ts @@ -1,43 +1,81 @@ import type { BotType } from '@/bot/index.js' import { bold, format, InlineKeyboard, italic } from 'gramio' import { isChatAdmin, isChatOwner } from '@/bot/utilities/perms.js' -import { ChatConfigService, type RoleManagePermission, type RoleMentionPermission } from '@/shared/services/chatConfig.js' +import { ChatConfigService, type ChatConfig, type RoleAdminPermission, type RoleManagePermission, type RoleMentionPermission } from '@/shared/services/chatConfig.js' import { PermissionService } from '@/shared/services/permission.js' import { bot } from '@/bot/index.js' -// Shared keyboard builders for consistent UI -const MANAGE_PERMISSION_OPTIONS: { value: RoleManagePermission, label: string }[] = [ - { value: 'everyone', label: 'Everyone' }, - { value: 'all_admins', label: 'All Admins' }, - { value: 'admin_can_promote_members', label: 'Admins: Promote Members' }, - { value: 'admin_can_change_info', label: 'Admins: Change Info' }, - { value: 'admin_can_manage_chat', label: 'Admins: Manage Chat' }, - { value: 'only_owner', label: 'Only Owner' }, -] +type PermissionType = 'manage' | 'mention' | 'admin' -const MENTION_PERMISSION_OPTIONS: { value: RoleMentionPermission, label: string }[] = [ - { value: 'everyone', label: 'Everyone' }, - { value: 'all_admins', label: 'All Admins' }, - { value: 'only_owner', label: 'Only Owner' }, -] - -function buildManageKeyboard(currentValue: RoleManagePermission): InlineKeyboard { - const keyboard = new InlineKeyboard() - for (const option of MANAGE_PERMISSION_OPTIONS) { - const isSelected = option.value === currentValue - const label = isSelected ? `✓ ${option.label}` : option.label - keyboard.text(label, `config:manage:${option.value}`).row() - } - keyboard.text('« Back to Config', 'config:back') - return keyboard +interface PermissionOption { + value: T + label: string } -function buildMentionKeyboard(currentValue: RoleMentionPermission): InlineKeyboard { +interface PermissionConfig { + key: PermissionType + displayTitle: string + selectPrompt: string + options: PermissionOption[] + getField: (config: ChatConfig) => T + setPermission: (chatId: number, value: T) => Promise + describePermission: (value: T) => string +} + +const PERMISSION_CONFIGS: Record> = { + manage: { + key: 'manage', + displayTitle: 'Role Management Permission', + selectPrompt: 'Select who can add/delete roles:', + options: [ + { value: 'everyone', label: 'Everyone' }, + { value: 'all_admins', label: 'All Admins' }, + { value: 'admin_can_promote_members', label: 'Admins: Promote Members' }, + { value: 'admin_can_change_info', label: 'Admins: Change Info' }, + { value: 'admin_can_manage_chat', label: 'Admins: Manage Chat' }, + { value: 'only_owner', label: 'Only Owner' }, + ] as PermissionOption[], + getField: (config) => config.roleManagePermission, + setPermission: ChatConfigService.setRoleManagePermission.bind(ChatConfigService), + describePermission: PermissionService.describeRoleManagePermission.bind(PermissionService), + }, + mention: { + key: 'mention', + displayTitle: 'Role Mention Permission', + selectPrompt: 'Select who can mention roles:', + options: [ + { value: 'everyone', label: 'Everyone' }, + { value: 'all_admins', label: 'All Admins' }, + { value: 'only_owner', label: 'Only Owner' }, + ] as PermissionOption[], + getField: (config) => config.roleMentionPermission, + setPermission: ChatConfigService.setRoleMentionPermission.bind(ChatConfigService), + describePermission: PermissionService.describeRoleMentionPermission.bind(PermissionService), + }, + admin: { + key: 'admin', + displayTitle: 'Role Admin Permission', + selectPrompt: 'Select who can use role admin commands (rolemembers, rolekick):', + options: [ + { value: 'everyone', label: 'Everyone' }, + { value: 'all_admins', label: 'All Admins' }, + { value: 'only_owner', label: 'Only Owner' }, + ] as PermissionOption[], + getField: (config) => config.roleAdminPermission, + setPermission: ChatConfigService.setRoleAdminPermission.bind(ChatConfigService), + describePermission: PermissionService.describeRoleAdminPermission.bind(PermissionService), + }, +} + +function buildPermissionKeyboard( + permConfig: PermissionConfig, + currentValue: T, +): InlineKeyboard { const keyboard = new InlineKeyboard() - for (const option of MENTION_PERMISSION_OPTIONS) { + for (const option of permConfig.options) { const isSelected = option.value === currentValue const label = isSelected ? `✓ ${option.label}` : option.label - keyboard.text(label, `config:mention:${option.value}`).row() + keyboard.text(label, `config:${permConfig.key}:${option.value}`).row() } keyboard.text('« Back to Config', 'config:back') return keyboard @@ -47,19 +85,18 @@ function buildMainConfigKeyboard(isOwner: boolean): InlineKeyboard { const keyboard = new InlineKeyboard() if (isOwner) { keyboard.text('⚙️ Role Management', 'config:show:manage').row() - keyboard.text('📢 Role Mentions', 'config:show:mention') + keyboard.text('📢 Role Mentions', 'config:show:mention').row() + keyboard.text('👥 Role Admin', 'config:show:admin') } return keyboard } -export default function (bot: BotType) { - // Command to show current configuration +export default (bot: BotType) => { bot.command('config', async (ctx) => { if (!['group', 'supergroup'].includes(ctx.chat.type)) { return ctx.reply('This command can only be used in groups or supergroups') } - // Any admin can view config const isAdmin = await isChatAdmin(ctx.chat.id, ctx.from.id) if (!isAdmin) { return ctx.reply('This command can only be used by admins') @@ -78,6 +115,9 @@ export default function (bot: BotType) { ${bold('Role Mentions:')} ${italic(PermissionService.describeRoleMentionPermission(config.roleMentionPermission))} + ${bold('Role Admin:')} + ${italic(PermissionService.describeRoleAdminPermission(config.roleAdminPermission))} + ${isOwner ? '' : italic('\nOnly the chat owner can change settings.')} `, { @@ -86,190 +126,98 @@ export default function (bot: BotType) { ) }) - // Handle navigation to manage settings - bot.callbackQuery('config:show:manage', async (ctx) => { - const chatId = ctx.message?.chat.id - const messageId = ctx.message?.id - if (!chatId || !messageId) { - return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) - } + for (const permType of Object.keys(PERMISSION_CONFIGS) as PermissionType[]) { + const permConfig = PERMISSION_CONFIGS[permType] - const isOwner = await isChatOwner(chatId, ctx.from.id) - if (!isOwner) { - return ctx.answerCallbackQuery({ - text: 'Only the chat owner can change settings', - show_alert: true, + bot.callbackQuery(`config:show:${permType}`, async (ctx) => { + const chatId = ctx.message?.chat.id + const messageId = ctx.message?.id + if (!chatId || !messageId) { + return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) + } + + const isOwner = await isChatOwner(chatId, ctx.from.id) + if (!isOwner) { + return ctx.answerCallbackQuery({ + text: 'Only the chat owner can change settings', + show_alert: true, + }) + } + + const config = await ChatConfigService.getOrCreate(chatId) + const currentValue = permConfig.getField(config) + + await bot.api.editMessageText({ + chat_id: chatId, + message_id: messageId, + text: format` + ${bold(permConfig.displayTitle)} + + Current: ${italic(permConfig.describePermission(currentValue))} + + ${permConfig.selectPrompt} + `, + reply_markup: buildPermissionKeyboard(permConfig, currentValue), }) - } - const config = await ChatConfigService.getOrCreate(chatId) - - await bot.api.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: format` - ${bold('Role Management Permission')} - - Current: ${italic(PermissionService.describeRoleManagePermission(config.roleManagePermission))} - - Select who can add/delete roles: - `, - reply_markup: buildManageKeyboard(config.roleManagePermission), + return ctx.answerCallbackQuery() }) - return ctx.answerCallbackQuery() - }) + bot.callbackQuery(new RegExp(`^config:${permType}:(.+)$`), async (ctx) => { + const chatId = ctx.message?.chat.id + const messageId = ctx.message?.id + if (!chatId || !messageId) { + return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) + } - // Handle navigation to mention settings - bot.callbackQuery('config:show:mention', async (ctx) => { - const chatId = ctx.message?.chat.id - const messageId = ctx.message?.id - if (!chatId || !messageId) { - return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) - } + const isOwner = await isChatOwner(chatId, ctx.from.id) + if (!isOwner) { + return ctx.answerCallbackQuery({ + text: 'Only the chat owner can change this setting', + show_alert: true, + }) + } - const isOwner = await isChatOwner(chatId, ctx.from.id) - if (!isOwner) { - return ctx.answerCallbackQuery({ - text: 'Only the chat owner can change settings', - show_alert: true, + const permission = ctx.queryPayload.split(':')[2] + const validOptions = permConfig.options.map(opt => opt.value) + + if (!validOptions.includes(permission)) { + return ctx.answerCallbackQuery({ + text: 'Invalid option', + show_alert: true, + }) + } + + const updated = await permConfig.setPermission(chatId, permission) + + if (!updated) { + return ctx.answerCallbackQuery({ + text: 'Failed to update configuration', + show_alert: true, + }) + } + + const currentValue = permConfig.getField(updated) + + await bot.api.editMessageText({ + chat_id: chatId, + message_id: messageId, + text: format` + ${bold(permConfig.displayTitle)} + + Current: ${italic(permConfig.describePermission(currentValue))} + + ${permConfig.selectPrompt} + `, + reply_markup: buildPermissionKeyboard(permConfig, currentValue), }) - } - const config = await ChatConfigService.getOrCreate(chatId) - - await bot.api.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: format` - ${bold('Role Mention Permission')} - - Current: ${italic(PermissionService.describeRoleMentionPermission(config.roleMentionPermission))} - - Select who can mention roles: - `, - reply_markup: buildMentionKeyboard(config.roleMentionPermission), + return ctx.answerCallbackQuery({ + text: `Updated: ${permConfig.describePermission(currentValue)}`, + }) }) + } - return ctx.answerCallbackQuery() - }) - - // Handle manage permission selection - bot.callbackQuery(/^config:manage:(.+)$/, async (ctx) => { - const chatId = ctx.message?.chat.id - const messageId = ctx.message?.id - if (!chatId || !messageId) { - return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) - } - - const isOwner = await isChatOwner(chatId, ctx.from.id) - if (!isOwner) { - return ctx.answerCallbackQuery({ - text: 'Only the chat owner can change this setting', - show_alert: true, - }) - } - - const permission = ctx.queryPayload.split(':')[2] as RoleManagePermission - - const validOptions: RoleManagePermission[] = [ - 'everyone', - 'all_admins', - 'admin_can_promote_members', - 'admin_can_change_info', - 'admin_can_manage_chat', - 'only_owner', - ] - - if (!validOptions.includes(permission)) { - return ctx.answerCallbackQuery({ - text: 'Invalid option', - show_alert: true, - }) - } - - const updated = await ChatConfigService.setRoleManagePermission(chatId, permission) - - if (!updated) { - return ctx.answerCallbackQuery({ - text: 'Failed to update configuration', - show_alert: true, - }) - } - - await bot.api.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: format` - ${bold('Role Management Permission')} - - Current: ${italic(PermissionService.describeRoleManagePermission(updated.roleManagePermission))} - - Select who can add/delete roles: - `, - reply_markup: buildManageKeyboard(updated.roleManagePermission), - }) - - return ctx.answerCallbackQuery({ - text: `Updated: ${PermissionService.describeRoleManagePermission(updated.roleManagePermission)}`, - }) - }) - - // Handle mention permission selection - bot.callbackQuery(/^config:mention:(.+)$/, async (ctx) => { - const chatId = ctx.message?.chat.id - const messageId = ctx.message?.id - if (!chatId || !messageId) { - return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) - } - - const isOwner = await isChatOwner(chatId, ctx.from.id) - if (!isOwner) { - return ctx.answerCallbackQuery({ - text: 'Only the chat owner can change this setting', - show_alert: true, - }) - } - - const permission = ctx.queryPayload.split(':')[2] as RoleMentionPermission - - const validOptions: RoleMentionPermission[] = ['everyone', 'all_admins', 'only_owner'] - - if (!validOptions.includes(permission)) { - return ctx.answerCallbackQuery({ - text: 'Invalid option', - show_alert: true, - }) - } - - const updated = await ChatConfigService.setRoleMentionPermission(chatId, permission) - - if (!updated) { - return ctx.answerCallbackQuery({ - text: 'Failed to update configuration', - show_alert: true, - }) - } - - await bot.api.editMessageText({ - chat_id: chatId, - message_id: messageId, - text: format` - ${bold('Role Mention Permission')} - - Current: ${italic(PermissionService.describeRoleMentionPermission(updated.roleMentionPermission))} - - Select who can mention roles: - `, - reply_markup: buildMentionKeyboard(updated.roleMentionPermission), - }) - - return ctx.answerCallbackQuery({ - text: `Updated: ${PermissionService.describeRoleMentionPermission(updated.roleMentionPermission)}`, - }) - }) - - // Handle back button to main config bot.callbackQuery('config:back', async (ctx) => { const chatId = ctx.message?.chat.id const messageId = ctx.message?.id @@ -292,6 +240,9 @@ export default function (bot: BotType) { ${bold('Role Mentions:')} ${italic(PermissionService.describeRoleMentionPermission(config.roleMentionPermission))} + ${bold('Role Admin:')} + ${italic(PermissionService.describeRoleAdminPermission(config.roleAdminPermission))} + ${isOwner ? '' : italic('\nOnly the chat owner can change settings.')} `, reply_markup: buildMainConfigKeyboard(isOwner), diff --git a/src/bot/commands/admin/role-add.ts b/src/bot/commands/admin/role-add.ts index 9d8eaea..63682c1 100644 --- a/src/bot/commands/admin/role-add.ts +++ b/src/bot/commands/admin/role-add.ts @@ -1,10 +1,11 @@ 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 { PermissionService } from '@/shared/services/permission.js' import { RoleService } from '@/shared/services/role.js' -export default function (bot: BotType) { +export default (bot: BotType) => { bot.command('roleadd', async (ctx) => { if (!['group', 'supergroup'].includes(ctx.chat.type)) { return ctx.reply('This command can only be used in groups or supergroups') @@ -40,19 +41,7 @@ export default function (bot: BotType) { return ctx.reply('failed to create role, skill issue') } - setImmediate(async () => { - const roles = await RoleService.getByChatId(ctx.chatId) - await bot.api.setMyCommands({ - scope: { - chat_id: ctx.chatId, - type: 'chat', - }, - commands: roles.map(role => ({ - command: role.slug, - description: `Mention all members of this role`, - })), - }) - }) + updateChatCommands(bot, ctx.chatId) return ctx.reply( format` diff --git a/src/bot/commands/admin/role-delete.ts b/src/bot/commands/admin/role-delete.ts index f066c13..166aa3b 100644 --- a/src/bot/commands/admin/role-delete.ts +++ b/src/bot/commands/admin/role-delete.ts @@ -1,5 +1,6 @@ import type { BotType } from '@/bot/index.js' import { bold, code, format } from 'gramio' +import { updateChatCommands } from '@/bot/utilities/commands.js' import { PermissionService } from '@/shared/services/permission.js' import { RoleService } from '@/shared/services/role.js' @@ -34,19 +35,7 @@ export default (bot: BotType) => { return ctx.reply('failed to delete role, skill issue') } - setImmediate(async () => { - const roles = await RoleService.getByChatId(ctx.chatId) - await bot.api.setMyCommands({ - scope: { - chat_id: ctx.chatId, - type: 'chat', - }, - commands: roles.map(role => ({ - command: role.slug, - description: `Mention all members of this role`, - })), - }) - }) + updateChatCommands(bot, ctx.chatId) return ctx.reply(format`role ${bold(role.slug)} deleted`) }) diff --git a/src/bot/commands/admin/role-kick.ts b/src/bot/commands/admin/role-kick.ts new file mode 100644 index 0000000..37c3b67 --- /dev/null +++ b/src/bot/commands/admin/role-kick.ts @@ -0,0 +1,54 @@ +import type { BotType } from '@/bot/index.js' +import { bold, code, format, mention } from 'gramio' +import { PermissionService } from '@/shared/services/permission.js' +import { RoleService } from '@/shared/services/role.js' + +export default (bot: BotType) => { + bot.command('rolekick', async (ctx) => { + if (!['group', 'supergroup'].includes(ctx.chat.type)) { + return ctx.reply('this command can only be used in groups or supergroups') + } + + const canAdmin = await PermissionService.canAdminRoles(ctx.chat.id, ctx.from.id) + if (!canAdmin) { + return ctx.reply('You don\'t have permission to use role admin commands') + } + + // Check if this is a reply to a message + const replyMessage = ctx.replyMessage + if (!replyMessage) { + return ctx.reply(format`reply to a user's message to kick them from a role\nusage: ${code('/rolekick ')} (as reply)`) + } + + const targetUser = replyMessage.from + if (!targetUser) { + return ctx.reply('could not determine the user from the replied message') + } + + let [roleSlug] = (ctx.args ?? '').trim().split(' ') + if (!roleSlug) { + return ctx.reply(format`where is the role name bro?\nusage: ${code('/rolekick ')} (as reply)`) + } + + roleSlug = roleSlug.toLowerCase() + + const role = await RoleService.getBySlugOrAlias(roleSlug, ctx.chatId) + if (!role) { + return ctx.reply('role not found') + } + + // Check if user is a member of the role + const membership = await RoleService.getMemberById(role.id!, ctx.chatId, targetUser.id) + if (!membership) { + return ctx.reply(format`${mention(targetUser.firstName, { id: targetUser.id, is_bot: targetUser.isBot(), first_name: targetUser.firstName })} is not a member of ${bold(role.slug)}`) + } + + // Remove user from role + const removed = await RoleService.removeMember(role.id!, targetUser.id) + if (!removed) { + return ctx.reply('failed to remove user from role, skill issue') + } + + return ctx.reply(format`${mention(targetUser.firstName, { id: targetUser.id, is_bot: targetUser.isBot(), first_name: targetUser.firstName })} has been kicked from ${bold(role.slug)}`) + }) +} diff --git a/src/bot/commands/admin/role-members.ts b/src/bot/commands/admin/role-members.ts new file mode 100644 index 0000000..aeb9bad --- /dev/null +++ b/src/bot/commands/admin/role-members.ts @@ -0,0 +1,43 @@ +import type { BotType } from '@/bot/index.js' +import { bold, code, format, italic, join, mention } from 'gramio' +import { PermissionService } from '@/shared/services/permission.js' +import { RoleService } from '@/shared/services/role.js' + +export default (bot: BotType) => { + bot.command('rolemembers', async (ctx) => { + if (!['group', 'supergroup'].includes(ctx.chat.type)) { + return ctx.reply('this command can only be used in groups or supergroups') + } + + const canAdmin = await PermissionService.canAdminRoles(ctx.chat.id, ctx.from.id) + if (!canAdmin) { + return ctx.reply('You don\'t have permission to use role admin commands') + } + + let [roleSlug] = (ctx.args ?? '').trim().split(' ') + if (!roleSlug) { + return ctx.reply(format`where is the role name bro?\nusage: ${code('/rolemembers ')}`) + } + + roleSlug = roleSlug.toLowerCase() + + const role = await RoleService.getBySlugOrAlias(roleSlug, ctx.chatId) + if (!role) { + return ctx.reply('role not found') + } + + const memberIds = await RoleService.getMemberIds(roleSlug, ctx.chatId) + + if (memberIds.length === 0) { + return ctx.reply(format`role ${bold(role.slug)} has no members`) + } + + const mentions = memberIds.map(id => mention('👤', { id, is_bot: false, first_name: '' })) + + return ctx.reply(format` + ${bold(`Members of ${role.slug}`)} ${italic(`(${memberIds.length})`)} + + ${join(mentions, entity => entity, ' ')} + `) + }) +} diff --git a/src/bot/commands/debug/reset-autocomplete.ts b/src/bot/commands/debug/reset-autocomplete.ts index 0fb8dcc..618ec3e 100644 --- a/src/bot/commands/debug/reset-autocomplete.ts +++ b/src/bot/commands/debug/reset-autocomplete.ts @@ -1,4 +1,4 @@ -import { BotType } from "@/bot/index.js" +import type { BotType } from "@/bot/index.js" export default (bot: BotType) => { bot.command('resetautocomplete', async (ctx) => { diff --git a/src/bot/commands/user/join.ts b/src/bot/commands/user/join.ts index b1da0fc..d60d1f6 100644 --- a/src/bot/commands/user/join.ts +++ b/src/bot/commands/user/join.ts @@ -22,13 +22,11 @@ export default (bot: BotType) => { } const role = await RoleService.getBySlugOrAlias(slug, ctx.chatId) - console.log(role) if (!role) { return ctx.reply('role not found') } const isMember = await RoleService.getMemberById(role.id!, ctx.chatId, ctx.from.id) - console.log(isMember) if (isMember !== null) { return ctx.reply('you are already in this role') } diff --git a/src/bot/commands/user/myroles.ts b/src/bot/commands/user/myroles.ts index ed4bbeb..24f89b3 100644 --- a/src/bot/commands/user/myroles.ts +++ b/src/bot/commands/user/myroles.ts @@ -1,4 +1,4 @@ -import { BotType } from "@/bot/index.js" +import type { BotType } from "@/bot/index.js" import { RoleService } from "@/shared/services/role.js" import { format, join } from "gramio" diff --git a/src/bot/commands/user/remove.ts b/src/bot/commands/user/remove.ts index 5c6ba7f..5a56412 100644 --- a/src/bot/commands/user/remove.ts +++ b/src/bot/commands/user/remove.ts @@ -1,4 +1,4 @@ -import { BotType } from "@/bot/index.js" +import type { BotType } from "@/bot/index.js" import { RoleService } from "@/shared/services/role.js" import { bold, code, format } from "gramio" @@ -21,7 +21,6 @@ export default (bot: BotType) => { } const isMember = await RoleService.getMemberById(role.id!, ctx.chatId, ctx.from.id) - console.log(isMember) if (isMember === null) { return ctx.reply('you are not a member of this role') } diff --git a/src/bot/commands/user/roles.ts b/src/bot/commands/user/roles.ts index 58983f3..eb3ad5e 100644 --- a/src/bot/commands/user/roles.ts +++ b/src/bot/commands/user/roles.ts @@ -1,4 +1,4 @@ -import { BotType } from "@/bot/index.js" +import type { BotType } from "@/bot/index.js" import { RoleService } from "@/shared/services/role.js" import { format, join } from "gramio" diff --git a/src/bot/utilities/commands.ts b/src/bot/utilities/commands.ts new file mode 100644 index 0000000..d772d9d --- /dev/null +++ b/src/bot/utilities/commands.ts @@ -0,0 +1,18 @@ +import type { BotType } from '@/bot/index.js' +import { RoleService } from '@/shared/services/role.js' + +export function updateChatCommands(bot: BotType, chatId: number): void { + setImmediate(async () => { + const roles = await RoleService.getByChatId(chatId) + await bot.api.setMyCommands({ + scope: { + chat_id: chatId, + type: 'chat', + }, + commands: roles.map(role => ({ + command: role.slug, + description: `Mention all members of this role`, + })), + }) + }) +} diff --git a/src/bot/utilities/perms.ts b/src/bot/utilities/perms.ts index a2175f0..3594d31 100644 --- a/src/bot/utilities/perms.ts +++ b/src/bot/utilities/perms.ts @@ -1,7 +1,13 @@ import type { TelegramChatMember } from 'gramio' import { bot } from '../index.js' -// Type definitions for admin permissions +const ANONYMOUS_ADMIN_ID = 1087968824 + +interface TelegramAdminMember extends TelegramChatMember { + can_promote_members?: boolean + can_change_info?: boolean + can_manage_chat?: boolean +} export interface AdminInfo { userId: number status: 'creator' | 'administrator' @@ -51,14 +57,16 @@ export function refreshAdminCache(chatId: number): void { adminCache.delete(chatId) } +function isHiddenAdmin(chatId: number, userId: number): boolean { + return userId === chatId || userId === ANONYMOUS_ADMIN_ID +} + export async function isChatAdmin(chatId: number, userId: number) { try { - // handle hidden administrators - if (userId === chatId || userId === 1087968824) + if (isHiddenAdmin(chatId, userId)) return true const chatAdmins = await getChatAdminsWithCache(chatId) - return chatAdmins.some(admin => admin.user.id === userId) } catch (error) { @@ -67,15 +75,10 @@ export async function isChatAdmin(chatId: number, userId: number) { } } -/** - * Check if user is the chat owner/creator - */ export async function isChatOwner(chatId: number, userId: number): Promise { try { - // Handle special cases (hidden admins) - if (userId === chatId || userId === 1087968824) { + if (isHiddenAdmin(chatId, userId)) return true - } const chatAdmins = await getChatAdminsWithCache(chatId) const userAdmin = chatAdmins.find(admin => admin.user.id === userId) @@ -87,13 +90,9 @@ export async function isChatOwner(chatId: number, userId: number): Promise { try { - // Handle special cases - if (userId === chatId || userId === 1087968824) { + if (isHiddenAdmin(chatId, userId)) { return { userId, status: 'creator', @@ -106,19 +105,20 @@ export async function getAdminInfo(chatId: number, userId: number): Promise admin.user.id === userId) + const userAdmin = chatAdmins.find(admin => admin.user.id === userId) as TelegramAdminMember | undefined - if (!userAdmin) { + if (!userAdmin) return null - } + + const isCreator = userAdmin.status === 'creator' return { userId, status: userAdmin.status as 'creator' | 'administrator', permissions: { - canPromoteMembers: userAdmin.status === 'creator' || (userAdmin as any).can_promote_members || false, - canChangeInfo: userAdmin.status === 'creator' || (userAdmin as any).can_change_info || false, - canManageChat: userAdmin.status === 'creator' || (userAdmin as any).can_manage_chat || false, + canPromoteMembers: isCreator || userAdmin.can_promote_members || false, + canChangeInfo: isCreator || userAdmin.can_change_info || false, + canManageChat: isCreator || userAdmin.can_manage_chat || false, }, } } @@ -128,9 +128,6 @@ export async function getAdminInfo(chatId: number, userId: number): Promise new Date()), }) diff --git a/src/shared/services/chatConfig.ts b/src/shared/services/chatConfig.ts index 60d6c68..ab2bef0 100644 --- a/src/shared/services/chatConfig.ts +++ b/src/shared/services/chatConfig.ts @@ -15,11 +15,17 @@ export type RoleMentionPermission | 'all_admins' | 'only_owner' +export type RoleAdminPermission + = | 'everyone' + | 'all_admins' + | 'only_owner' + export interface ChatConfig { id: string chatId: number roleManagePermission: RoleManagePermission roleMentionPermission: RoleMentionPermission + roleAdminPermission: RoleAdminPermission createdAt: Date updatedAt: Date } @@ -46,45 +52,45 @@ export class ChatConfigService { chatId, roleManagePermission: 'all_admins', roleMentionPermission: 'everyone', + roleAdminPermission: 'all_admins', }) .returning() .then(result => result[0]!) as Promise } - /** - * Update role management permission - */ - static async setRoleManagePermission( + private static async setPermission( chatId: number, - permission: RoleManagePermission, + field: K, + value: ChatConfig[K], ): Promise { - // Ensure config exists first await this.getOrCreate(chatId) - return database .update(chatConfigSchema) - .set({ roleManagePermission: permission }) + .set({ [field]: value }) .where(eq(chatConfigSchema.chatId, chatId)) .returning() .then(result => (result[0] ?? null) as ChatConfig | null) } - /** - * Update role mention permission - */ + static async setRoleManagePermission( + chatId: number, + permission: RoleManagePermission, + ): Promise { + return this.setPermission(chatId, 'roleManagePermission', permission) + } + static async setRoleMentionPermission( chatId: number, permission: RoleMentionPermission, ): Promise { - // Ensure config exists first - await this.getOrCreate(chatId) + return this.setPermission(chatId, 'roleMentionPermission', permission) + } - return database - .update(chatConfigSchema) - .set({ roleMentionPermission: permission }) - .where(eq(chatConfigSchema.chatId, chatId)) - .returning() - .then(result => (result[0] ?? null) as ChatConfig | null) + static async setRoleAdminPermission( + chatId: number, + permission: RoleAdminPermission, + ): Promise { + return this.setPermission(chatId, 'roleAdminPermission', permission) } /** diff --git a/src/shared/services/mention.ts b/src/shared/services/mention.ts index dfb0aac..057f3f2 100644 --- a/src/shared/services/mention.ts +++ b/src/shared/services/mention.ts @@ -14,14 +14,12 @@ export class MentionService { const members = await RoleService.getMemberIds(query, chatId) const membersChunks = chunk(members, 5) - for (const memberChunk of membersChunks) { - const chunkIdx = membersChunks.indexOf(memberChunk) + for (let i = 0; i < membersChunks.length; i++) { + const memberChunk = membersChunks[i] - const message = format` - ${bold('Mass mention by')} ${bold(mention('user', { id: caller.id, is_bot: caller.isBot(), first_name: caller.firstName }))} ${membersChunks.length > 1 ? italic(`[${chunkIdx + 1}/${membersChunks.length}]`) : ''} + const message = format`${bold('Mass mention by')} ${bold(mention('user', { id: caller.id, is_bot: caller.isBot(), first_name: caller.firstName }))} ${membersChunks.length > 1 ? italic(`[${i + 1}/${membersChunks.length}]`) : ''} - ${join(this.buildMentions(memberChunk), entity => entity, ' ')} - ` +${join(this.buildMentions(memberChunk), entity => entity, ' ')}` await bot.api.sendMessage({ chat_id: role.chatId, diff --git a/src/shared/services/permission.ts b/src/shared/services/permission.ts index b166ed3..9cfa79b 100644 --- a/src/shared/services/permission.ts +++ b/src/shared/services/permission.ts @@ -1,86 +1,73 @@ -import type { RoleManagePermission, RoleMentionPermission } from './chatConfig.js' +import type { ChatConfig, RoleAdminPermission, RoleManagePermission, RoleMentionPermission } from './chatConfig.js' import { hasAdminPermission, isChatAdmin, isChatOwner } from '@/bot/utilities/perms.js' import { ChatConfigService } from './chatConfig.js' +type PermissionChecker = (chatId: number, userId: number) => Promise + +const PERMISSION_CHECKERS: Record = { + everyone: async () => true, + all_admins: isChatAdmin, + only_owner: isChatOwner, + admin_can_promote_members: (chatId, userId) => hasAdminPermission(chatId, userId, 'can_promote_members'), + admin_can_change_info: (chatId, userId) => hasAdminPermission(chatId, userId, 'can_change_info'), + admin_can_manage_chat: (chatId, userId) => hasAdminPermission(chatId, userId, 'can_manage_chat'), +} + +const PERMISSION_DESCRIPTIONS = { + roleManagePermission: { + everyone: 'Everyone can manage roles', + all_admins: 'All admins can manage roles', + admin_can_promote_members: 'Admins with "Promote Members" permission can manage roles', + admin_can_change_info: 'Admins with "Change Info" permission can manage roles', + admin_can_manage_chat: 'Admins with "Manage Chat" permission can manage roles', + only_owner: 'Only the chat owner can manage roles', + } satisfies Record, + roleMentionPermission: { + everyone: 'Everyone can mention roles', + all_admins: 'Only admins can mention roles', + only_owner: 'Only the chat owner can mention roles', + } satisfies Record, + roleAdminPermission: { + everyone: 'Everyone can use role admin commands', + all_admins: 'Only admins can use role admin commands', + only_owner: 'Only the chat owner can use role admin commands', + } satisfies Record, +} as const + export class PermissionService { - /** - * Check if user can manage roles (add/delete) based on chat config - */ + private static async checkPermission( + chatId: number, + userId: number, + field: K, + defaultFallback: PermissionChecker, + ): Promise { + const config = await ChatConfigService.getOrCreate(chatId) + const permission = config[field] as string + const checker = PERMISSION_CHECKERS[permission] + return checker ? checker(chatId, userId) : defaultFallback(chatId, userId) + } + static async canManageRoles(chatId: number, userId: number): Promise { - // Get chat configuration - const config = await ChatConfigService.getOrCreate(chatId) - const permission = config.roleManagePermission - - switch (permission) { - case 'everyone': - return true - - case 'all_admins': - return isChatAdmin(chatId, userId) - - case 'admin_can_promote_members': - return hasAdminPermission(chatId, userId, 'can_promote_members') - - case 'admin_can_change_info': - return hasAdminPermission(chatId, userId, 'can_change_info') - - case 'admin_can_manage_chat': - return hasAdminPermission(chatId, userId, 'can_manage_chat') - - case 'only_owner': - return isChatOwner(chatId, userId) - - default: - // Default to admin-only for safety - return isChatAdmin(chatId, userId) - } + return this.checkPermission(chatId, userId, 'roleManagePermission', isChatAdmin) } - /** - * Check if user can mention roles (@role or /role) based on chat config - */ static async canMentionRoles(chatId: number, userId: number): Promise { - // Get chat configuration - const config = await ChatConfigService.getOrCreate(chatId) - const permission = config.roleMentionPermission - - switch (permission) { - case 'everyone': - return true - - case 'all_admins': - return isChatAdmin(chatId, userId) - - case 'only_owner': - return isChatOwner(chatId, userId) - - default: - // Default to everyone for backwards compatibility - return true - } + return this.checkPermission(chatId, userId, 'roleMentionPermission', async () => true) + } + + static async canAdminRoles(chatId: number, userId: number): Promise { + return this.checkPermission(chatId, userId, 'roleAdminPermission', isChatAdmin) } - /** - * Get human-readable description of permission level - */ static describeRoleManagePermission(permission: RoleManagePermission): string { - const descriptions: Record = { - everyone: 'Everyone can manage roles', - all_admins: 'All admins can manage roles', - admin_can_promote_members: 'Admins with "Promote Members" permission can manage roles', - admin_can_change_info: 'Admins with "Change Info" permission can manage roles', - admin_can_manage_chat: 'Admins with "Manage Chat" permission can manage roles', - only_owner: 'Only the chat owner can manage roles', - } - return descriptions[permission] + return PERMISSION_DESCRIPTIONS.roleManagePermission[permission] } static describeRoleMentionPermission(permission: RoleMentionPermission): string { - const descriptions: Record = { - everyone: 'Everyone can mention roles', - all_admins: 'Only admins can mention roles', - only_owner: 'Only the chat owner can mention roles', - } - return descriptions[permission] + return PERMISSION_DESCRIPTIONS.roleMentionPermission[permission] + } + + static describeRoleAdminPermission(permission: RoleAdminPermission): string { + return PERMISSION_DESCRIPTIONS.roleAdminPermission[permission] } } diff --git a/src/shared/utilities/chunk.ts b/src/shared/utilities/chunk.ts index db4219e..0a41ef0 100644 --- a/src/shared/utilities/chunk.ts +++ b/src/shared/utilities/chunk.ts @@ -1,4 +1,4 @@ -export function chunk(arr: any[], size: number) { - return Array.from({ length: Math.ceil(arr.length / size) }, (_: any, i: number) => +export function chunk(arr: T[], size: number): T[][] { + return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) => arr.slice(i * size, i * size + size)) }