feat: config management

fuck it we're vibecoding now
This commit is contained in:
devilreef 2025-11-28 13:41:13 +06:00
parent 1ea3f3ba97
commit 1eb2200fbe
14 changed files with 1082 additions and 33 deletions

117
CLAUDE.md Normal file
View file

@ -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 <name>` - Create new role (permission configurable, updates bot commands dynamically)
- `/roledel` - Delete role (permission configurable)
- `/join <name>` - Join a role (always available to everyone)
- `/leave <name>` - 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

View file

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

View file

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

View file

@ -8,6 +8,13 @@
"when": 1763720438133,
"tag": "0000_outgoing_magneto",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764313289570,
"tag": "0001_legal_blob",
"breakpoints": true
}
]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +1,2 @@
export const ROLE_NAME_REGEX = /^[a-zA-Z]{4,32}$/
export const RESERVED_NAMES = ['roleadd', 'roledel', 'join', 'leave', 'myroles', 'roles']
export const ROLE_NAME_REGEX = /^[a-z]{4,32}$/i
export const RESERVED_NAMES = ['roleadd', 'roledel', 'join', 'leave', 'myroles', 'roles', 'config', 'reload']

View file

@ -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<number, AdminCacheEntry>()
const CACHE_TTL = 60 * 60 * 1000 // 1 hour in milliseconds
/**
* Get chat administrators with caching (1-hour TTL)
*/
async function getChatAdminsWithCache(chatId: number): Promise<TelegramChatMember[]> {
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<boolean> {
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<AdminInfo | null> {
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<boolean> {
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
}
}

View file

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

View file

@ -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<ChatConfig> {
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<ChatConfig>
}
/**
* Update role management permission
*/
static async setRoleManagePermission(
chatId: number,
permission: RoleManagePermission,
): Promise<ChatConfig | null> {
// 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<ChatConfig | null> {
// 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<ChatConfig | null> {
return database
.select()
.from(chatConfigSchema)
.where(eq(chatConfigSchema.chatId, chatId))
.then(result => (result[0] ?? null) as ChatConfig | null)
}
}

View file

@ -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<boolean> {
// 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<boolean> {
// 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<RoleManagePermission, string> = {
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<RoleMentionPermission, string> = {
everyone: 'Everyone can mention roles',
all_admins: 'Only admins can mention roles',
only_owner: 'Only the chat owner can mention roles',
}
return descriptions[permission]
}
}