From 3f176902a2af5e170af69ed4a298beb626504df0 Mon Sep 17 00:00:00 2001 From: Verum Date: Mon, 30 Mar 2026 17:47:43 +0700 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=A0=D0=90=D0=A1=D0=9E=D0=A2=D0=90?= =?UTF-8?q?=D0=90=D0=90=D0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/glitchup_bot/bot/handlers/commands.py | 1002 ++++++++++++++------- src/glitchup_bot/bot/keyboards.py | 102 ++- 2 files changed, 739 insertions(+), 365 deletions(-) diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py index 7a2388e..ee3dc9d 100644 --- a/src/glitchup_bot/bot/handlers/commands.py +++ b/src/glitchup_bot/bot/handlers/commands.py @@ -3,10 +3,21 @@ from collections.abc import Awaitable, Callable from html import escape from aiogram import F, Router +from aiogram.exceptions import TelegramBadRequest from aiogram.filters import Command from aiogram.types import CallbackQuery, Message -from glitchup_bot.bot.keyboards import admin_menu_keyboard, help_menu_keyboard +from glitchup_bot.bot.keyboards import ( + admin_home_keyboard, + admin_overview_keyboard, + admin_result_keyboard, + admin_sync_keyboard, + help_home_keyboard, + help_monitoring_keyboard, + help_releases_keyboard, + help_result_keyboard, + help_subscriptions_keyboard, +) from glitchup_bot.config import settings from glitchup_bot.services.digest_builder import ( build_digest, @@ -50,6 +61,163 @@ def _is_admin_user(user_id: int | None) -> bool: return settings.is_admin(user_id) +def _help_text(is_admin: bool) -> str: + lines = [ + "GlitchUp Bot", + "", + "Понятное меню для просмотра ошибок, релизов и подписок.", + "", + "Что можно сделать:", + "• открыть сводку за неделю или за сегодня", + "• посмотреть топ самых шумных и старых issues", + "• проверить последние релизы", + "• включить или отключить личные уведомления", + "• узнать статус последней синхронизации", + "", + "Быстрые команды:", + "• /week, /today, /top, /stale", + "• /releases, /release <version>", + "• /project <slug>", + "• /subscribe backend|frontend", + "• /unsubscribe backend|frontend", + ] + + if is_admin: + lines.extend( + [ + "", + "Для админа:", + "• /admin — панель управления", + "• /sync — принудительная синхронизация", + "• /ownership — текущее распределение routing-настроек", + "• /mute_list — список mute rules", + ] + ) + + return "\n".join(lines) + + +def _help_monitoring_text() -> str: + return "\n".join( + [ + "Обзор и мониторинг", + "", + "Здесь собраны основные срезы по состоянию проектов.", + "", + "• Сводка за неделю — общая картина по новым issues и regressions", + "• Сегодня — свежие проблемы за текущий день", + "• Топ issues — самые шумные ошибки по числу событий", + "• Старые issues — незакрытые проблемы, которые давно висят", + ] + ) + + +def _help_releases_text() -> str: + return "\n".join( + [ + "Релизы и версии", + "", + "Раздел помогает быстро понять, после каких релизов появились незакрытые issues.", + "", + "• Список релизов покажет версии, у которых есть активные проблемы", + "• Как открыть релиз подскажет, как перейти к деталям по конкретной версии", + ] + ) + + +def _help_release_guide_text() -> str: + return "\n".join( + [ + "Как смотреть детали релиза", + "", + "1. Открой Список релизов и скопируй нужную версию.", + "2. Выполни команду /release <version>.", + "3. Бот покажет issues, связанные именно с этим релизом.", + "", + "Пример: /release 2026.03.27", + ] + ) + + +def _help_subscriptions_text() -> str: + return "\n".join( + [ + "Подписки", + "", + "Здесь можно управлять личными уведомлениями в DM.", + "", + "• backend — проблемы серверной части", + "• frontend — проблемы клиентских приложений и web", + "", + "Подписка добавляет вас в runtime-настройки без редактирования `.env`.", + ] + ) + + +def _admin_text() -> str: + return "\n".join( + [ + "Админ-панель GlitchUp Bot", + "", + "Панель разделена на понятные зоны, чтобы не искать нужное действие в длинном списке.", + "", + "• Центр синхронизации — запуск sync и проверка статуса", + "• Сводки и мониторинг — основные обзорные экраны", + "• Ownership и topics — текущая маршрутизация", + "• Mute rules — правила скрытия шумных событий", + ] + ) + + +def _admin_sync_text() -> str: + return "\n".join( + [ + "Центр синхронизации", + "", + "Отсюда удобно запускать ручной sync и смотреть, " + "когда данные обновлялись в последний раз.", + ] + ) + + +def _admin_overview_text() -> str: + return "\n".join( + [ + "Сводки и мониторинг", + "", + "Быстрый доступ к основным обзорным экранам для ручной проверки состояния проектов.", + ] + ) + + +def _admin_guide_text() -> str: + return "\n".join( + [ + "Подсказка по админке", + "", + "Через кнопки удобно смотреть состояние системы, " + "а точечные настройки меняются командами.", + "", + "• /owner slug backend — переназначить проект в группу", + "• /topic backend 123 — сменить topic override", + "• /mute_add payment.*timeout — добавить mute rule", + ] + ) + + +def _sync_summary_text(summary) -> str: + return "\n".join( + [ + "Sync завершён", + "", + f"• проектов: {summary.project_count}", + f"• issues: {summary.issue_count}", + f"• помечено resolved: {summary.resolved_count}", + f"• время: {escape(summary.synced_at.isoformat())}", + ] + ) + + async def _require_admin(message: Message) -> bool: if _is_admin_user(_sender_id(message)): return True @@ -66,80 +234,6 @@ async def _require_admin_callback(callback: CallbackQuery) -> bool: return False -def _help_text(is_admin: bool) -> str: - lines = [ - "GlitchUp Bot Help", - "", - "Быстрые действия доступны кнопками ниже.", - "", - "Пользовательские команды:", - "• /week — digest за неделю", - "• /today — новые issues за сегодня", - "• /project <slug> — сводка по проекту", - "• /top — самые шумные issues", - "• /stale — старые незакрытые issues", - "• /releases — список релизов с issues", - "• /release <version> — детали по релизу", - "• /sync_status — статус последней синхронизации", - "• /subscribe <backend|frontend> — подписка на DM", - "• /unsubscribe <backend|frontend> — отписка от DM", - ] - - if is_admin: - lines.extend( - [ - "", - "Админ-команды:", - "• /admin — открыть панель управления", - "• /sync — принудительный sync", - "• /ownership — показать overrides", - "• /owner <slug> <backend|frontend>", - "• /owner_reset <slug>", - "• /topic <backend|frontend|digest> <topic_id>", - "• /topic_reset <backend|frontend|digest>", - "• /mute_add <regex>", - "• /mute_list", - "• /mute_del <id>", - ] - ) - - lines.extend( - [ - "", - "Как пользоваться:", - "1. Открой /help и выбери нужный раздел кнопками.", - "2. Для ежедневной работы достаточно кнопок digest/today/top/stale/releases.", - "3. Для администрирования используй /admin.", - ] - ) - - return "\n".join(lines) - - -def _admin_text() -> str: - return "\n".join( - [ - "Админ-панель GlitchUp Bot", - "", - "Основные действия доступны кнопками ниже.", - "", - "Быстрые действия:", - "• Запустить sync", - "• Посмотреть sync status", - "• Посмотреть ownership и mute rules", - "• Открыть основные сводки", - "", - "Команды настройки:", - "• /owner <slug> <backend|frontend>", - "• /owner_reset <slug>", - "• /topic <backend|frontend|digest> <topic_id>", - "• /topic_reset <backend|frontend|digest>", - "• /mute_add <regex>", - "• /mute_del <id>", - ] - ) - - async def _answer_text( target: Message | CallbackQuery, text: str, @@ -161,293 +255,103 @@ async def _answer_text( ) +async def _show_callback_screen( + callback: CallbackQuery, + text: str, + *, + reply_markup=None, + disable_web_page_preview: bool = True, +) -> None: + if callback.message is None: + return + + try: + await callback.message.edit_text( + text, + reply_markup=reply_markup, + disable_web_page_preview=disable_web_page_preview, + ) + except TelegramBadRequest as exc: + if "message is not modified" in str(exc).lower(): + return + await callback.message.answer( + text, + reply_markup=reply_markup, + disable_web_page_preview=disable_web_page_preview, + ) + + +async def _deliver_text( + target: Message | CallbackQuery, + text: str, + *, + reply_markup=None, + disable_web_page_preview: bool = True, +) -> None: + if isinstance(target, CallbackQuery): + await _show_callback_screen( + target, + text, + reply_markup=reply_markup, + disable_web_page_preview=disable_web_page_preview, + ) + return + + await _answer_text( + target, + text, + reply_markup=reply_markup, + disable_web_page_preview=disable_web_page_preview, + ) + + async def _handle_subscription_action( target: Message | CallbackQuery, group_name: str, action: str, user_id: int | None, + *, + reply_markup=None, ) -> None: if user_id is None: - await _answer_text(target, "Не удалось определить пользователя.") + await _deliver_text( + target, + "Не удалось определить пользователя.", + reply_markup=reply_markup, + ) return if action == "subscribe": await add_subscriber(group_name, user_id) - await _answer_text(target, f"Подписка на {escape(group_name)} включена.") + await _deliver_text( + target, + f"Подписка на {escape(group_name)} включена.", + reply_markup=reply_markup, + ) return removed = await remove_subscriber(group_name, user_id) if not removed: - await _answer_text(target, "Runtime-подписка не найдена.") + await _deliver_text(target, "Runtime-подписка не найдена.", reply_markup=reply_markup) return - await _answer_text(target, f"Подписка на {escape(group_name)} отключена.") + await _deliver_text( + target, + f"Подписка на {escape(group_name)} отключена.", + reply_markup=reply_markup, + ) async def _run_summary_action( target: Message | CallbackQuery, loader: Callable[[], Awaitable[str]], + *, + reply_markup=None, ) -> None: - await _answer_text(target, await loader()) + await _deliver_text(target, await loader(), reply_markup=reply_markup) -@router.message(Command("start")) -async def cmd_start(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - text = ( - "GlitchUp Bot\n\nБот запущен и готов к работе.\nДля удобной навигации открой /help." - ) - await message.answer( - text, - reply_markup=help_menu_keyboard(is_admin), - disable_web_page_preview=True, - ) - - -@router.message(Command("help")) -async def cmd_help(message: Message) -> None: - is_admin = _is_admin_user(_sender_id(message)) - await message.answer( - _help_text(is_admin), - reply_markup=help_menu_keyboard(is_admin), - disable_web_page_preview=True, - ) - - -@router.message(Command("admin")) -async def cmd_admin(message: Message) -> None: - if not await _require_admin(message): - return - - await message.answer( - _admin_text(), - reply_markup=admin_menu_keyboard(), - disable_web_page_preview=True, - ) - - -@router.callback_query(F.data.startswith("help:")) -async def cb_help_actions(callback: CallbackQuery) -> None: - data = callback.data or "" - action = data.removeprefix("help:") - await callback.answer() - - if action == "week": - await _run_summary_action(callback, lambda: build_digest(refresh=True)) - return - if action == "today": - await _run_summary_action(callback, lambda: build_today_summary(refresh=True)) - return - if action == "top": - await _run_summary_action(callback, lambda: build_top_issues(refresh=True)) - return - if action == "stale": - await _run_summary_action(callback, lambda: build_stale_issues(refresh=True)) - return - if action == "releases": - await _run_summary_action(callback, lambda: build_release_summary(refresh=True)) - return - if action == "sync_status": - await _run_summary_action(callback, build_sync_status) - return - if action.startswith("sub:"): - await _handle_subscription_action( - callback, - action.split(":", 1)[1], - "subscribe", - _callback_sender_id(callback), - ) - return - if action.startswith("unsub:"): - await _handle_subscription_action( - callback, - action.split(":", 1)[1], - "unsubscribe", - _callback_sender_id(callback), - ) - return - - -@router.callback_query(F.data == "admin:open") -async def cb_admin_open(callback: CallbackQuery) -> None: - if not await _require_admin_callback(callback): - return - - await callback.answer() - await callback.message.answer( - _admin_text(), - reply_markup=admin_menu_keyboard(), - disable_web_page_preview=True, - ) - - -@router.callback_query(F.data.startswith("admin:")) -async def cb_admin_actions(callback: CallbackQuery) -> None: - if not await _require_admin_callback(callback): - return - - action = (callback.data or "").removeprefix("admin:") - await callback.answer() - - if action == "sync": - summary = await run_manual_sync() - await callback.message.answer( - "Sync завершён\n\n" - f"• проектов: {summary.project_count}\n" - f"• issues: {summary.issue_count}\n" - f"• помечено resolved: {summary.resolved_count}\n" - f"• время: {escape(summary.synced_at.isoformat())}", - disable_web_page_preview=True, - ) - return - if action == "sync_status": - await _run_summary_action(callback, build_sync_status) - return - if action == "ownership": - await cmd_ownership(callback.message) - return - if action == "mute_list": - await cmd_mute_list(callback.message) - return - if action == "releases": - await _run_summary_action(callback, lambda: build_release_summary(refresh=True)) - return - if action == "today": - await _run_summary_action(callback, lambda: build_today_summary(refresh=True)) - return - if action == "week": - await _run_summary_action(callback, lambda: build_digest(refresh=True)) - return - if action == "top": - await _run_summary_action(callback, lambda: build_top_issues(refresh=True)) - return - if action == "stale": - await _run_summary_action(callback, lambda: build_stale_issues(refresh=True)) - return - if action == "guide": - await callback.message.answer( - "\n".join( - [ - "Подсказка по админке", - "", - "Через кнопки можно быстро смотреть состояние и запускать sync.", - "Изменение параметров делается командами:", - "• /owner slug backend", - "• /topic backend 123", - "• /mute_add payment.*timeout", - ] - ), - disable_web_page_preview=True, - ) - return - - -@router.message(Command("week")) -async def cmd_week(message: Message) -> None: - await message.answer(await build_digest(refresh=True), disable_web_page_preview=True) - - -@router.message(Command("today")) -async def cmd_today(message: Message) -> None: - await message.answer(await build_today_summary(refresh=True), disable_web_page_preview=True) - - -@router.message(Command("project")) -async def cmd_project(message: Message) -> None: - args = message.text.split(maxsplit=1) if message.text else [] - if len(args) < 2: - await message.answer("Использование: /project <slug>") - return - - await message.answer( - await build_project_summary(args[1].strip(), refresh=True), - disable_web_page_preview=True, - ) - - -@router.message(Command("top")) -async def cmd_top(message: Message) -> None: - await message.answer(await build_top_issues(refresh=True), disable_web_page_preview=True) - - -@router.message(Command("stale")) -async def cmd_stale(message: Message) -> None: - await message.answer(await build_stale_issues(refresh=True), disable_web_page_preview=True) - - -@router.message(Command("releases")) -async def cmd_releases(message: Message) -> None: - await message.answer(await build_release_summary(refresh=True), disable_web_page_preview=True) - - -@router.message(Command("release")) -async def cmd_release(message: Message) -> None: - args = message.text.split(maxsplit=1) if message.text else [] - if len(args) < 2: - await message.answer("Использование: /release <version>") - return - - await message.answer( - await build_release_detail(args[1].strip(), refresh=True), - disable_web_page_preview=True, - ) - - -@router.message(Command("sync_status")) -async def cmd_sync_status(message: Message) -> None: - await message.answer(await build_sync_status()) - - -@router.message(Command("sync")) -async def cmd_sync(message: Message) -> None: - if not await _require_admin(message): - return - - summary = await run_manual_sync() - await message.answer( - "Sync завершён\n\n" - f"• проектов: {summary.project_count}\n" - f"• issues: {summary.issue_count}\n" - f"• помечено resolved: {summary.resolved_count}\n" - f"• время: {escape(summary.synced_at.isoformat())}" - ) - - -@router.message(Command("subscribe")) -async def cmd_subscribe(message: Message) -> None: - args = message.text.split(maxsplit=1) if message.text else [] - if len(args) < 2: - await message.answer("Использование: /subscribe <backend|frontend>") - return - - await _handle_subscription_action( - message, - args[1].strip().lower(), - "subscribe", - _sender_id(message), - ) - - -@router.message(Command("unsubscribe")) -async def cmd_unsubscribe(message: Message) -> None: - args = message.text.split(maxsplit=1) if message.text else [] - if len(args) < 2: - await message.answer("Использование: /unsubscribe <backend|frontend>") - return - - await _handle_subscription_action( - message, - args[1].strip().lower(), - "unsubscribe", - _sender_id(message), - ) - - -@router.message(Command("ownership")) -async def cmd_ownership(message: Message) -> None: - if not await _require_admin(message): - return - +async def _ownership_text() -> str: project_overrides = await list_project_overrides() topic_overrides = await list_topic_overrides() subscriber_overrides = await list_subscriber_overrides() @@ -493,7 +397,413 @@ async def cmd_ownership(message: Message) -> None: else: lines.append("• none") - await message.answer("\n".join(lines), disable_web_page_preview=True) + return "\n".join(lines) + + +async def _mute_rules_text() -> str: + rules = await list_rules() + if not rules: + return "Mute rules не настроены." + + lines = ["Mute rules", ""] + for rule in rules: + suffix = f" — {escape(rule.description)}" if rule.description else "" + lines.append(f"• #{rule.id} {escape(rule.pattern)}{suffix}") + + return "\n".join(lines) + + +@router.message(Command("start")) +async def cmd_start(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + _help_text(is_admin), + reply_markup=help_home_keyboard(is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("help")) +async def cmd_help(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + _help_text(is_admin), + reply_markup=help_home_keyboard(is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("admin")) +async def cmd_admin(message: Message) -> None: + if not await _require_admin(message): + return + + await message.answer( + _admin_text(), + reply_markup=admin_home_keyboard(), + disable_web_page_preview=True, + ) + + +@router.callback_query(F.data.startswith("help:")) +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)) + await callback.answer() + + if action == "open": + await _show_callback_screen( + callback, + _help_text(is_admin), + reply_markup=help_home_keyboard(is_admin), + ) + return + if action == "menu:monitoring": + await _show_callback_screen( + callback, + _help_monitoring_text(), + reply_markup=help_monitoring_keyboard(is_admin), + ) + return + if action == "menu:releases": + await _show_callback_screen( + callback, + _help_releases_text(), + reply_markup=help_releases_keyboard(is_admin), + ) + return + if action == "menu:subscriptions": + await _show_callback_screen( + callback, + _help_subscriptions_text(), + reply_markup=help_subscriptions_keyboard(is_admin), + ) + return + if action == "release_guide": + await _show_callback_screen( + callback, + _help_release_guide_text(), + reply_markup=help_result_keyboard("help:menu:releases", is_admin), + ) + return + if action == "week": + await _run_summary_action( + callback, + lambda: build_digest(refresh=True), + reply_markup=help_result_keyboard("help:menu:monitoring", 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), + ) + 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), + ) + 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), + ) + 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), + ) + return + if action == "sync_status": + await _run_summary_action( + callback, + build_sync_status, + reply_markup=help_result_keyboard("help:open", is_admin), + ) + return + if action.startswith("sub:"): + await _handle_subscription_action( + callback, + action.split(":", 1)[1], + "subscribe", + _callback_sender_id(callback), + reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin), + ) + return + if action.startswith("unsub:"): + await _handle_subscription_action( + callback, + action.split(":", 1)[1], + "unsubscribe", + _callback_sender_id(callback), + reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin), + ) + return + + +@router.callback_query(F.data == "admin:open") +async def cb_admin_open(callback: CallbackQuery) -> None: + if not await _require_admin_callback(callback): + return + + await callback.answer() + await _show_callback_screen( + callback, + _admin_text(), + reply_markup=admin_home_keyboard(), + ) + + +@router.callback_query(F.data.startswith("admin:")) +async def cb_admin_actions(callback: CallbackQuery) -> None: + if not await _require_admin_callback(callback): + return + + action = (callback.data or "").removeprefix("admin:") + await callback.answer() + + if action == "menu:sync": + await _show_callback_screen( + callback, + _admin_sync_text(), + reply_markup=admin_sync_keyboard(), + ) + return + if action == "menu:overview": + await _show_callback_screen( + callback, + _admin_overview_text(), + reply_markup=admin_overview_keyboard(), + ) + return + if action == "sync": + summary = await run_manual_sync() + await _show_callback_screen( + callback, + _sync_summary_text(summary), + reply_markup=admin_result_keyboard("admin:menu:sync"), + ) + return + if action == "sync_status": + await _run_summary_action( + callback, + build_sync_status, + reply_markup=admin_result_keyboard("admin:menu:sync"), + ) + return + if action == "ownership": + await _show_callback_screen( + callback, + await _ownership_text(), + reply_markup=admin_result_keyboard("admin:open"), + ) + return + if action == "mute_list": + await _show_callback_screen( + callback, + await _mute_rules_text(), + reply_markup=admin_result_keyboard("admin:open"), + ) + return + if action == "releases": + await _run_summary_action( + callback, + lambda: build_release_summary(refresh=True), + reply_markup=admin_result_keyboard("admin:menu:overview"), + ) + return + if action == "today": + await _run_summary_action( + callback, + lambda: build_today_summary(refresh=True), + reply_markup=admin_result_keyboard("admin:menu:overview"), + ) + return + if action == "week": + await _run_summary_action( + callback, + lambda: build_digest(refresh=True), + reply_markup=admin_result_keyboard("admin:menu:overview"), + ) + return + if action == "top": + await _run_summary_action( + callback, + lambda: build_top_issues(refresh=True), + reply_markup=admin_result_keyboard("admin:menu:overview"), + ) + return + if action == "stale": + await _run_summary_action( + callback, + lambda: build_stale_issues(refresh=True), + reply_markup=admin_result_keyboard("admin:menu:overview"), + ) + return + if action == "guide": + await _show_callback_screen( + callback, + _admin_guide_text(), + reply_markup=admin_result_keyboard("admin:open"), + ) + return + + +@router.message(Command("week")) +async def cmd_week(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_digest(refresh=True), + reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("today")) +async def cmd_today(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_today_summary(refresh=True), + reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("project")) +async def cmd_project(message: Message) -> None: + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: + await message.answer("Использование: /project <slug>") + return + + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_project_summary(args[1].strip(), refresh=True), + reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("top")) +async def cmd_top(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_top_issues(refresh=True), + reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("stale")) +async def cmd_stale(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_stale_issues(refresh=True), + reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("releases")) +async def cmd_releases(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_release_summary(refresh=True), + reply_markup=help_result_keyboard("help:menu:releases", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("release")) +async def cmd_release(message: Message) -> None: + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: + await message.answer("Использование: /release <version>") + return + + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_release_detail(args[1].strip(), refresh=True), + reply_markup=help_result_keyboard("help:menu:releases", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("sync_status")) +async def cmd_sync_status(message: Message) -> None: + is_admin = _is_admin_user(_sender_id(message)) + await message.answer( + await build_sync_status(), + reply_markup=help_result_keyboard("help:open", is_admin), + disable_web_page_preview=True, + ) + + +@router.message(Command("sync")) +async def cmd_sync(message: Message) -> None: + if not await _require_admin(message): + return + + summary = await run_manual_sync() + await message.answer( + _sync_summary_text(summary), + reply_markup=admin_result_keyboard("admin:menu:sync"), + disable_web_page_preview=True, + ) + + +@router.message(Command("subscribe")) +async def cmd_subscribe(message: Message) -> None: + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: + await message.answer("Использование: /subscribe <backend|frontend>") + return + + is_admin = _is_admin_user(_sender_id(message)) + await _handle_subscription_action( + message, + args[1].strip().lower(), + "subscribe", + _sender_id(message), + reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin), + ) + + +@router.message(Command("unsubscribe")) +async def cmd_unsubscribe(message: Message) -> None: + args = message.text.split(maxsplit=1) if message.text else [] + if len(args) < 2: + await message.answer("Использование: /unsubscribe <backend|frontend>") + return + + is_admin = _is_admin_user(_sender_id(message)) + await _handle_subscription_action( + message, + args[1].strip().lower(), + "unsubscribe", + _sender_id(message), + reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin), + ) + + +@router.message(Command("ownership")) +async def cmd_ownership(message: Message) -> None: + if not await _require_admin(message): + return + + await message.answer( + await _ownership_text(), + reply_markup=admin_result_keyboard("admin:open"), + disable_web_page_preview=True, + ) @router.message(Command("owner")) @@ -587,17 +897,11 @@ async def cmd_mute_list(message: Message) -> None: if not await _require_admin(message): return - rules = await list_rules() - if not rules: - await message.answer("Mute rules не настроены.") - return - - lines = ["Mute rules", ""] - for rule in rules: - suffix = f" — {escape(rule.description)}" if rule.description else "" - lines.append(f"• #{rule.id} {escape(rule.pattern)}{suffix}") - - await message.answer("\n".join(lines), disable_web_page_preview=True) + await message.answer( + await _mute_rules_text(), + reply_markup=admin_result_keyboard("admin:open"), + disable_web_page_preview=True, + ) @router.message(Command("mute_del")) diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py index c0827f0..bdb4e98 100644 --- a/src/glitchup_bot/bot/keyboards.py +++ b/src/glitchup_bot/bot/keyboards.py @@ -2,35 +2,105 @@ from aiogram.types import InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -def help_menu_keyboard(is_admin: bool) -> InlineKeyboardMarkup: +def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring") + builder.button(text="Релизы и версии", callback_data="help:menu:releases") + builder.button(text="Подписки", callback_data="help:menu:subscriptions") + builder.button(text="Статус синхронизации", callback_data="help:sync_status") + if is_admin: + builder.button(text="Админ-панель", callback_data="admin:open") + builder.adjust(2, 2, 1) + return builder.as_markup() + + +def help_monitoring_keyboard(is_admin: bool) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Сводка за неделю", callback_data="help:week") builder.button(text="Сегодня", callback_data="help:today") builder.button(text="Топ issues", callback_data="help:top") builder.button(text="Старые issues", callback_data="help:stale") - builder.button(text="Релизы", callback_data="help:releases") - builder.button(text="Статус sync", callback_data="help:sync_status") - builder.button(text="Подписка backend", callback_data="help:sub:backend") - builder.button(text="Подписка frontend", callback_data="help:sub:frontend") - builder.button(text="Отписка backend", callback_data="help:unsub:backend") - builder.button(text="Отписка frontend", callback_data="help:unsub:frontend") + builder.button(text="Назад", callback_data="help:open") if is_admin: builder.button(text="Админ-панель", callback_data="admin:open") - builder.adjust(2) + builder.adjust(2, 2, 1, 1) return builder.as_markup() -def admin_menu_keyboard() -> InlineKeyboardMarkup: +def help_releases_keyboard(is_admin: bool) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Список релизов", callback_data="help:releases") + builder.button(text="Как открыть релиз", callback_data="help:release_guide") + builder.button(text="Назад", callback_data="help:open") + if is_admin: + builder.button(text="Админ-панель", callback_data="admin:open") + builder.adjust(2, 1, 1) + return builder.as_markup() + + +def help_subscriptions_keyboard(is_admin: bool) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Подписаться на backend", callback_data="help:sub:backend") + builder.button(text="Подписаться на frontend", callback_data="help:sub:frontend") + builder.button(text="Отписаться от backend", callback_data="help:unsub:backend") + builder.button(text="Отписаться от frontend", callback_data="help:unsub:frontend") + builder.button(text="Назад", callback_data="help:open") + if is_admin: + builder.button(text="Админ-панель", callback_data="admin:open") + builder.adjust(2, 2, 1, 1) + return builder.as_markup() + + +def help_result_keyboard(back_callback: str, is_admin: bool) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + 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) + else: + builder.adjust(2) + return builder.as_markup() + + +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="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) + return builder.as_markup() + + +def admin_sync_keyboard() -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button(text="Запустить sync", callback_data="admin:sync") builder.button(text="Статус sync", callback_data="admin:sync_status") - builder.button(text="Ownership", callback_data="admin:ownership") - builder.button(text="Mute rules", callback_data="admin:mute_list") - builder.button(text="Релизы", callback_data="admin:releases") - builder.button(text="Today", callback_data="admin:today") - builder.button(text="Week digest", callback_data="admin:week") + builder.button(text="Назад", callback_data="admin:open") + builder.button(text="Пользовательское меню", callback_data="help:open") + builder.adjust(2, 2) + return builder.as_markup() + + +def admin_overview_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Сводка за неделю", callback_data="admin:week") + builder.button(text="Сегодня", callback_data="admin:today") builder.button(text="Топ issues", callback_data="admin:top") builder.button(text="Старые issues", callback_data="admin:stale") - builder.button(text="Инструкция", callback_data="admin:guide") - builder.adjust(2) + builder.button(text="Релизы", callback_data="admin:releases") + builder.button(text="Назад", callback_data="admin:open") + builder.adjust(2, 2, 1, 1) + return builder.as_markup() + + +def admin_result_keyboard(back_callback: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + 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) return builder.as_markup()