diff --git a/migrations/versions/20260330_0003_bot_admins.py b/migrations/versions/20260330_0003_bot_admins.py new file mode 100644 index 0000000..2b267e4 --- /dev/null +++ b/migrations/versions/20260330_0003_bot_admins.py @@ -0,0 +1,37 @@ +"""bot admins + +Revision ID: 20260330_0003 +Revises: 20260327_0002 +Create Date: 2026-03-30 11:30:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "20260330_0003" +down_revision: str | None = "20260327_0002" +branch_labels: Sequence[str] | None = None +depends_on: Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "bot_admins", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + ) + op.create_index(op.f("ix_bot_admins_user_id"), "bot_admins", ["user_id"], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f("ix_bot_admins_user_id"), table_name="bot_admins") + op.drop_table("bot_admins") diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py index ee3dc9d..5de10d8 100644 --- a/src/glitchup_bot/bot/handlers/commands.py +++ b/src/glitchup_bot/bot/handlers/commands.py @@ -1,6 +1,9 @@ import logging +from collections import OrderedDict from collections.abc import Awaitable, Callable +from dataclasses import dataclass from html import escape +from uuid import uuid4 from aiogram import F, Router from aiogram.exceptions import TelegramBadRequest @@ -18,7 +21,12 @@ from glitchup_bot.bot.keyboards import ( help_result_keyboard, help_subscriptions_keyboard, ) -from glitchup_bot.config import settings +from glitchup_bot.services.admins import ( + add_admin, + is_admin, + list_effective_admins, + remove_admin, +) from glitchup_bot.services.digest_builder import ( build_digest, build_project_summary, @@ -47,6 +55,20 @@ from glitchup_bot.services.routing import ( router = Router() logger = logging.getLogger(__name__) +MAX_PAGE_CHARS = 3000 +MAX_PAGE_LINES = 18 +MAX_PAGINATION_SESSIONS = 200 + + +@dataclass(slots=True) +class PaginationSession: + pages: list[str] + back_callback: str + is_admin: bool + admin_mode: bool + + +PAGINATION_SESSIONS: OrderedDict[str, PaginationSession] = OrderedDict() def _sender_id(message: Message) -> int | None: @@ -57,8 +79,72 @@ def _callback_sender_id(callback: CallbackQuery) -> int | None: return callback.from_user.id if callback.from_user else None -def _is_admin_user(user_id: int | None) -> bool: - return settings.is_admin(user_id) +async def _is_admin_user(user_id: int | None) -> bool: + return await is_admin(user_id) + + +def _paginate_text(text: str) -> list[str]: + if len(text) <= MAX_PAGE_CHARS and text.count("\n") + 1 <= MAX_PAGE_LINES: + return [text] + + pages: list[str] = [] + current_lines: list[str] = [] + current_len = 0 + + for line in text.splitlines(): + projected_len = current_len + len(line) + (1 if current_lines else 0) + if current_lines and ( + projected_len > MAX_PAGE_CHARS or len(current_lines) >= MAX_PAGE_LINES + ): + pages.append("\n".join(current_lines)) + current_lines = [line] + current_len = len(line) + continue + + current_lines.append(line) + current_len = projected_len + + if current_lines: + pages.append("\n".join(current_lines)) + + return pages or [text] + + +def _store_pagination_session(session: PaginationSession) -> str: + token = uuid4().hex[:12] + PAGINATION_SESSIONS[token] = session + PAGINATION_SESSIONS.move_to_end(token) + + while len(PAGINATION_SESSIONS) > MAX_PAGINATION_SESSIONS: + PAGINATION_SESSIONS.popitem(last=False) + + return token + + +def _result_keyboard( + *, + back_callback: str, + is_admin: bool, + admin_mode: bool, + page_token: str | None = None, + page_index: int = 0, + total_pages: int = 1, +): + if admin_mode: + return admin_result_keyboard( + back_callback, + page_token=page_token, + page_index=page_index, + total_pages=total_pages, + ) + + return help_result_keyboard( + back_callback, + is_admin, + page_token=page_token, + page_index=page_index, + total_pages=total_pages, + ) def _help_text(is_admin: bool) -> str: @@ -89,6 +175,9 @@ def _help_text(is_admin: bool) -> str: "Для админа:", "• /admin — панель управления", "• /sync — принудительная синхронизация", + "• /admins — список администраторов", + "• /admin_add <user_id> или reply на сообщение", + "• /admin_del <user_id>", "• /ownership — текущее распределение routing-настроек", "• /mute_list — список mute rules", ] @@ -163,6 +252,7 @@ def _admin_text() -> str: "", "• Центр синхронизации — запуск sync и проверка статуса", "• Сводки и мониторинг — основные обзорные экраны", + "• Администраторы — список и управление доступом", "• Ownership и topics — текущая маршрутизация", "• Mute rules — правила скрытия шумных событий", ] @@ -198,6 +288,10 @@ def _admin_guide_text() -> str: "Через кнопки удобно смотреть состояние системы, " "а точечные настройки меняются командами.", "", + "• /admin_add 123456 — добавить администратора", + "• /admin_add ответом на сообщение — добавить автора сообщения", + "• /admin_del 123456 — удалить runtime-администратора", + "", "• /owner slug backend — переназначить проект в группу", "• /topic backend 123 — сменить topic override", "• /mute_add payment.*timeout — добавить mute rule", @@ -218,8 +312,40 @@ def _sync_summary_text(summary) -> str: ) +async def _admins_text() -> str: + admins = await list_effective_admins() + if not admins: + return "Список администраторов пуст." + + lines = [ + "Администраторы", + "", + "Добавлять можно командой /admin_add <user_id> " + "или ответом на сообщение пользователя.", + "", + ] + for user_id, source in admins: + source_label = source.replace("env", "из .env") + lines.append(f"• {user_id} — {source_label}") + + return "\n".join(lines) + + +def _extract_target_user_id(message: Message) -> int | None: + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) >= 2: + raw_value = args[1].strip() + return int(raw_value) if raw_value.lstrip("-").isdigit() else None + + reply = message.reply_to_message + if reply and reply.from_user: + return reply.from_user.id + + return None + + async def _require_admin(message: Message) -> bool: - if _is_admin_user(_sender_id(message)): + if await _is_admin_user(_sender_id(message)): return True await message.answer("Команда доступна только администраторам.") @@ -227,7 +353,7 @@ async def _require_admin(message: Message) -> bool: async def _require_admin_callback(callback: CallbackQuery) -> bool: - if _is_admin_user(_callback_sender_id(callback)): + if await _is_admin_user(_callback_sender_id(callback)): return True await callback.answer("Только для администраторов", show_alert=True) @@ -305,6 +431,49 @@ async def _deliver_text( ) +async def _deliver_result( + target: Message | CallbackQuery, + text: str, + *, + back_callback: str, + is_admin: bool, + admin_mode: bool = False, +) -> None: + pages = _paginate_text(text) + if len(pages) == 1: + await _deliver_text( + target, + pages[0], + reply_markup=_result_keyboard( + back_callback=back_callback, + is_admin=is_admin, + admin_mode=admin_mode, + ), + ) + return + + token = _store_pagination_session( + PaginationSession( + pages=pages, + back_callback=back_callback, + is_admin=is_admin, + admin_mode=admin_mode, + ) + ) + await _deliver_text( + target, + pages[0], + reply_markup=_result_keyboard( + back_callback=back_callback, + is_admin=is_admin, + admin_mode=admin_mode, + page_token=token, + page_index=0, + total_pages=len(pages), + ), + ) + + async def _handle_subscription_action( target: Message | CallbackQuery, group_name: str, @@ -346,9 +515,17 @@ async def _run_summary_action( target: Message | CallbackQuery, loader: Callable[[], Awaitable[str]], *, - reply_markup=None, + back_callback: str, + is_admin: bool, + admin_mode: bool = False, ) -> None: - await _deliver_text(target, await loader(), reply_markup=reply_markup) + await _deliver_result( + target, + await loader(), + back_callback=back_callback, + is_admin=is_admin, + admin_mode=admin_mode, + ) async def _ownership_text() -> str: @@ -415,7 +592,7 @@ async def _mute_rules_text() -> str: @router.message(Command("start")) async def cmd_start(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) + is_admin = await _is_admin_user(_sender_id(message)) await message.answer( _help_text(is_admin), reply_markup=help_home_keyboard(is_admin), @@ -425,7 +602,7 @@ async def cmd_start(message: Message) -> None: @router.message(Command("help")) async def cmd_help(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) + is_admin = await _is_admin_user(_sender_id(message)) await message.answer( _help_text(is_admin), reply_markup=help_home_keyboard(is_admin), @@ -449,7 +626,7 @@ async def cmd_admin(message: Message) -> None: async def cb_help_actions(callback: CallbackQuery) -> None: data = callback.data or "" action = data.removeprefix("help:") - is_admin = _is_admin_user(_callback_sender_id(callback)) + is_admin = await _is_admin_user(_callback_sender_id(callback)) await callback.answer() if action == "open": @@ -491,42 +668,48 @@ async def cb_help_actions(callback: CallbackQuery) -> None: await _run_summary_action( callback, lambda: build_digest(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + back_callback="help:menu:monitoring", + is_admin=is_admin, ) return if action == "today": await _run_summary_action( callback, lambda: build_today_summary(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + back_callback="help:menu:monitoring", + is_admin=is_admin, ) return if action == "top": await _run_summary_action( callback, lambda: build_top_issues(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + back_callback="help:menu:monitoring", + is_admin=is_admin, ) return if action == "stale": await _run_summary_action( callback, lambda: build_stale_issues(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + back_callback="help:menu:monitoring", + is_admin=is_admin, ) return if action == "releases": await _run_summary_action( callback, lambda: build_release_summary(refresh=True), - reply_markup=help_result_keyboard("help:menu:releases", is_admin), + back_callback="help:menu:releases", + is_admin=is_admin, ) return if action == "sync_status": await _run_summary_action( callback, build_sync_status, - reply_markup=help_result_keyboard("help:open", is_admin), + back_callback="help:open", + is_admin=is_admin, ) return if action.startswith("sub:"): @@ -584,96 +767,169 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: reply_markup=admin_overview_keyboard(), ) return + if action == "admins": + await _deliver_result( + callback, + await _admins_text(), + back_callback="admin:open", + is_admin=True, + admin_mode=True, + ) + return if action == "sync": summary = await run_manual_sync() - await _show_callback_screen( + await _deliver_result( callback, _sync_summary_text(summary), - reply_markup=admin_result_keyboard("admin:menu:sync"), + back_callback="admin:menu:sync", + is_admin=True, + admin_mode=True, ) return if action == "sync_status": await _run_summary_action( callback, build_sync_status, - reply_markup=admin_result_keyboard("admin:menu:sync"), + back_callback="admin:menu:sync", + is_admin=True, + admin_mode=True, ) return if action == "ownership": - await _show_callback_screen( + await _deliver_result( callback, await _ownership_text(), - reply_markup=admin_result_keyboard("admin:open"), + back_callback="admin:open", + is_admin=True, + admin_mode=True, ) return if action == "mute_list": - await _show_callback_screen( + await _deliver_result( callback, await _mute_rules_text(), - reply_markup=admin_result_keyboard("admin:open"), + back_callback="admin:open", + is_admin=True, + admin_mode=True, ) return if action == "releases": await _run_summary_action( callback, lambda: build_release_summary(refresh=True), - reply_markup=admin_result_keyboard("admin:menu:overview"), + back_callback="admin:menu:overview", + is_admin=True, + admin_mode=True, ) return if action == "today": await _run_summary_action( callback, lambda: build_today_summary(refresh=True), - reply_markup=admin_result_keyboard("admin:menu:overview"), + back_callback="admin:menu:overview", + is_admin=True, + admin_mode=True, ) return if action == "week": await _run_summary_action( callback, lambda: build_digest(refresh=True), - reply_markup=admin_result_keyboard("admin:menu:overview"), + back_callback="admin:menu:overview", + is_admin=True, + admin_mode=True, ) return if action == "top": await _run_summary_action( callback, lambda: build_top_issues(refresh=True), - reply_markup=admin_result_keyboard("admin:menu:overview"), + back_callback="admin:menu:overview", + is_admin=True, + admin_mode=True, ) return if action == "stale": await _run_summary_action( callback, lambda: build_stale_issues(refresh=True), - reply_markup=admin_result_keyboard("admin:menu:overview"), + back_callback="admin:menu:overview", + is_admin=True, + admin_mode=True, ) return if action == "guide": - await _show_callback_screen( + await _deliver_result( callback, _admin_guide_text(), - reply_markup=admin_result_keyboard("admin:open"), + back_callback="admin:open", + is_admin=True, + admin_mode=True, ) return +@router.callback_query(F.data.startswith("page:")) +async def cb_paginated_results(callback: CallbackQuery) -> None: + payload = (callback.data or "").split(":") + if len(payload) != 3: + await callback.answer() + return + + _, token, raw_index = payload + session = PAGINATION_SESSIONS.get(token) + if session is None: + await callback.answer("Эта навигация устарела. Открой раздел заново.", show_alert=True) + return + + if session.admin_mode and not await _is_admin_user(_callback_sender_id(callback)): + await callback.answer("Только для администраторов", show_alert=True) + return + + if not raw_index.isdigit(): + await callback.answer() + return + + page_index = int(raw_index) + if page_index < 0 or page_index >= len(session.pages): + await callback.answer() + return + + await callback.answer() + PAGINATION_SESSIONS.move_to_end(token) + await _show_callback_screen( + callback, + session.pages[page_index], + reply_markup=_result_keyboard( + back_callback=session.back_callback, + is_admin=session.is_admin, + admin_mode=session.admin_mode, + page_token=token, + page_index=page_index, + total_pages=len(session.pages), + ), + ) + + @router.message(Command("week")) async def cmd_week(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_digest(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:monitoring", + is_admin=is_admin, ) @router.message(Command("today")) async def cmd_today(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_today_summary(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:monitoring", + is_admin=is_admin, ) @@ -684,41 +940,45 @@ async def cmd_project(message: Message) -> None: await message.answer("Использование: /project <slug>") return - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_project_summary(args[1].strip(), refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:monitoring", + is_admin=is_admin, ) @router.message(Command("top")) async def cmd_top(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_top_issues(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:monitoring", + is_admin=is_admin, ) @router.message(Command("stale")) async def cmd_stale(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_stale_issues(refresh=True), - reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:monitoring", + is_admin=is_admin, ) @router.message(Command("releases")) async def cmd_releases(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_release_summary(refresh=True), - reply_markup=help_result_keyboard("help:menu:releases", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:releases", + is_admin=is_admin, ) @@ -729,21 +989,23 @@ async def cmd_release(message: Message) -> None: await message.answer("Использование: /release <version>") return - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_release_detail(args[1].strip(), refresh=True), - reply_markup=help_result_keyboard("help:menu:releases", is_admin), - disable_web_page_preview=True, + back_callback="help:menu:releases", + is_admin=is_admin, ) @router.message(Command("sync_status")) async def cmd_sync_status(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( + is_admin = await _is_admin_user(_sender_id(message)) + await _deliver_result( + message, await build_sync_status(), - reply_markup=help_result_keyboard("help:open", is_admin), - disable_web_page_preview=True, + back_callback="help:open", + is_admin=is_admin, ) @@ -753,10 +1015,78 @@ async def cmd_sync(message: Message) -> None: return summary = await run_manual_sync() - await message.answer( + await _deliver_result( + message, _sync_summary_text(summary), - reply_markup=admin_result_keyboard("admin:menu:sync"), - disable_web_page_preview=True, + back_callback="admin:menu:sync", + is_admin=True, + admin_mode=True, + ) + + +@router.message(Command("admins")) +async def cmd_admins(message: Message) -> None: + if not await _require_admin(message): + return + + await _deliver_result( + message, + await _admins_text(), + back_callback="admin:open", + is_admin=True, + admin_mode=True, + ) + + +@router.message(Command("admin_add")) +async def cmd_admin_add(message: Message) -> None: + if not await _require_admin(message): + return + + user_id = _extract_target_user_id(message) + if user_id is None: + await message.answer( + "Использование: /admin_add <user_id> или ответом на сообщение пользователя." + ) + return + + added = await add_admin(user_id) + text = ( + f"Администратор {user_id} добавлен." + if added + else f"Пользователь {user_id} уже есть среди runtime-администраторов." + ) + await _deliver_result( + message, + text, + back_callback="admin:open", + is_admin=True, + admin_mode=True, + ) + + +@router.message(Command("admin_del")) +async def cmd_admin_del(message: Message) -> None: + if not await _require_admin(message): + return + + user_id = _extract_target_user_id(message) + if user_id is None: + await message.answer("Использование: /admin_del <user_id>") + return + + removed = await remove_admin(user_id) + text = ( + f"Runtime-администратор {user_id} удалён." + if removed + else f"Runtime-администратор {user_id} не найден." + ) + await _deliver_result( + message, + text, + back_callback="admin:open", + is_admin=True, + admin_mode=True, ) @@ -767,7 +1097,7 @@ async def cmd_subscribe(message: Message) -> None: await message.answer("Использование: /subscribe <backend|frontend>") return - is_admin = _is_admin_user(_sender_id(message)) + is_admin = await _is_admin_user(_sender_id(message)) await _handle_subscription_action( message, args[1].strip().lower(), @@ -784,7 +1114,7 @@ async def cmd_unsubscribe(message: Message) -> None: await message.answer("Использование: /unsubscribe <backend|frontend>") return - is_admin = _is_admin_user(_sender_id(message)) + is_admin = await _is_admin_user(_sender_id(message)) await _handle_subscription_action( message, args[1].strip().lower(), @@ -799,10 +1129,12 @@ async def cmd_ownership(message: Message) -> None: if not await _require_admin(message): return - await message.answer( + await _deliver_result( + message, await _ownership_text(), - reply_markup=admin_result_keyboard("admin:open"), - disable_web_page_preview=True, + back_callback="admin:open", + is_admin=True, + admin_mode=True, ) @@ -897,10 +1229,12 @@ async def cmd_mute_list(message: Message) -> None: if not await _require_admin(message): return - await message.answer( + await _deliver_result( + message, await _mute_rules_text(), - reply_markup=admin_result_keyboard("admin:open"), - disable_web_page_preview=True, + back_callback="admin:open", + is_admin=True, + admin_mode=True, ) diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py index bdb4e98..de844f9 100644 --- a/src/glitchup_bot/bot/keyboards.py +++ b/src/glitchup_bot/bot/keyboards.py @@ -2,6 +2,26 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder +def _add_pagination_row( + builder: InlineKeyboardBuilder, + *, + page_token: str | None, + page_index: int, + total_pages: int, +) -> None: + if not page_token or total_pages <= 1: + return + + if page_index > 0: + builder.button(text="◀", callback_data=f"page:{page_token}:{page_index - 1}") + builder.button( + text=f"{page_index + 1}/{total_pages}", + callback_data=f"page:{page_token}:{page_index}", + ) + if page_index + 1 < total_pages: + builder.button(text="▶", callback_data=f"page:{page_token}:{page_index + 1}") + + def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring") @@ -51,15 +71,34 @@ def help_subscriptions_keyboard(is_admin: bool) -> InlineKeyboardMarkup: return builder.as_markup() -def help_result_keyboard(back_callback: str, is_admin: bool) -> InlineKeyboardMarkup: +def help_result_keyboard( + back_callback: str, + is_admin: bool, + *, + page_token: str | None = None, + page_index: int = 0, + total_pages: int = 1, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() + _add_pagination_row( + builder, + page_token=page_token, + page_index=page_index, + total_pages=total_pages, + ) builder.button(text="Назад", callback_data=back_callback) builder.button(text="Главное меню", callback_data="help:open") if is_admin: builder.button(text="Админ-панель", callback_data="admin:open") - builder.adjust(2, 1) + if total_pages > 1: + builder.adjust(3, 2, 1) + else: + builder.adjust(2, 1) else: - builder.adjust(2) + if total_pages > 1: + builder.adjust(3, 2) + else: + builder.adjust(2) return builder.as_markup() @@ -67,11 +106,12 @@ def admin_home_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Центр синхронизации", callback_data="admin:menu:sync") builder.button(text="Сводки и мониторинг", callback_data="admin:menu:overview") + builder.button(text="Администраторы", callback_data="admin:admins") builder.button(text="Ownership и topics", callback_data="admin:ownership") builder.button(text="Mute rules", callback_data="admin:mute_list") builder.button(text="Инструкция", callback_data="admin:guide") builder.button(text="Пользовательское меню", callback_data="help:open") - builder.adjust(2, 2, 1, 1) + builder.adjust(2, 2, 2, 1) return builder.as_markup() @@ -97,10 +137,25 @@ def admin_overview_keyboard() -> InlineKeyboardMarkup: return builder.as_markup() -def admin_result_keyboard(back_callback: str) -> InlineKeyboardMarkup: +def admin_result_keyboard( + back_callback: str, + *, + page_token: str | None = None, + page_index: int = 0, + total_pages: int = 1, +) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() + _add_pagination_row( + builder, + page_token=page_token, + page_index=page_index, + total_pages=total_pages, + ) builder.button(text="Назад", callback_data=back_callback) builder.button(text="Админ-панель", callback_data="admin:open") builder.button(text="Пользовательское меню", callback_data="help:open") - builder.adjust(2, 1) + if total_pages > 1: + builder.adjust(3, 2, 1) + else: + builder.adjust(2, 1) return builder.as_markup() diff --git a/src/glitchup_bot/models/__init__.py b/src/glitchup_bot/models/__init__.py index eeb744f..a78d6c2 100644 --- a/src/glitchup_bot/models/__init__.py +++ b/src/glitchup_bot/models/__init__.py @@ -1,3 +1,4 @@ +from glitchup_bot.models.admins import BotAdmin from glitchup_bot.models.base import Base from glitchup_bot.models.issues import IssueCache from glitchup_bot.models.mute_rules import MuteRule @@ -11,6 +12,7 @@ from glitchup_bot.models.sync import SyncState __all__ = [ "Base", + "BotAdmin", "GroupSubscriberOverride", "GroupTopicOverride", "IssueCache", diff --git a/src/glitchup_bot/models/admins.py b/src/glitchup_bot/models/admins.py new file mode 100644 index 0000000..f9e9762 --- /dev/null +++ b/src/glitchup_bot/models/admins.py @@ -0,0 +1,14 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, Integer, func +from sqlalchemy.orm import Mapped, mapped_column + +from glitchup_bot.models.base import Base + + +class BotAdmin(Base): + __tablename__ = "bot_admins" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) diff --git a/src/glitchup_bot/services/admins.py b/src/glitchup_bot/services/admins.py new file mode 100644 index 0000000..17ce61a --- /dev/null +++ b/src/glitchup_bot/services/admins.py @@ -0,0 +1,63 @@ +from sqlalchemy import select + +from glitchup_bot.config import settings +from glitchup_bot.models.admins import BotAdmin +from glitchup_bot.models.database import get_session_factory + + +async def list_runtime_admins() -> list[BotAdmin]: + async with get_session_factory()() as session: + result = await session.execute(select(BotAdmin).order_by(BotAdmin.user_id)) + return list(result.scalars().all()) + + +async def list_effective_admins() -> list[tuple[int, str]]: + sources_by_user: dict[int, set[str]] = {} + + for user_id in settings.telegram_admin_ids: + sources_by_user.setdefault(user_id, set()).add("env") + + for record in await list_runtime_admins(): + sources_by_user.setdefault(record.user_id, set()).add("runtime") + + return [ + (user_id, " + ".join(sorted(sources))) + for user_id, sources in sorted(sources_by_user.items(), key=lambda item: item[0]) + ] + + +async def is_admin(user_id: int | None) -> bool: + if user_id is None: + return False + if user_id in settings.telegram_admin_ids: + return True + + runtime_admins = await list_runtime_admins() + if any(record.user_id == user_id for record in runtime_admins): + return True + + return not settings.telegram_admin_ids and not runtime_admins + + +async def add_admin(user_id: int) -> bool: + async with get_session_factory()() as session: + result = await session.execute(select(BotAdmin).where(BotAdmin.user_id == user_id)) + record = result.scalar_one_or_none() + if record is not None: + return False + + session.add(BotAdmin(user_id=user_id)) + await session.commit() + return True + + +async def remove_admin(user_id: int) -> bool: + async with get_session_factory()() as session: + result = await session.execute(select(BotAdmin).where(BotAdmin.user_id == user_id)) + record = result.scalar_one_or_none() + if record is None: + return False + + await session.delete(record) + await session.commit() + return True