refactor: simplify codebase with unified patterns and reduced duplication

- 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
This commit is contained in:
devilreef 2026-02-17 21:27:35 +06:00
parent 1eb2200fbe
commit 85c7410ffb
21 changed files with 638 additions and 364 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T> {
value: T
label: string
}
function buildMentionKeyboard(currentValue: RoleMentionPermission): InlineKeyboard {
interface PermissionConfig<T extends string> {
key: PermissionType
displayTitle: string
selectPrompt: string
options: PermissionOption<T>[]
getField: (config: ChatConfig) => T
setPermission: (chatId: number, value: T) => Promise<ChatConfig | null>
describePermission: (value: T) => string
}
const PERMISSION_CONFIGS: Record<PermissionType, PermissionConfig<any>> = {
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<RoleManagePermission>[],
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<RoleMentionPermission>[],
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<RoleAdminPermission>[],
getField: (config) => config.roleAdminPermission,
setPermission: ChatConfigService.setRoleAdminPermission.bind(ChatConfigService),
describePermission: PermissionService.describeRoleAdminPermission.bind(PermissionService),
},
}
function buildPermissionKeyboard<T extends string>(
permConfig: PermissionConfig<T>,
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),

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<boolean> {
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<boole
}
}
/**
* 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) {
if (isHiddenAdmin(chatId, userId)) {
return {
userId,
status: 'creator',
@ -106,19 +105,20 @@ export async function getAdminInfo(chatId: number, userId: number): Promise<Admi
}
const chatAdmins = await getChatAdminsWithCache(chatId)
const userAdmin = chatAdmins.find(admin => 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<Admi
}
}
/**
* Check if user has specific Telegram admin permission
*/
export async function hasAdminPermission(
chatId: number,
userId: number,

View file

@ -16,6 +16,12 @@ export const roleMentionPermissionEnum = pgEnum('role_mention_permission', [
'only_owner',
])
export const roleAdminPermissionEnum = pgEnum('role_admin_permission', [
'everyone',
'all_admins',
'only_owner',
])
export const roleSchema = pgTable('roles', {
id: uuid('id').primaryKey().defaultRandom(),
slug: varchar('slug', { length: 32 }).notNull(),
@ -48,6 +54,11 @@ export const chatConfigSchema = pgTable('chat_configs', {
.notNull()
.default('everyone'),
// Who can use admin commands (rolemembers, rolekick)
roleAdminPermission: roleAdminPermissionEnum('role_admin_permission')
.notNull()
.default('all_admins'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow().$onUpdate(() => new Date()),
})

View file

@ -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<ChatConfig>
}
/**
* Update role management permission
*/
static async setRoleManagePermission(
private static async setPermission<K extends keyof ChatConfig>(
chatId: number,
permission: RoleManagePermission,
field: K,
value: ChatConfig[K],
): Promise<ChatConfig | null> {
// 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<ChatConfig | null> {
return this.setPermission(chatId, 'roleManagePermission', permission)
}
static async setRoleMentionPermission(
chatId: number,
permission: RoleMentionPermission,
): Promise<ChatConfig | null> {
// 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<ChatConfig | null> {
return this.setPermission(chatId, 'roleAdminPermission', permission)
}
/**

View file

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

View file

@ -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<boolean>
const PERMISSION_CHECKERS: Record<string, PermissionChecker> = {
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<RoleManagePermission, string>,
roleMentionPermission: {
everyone: 'Everyone can mention roles',
all_admins: 'Only admins can mention roles',
only_owner: 'Only the chat owner can mention roles',
} satisfies Record<RoleMentionPermission, string>,
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<RoleAdminPermission, string>,
} as const
export class PermissionService {
/**
* Check if user can manage roles (add/delete) based on chat config
*/
private static async checkPermission<K extends keyof ChatConfig>(
chatId: number,
userId: number,
field: K,
defaultFallback: PermissionChecker,
): Promise<boolean> {
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<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)
}
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<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
}
return this.checkPermission(chatId, userId, 'roleMentionPermission', async () => true)
}
static async canAdminRoles(chatId: number, userId: number): Promise<boolean> {
return this.checkPermission(chatId, userId, 'roleAdminPermission', isChatAdmin)
}
/**
* 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]
return PERMISSION_DESCRIPTIONS.roleManagePermission[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]
return PERMISSION_DESCRIPTIONS.roleMentionPermission[permission]
}
static describeRoleAdminPermission(permission: RoleAdminPermission): string {
return PERMISSION_DESCRIPTIONS.roleAdminPermission[permission]
}
}

View file

@ -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<T>(arr: T[], size: number): T[][] {
return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
arr.slice(i * size, i * size + size))
}