diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ad4cc6c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +A Telegram bot built with GramIO that enables role-based mentions in group chats. Users can create custom roles, join/leave them, and mention all role members at once using @mentions or commands. + +## Development Commands + +- `pnpm dev` - Run bot in development mode with hot reload +- `pnpm start` - Run bot in production mode +- `pnpm drizzle-kit generate --config=src/shared/database/config.ts` - Generate database migrations after schema changes +- `pnpm drizzle-kit migrate --config=src/shared/database/config.ts` - Apply pending migrations to database +- `pnpm drizzle-kit studio --config=src/shared/database/config.ts` - Open Drizzle Studio for database inspection + +## Architecture + +### Core Components + +**Bot Entry Point** (`src/index.ts` → `src/bot/index.ts`) +- Bot initialization uses GramIO framework +- Commands are auto-loaded from `src/bot/commands/` using `@gramio/autoload` +- Two middleware handlers in `src/bot/index.ts`: + 1. Message handler for @mention syntax (e.g., `@rolename`) - triggers role mentions + 2. Command handler for bare commands (e.g., `/rolename`) - also triggers role mentions +- Bot username is captured on startup to properly handle commands with @botname suffix + +**Database** (PostgreSQL with Drizzle ORM) +- Schema defined in `src/shared/database/schema.ts` +- Three tables: `roles` (id, slug, aliases, chatId), `role_members` (id, roleId, userId), and `chat_configs` (id, chatId, roleManagePermission, roleMentionPermission) +- Configuration in `src/shared/database/config.ts` +- Path aliases use `@/*` mapping to `./src/*` + +**Services** (`src/shared/services/`) +- `RoleService` - All role and role member database operations +- `MentionService` - Handles mentioning all members of a role + - Chunks members into groups of 5 to avoid hitting Telegram's limits + - Builds mention entities using GramIO's `mention()` helper + - Sends multiple messages if role has >5 members, with pagination indicators +- `ChatConfigService` - Manages per-chat permission configuration + - Lazy initialization via `getOrCreate()` - configs created on first access + - Defaults: `all_admins` for role management, `everyone` for mentions +- `PermissionService` - Centralized permission checking logic + - `canManageRoles()` - Checks if user can add/delete roles based on chat config + - `canMentionRoles()` - Checks if user can trigger role mentions based on chat config + +**Commands** (`src/bot/commands/`) +- Admin commands require chat admin permissions (checked via `isChatAdmin()`) +- `/roleadd ` - Create new role (permission configurable, updates bot commands dynamically) +- `/roledel` - Delete role (permission configurable) +- `/join ` - Join a role (always available to everyone) +- `/leave ` - Leave a role (always available to everyone) +- `/myroles` - List your roles +- `/roles` - List all roles in chat +- `/config` - View and configure permissions (any admin can view, owner can change via inline buttons) +- `/reload` - Refresh admin cache (any admin) + +**Role Triggering** +- Roles can be triggered two ways: + 1. Via @mention in any message (e.g., "hey @developers") + 2. Via bare command (e.g., "/developers" or "/developers@botname") +- Both methods call `MentionService.mentionAll()` + +**Utilities** +- `src/bot/utilities/perms.ts` - Permission checking utilities + - `isChatAdmin()` - Checks if user is admin (uses 1-hour cache) + - `isChatOwner()` - Checks if user is chat creator/owner + - `getAdminInfo()` - Returns detailed admin info with granular permissions + - `hasAdminPermission()` - Checks specific Telegram admin permissions (can_promote_members, can_change_info, can_manage_chat) + - `refreshAdminCache()` - Clears admin cache for a specific chat + - Admin cache: 1-hour TTL to reduce Telegram API calls, falls back to stale data on error + - Handles hidden admins including anonymous admin bot ID 1087968824 +- `src/bot/utilities/constants.ts` - Role name validation regex and reserved command names +- `src/shared/utilities/chunk.ts` - Array chunking utility for member batching + +### Important Patterns + +**Permission System** +- Chat owners can configure permissions via `/config` command with inline keyboard navigation +- Role management options: `everyone`, `all_admins`, `admin_can_promote_members`, `admin_can_change_info`, `admin_can_manage_chat`, `only_owner` +- Role mention options: `everyone`, `all_admins`, `only_owner` +- Permissions checked via `PermissionService` which consults `chat_configs` table +- Join/leave functionality always available to everyone (no configuration) +- Admin cache with 1-hour TTL reduces Telegram API calls - use `/reload` to manually refresh + +**Role Name Validation** +- Must match `ROLE_NAME_REGEX`: 4-32 characters, letters only +- Cannot use `RESERVED_NAMES` (built-in command names) + +**Chat Scope** +- All roles are scoped to `chatId` - same role name can exist in different chats +- Bot only works in groups/supergroups, not private chats + +**Dynamic Command Registration** +- When admin creates/deletes roles, bot updates chat-specific command list via `setMyCommands()` with scope type 'chat' +- This allows autocomplete for role names in that specific chat + +**Environment Variables** +- Required: `DATABASE_URL`, `BOT_TOKEN` +- Optional: `NODE_ENV` (defaults to 'production') +- Loaded via Node's `loadEnvFile()` from `.env` file + +### Database Migrations + +After modifying `src/shared/database/schema.ts`, generate and apply migrations using drizzle-kit with the explicit config path: +- `pnpm drizzle-kit generate --config=src/shared/database/config.ts` +- `pnpm drizzle-kit migrate --config=src/shared/database/config.ts` + +The migration output directory is `./drizzle/`. + +**Current Schema:** +- `roles` - Role definitions with slug, aliases, and chatId +- `role_members` - User memberships in roles +- `chat_configs` - Per-chat permission configuration (lazy-initialized) + - Enums: `role_manage_permission` and `role_mention_permission` + - Default values ensure backwards compatibility with existing chats diff --git a/drizzle/0001_legal_blob.sql b/drizzle/0001_legal_blob.sql new file mode 100644 index 0000000..b8d5787 --- /dev/null +++ b/drizzle/0001_legal_blob.sql @@ -0,0 +1,11 @@ +CREATE TYPE "public"."role_manage_permission" AS ENUM('everyone', 'all_admins', 'admin_can_promote_members', 'admin_can_change_info', 'admin_can_manage_chat', 'only_owner');--> statement-breakpoint +CREATE TYPE "public"."role_mention_permission" AS ENUM('everyone', 'all_admins', 'only_owner');--> statement-breakpoint +CREATE TABLE "chat_configs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "chat_id" bigint NOT NULL, + "role_manage_permission" "role_manage_permission" DEFAULT 'all_admins' NOT NULL, + "role_mention_permission" "role_mention_permission" DEFAULT 'everyone' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "chat_configs_chat_id_unique" UNIQUE("chat_id") +); diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..1e64ac4 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,211 @@ +{ + "id": "fb792b24-bbbd-4b38-9191-24a83d05d277", + "prevId": "2a786bb1-c05b-4d36-a401-293ccb96076f", + "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'" + }, + "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_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 4c155c8..d89f052 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1763720438133, "tag": "0000_outgoing_magneto", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1764313289570, + "tag": "0001_legal_blob", + "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 new file mode 100644 index 0000000..f3562f4 --- /dev/null +++ b/src/bot/commands/admin/config-refresh.ts @@ -0,0 +1,25 @@ +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 + 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( + format`${bold('Admin cache refreshed!')} Permission checks will use fresh data from Telegram.`, + ) + }) +} diff --git a/src/bot/commands/admin/config.ts b/src/bot/commands/admin/config.ts new file mode 100644 index 0000000..a53d6cc --- /dev/null +++ b/src/bot/commands/admin/config.ts @@ -0,0 +1,302 @@ +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 { 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' }, +] + +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 +} + +function buildMentionKeyboard(currentValue: RoleMentionPermission): InlineKeyboard { + const keyboard = new InlineKeyboard() + for (const option of MENTION_PERMISSION_OPTIONS) { + const isSelected = option.value === currentValue + const label = isSelected ? `✓ ${option.label}` : option.label + keyboard.text(label, `config:mention:${option.value}`).row() + } + keyboard.text('« Back to Config', 'config:back') + return keyboard +} + +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') + } + return keyboard +} + +export default function (bot: BotType) { + // Command to show current configuration + 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') + } + + const isOwner = await isChatOwner(ctx.chat.id, ctx.from.id) + const config = await ChatConfigService.getOrCreate(ctx.chat.id) + + return ctx.reply( + format` + ${bold('Chat Configuration')} + + ${bold('Role Management:')} + ${italic(PermissionService.describeRoleManagePermission(config.roleManagePermission))} + + ${bold('Role Mentions:')} + ${italic(PermissionService.describeRoleMentionPermission(config.roleMentionPermission))} + + ${isOwner ? '' : italic('\nOnly the chat owner can change settings.')} + `, + { + reply_markup: buildMainConfigKeyboard(isOwner), + }, + ) + }) + + // 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 }) + } + + 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) + + 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() + }) + + // 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 settings', + show_alert: true, + }) + } + + 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() + }) + + // 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 + if (!chatId || !messageId) { + return ctx.answerCallbackQuery({ text: 'Error: Could not determine chat', show_alert: true }) + } + + const isOwner = await isChatOwner(chatId, ctx.from.id) + const config = await ChatConfigService.getOrCreate(chatId) + + await bot.api.editMessageText({ + chat_id: chatId, + message_id: messageId, + text: format` + ${bold('Chat Configuration')} + + ${bold('Role Management:')} + ${italic(PermissionService.describeRoleManagePermission(config.roleManagePermission))} + + ${bold('Role Mentions:')} + ${italic(PermissionService.describeRoleMentionPermission(config.roleMentionPermission))} + + ${isOwner ? '' : italic('\nOnly the chat owner can change settings.')} + `, + reply_markup: buildMainConfigKeyboard(isOwner), + }) + + return ctx.answerCallbackQuery() + }) +} diff --git a/src/bot/commands/admin/role-add.ts b/src/bot/commands/admin/role-add.ts index 4369f8b..9d8eaea 100644 --- a/src/bot/commands/admin/role-add.ts +++ b/src/bot/commands/admin/role-add.ts @@ -1,7 +1,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 { isChatAdmin } from '@/bot/utilities/perms.js' +import { PermissionService } from '@/shared/services/permission.js' import { RoleService } from '@/shared/services/role.js' export default function (bot: BotType) { @@ -10,9 +10,9 @@ export default function (bot: BotType) { return ctx.reply('This command can only be used in groups or supergroups') } - const isAdmin = await isChatAdmin(ctx.chat.id, ctx.from.id) - if (!isAdmin) { - return ctx.reply('this command can only be used by admins') + const canManage = await PermissionService.canManageRoles(ctx.chat.id, ctx.from.id) + if (!canManage) { + return ctx.reply('You don\'t have permission to manage roles') } let [slug] = (ctx.args ?? '').split(' ') @@ -45,9 +45,9 @@ export default function (bot: BotType) { await bot.api.setMyCommands({ scope: { chat_id: ctx.chatId, - type: 'chat' + type: 'chat', }, - commands: roles.map((role) => ({ + commands: roles.map(role => ({ command: role.slug, description: `Mention all members of this role`, })), diff --git a/src/bot/commands/admin/role-delete.ts b/src/bot/commands/admin/role-delete.ts index dd2faf0..f066c13 100644 --- a/src/bot/commands/admin/role-delete.ts +++ b/src/bot/commands/admin/role-delete.ts @@ -1,7 +1,7 @@ -import { BotType } from "@/bot/index.js" -import { isChatAdmin } from "@/bot/utilities/perms.js" -import { RoleService } from "@/shared/services/role.js" -import { bold, code, format } from "gramio" +import type { BotType } from '@/bot/index.js' +import { bold, code, format } from 'gramio' +import { PermissionService } from '@/shared/services/permission.js' +import { RoleService } from '@/shared/services/role.js' export default (bot: BotType) => { bot.command('roledel', async (ctx) => { @@ -9,9 +9,9 @@ export default (bot: BotType) => { return ctx.reply('this command can only be used in groups or supergroups') } - const isAdmin = await isChatAdmin(ctx.chat.id, ctx.from.id) - if (!isAdmin) { - return ctx.reply('this command can only be used by admins') + const canManage = await PermissionService.canManageRoles(ctx.chat.id, ctx.from.id) + if (!canManage) { + return ctx.reply('You don\'t have permission to manage roles') } let [roleSlug] = (ctx.args ?? '').trim().split(' ') @@ -27,7 +27,7 @@ export default (bot: BotType) => { } const members = await RoleService.getMemberIds(roleSlug, ctx.chatId) - await Promise.all(members.map((member) => RoleService.removeMember(role.id!, member))) + await Promise.all(members.map(member => RoleService.removeMember(role.id!, member))) const deletedRole = await RoleService.delete(role.id!) if (!deletedRole) { @@ -36,18 +36,18 @@ export default (bot: BotType) => { 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`, - })), - }) + 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`, + })), + }) }) return ctx.reply(format`role ${bold(role.slug)} deleted`) }) -} \ No newline at end of file +} diff --git a/src/bot/index.ts b/src/bot/index.ts index 959c35c..170bc8c 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -3,6 +3,7 @@ import { autoload } from '@gramio/autoload' import { Bot } from 'gramio' import env from '@/shared/env.js' import { MentionService } from '@/shared/services/mention.js' +import { PermissionService } from '@/shared/services/permission.js' import { RoleService } from '@/shared/services/role.js' let botUsername: string | undefined @@ -23,6 +24,16 @@ bot.on('message', async (ctx, next) => { if (!mentions.length) { return next() } + + // Check if user has permission to mention roles + const canMention = await PermissionService.canMentionRoles(ctx.chatId, ctx.from.id) + if (!canMention) { + await ctx.reply('You don\'t have permission to mention roles', { + reply_parameters: { message_id: ctx.id, allow_sending_without_reply: true }, + }) + return // Stop processing + } + const rolesToMention = await Promise.all(mentions.map(mention => RoleService.getBySlugOrAlias(mention.slice(1), ctx.chatId))).then(roles => roles.filter(role => role !== null)) if (!rolesToMention.length) { @@ -61,6 +72,13 @@ bot.on('message', async (ctx, next) => { return next() } + // Check if user has permission to mention roles + const canMention = await PermissionService.canMentionRoles(ctx.chatId, ctx.from.id) + if (!canMention) { + await ctx.reply('You don\'t have permission to mention roles') + return // Stop processing + } + await MentionService.mentionAll(role.slug, ctx.chatId, ctx.from, ctx.replyMessage?.id ?? undefined) }) @@ -79,6 +97,8 @@ bot.onStart(async ({ info }) => { { command: 'leave', description: 'Leave a role' }, { command: 'myroles', description: 'View your roles in this chat' }, { command: 'roles', description: 'View all roles in this chat' }, + { command: 'config', description: 'View and configure chat permissions' }, + { command: 'reload', description: 'Refresh admin cache' }, ], suppress: true, }) diff --git a/src/bot/utilities/constants.ts b/src/bot/utilities/constants.ts index 600eeb6..196ab29 100644 --- a/src/bot/utilities/constants.ts +++ b/src/bot/utilities/constants.ts @@ -1,2 +1,2 @@ -export const ROLE_NAME_REGEX = /^[a-zA-Z]{4,32}$/ -export const RESERVED_NAMES = ['roleadd', 'roledel', 'join', 'leave', 'myroles', 'roles'] \ No newline at end of file +export const ROLE_NAME_REGEX = /^[a-z]{4,32}$/i +export const RESERVED_NAMES = ['roleadd', 'roledel', 'join', 'leave', 'myroles', 'roles', 'config', 'reload'] diff --git a/src/bot/utilities/perms.ts b/src/bot/utilities/perms.ts index 5b4002b..a2175f0 100644 --- a/src/bot/utilities/perms.ts +++ b/src/bot/utilities/perms.ts @@ -1,14 +1,63 @@ +import type { TelegramChatMember } from 'gramio' import { bot } from '../index.js' +// Type definitions for admin permissions +export interface AdminInfo { + userId: number + status: 'creator' | 'administrator' + permissions: { + canPromoteMembers: boolean + canChangeInfo: boolean + canManageChat: boolean + } +} + +// Admin cache with 1-hour TTL +interface AdminCacheEntry { + data: TelegramChatMember[] + timestamp: number +} + +const adminCache = new Map() +const CACHE_TTL = 60 * 60 * 1000 // 1 hour in milliseconds + +/** + * Get chat administrators with caching (1-hour TTL) + */ +async function getChatAdminsWithCache(chatId: number): Promise { + const cached = adminCache.get(chatId) + + // Return cached data if still valid + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return cached.data + } + + try { + const admins = await bot.api.getChatAdministrators({ chat_id: chatId }) + adminCache.set(chatId, { data: admins, timestamp: Date.now() }) + return admins + } + catch (error) { + console.error('Error fetching chat administrators:', error) + // Return cached data even if expired, or empty array + return cached?.data ?? [] + } +} + +/** + * Refresh admin cache for a specific chat + */ +export function refreshAdminCache(chatId: number): void { + adminCache.delete(chatId) +} + export async function isChatAdmin(chatId: number, userId: number) { try { // handle hidden administrators if (userId === chatId || userId === 1087968824) return true - const chatAdmins = await bot.api.getChatAdministrators({ - chat_id: chatId, - }) + const chatAdmins = await getChatAdminsWithCache(chatId) return chatAdmins.some(admin => admin.user.id === userId) } @@ -17,3 +66,91 @@ export async function isChatAdmin(chatId: number, userId: number) { return false } } + +/** + * 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) { + return true + } + + const chatAdmins = await getChatAdminsWithCache(chatId) + const userAdmin = chatAdmins.find(admin => admin.user.id === userId) + return userAdmin?.status === 'creator' + } + catch (error) { + console.error('Error checking if user is chat owner:', error) + return false + } +} + +/** + * Get detailed admin info for a user + */ +export async function getAdminInfo(chatId: number, userId: number): Promise { + try { + // Handle special cases + if (userId === chatId || userId === 1087968824) { + return { + userId, + status: 'creator', + permissions: { + canPromoteMembers: true, + canChangeInfo: true, + canManageChat: true, + }, + } + } + + const chatAdmins = await getChatAdminsWithCache(chatId) + const userAdmin = chatAdmins.find(admin => admin.user.id === userId) + + if (!userAdmin) { + return null + } + + 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, + }, + } + } + catch (error) { + console.error('Error getting admin info:', error) + return null + } +} + +/** + * Check if user has specific Telegram admin permission + */ +export async function hasAdminPermission( + chatId: number, + userId: number, + permission: 'can_promote_members' | 'can_change_info' | 'can_manage_chat', +): Promise { + const adminInfo = await getAdminInfo(chatId, userId) + if (!adminInfo) + return false + + if (adminInfo.status === 'creator') + return true + + switch (permission) { + case 'can_promote_members': + return adminInfo.permissions.canPromoteMembers + case 'can_change_info': + return adminInfo.permissions.canChangeInfo + case 'can_manage_chat': + return adminInfo.permissions.canManageChat + default: + return false + } +} diff --git a/src/shared/database/schema.ts b/src/shared/database/schema.ts index 06a6141..3aff6d3 100644 --- a/src/shared/database/schema.ts +++ b/src/shared/database/schema.ts @@ -1,5 +1,20 @@ -import { sql } from 'drizzle-orm' -import { bigint, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core' +import { bigint, pgEnum, pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core' + +// Enums for permission levels +export const roleManagePermissionEnum = pgEnum('role_manage_permission', [ + 'everyone', + 'all_admins', + 'admin_can_promote_members', + 'admin_can_change_info', + 'admin_can_manage_chat', + 'only_owner', +]) + +export const roleMentionPermissionEnum = pgEnum('role_mention_permission', [ + 'everyone', + 'all_admins', + 'only_owner', +]) export const roleSchema = pgTable('roles', { id: uuid('id').primaryKey().defaultRandom(), @@ -8,7 +23,7 @@ export const roleSchema = pgTable('roles', { chatId: bigint('chat_id', { mode: 'number' }).notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), - updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => sql`now()`), + updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()), }) export const roleMembersSchema = pgTable('role_members', { @@ -18,3 +33,21 @@ export const roleMembersSchema = pgTable('role_members', { 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(), + + // Who can manage roles (add/delete) + roleManagePermission: roleManagePermissionEnum('role_manage_permission') + .notNull() + .default('all_admins'), + + // Who can mention roles (@role or /role commands) + roleMentionPermission: roleMentionPermissionEnum('role_mention_permission') + .notNull() + .default('everyone'), + + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()), +}) diff --git a/src/shared/services/chatConfig.ts b/src/shared/services/chatConfig.ts new file mode 100644 index 0000000..60d6c68 --- /dev/null +++ b/src/shared/services/chatConfig.ts @@ -0,0 +1,100 @@ +import { eq } from 'drizzle-orm' +import database from '../database/index.js' +import { chatConfigSchema } from '../database/schema.js' + +export type RoleManagePermission + = | 'everyone' + | 'all_admins' + | 'admin_can_promote_members' + | 'admin_can_change_info' + | 'admin_can_manage_chat' + | 'only_owner' + +export type RoleMentionPermission + = | 'everyone' + | 'all_admins' + | 'only_owner' + +export interface ChatConfig { + id: string + chatId: number + roleManagePermission: RoleManagePermission + roleMentionPermission: RoleMentionPermission + createdAt: Date + updatedAt: Date +} + +export class ChatConfigService { + /** + * Get config for a chat (creates default if doesn't exist) + */ + static async getOrCreate(chatId: number): Promise { + const existing = await database + .select() + .from(chatConfigSchema) + .where(eq(chatConfigSchema.chatId, chatId)) + .then(result => result[0] ?? null) + + if (existing) { + return existing as ChatConfig + } + + // Create default config + return database + .insert(chatConfigSchema) + .values({ + chatId, + roleManagePermission: 'all_admins', + roleMentionPermission: 'everyone', + }) + .returning() + .then(result => result[0]!) as Promise + } + + /** + * Update role management permission + */ + static async setRoleManagePermission( + chatId: number, + permission: RoleManagePermission, + ): Promise { + // Ensure config exists first + await this.getOrCreate(chatId) + + return database + .update(chatConfigSchema) + .set({ roleManagePermission: permission }) + .where(eq(chatConfigSchema.chatId, chatId)) + .returning() + .then(result => (result[0] ?? null) as ChatConfig | null) + } + + /** + * Update role mention permission + */ + static async setRoleMentionPermission( + chatId: number, + permission: RoleMentionPermission, + ): Promise { + // Ensure config exists first + await this.getOrCreate(chatId) + + return database + .update(chatConfigSchema) + .set({ roleMentionPermission: permission }) + .where(eq(chatConfigSchema.chatId, chatId)) + .returning() + .then(result => (result[0] ?? null) as ChatConfig | null) + } + + /** + * Get current config (without creating) + */ + static async get(chatId: number): Promise { + return database + .select() + .from(chatConfigSchema) + .where(eq(chatConfigSchema.chatId, chatId)) + .then(result => (result[0] ?? null) as ChatConfig | null) + } +} diff --git a/src/shared/services/permission.ts b/src/shared/services/permission.ts new file mode 100644 index 0000000..b166ed3 --- /dev/null +++ b/src/shared/services/permission.ts @@ -0,0 +1,86 @@ +import type { RoleManagePermission, RoleMentionPermission } from './chatConfig.js' +import { hasAdminPermission, isChatAdmin, isChatOwner } from '@/bot/utilities/perms.js' +import { ChatConfigService } from './chatConfig.js' + +export class PermissionService { + /** + * Check if user can manage roles (add/delete) based on chat config + */ + 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) + } + } + + /** + * 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 + } + } + + /** + * 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] + } + + 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] + } +}