Упрощение для балбесов
Some checks failed
CI / Lint (ruff + mypy) (push) Failing after 35s
CI / Run tests (push) Has been skipped
CI / Docker build test (push) Successful in 18s

This commit is contained in:
2026-03-30 18:26:49 +07:00
parent bdae79db58
commit ea4a6fbe38
6 changed files with 548 additions and 452 deletions

View File

@@ -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(
[
"<b>GlitchUp Bot</b>",
"",
"Выберите нужную сводку кнопками ниже.",
"Пользователю доступны только базовые экраны.",
"",
"• Сегодня",
"• Неделя",
"• Самые шумные",
"• Давно висят",
*(["", "• Для управления откройте админ-панель"] 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 = [
"<b>GlitchUp Bot</b>",
f"Синхронизация: {escape(sync_value)}",
"",
"Понятное меню для просмотра ошибок, релизов и подписок.",
"",
"<b>Что можно сделать:</b>",
"• открыть сводку за неделю или за сегодня",
"• посмотреть топ самых шумных и старых issues",
"• проверить последние релизы",
"• включить или отключить личные уведомления",
"• узнать статус последней синхронизации",
"",
"<b>Быстрые команды:</b>",
"• /week, /today, /top, /stale",
"• /releases, /release &lt;version&gt;",
"• /project &lt;slug&gt;",
"• /subscribe backend|frontend",
"• /unsubscribe backend|frontend",
"Откройте нужную сводку кнопками ниже.",
]
if is_admin:
lines.extend(
[
"",
"<b>Для админа:</b>",
"• /admin — панель управления",
"• /sync — принудительная синхронизация",
"• /admins — список администраторов",
"• /admin_add &lt;user_id&gt; или reply на сообщение",
"• /admin_del &lt;user_id&gt;",
"• /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(
[
"<b>Обзор и мониторинг</b>",
"<b>Сводки</b>",
"",
"Здесь собраны основные срезы по состоянию проектов.",
"",
"• <b>Сводка за неделю</b> — общая картина по новым issues и regressions",
"• <b>Сегодня</b> — свежие проблемы за текущий день",
"• <b>Топ issues</b> — самые шумные ошибки по числу событий",
"• <b>Старые issues</b> — незакрытые проблемы, которые давно висят",
]
)
def _help_releases_text() -> str:
return "\n".join(
[
"<b>Релизы и версии</b>",
"",
"Раздел помогает быстро понять, после каких релизов появились незакрытые issues.",
"",
"• <b>Список релизов</b> покажет версии, у которых есть активные проблемы",
"• <b>Как открыть релиз</b> подскажет, как перейти к деталям по конкретной версии",
]
)
def _help_release_guide_text() -> str:
return "\n".join(
[
"<b>Как смотреть детали релиза</b>",
"",
"1. Открой <b>Список релизов</b> и скопируй нужную версию.",
"2. Выполни команду <code>/release &lt;version&gt;</code>.",
"3. Бот покажет issues, связанные именно с этим релизом.",
"",
"Пример: <code>/release 2026.03.27</code>",
]
)
def _help_subscriptions_text() -> str:
return "\n".join(
[
"<b>Подписки</b>",
"",
"Здесь можно управлять личными уведомлениями в DM.",
"",
"• <b>backend</b> — проблемы серверной части",
"• <b>frontend</b> — проблемы клиентских приложений и web",
"",
"Подписка добавляет вас в runtime-настройки без редактирования `.env`.",
"Быстрый доступ к основным экранам без лишних разделов.",
]
)
@@ -248,13 +196,12 @@ def _admin_text() -> str:
[
"<b>Админ-панель GlitchUp Bot</b>",
"",
"Панель разделена на понятные зоны, чтобы не искать нужное действие в длинном списке.",
"",
"<b>Центр синхронизации</b> — запуск sync и проверка статуса",
"<b>Сводки и мониторинг</b> — основные обзорные экраны",
"<b>Администраторы</b> — список и управление доступом",
"<b>Ownership и topics</b> — текущая маршрутизация",
"• <b>Mute rules</b> — правила скрытия шумных событий",
"Здесь только практичные разделы:",
"• синхронизация",
"основные сводки",
"получатели по backend/frontend",
"администраторы",
"routing и mute rules",
]
)
@@ -262,20 +209,31 @@ def _admin_text() -> str:
def _admin_sync_text() -> str:
return "\n".join(
[
"<b>Центр синхронизации</b>",
"<b>Синхронизация</b>",
"",
"Отсюда удобно запускать ручной sync и смотреть, "
"Отсюда удобно запускать ручной sync и проверять, "
"когда данные обновлялись в последний раз.",
]
)
def _admin_overview_text() -> str:
def _admin_recipients_text() -> str:
return "\n".join(
[
"<b>Сводки и мониторинг</b>",
"<b>Получатели уведомлений</b>",
"",
"Быстрый доступ к основным обзорным экранам для ручной проверки состояния проектов.",
"Выберите группу и назначайте Telegram ID через кнопки.",
"Самоподписка пользователей отключена.",
]
)
def _recipient_group_text(group_name: str) -> str:
return "\n".join(
[
f"<b>{escape(group_name.capitalize())} получатели</b>",
"",
"Можно посмотреть список, добавить новый Telegram ID или удалить существующий.",
]
)
@@ -285,8 +243,7 @@ def _admin_guide_text() -> str:
[
"<b>Подсказка по админке</b>",
"",
"Через кнопки удобно смотреть состояние системы, "
"а точечные настройки меняются командами.",
"Почти всё вынесено в кнопки и короткие сценарии.",
"",
"• <code>/admin_add 123456</code> — добавить администратора",
"• <code>/admin_add</code> ответом на сообщение — добавить автора сообщения",
@@ -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"<b>{title} получатели</b>\n\nСписок пуст."
lines = [f"<b>{title} получатели</b>", ""]
for user_id in subscribers:
lines.append(f"• <code>{user_id}</code>")
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"<b>{escape(group_name.capitalize())}</b>",
"",
"Отправьте следующим сообщением 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"<b>{escape(group_name.capitalize())}</b>",
"",
"Отправьте следующим сообщением 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 &lt;version&gt;")
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 &lt;backend|frontend&gt;")
@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 <code>{target_id}</code> добавлен в группу "
f"<b>{escape(group_name)}</b>."
)
else:
removed = await remove_subscriber(group_name, target_id)
text = (
f"Telegram ID <code>{target_id}</code> удалён из группы "
f"<b>{escape(group_name)}</b>."
if removed
else f"Telegram ID <code>{target_id}</code> не найден в группе "
f"<b>{escape(group_name)}</b>."
)
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 &lt;backend|frontend&gt;")
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"))

View File

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

View File

@@ -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")

View File

@@ -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'<a href="{escape(issue.link, quote=True)}">{title}</a> ({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'<a href="{escape(link, quote=True)}">{safe_title}</a> ({safe_slug})'
return f"{safe_title} ({safe_slug})"
def _format_issue_line(issue: AggregatedIssue, *, include_seen: bool = False) -> str:
parts = [f"• <b>{issue.event_count}</b> событий"]
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 = [
"<b>📊 GlitchTip digest за неделю</b>",
"<b>📊 Сводка за неделю</b>",
"",
"<b>Всего:</b>",
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("<b>По проектам:</b>")
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"• <b>{escape(slug)}</b> — {', '.join(parts)}")
lines.append("")
if by_release:
lines.append("<b>После релизов:</b>")
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"• <b>{escape(release_name)}</b> — "
f"{len(release_issues)} issues, "
f"{sum(issue.event_count for issue in release_issues)} событий"
f"• <b>{escape(slug)}</b> — {stats['issues']} групп, {stats['events']} событий"
)
lines.append("")
if top_noisy:
lines.append("<b>Топ шумных:</b>")
lines.append("<b>Самые шумные:</b>")
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("<b>Хвосты:</b>")
lines.append("<b>Давно висят:</b>")
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"• <b>{age} дн.</b> — "
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"<b>📋 Сегодня: {len(today_issues)} новых issues</b>", ""]
total_events = sum(issue.event_count for issue in today_issues)
lines = [
f"<b>📋 Сегодня: {len(today_issues)} групп проблем</b>",
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"<b>{escape(project_slug)}</b>: нет unresolved issues."
aggregated = _aggregate_issues(await _load_issues([project_slug], refresh=refresh))
if not aggregated:
return f"<b>{escape(project_slug)}</b>: нет активных проблем."
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"<b>📦 {escape(project_slug)}</b>",
"",
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 = ["<b>🔊 Топ шумных issues</b>", ""]
for issue in sorted(issues, key=lambda item: item.event_count, reverse=True)[:limit]:
lines.append(f"• <b>{issue.event_count}</b> событий — {_issue_label(issue)}")
lines = ["<b>🔊 Самые шумные проблемы</b>", ""]
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 = ["<b>🕸 Старые незакрытые issues</b>", ""]
lines = ["<b>🕸 Старые незакрытые проблемы</b>", ""]
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"• <b>{age} дн.</b> — {_issue_label(issue)} ({issue.event_count} событий)")
lines.append(
f"• <b>{age} дн.</b> — {_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 = ["<b>🚀 Релизы с незакрытыми issue</b>", ""]
lines = ["<b>🚀 Релизы с проблемами</b>", ""]
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"• <b>{escape(release_name)}</b> — {len(release_issues)} issues, "
f"• <b>{escape(release_name)}</b> — {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"Для релиза <b>{escape(release_name)}</b> незакрытых issues не найдено."
return f"Для релиза <b>{escape(release_name)}</b> активных проблем не найдено."
lines = [f"<b>🚀 Релиз {escape(release_name)}</b>", ""]
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)

View File

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

View File

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