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

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