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