diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py index 5de10d8..db0cd4d 100644 --- a/src/glitchup_bot/bot/handlers/commands.py +++ b/src/glitchup_bot/bot/handlers/commands.py @@ -13,13 +13,12 @@ from aiogram.types import CallbackQuery, Message from glitchup_bot.bot.keyboards import ( admin_home_keyboard, admin_overview_keyboard, + admin_recipient_group_keyboard, + admin_recipients_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.services.admins import ( add_admin, @@ -30,8 +29,6 @@ from glitchup_bot.services.admins import ( from glitchup_bot.services.digest_builder import ( build_digest, build_project_summary, - build_release_detail, - build_release_summary, build_stale_issues, build_sync_status, build_today_summary, @@ -52,12 +49,14 @@ from glitchup_bot.services.routing import ( set_project_group, set_topic_override, ) +from glitchup_bot.services.sync_service import get_last_sync_state router = Router() logger = logging.getLogger(__name__) MAX_PAGE_CHARS = 3000 MAX_PAGE_LINES = 18 MAX_PAGINATION_SESSIONS = 200 +PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {} @dataclass(slots=True) @@ -148,97 +147,46 @@ def _result_keyboard( def _help_text(is_admin: bool) -> str: + return "\n".join( + [ + "GlitchUp Bot", + "", + "Выберите нужную сводку кнопками ниже.", + "Пользователю доступны только базовые экраны.", + "", + "• Сегодня", + "• Неделя", + "• Самые шумные", + "• Давно висят", + *(["", "• Для управления откройте админ-панель"] if is_admin else []), + ] + ) + + +async def _start_text(is_admin: bool) -> str: + state = await get_last_sync_state("api_sync") + sync_value = ( + state.last_successful_at.astimezone().strftime("%Y-%m-%d %H:%M") + if state and state.last_successful_at + else "ещё не было" + ) lines = [ "GlitchUp Bot", + f"Синхронизация: {escape(sync_value)}", "", - "Понятное меню для просмотра ошибок, релизов и подписок.", - "", - "Что можно сделать:", - "• открыть сводку за неделю или за сегодня", - "• посмотреть топ самых шумных и старых issues", - "• проверить последние релизы", - "• включить или отключить личные уведомления", - "• узнать статус последней синхронизации", - "", - "Быстрые команды:", - "• /week, /today, /top, /stale", - "• /releases, /release <version>", - "• /project <slug>", - "• /subscribe backend|frontend", - "• /unsubscribe backend|frontend", + "Откройте нужную сводку кнопками ниже.", ] - if is_admin: - lines.extend( - [ - "", - "Для админа:", - "• /admin — панель управления", - "• /sync — принудительная синхронизация", - "• /admins — список администраторов", - "• /admin_add <user_id> или reply на сообщение", - "• /admin_del <user_id>", - "• /ownership — текущее распределение routing-настроек", - "• /mute_list — список mute rules", - ] - ) - + lines.extend(["", "Администрирование доступно через кнопку ниже."]) return "\n".join(lines) -def _help_monitoring_text() -> str: +def _admin_overview_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`.", + "Быстрый доступ к основным экранам без лишних разделов.", ] ) @@ -248,13 +196,12 @@ def _admin_text() -> str: [ "Админ-панель GlitchUp Bot", "", - "Панель разделена на понятные зоны, чтобы не искать нужное действие в длинном списке.", - "", - "• Центр синхронизации — запуск sync и проверка статуса", - "• Сводки и мониторинг — основные обзорные экраны", - "• Администраторы — список и управление доступом", - "• Ownership и topics — текущая маршрутизация", - "• Mute rules — правила скрытия шумных событий", + "Здесь только практичные разделы:", + "• синхронизация", + "• основные сводки", + "• получатели по backend/frontend", + "• администраторы", + "• routing и mute rules", ] ) @@ -262,20 +209,31 @@ def _admin_text() -> str: def _admin_sync_text() -> str: return "\n".join( [ - "Центр синхронизации", + "Синхронизация", "", - "Отсюда удобно запускать ручной sync и смотреть, " + "Отсюда удобно запускать ручной sync и проверять, " "когда данные обновлялись в последний раз.", ] ) -def _admin_overview_text() -> str: +def _admin_recipients_text() -> str: return "\n".join( [ - "Сводки и мониторинг", + "Получатели уведомлений", "", - "Быстрый доступ к основным обзорным экранам для ручной проверки состояния проектов.", + "Выберите группу и назначайте Telegram ID через кнопки.", + "Самоподписка пользователей отключена.", + ] + ) + + +def _recipient_group_text(group_name: str) -> str: + return "\n".join( + [ + f"{escape(group_name.capitalize())} получатели", + "", + "Можно посмотреть список, добавить новый Telegram ID или удалить существующий.", ] ) @@ -285,8 +243,7 @@ def _admin_guide_text() -> str: [ "Подсказка по админке", "", - "Через кнопки удобно смотреть состояние системы, " - "а точечные настройки меняются командами.", + "Почти всё вынесено в кнопки и короткие сценарии.", "", "• /admin_add 123456 — добавить администратора", "• /admin_add ответом на сообщение — добавить автора сообщения", @@ -344,6 +301,18 @@ def _extract_target_user_id(message: Message) -> int | None: return None +async def _recipients_text(group_name: str) -> str: + subscribers = await resolve_subscribers(group_name) + title = "Backend" if group_name == "backend" else "Frontend" + if not subscribers: + return f"{title} получатели\n\nСписок пуст." + + lines = [f"{title} получатели", ""] + for user_id in subscribers: + lines.append(f"• {user_id}") + return "\n".join(lines) + + async def _require_admin(message: Message) -> bool: if await _is_admin_user(_sender_id(message)): return True @@ -594,7 +563,7 @@ async def _mute_rules_text() -> str: async def cmd_start(message: Message) -> None: is_admin = await _is_admin_user(_sender_id(message)) await message.answer( - _help_text(is_admin), + await _start_text(is_admin), reply_markup=help_home_keyboard(is_admin), disable_web_page_preview=True, ) @@ -636,100 +605,38 @@ async def cb_help_actions(callback: CallbackQuery) -> None: 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), - back_callback="help:menu:monitoring", + lambda: build_digest(refresh=False), + back_callback="help:open", is_admin=is_admin, ) return if action == "today": await _run_summary_action( callback, - lambda: build_today_summary(refresh=True), - back_callback="help:menu:monitoring", + lambda: build_today_summary(refresh=False), + back_callback="help:open", is_admin=is_admin, ) return if action == "top": await _run_summary_action( callback, - lambda: build_top_issues(refresh=True), - back_callback="help:menu:monitoring", + lambda: build_top_issues(refresh=False), + back_callback="help:open", is_admin=is_admin, ) return if action == "stale": await _run_summary_action( callback, - lambda: build_stale_issues(refresh=True), - back_callback="help:menu:monitoring", - is_admin=is_admin, - ) - return - if action == "releases": - await _run_summary_action( - callback, - lambda: build_release_summary(refresh=True), - back_callback="help:menu:releases", - is_admin=is_admin, - ) - return - if action == "sync_status": - await _run_summary_action( - callback, - build_sync_status, + lambda: build_stale_issues(refresh=False), back_callback="help:open", is_admin=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") @@ -767,6 +674,65 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: reply_markup=admin_overview_keyboard(), ) return + if action == "menu:recipients": + await _show_callback_screen( + callback, + _admin_recipients_text(), + reply_markup=admin_recipients_keyboard(), + ) + return + if action in {"recipients:backend", "recipients:frontend"}: + group_name = action.split(":", 1)[1] + await _show_callback_screen( + callback, + _recipient_group_text(group_name), + reply_markup=admin_recipient_group_keyboard(group_name), + ) + return + if action.startswith("recipients:list:"): + group_name = action.rsplit(":", 1)[1] + await _deliver_result( + callback, + await _recipients_text(group_name), + back_callback=f"admin:recipients:{group_name}", + is_admin=True, + admin_mode=True, + ) + return + if action.startswith("recipients:add:"): + group_name = action.rsplit(":", 1)[1] + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_RECIPIENT_ACTIONS[admin_id] = ("add", group_name) + await _show_callback_screen( + callback, + "\n".join( + [ + f"{escape(group_name.capitalize())}", + "", + "Отправьте следующим сообщением Telegram ID, который нужно добавить.", + ] + ), + reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"), + ) + return + if action.startswith("recipients:del:"): + group_name = action.rsplit(":", 1)[1] + admin_id = _callback_sender_id(callback) + if admin_id is not None: + PENDING_RECIPIENT_ACTIONS[admin_id] = ("del", group_name) + await _show_callback_screen( + callback, + "\n".join( + [ + f"{escape(group_name.capitalize())}", + "", + "Отправьте следующим сообщением Telegram ID, который нужно удалить.", + ] + ), + reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"), + ) + return if action == "admins": await _deliver_result( callback, @@ -813,19 +779,10 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: admin_mode=True, ) return - if action == "releases": - await _run_summary_action( - callback, - lambda: build_release_summary(refresh=True), - 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), + lambda: build_today_summary(refresh=False), back_callback="admin:menu:overview", is_admin=True, admin_mode=True, @@ -834,7 +791,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: if action == "week": await _run_summary_action( callback, - lambda: build_digest(refresh=True), + lambda: build_digest(refresh=False), back_callback="admin:menu:overview", is_admin=True, admin_mode=True, @@ -843,7 +800,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: if action == "top": await _run_summary_action( callback, - lambda: build_top_issues(refresh=True), + lambda: build_top_issues(refresh=False), back_callback="admin:menu:overview", is_admin=True, admin_mode=True, @@ -852,7 +809,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None: if action == "stale": await _run_summary_action( callback, - lambda: build_stale_issues(refresh=True), + lambda: build_stale_issues(refresh=False), back_callback="admin:menu:overview", is_admin=True, admin_mode=True, @@ -916,8 +873,8 @@ async def cmd_week(message: Message) -> None: is_admin = await _is_admin_user(_sender_id(message)) await _deliver_result( message, - await build_digest(refresh=True), - back_callback="help:menu:monitoring", + await build_digest(refresh=False), + back_callback="help:open", is_admin=is_admin, ) @@ -927,8 +884,8 @@ async def cmd_today(message: Message) -> None: is_admin = await _is_admin_user(_sender_id(message)) await _deliver_result( message, - await build_today_summary(refresh=True), - back_callback="help:menu:monitoring", + await build_today_summary(refresh=False), + back_callback="help:open", is_admin=is_admin, ) @@ -943,8 +900,8 @@ async def cmd_project(message: Message) -> None: is_admin = await _is_admin_user(_sender_id(message)) await _deliver_result( message, - await build_project_summary(args[1].strip(), refresh=True), - back_callback="help:menu:monitoring", + await build_project_summary(args[1].strip(), refresh=False), + back_callback="help:open", is_admin=is_admin, ) @@ -954,8 +911,8 @@ async def cmd_top(message: Message) -> None: is_admin = await _is_admin_user(_sender_id(message)) await _deliver_result( message, - await build_top_issues(refresh=True), - back_callback="help:menu:monitoring", + await build_top_issues(refresh=False), + back_callback="help:open", is_admin=is_admin, ) @@ -965,37 +922,20 @@ async def cmd_stale(message: Message) -> None: is_admin = await _is_admin_user(_sender_id(message)) await _deliver_result( message, - await build_stale_issues(refresh=True), - back_callback="help:menu:monitoring", + await build_stale_issues(refresh=False), + back_callback="help:open", is_admin=is_admin, ) @router.message(Command("releases")) async def cmd_releases(message: Message) -> None: - is_admin = await _is_admin_user(_sender_id(message)) - await _deliver_result( - message, - await build_release_summary(refresh=True), - back_callback="help:menu:releases", - is_admin=is_admin, - ) + await message.answer("Раздел релизов скрыт из упрощённого интерфейса.") @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 = await _is_admin_user(_sender_id(message)) - await _deliver_result( - message, - await build_release_detail(args[1].strip(), refresh=True), - back_callback="help:menu:releases", - is_admin=is_admin, - ) + await message.answer("Раздел релизов скрыт из упрощённого интерфейса.") @router.message(Command("sync_status")) @@ -1090,38 +1030,57 @@ async def cmd_admin_del(message: Message) -> None: ) -@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>") +@router.message(F.text) +async def cmd_pending_recipient_input(message: Message) -> None: + user_id = _sender_id(message) + if user_id is None or user_id not in PENDING_RECIPIENT_ACTIONS: + return + if not await _require_admin(message): return - is_admin = await _is_admin_user(_sender_id(message)) - await _handle_subscription_action( + raw_value = (message.text or "").strip() + if raw_value.startswith("/"): + return + if not raw_value.lstrip("-").isdigit(): + await message.answer("Нужен числовой Telegram ID.") + return + + action, group_name = PENDING_RECIPIENT_ACTIONS.pop(user_id) + target_id = int(raw_value) + + if action == "add": + await add_subscriber(group_name, target_id) + text = ( + f"Telegram ID {target_id} добавлен в группу " + f"{escape(group_name)}." + ) + else: + removed = await remove_subscriber(group_name, target_id) + text = ( + f"Telegram ID {target_id} удалён из группы " + f"{escape(group_name)}." + if removed + else f"Telegram ID {target_id} не найден в группе " + f"{escape(group_name)}." + ) + + await _deliver_result( message, - args[1].strip().lower(), - "subscribe", - _sender_id(message), - reply_markup=help_result_keyboard("help:menu:subscriptions", is_admin), + text, + back_callback=f"admin:recipients:{group_name}", + is_admin=True, + admin_mode=True, ) +@router.message(Command("subscribe")) +async def cmd_subscribe(message: Message) -> None: + await message.answer("Самоподписка отключена. Получателей настраивает администратор.") + + @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 = await _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), - ) + await message.answer("Самоподписка отключена. Получателей настраивает администратор.") @router.message(Command("ownership")) diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py index de844f9..878f0c2 100644 --- a/src/glitchup_bot/bot/keyboards.py +++ b/src/glitchup_bot/bot/keyboards.py @@ -24,50 +24,15 @@ def _add_pagination_row( 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:open") + builder.button(text="Неделя", callback_data="help:week") + builder.button(text="Самые шумные", callback_data="help:top") + builder.button(text="Давно висят", callback_data="help:stale") if is_admin: builder.button(text="Админ-панель", callback_data="admin:open") - builder.adjust(2, 2, 1, 1) - return builder.as_markup() - - -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) + builder.adjust(2, 2, 1) + else: + builder.adjust(2, 2) return builder.as_markup() @@ -104,14 +69,15 @@ def help_result_keyboard( 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:menu:overview") + builder.button(text="Синхронизация", callback_data="admin:menu:sync") + builder.button(text="Получатели", callback_data="admin:menu:recipients") builder.button(text="Администраторы", callback_data="admin:admins") - builder.button(text="Ownership и topics", callback_data="admin:ownership") + builder.button(text="Routing и 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, 2, 1) + builder.adjust(2, 2, 2, 1, 1) return builder.as_markup() @@ -127,13 +93,31 @@ def admin_sync_keyboard() -> InlineKeyboardMarkup: 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:releases") + builder.button(text="Неделя", callback_data="admin:week") + builder.button(text="Самые шумные", callback_data="admin:top") + builder.button(text="Давно висят", callback_data="admin:stale") builder.button(text="Назад", callback_data="admin:open") - builder.adjust(2, 2, 1, 1) + builder.adjust(2, 2, 1) + return builder.as_markup() + + +def admin_recipients_keyboard() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Backend", callback_data="admin:recipients:backend") + builder.button(text="Frontend", callback_data="admin:recipients:frontend") + builder.button(text="Назад", callback_data="admin:open") + builder.adjust(2, 1) + return builder.as_markup() + + +def admin_recipient_group_keyboard(group_name: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + builder.button(text="Список", callback_data=f"admin:recipients:list:{group_name}") + builder.button(text="Добавить ID", callback_data=f"admin:recipients:add:{group_name}") + builder.button(text="Удалить ID", callback_data=f"admin:recipients:del:{group_name}") + builder.button(text="Назад", callback_data="admin:menu:recipients") + builder.adjust(2, 1, 1) return builder.as_markup() diff --git a/src/glitchup_bot/main.py b/src/glitchup_bot/main.py index f3288c9..5c03f2b 100644 --- a/src/glitchup_bot/main.py +++ b/src/glitchup_bot/main.py @@ -8,6 +8,7 @@ from glitchup_bot.bot.bot import close_bot, get_bot, get_dispatcher from glitchup_bot.config import settings from glitchup_bot.glitchtip_client.client import close_glitchtip_client from glitchup_bot.models.database import dispose_engine +from glitchup_bot.services.sync_service import warm_issue_cache_on_startup from glitchup_bot.tasks.scheduler import setup_scheduler, shutdown_scheduler logging.basicConfig( @@ -37,6 +38,7 @@ async def shutdown_resources() -> None: async def main() -> None: logger.info("GlitchUp Bot starting") + await warm_issue_cache_on_startup() setup_scheduler() api_task = asyncio.create_task(start_api(), name="api") diff --git a/src/glitchup_bot/services/digest_builder.py b/src/glitchup_bot/services/digest_builder.py index 979a788..e0b4d97 100644 --- a/src/glitchup_bot/services/digest_builder.py +++ b/src/glitchup_bot/services/digest_builder.py @@ -1,4 +1,6 @@ +import re from collections import defaultdict +from dataclasses import dataclass from datetime import UTC, datetime, timedelta from html import escape @@ -11,12 +13,96 @@ from glitchup_bot.services.sync_service import ( ) -def _issue_label(issue: IssueSnapshot) -> str: - title = escape(issue.title) - slug = escape(issue.project_slug) - if issue.link: - return f'{title} ({slug})' - return f"{title} ({slug})" +@dataclass(slots=True) +class AggregatedIssue: + project_slug: str + signature: str + title: str + link: str | None + first_seen: datetime | None + last_seen: datetime | None + event_count: int + occurrences: int + is_regression: bool + release: str | None + + +_TIMESTAMP_PREFIX_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s+\|\s+[^|]+\|\s+[^-]+-\s+" +) + + +def _clean_issue_title(title: str) -> str: + cleaned = _TIMESTAMP_PREFIX_RE.sub("", title).strip() + if cleaned: + return cleaned + if " - " in title: + tail = title.split(" - ", 1)[1].strip() + if tail: + return tail + return title.strip() or "unknown" + + +def _issue_label(title: str, project_slug: str, link: str | None) -> str: + safe_title = escape(title) + safe_slug = escape(project_slug) + if link: + return f'{safe_title} ({safe_slug})' + return f"{safe_title} ({safe_slug})" + + +def _format_issue_line(issue: AggregatedIssue, *, include_seen: bool = False) -> str: + parts = [f"• {issue.event_count} событий"] + if issue.occurrences > 1: + parts.append(f"{issue.occurrences} повторов") + parts.append(_issue_label(issue.title, issue.project_slug, issue.link)) + line = " — ".join([parts[0], ", ".join(parts[1:])]) + if include_seen and issue.last_seen: + return f"{line}\nпоследнее: {escape(issue.last_seen.strftime('%Y-%m-%d %H:%M'))}" + return line + + +def _aggregate_issues(issues: list[IssueSnapshot]) -> list[AggregatedIssue]: + grouped: dict[tuple[str, str], AggregatedIssue] = {} + + for issue in issues: + cleaned_title = _clean_issue_title(issue.title) + signature = (issue.culprit or cleaned_title).strip().lower() + key = (issue.project_slug, signature) + current = grouped.get(key) + + if current is None: + grouped[key] = AggregatedIssue( + project_slug=issue.project_slug, + signature=signature, + title=cleaned_title, + link=issue.link, + first_seen=issue.first_seen, + last_seen=issue.last_seen, + event_count=issue.event_count, + occurrences=1, + is_regression=issue.is_regression, + release=issue.release, + ) + continue + + current.event_count += issue.event_count + current.occurrences += 1 + current.is_regression = current.is_regression or issue.is_regression + if issue.first_seen and ( + current.first_seen is None or issue.first_seen < current.first_seen + ): + current.first_seen = issue.first_seen + if issue.last_seen and (current.last_seen is None or issue.last_seen > current.last_seen): + current.last_seen = issue.last_seen + if issue.link: + current.link = issue.link + if not current.link and issue.link: + current.link = issue.link + if not current.release and issue.release: + current.release = issue.release + + return list(grouped.values()) async def _load_issues( @@ -35,41 +121,32 @@ async def _load_issues( async def build_digest(refresh: bool = True) -> str: now = datetime.now(UTC) week_ago = now - timedelta(days=7) - issues = await _load_issues(refresh=refresh) + aggregated = _aggregate_issues(await _load_issues(refresh=refresh)) - new_issues: list[IssueSnapshot] = [] - regressions: list[IssueSnapshot] = [] - stale: list[IssueSnapshot] = [] - by_release: dict[str, list[IssueSnapshot]] = defaultdict(list) + new_issues = [ + issue for issue in aggregated if issue.first_seen and issue.first_seen >= week_ago + ] + regressions = [issue for issue in aggregated if issue.is_regression] + stale = [issue for issue in aggregated if issue.first_seen and issue.first_seen < week_ago] project_stats: dict[str, dict[str, int]] = defaultdict( - lambda: {"new": 0, "regression": 0, "events": 0} + lambda: {"issues": 0, "regression": 0, "events": 0} ) - top_noisy = sorted(issues, key=lambda item: item.event_count, reverse=True) + top_noisy = sorted(aggregated, key=lambda item: item.event_count, reverse=True) - for issue in issues: + for issue in aggregated: if issue.first_seen and issue.first_seen >= week_ago: - new_issues.append(issue) - project_stats[issue.project_slug]["new"] += 1 - + project_stats[issue.project_slug]["issues"] += 1 if issue.is_regression: - regressions.append(issue) project_stats[issue.project_slug]["regression"] += 1 - - if issue.first_seen and issue.first_seen < week_ago: - stale.append(issue) - - if issue.release: - by_release[issue.release].append(issue) - project_stats[issue.project_slug]["events"] += issue.event_count lines = [ - "📊 GlitchTip digest за неделю", + "📊 Сводка за неделю", "", "Всего:", - f"• новых issues: {len(new_issues)}", + f"• новых групп проблем: {len(new_issues)}", f"• regressions: {len(regressions)}", - f"• unresolved > 7 дней: {len(stale)}", + f"• старых групп > 7 дней: {len(stale)}", "", ] @@ -77,47 +154,35 @@ async def build_digest(refresh: bool = True) -> str: lines.append("По проектам:") for slug, stats in sorted( project_stats.items(), - key=lambda item: (item[1]["new"], item[1]["regression"], item[0]), - reverse=True, - )[:5]: - parts = [f"{stats['new']} новых"] - if stats["regression"]: - parts.append(f"{stats['regression']} regression") - lines.append(f"• {escape(slug)} — {', '.join(parts)}") - lines.append("") - - if by_release: - lines.append("После релизов:") - for release_name, release_issues in sorted( - by_release.items(), - key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])), + key=lambda item: (item[1]["issues"], item[1]["events"], item[0]), reverse=True, )[:5]: lines.append( - f"• {escape(release_name)} — " - f"{len(release_issues)} issues, " - f"{sum(issue.event_count for issue in release_issues)} событий" + f"• {escape(slug)} — {stats['issues']} групп, {stats['events']} событий" ) lines.append("") if top_noisy: - lines.append("Топ шумных:") + lines.append("Самые шумные:") for issue in top_noisy[:5]: - lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий") + lines.append(_format_issue_line(issue)) lines.append("") if stale: - lines.append("Хвосты:") + lines.append("Давно висят:") for issue in sorted( stale, key=lambda item: (now - (item.first_seen or now)).days, reverse=True, )[:5]: age = (now - (issue.first_seen or now)).days - lines.append(f"• {_issue_label(issue)} — {age} дн. без разбора") + lines.append( + f"• {age} дн. — " + f"{_issue_label(issue.title, issue.project_slug, issue.link)}" + ) if len(lines) == 7: - lines.append("Все чисто! Новых проблем нет.") + lines.append("Все спокойно. Новых проблем нет.") return "\n".join(lines) @@ -125,36 +190,41 @@ async def build_digest(refresh: bool = True) -> str: async def build_today_summary(refresh: bool = True) -> str: now = datetime.now(UTC) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) - issues = await _load_issues(refresh=refresh) + aggregated = _aggregate_issues(await _load_issues(refresh=refresh)) today_issues = [ - issue for issue in issues if issue.first_seen and issue.first_seen >= today_start + issue for issue in aggregated if issue.first_seen and issue.first_seen >= today_start ] if not today_issues: - return "За сегодня новых issues не обнаружено." + return "За сегодня новых проблем не обнаружено." - lines = [f"📋 Сегодня: {len(today_issues)} новых issues", ""] + total_events = sum(issue.event_count for issue in today_issues) + lines = [ + f"📋 Сегодня: {len(today_issues)} групп проблем", + f"• событий за сегодня: {total_events}", + "", + ] for issue in sorted( today_issues, - key=lambda item: item.first_seen or now, + key=lambda item: (item.event_count, item.last_seen or now), reverse=True, )[:10]: - lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий") + lines.append(_format_issue_line(issue, include_seen=True)) return "\n".join(lines) async def build_project_summary(project_slug: str, refresh: bool = True) -> str: - issues = await _load_issues([project_slug], refresh=refresh) - if not issues: - return f"{escape(project_slug)}: нет unresolved issues." + aggregated = _aggregate_issues(await _load_issues([project_slug], refresh=refresh)) + if not aggregated: + return f"{escape(project_slug)}: нет активных проблем." - total_events = sum(issue.event_count for issue in issues) - regressions = [issue for issue in issues if issue.is_regression] + total_events = sum(issue.event_count for issue in aggregated) + regressions = [issue for issue in aggregated if issue.is_regression] lines = [ f"📦 {escape(project_slug)}", "", - f"• unresolved issues: {len(issues)}", + f"• групп проблем: {len(aggregated)}", f"• всего событий: {total_events}", f"• regressions: {len(regressions)}", "", @@ -162,23 +232,23 @@ async def build_project_summary(project_slug: str, refresh: bool = True) -> str: ] for issue in sorted( - issues, + aggregated, key=lambda item: item.last_seen or datetime.min.replace(tzinfo=UTC), reverse=True, )[:5]: - lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий") + lines.append(_format_issue_line(issue, include_seen=True)) return "\n".join(lines) async def build_top_issues(limit: int = 10, refresh: bool = True) -> str: - issues = await _load_issues(refresh=refresh) - if not issues: - return "Нет unresolved issues." + aggregated = _aggregate_issues(await _load_issues(refresh=refresh)) + if not aggregated: + return "Нет активных проблем." - lines = ["🔊 Топ шумных issues", ""] - for issue in sorted(issues, key=lambda item: item.event_count, reverse=True)[:limit]: - lines.append(f"• {issue.event_count} событий — {_issue_label(issue)}") + lines = ["🔊 Самые шумные проблемы", ""] + for issue in sorted(aggregated, key=lambda item: item.event_count, reverse=True)[:limit]: + lines.append(_format_issue_line(issue, include_seen=True)) return "\n".join(lines) @@ -189,44 +259,48 @@ async def build_stale_issues( refresh: bool = True, ) -> str: now = datetime.now(UTC) - issues = await _load_issues(refresh=refresh) + aggregated = _aggregate_issues(await _load_issues(refresh=refresh)) stale = [ - issue for issue in issues if issue.first_seen and (now - issue.first_seen).days >= min_days + issue + for issue in aggregated + if issue.first_seen and (now - issue.first_seen).days >= min_days ] if not stale: - return "Нет старых незакрытых issues (> 7 дней)." + return "Нет старых незакрытых проблем." - lines = ["🕸 Старые незакрытые issues", ""] + lines = ["🕸 Старые незакрытые проблемы", ""] for issue in sorted( stale, key=lambda item: now - (item.first_seen or now), reverse=True, )[:limit]: age = (now - (issue.first_seen or now)).days - lines.append(f"• {age} дн. — {_issue_label(issue)} ({issue.event_count} событий)") + lines.append( + f"• {age} дн. — {_issue_label(issue.title, issue.project_slug, issue.link)}" + ) return "\n".join(lines) async def build_release_summary(limit: int = 10, refresh: bool = True) -> str: - issues = await _load_issues(refresh=refresh) - grouped: dict[str, list[IssueSnapshot]] = defaultdict(list) - for issue in issues: + aggregated = _aggregate_issues(await _load_issues(refresh=refresh)) + grouped: dict[str, list[AggregatedIssue]] = defaultdict(list) + for issue in aggregated: if issue.release: grouped[issue.release].append(issue) if not grouped: return "Релизы в данных GlitchTip не обнаружены." - lines = ["🚀 Релизы с незакрытыми issue", ""] + lines = ["🚀 Релизы с проблемами", ""] for release_name, release_issues in sorted( grouped.items(), key=lambda item: (len(item[1]), sum(issue.event_count for issue in item[1])), reverse=True, )[:limit]: lines.append( - f"• {escape(release_name)} — {len(release_issues)} issues, " + f"• {escape(release_name)} — {len(release_issues)} групп, " f"{sum(issue.event_count for issue in release_issues)} событий" ) @@ -234,15 +308,14 @@ async def build_release_summary(limit: int = 10, refresh: bool = True) -> str: async def build_release_detail(release_name: str, refresh: bool = True) -> str: - issues = await _load_issues(refresh=refresh) - matched = [issue for issue in issues if issue.release == release_name] + aggregated = _aggregate_issues(await _load_issues(refresh=refresh)) + matched = [issue for issue in aggregated if issue.release == release_name] if not matched: - return f"Для релиза {escape(release_name)} незакрытых issues не найдено." + return f"Для релиза {escape(release_name)} активных проблем не найдено." lines = [f"🚀 Релиз {escape(release_name)}", ""] for issue in sorted(matched, key=lambda item: item.event_count, reverse=True)[:10]: - suffix = " regression" if issue.is_regression else "" - lines.append(f"• {_issue_label(issue)} — {issue.event_count} событий{suffix}") + lines.append(_format_issue_line(issue, include_seen=True)) return "\n".join(lines) diff --git a/src/glitchup_bot/services/sync_service.py b/src/glitchup_bot/services/sync_service.py index 4c2d761..7839d2c 100644 --- a/src/glitchup_bot/services/sync_service.py +++ b/src/glitchup_bot/services/sync_service.py @@ -1,4 +1,5 @@ import logging +from asyncio import Lock, wait_for from collections import defaultdict from dataclasses import dataclass from datetime import UTC, datetime @@ -13,6 +14,7 @@ from glitchup_bot.models.issues import IssueCache from glitchup_bot.models.sync import SyncState logger = logging.getLogger(__name__) +_sync_lock = Lock() @dataclass(slots=True) @@ -106,86 +108,109 @@ async def mark_sync_success(source: str) -> None: async def sync_issues(project_slugs: list[str] | None = None) -> SyncSummary: - slugs = project_slugs or _configured_project_slugs() - client = get_glitchtip_client() - snapshots: list[IssueSnapshot] = [] + async with _sync_lock: + slugs = project_slugs or _configured_project_slugs() + client = get_glitchtip_client() + snapshots: list[IssueSnapshot] = [] - for slug in slugs: - issues = await client.list_issues(slug) - snapshots.extend( - _normalize_issue(slug, issue) for issue in issues if issue.get("id") is not None + for slug in slugs: + issues = await client.list_issues(slug) + snapshots.extend( + _normalize_issue(slug, issue) for issue in issues if issue.get("id") is not None + ) + + issue_ids_by_slug: dict[str, set[int]] = defaultdict(set) + for snapshot in snapshots: + issue_ids_by_slug[snapshot.project_slug].add(snapshot.issue_id) + + now = datetime.now(UTC) + resolved_count = 0 + + async with get_session_factory()() as session: + existing_rows = ( + await session.execute(select(IssueCache).where(IssueCache.project_slug.in_(slugs))) + ).scalars() + existing_by_id = {row.glitchtip_issue_id: row for row in existing_rows} + + for snapshot in snapshots: + row = existing_by_id.get(snapshot.issue_id) + if row is None: + row = IssueCache( + glitchtip_issue_id=snapshot.issue_id, + project_slug=snapshot.project_slug, + title=snapshot.title, + culprit=snapshot.culprit, + level=snapshot.level, + status=snapshot.status, + first_seen=snapshot.first_seen, + last_seen=snapshot.last_seen, + event_count=snapshot.event_count, + is_regression=snapshot.is_regression, + link=snapshot.link, + release=snapshot.release, + ) + session.add(row) + continue + + row.project_slug = snapshot.project_slug + row.title = snapshot.title + row.culprit = snapshot.culprit + row.level = snapshot.level + row.status = snapshot.status + row.first_seen = snapshot.first_seen + row.last_seen = snapshot.last_seen + row.event_count = snapshot.event_count + row.is_regression = snapshot.is_regression + row.link = snapshot.link + row.release = snapshot.release + row.updated_at = now + + for row in existing_by_id.values(): + if row.glitchtip_issue_id in issue_ids_by_slug[row.project_slug]: + continue + if row.status != "resolved": + row.status = "resolved" + row.updated_at = now + resolved_count += 1 + + result = await session.execute(select(SyncState).where(SyncState.source == "api_sync")) + state = result.scalar_one_or_none() + if state is None: + state = SyncState(source="api_sync", last_successful_at=now) + session.add(state) + else: + state.last_successful_at = now + + await session.commit() + + return SyncSummary( + project_count=len(slugs), + issue_count=len(snapshots), + resolved_count=resolved_count, + synced_at=now, ) - issue_ids_by_slug: dict[str, set[int]] = defaultdict(set) - for snapshot in snapshots: - issue_ids_by_slug[snapshot.project_slug].add(snapshot.issue_id) - now = datetime.now(UTC) - resolved_count = 0 - - async with get_session_factory()() as session: - existing_rows = ( - await session.execute(select(IssueCache).where(IssueCache.project_slug.in_(slugs))) - ).scalars() - existing_by_id = {row.glitchtip_issue_id: row for row in existing_rows} - - for snapshot in snapshots: - row = existing_by_id.get(snapshot.issue_id) - if row is None: - row = IssueCache( - glitchtip_issue_id=snapshot.issue_id, - project_slug=snapshot.project_slug, - title=snapshot.title, - culprit=snapshot.culprit, - level=snapshot.level, - status=snapshot.status, - first_seen=snapshot.first_seen, - last_seen=snapshot.last_seen, - event_count=snapshot.event_count, - is_regression=snapshot.is_regression, - link=snapshot.link, - release=snapshot.release, - ) - session.add(row) - continue - - row.project_slug = snapshot.project_slug - row.title = snapshot.title - row.culprit = snapshot.culprit - row.level = snapshot.level - row.status = snapshot.status - row.first_seen = snapshot.first_seen - row.last_seen = snapshot.last_seen - row.event_count = snapshot.event_count - row.is_regression = snapshot.is_regression - row.link = snapshot.link - row.release = snapshot.release - row.updated_at = now - - for row in existing_by_id.values(): - if row.glitchtip_issue_id in issue_ids_by_slug[row.project_slug]: - continue - if row.status != "resolved": - row.status = "resolved" - row.updated_at = now - resolved_count += 1 - - result = await session.execute(select(SyncState).where(SyncState.source == "api_sync")) - state = result.scalar_one_or_none() - if state is None: - state = SyncState(source="api_sync", last_successful_at=now) - session.add(state) - else: - state.last_successful_at = now - - await session.commit() - - return SyncSummary( - project_count=len(slugs), - issue_count=len(snapshots), - resolved_count=resolved_count, - synced_at=now, - ) +async def warm_issue_cache_on_startup(timeout_seconds: int = 180) -> bool: + logger.info("Starting startup cache warmup") + try: + summary = await wait_for(sync_issues(), timeout=timeout_seconds) + logger.info( + "Startup cache warmup finished: %s projects, %s issues, %s resolved", + summary.project_count, + summary.issue_count, + summary.resolved_count, + ) + return True + except TimeoutError: + logger.warning( + "Startup cache warmup timed out after %s seconds; continuing with cached data", + timeout_seconds, + ) + return False + except Exception: + logger.exception("Startup cache warmup failed; continuing with cached data") + return False async def load_issue_snapshots( diff --git a/tests/test_digest_builder.py b/tests/test_digest_builder.py index d73c1c9..5f0b122 100644 --- a/tests/test_digest_builder.py +++ b/tests/test_digest_builder.py @@ -46,12 +46,12 @@ async def test_build_digest_aggregates_projects(monkeypatch): text = await digest_builder.build_digest() - assert "новых issues: 1" in text + assert "новых групп проблем: 1" in text assert "regressions: 1" in text - assert "unresolved > 7 дней: 1" in text + assert "старых групп > 7 дней: 1" in text assert "backend-production" in text assert "Old frontend issue" in text - assert "2026.03.20" in text + assert "12 событий" in text @pytest.mark.asyncio @@ -94,7 +94,8 @@ async def test_build_today_summary_limits_to_today(monkeypatch): text = await digest_builder.build_today_summary() - assert "Сегодня: 1 новых issues" in text + assert "Сегодня: 1 групп проблем" in text + assert "событий за сегодня: 2" in text assert "Today issue" in text assert "Old issue" not in text @@ -127,7 +128,7 @@ async def test_build_project_summary(monkeypatch): assert "backend-production" in text assert "Project issue" in text - assert "5 событий" in text + assert "всего событий: 5" in text @pytest.mark.asyncio @@ -219,9 +220,61 @@ async def test_build_release_summary_and_detail(monkeypatch): detail = await digest_builder.build_release_detail("2026.03.27") assert "2026.03.27" in summary - assert "2 issues" in summary + assert "2 групп" in summary assert "Release issue" in detail +@pytest.mark.asyncio +async def test_build_today_summary_groups_similar_titles(monkeypatch): + now = datetime.now(UTC) + issues = [ + IssueSnapshot( + 1, + "backend-production", + ( + "2026-03-30 10:59:52.605 | ERROR | logging:callHandlers:1762 - " + "The garbage collector is trying to clean up" + ), + None, + "error", + "unresolved", + now - timedelta(hours=2), + now - timedelta(minutes=5), + 1, + False, + None, + None, + ), + IssueSnapshot( + 2, + "backend-production", + ( + "2026-03-30 09:59:26.932 | ERROR | logging:callHandlers:1762 - " + "The garbage collector is trying to clean up" + ), + None, + "error", + "unresolved", + now - timedelta(hours=1), + now, + 1, + False, + None, + None, + ), + ] + + monkeypatch.setattr( + digest_builder, "_load_issues", lambda *args, **kwargs: _async_value(issues) + ) + + text = await digest_builder.build_today_summary() + + assert "Сегодня: 1 групп проблем" in text + assert "событий за сегодня: 2" in text + assert "2 повторов" in text + assert "The garbage collector is trying to clean up" in text + + async def _async_value(value): return value