Упрощение для балбесов
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"))