initial commit
Some checks failed
CI / Run tests (push) Has been cancelled
CI / Docker build test (push) Has been cancelled
CI / Lint (ruff + mypy) (push) Has been cancelled

This commit is contained in:
2026-03-30 16:46:26 +07:00
commit 2a7dfa95c8
67 changed files with 5864 additions and 0 deletions

View File

@@ -0,0 +1,618 @@
import logging
from collections.abc import Awaitable, Callable
from html import escape
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from glitchup_bot.bot.keyboards import admin_menu_keyboard, help_menu_keyboard
from glitchup_bot.config import settings
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,
build_top_issues,
run_manual_sync,
)
from glitchup_bot.services.mute_rules import add_rule, list_rules, remove_rule
from glitchup_bot.services.routing import (
add_subscriber,
clear_project_group,
clear_topic_override,
list_project_overrides,
list_subscriber_overrides,
list_topic_overrides,
remove_subscriber,
resolve_subscribers,
resolve_topic_id,
set_project_group,
set_topic_override,
)
router = Router()
logger = logging.getLogger(__name__)
def _sender_id(message: Message) -> int | None:
return message.from_user.id if message.from_user else None
def _callback_sender_id(callback: CallbackQuery) -> int | None:
return callback.from_user.id if callback.from_user else None
def _is_admin_user(user_id: int | None) -> bool:
return settings.is_admin(user_id)
async def _require_admin(message: Message) -> bool:
if _is_admin_user(_sender_id(message)):
return True
await message.answer("Команда доступна только администраторам.")
return False
async def _require_admin_callback(callback: CallbackQuery) -> bool:
if _is_admin_user(_callback_sender_id(callback)):
return True
await callback.answer("Только для администраторов", show_alert=True)
return False
def _help_text(is_admin: bool) -> str:
lines = [
"<b>GlitchUp Bot Help</b>",
"",
"Быстрые действия доступны кнопками ниже.",
"",
"<b>Пользовательские команды:</b>",
"• /week — digest за неделю",
"• /today — новые issues за сегодня",
"• /project &lt;slug&gt; — сводка по проекту",
"• /top — самые шумные issues",
"• /stale — старые незакрытые issues",
"• /releases — список релизов с issues",
"• /release &lt;version&gt; — детали по релизу",
"• /sync_status — статус последней синхронизации",
"• /subscribe &lt;backend|frontend&gt; — подписка на DM",
"• /unsubscribe &lt;backend|frontend&gt; — отписка от DM",
]
if is_admin:
lines.extend(
[
"",
"<b>Админ-команды:</b>",
"• /admin — открыть панель управления",
"• /sync — принудительный sync",
"• /ownership — показать overrides",
"• /owner &lt;slug&gt; &lt;backend|frontend&gt;",
"• /owner_reset &lt;slug&gt;",
"• /topic &lt;backend|frontend|digest&gt; &lt;topic_id&gt;",
"• /topic_reset &lt;backend|frontend|digest&gt;",
"• /mute_add &lt;regex&gt;",
"• /mute_list",
"• /mute_del &lt;id&gt;",
]
)
lines.extend(
[
"",
"<b>Как пользоваться:</b>",
"1. Открой /help и выбери нужный раздел кнопками.",
"2. Для ежедневной работы достаточно кнопок digest/today/top/stale/releases.",
"3. Для администрирования используй /admin.",
]
)
return "\n".join(lines)
def _admin_text() -> str:
return "\n".join(
[
"<b>Админ-панель GlitchUp Bot</b>",
"",
"Основные действия доступны кнопками ниже.",
"",
"<b>Быстрые действия:</b>",
"• Запустить sync",
"• Посмотреть sync status",
"• Посмотреть ownership и mute rules",
"• Открыть основные сводки",
"",
"<b>Команды настройки:</b>",
"• /owner &lt;slug&gt; &lt;backend|frontend&gt;",
"• /owner_reset &lt;slug&gt;",
"• /topic &lt;backend|frontend|digest&gt; &lt;topic_id&gt;",
"• /topic_reset &lt;backend|frontend|digest&gt;",
"• /mute_add &lt;regex&gt;",
"• /mute_del &lt;id&gt;",
]
)
async def _answer_text(
target: Message | CallbackQuery,
text: str,
*,
reply_markup=None,
disable_web_page_preview: bool = True,
) -> None:
if isinstance(target, CallbackQuery):
await target.message.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
else:
await target.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
async def _handle_subscription_action(
target: Message | CallbackQuery,
group_name: str,
action: str,
user_id: int | None,
) -> None:
if user_id is None:
await _answer_text(target, "Не удалось определить пользователя.")
return
if action == "subscribe":
await add_subscriber(group_name, user_id)
await _answer_text(target, f"Подписка на <b>{escape(group_name)}</b> включена.")
return
removed = await remove_subscriber(group_name, user_id)
if not removed:
await _answer_text(target, "Runtime-подписка не найдена.")
return
await _answer_text(target, f"Подписка на <b>{escape(group_name)}</b> отключена.")
async def _run_summary_action(
target: Message | CallbackQuery,
loader: Callable[[], Awaitable[str]],
) -> None:
await _answer_text(target, await loader())
@router.message(Command("start"))
async def cmd_start(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message))
text = (
"<b>GlitchUp Bot</b>\n\nБот запущен и готов к работе.\nДля удобной навигации открой /help."
)
await message.answer(
text,
reply_markup=help_menu_keyboard(is_admin),
disable_web_page_preview=True,
)
@router.message(Command("help"))
async def cmd_help(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message))
await message.answer(
_help_text(is_admin),
reply_markup=help_menu_keyboard(is_admin),
disable_web_page_preview=True,
)
@router.message(Command("admin"))
async def cmd_admin(message: Message) -> None:
if not await _require_admin(message):
return
await message.answer(
_admin_text(),
reply_markup=admin_menu_keyboard(),
disable_web_page_preview=True,
)
@router.callback_query(F.data.startswith("help:"))
async def cb_help_actions(callback: CallbackQuery) -> None:
data = callback.data or ""
action = data.removeprefix("help:")
await callback.answer()
if action == "week":
await _run_summary_action(callback, lambda: build_digest(refresh=True))
return
if action == "today":
await _run_summary_action(callback, lambda: build_today_summary(refresh=True))
return
if action == "top":
await _run_summary_action(callback, lambda: build_top_issues(refresh=True))
return
if action == "stale":
await _run_summary_action(callback, lambda: build_stale_issues(refresh=True))
return
if action == "releases":
await _run_summary_action(callback, lambda: build_release_summary(refresh=True))
return
if action == "sync_status":
await _run_summary_action(callback, build_sync_status)
return
if action.startswith("sub:"):
await _handle_subscription_action(
callback,
action.split(":", 1)[1],
"subscribe",
_callback_sender_id(callback),
)
return
if action.startswith("unsub:"):
await _handle_subscription_action(
callback,
action.split(":", 1)[1],
"unsubscribe",
_callback_sender_id(callback),
)
return
@router.callback_query(F.data == "admin:open")
async def cb_admin_open(callback: CallbackQuery) -> None:
if not await _require_admin_callback(callback):
return
await callback.answer()
await callback.message.answer(
_admin_text(),
reply_markup=admin_menu_keyboard(),
disable_web_page_preview=True,
)
@router.callback_query(F.data.startswith("admin:"))
async def cb_admin_actions(callback: CallbackQuery) -> None:
if not await _require_admin_callback(callback):
return
action = (callback.data or "").removeprefix("admin:")
await callback.answer()
if action == "sync":
summary = await run_manual_sync()
await callback.message.answer(
"<b>Sync завершён</b>\n\n"
f"• проектов: {summary.project_count}\n"
f"• issues: {summary.issue_count}\n"
f"• помечено resolved: {summary.resolved_count}\n"
f"• время: {escape(summary.synced_at.isoformat())}",
disable_web_page_preview=True,
)
return
if action == "sync_status":
await _run_summary_action(callback, build_sync_status)
return
if action == "ownership":
await cmd_ownership(callback.message)
return
if action == "mute_list":
await cmd_mute_list(callback.message)
return
if action == "releases":
await _run_summary_action(callback, lambda: build_release_summary(refresh=True))
return
if action == "today":
await _run_summary_action(callback, lambda: build_today_summary(refresh=True))
return
if action == "week":
await _run_summary_action(callback, lambda: build_digest(refresh=True))
return
if action == "top":
await _run_summary_action(callback, lambda: build_top_issues(refresh=True))
return
if action == "stale":
await _run_summary_action(callback, lambda: build_stale_issues(refresh=True))
return
if action == "guide":
await callback.message.answer(
"\n".join(
[
"<b>Подсказка по админке</b>",
"",
"Через кнопки можно быстро смотреть состояние и запускать sync.",
"Изменение параметров делается командами:",
"• /owner slug backend",
"• /topic backend 123",
"• /mute_add payment.*timeout",
]
),
disable_web_page_preview=True,
)
return
@router.message(Command("week"))
async def cmd_week(message: Message) -> None:
await message.answer(await build_digest(refresh=True), disable_web_page_preview=True)
@router.message(Command("today"))
async def cmd_today(message: Message) -> None:
await message.answer(await build_today_summary(refresh=True), disable_web_page_preview=True)
@router.message(Command("project"))
async def cmd_project(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /project &lt;slug&gt;")
return
await message.answer(
await build_project_summary(args[1].strip(), refresh=True),
disable_web_page_preview=True,
)
@router.message(Command("top"))
async def cmd_top(message: Message) -> None:
await message.answer(await build_top_issues(refresh=True), disable_web_page_preview=True)
@router.message(Command("stale"))
async def cmd_stale(message: Message) -> None:
await message.answer(await build_stale_issues(refresh=True), disable_web_page_preview=True)
@router.message(Command("releases"))
async def cmd_releases(message: Message) -> None:
await message.answer(await build_release_summary(refresh=True), disable_web_page_preview=True)
@router.message(Command("release"))
async def cmd_release(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /release &lt;version&gt;")
return
await message.answer(
await build_release_detail(args[1].strip(), refresh=True),
disable_web_page_preview=True,
)
@router.message(Command("sync_status"))
async def cmd_sync_status(message: Message) -> None:
await message.answer(await build_sync_status())
@router.message(Command("sync"))
async def cmd_sync(message: Message) -> None:
if not await _require_admin(message):
return
summary = await run_manual_sync()
await message.answer(
"<b>Sync завершён</b>\n\n"
f"• проектов: {summary.project_count}\n"
f"• issues: {summary.issue_count}\n"
f"• помечено resolved: {summary.resolved_count}\n"
f"• время: {escape(summary.synced_at.isoformat())}"
)
@router.message(Command("subscribe"))
async def cmd_subscribe(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /subscribe &lt;backend|frontend&gt;")
return
await _handle_subscription_action(
message,
args[1].strip().lower(),
"subscribe",
_sender_id(message),
)
@router.message(Command("unsubscribe"))
async def cmd_unsubscribe(message: Message) -> None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /unsubscribe &lt;backend|frontend&gt;")
return
await _handle_subscription_action(
message,
args[1].strip().lower(),
"unsubscribe",
_sender_id(message),
)
@router.message(Command("ownership"))
async def cmd_ownership(message: Message) -> None:
if not await _require_admin(message):
return
project_overrides = await list_project_overrides()
topic_overrides = await list_topic_overrides()
subscriber_overrides = await list_subscriber_overrides()
backend_subscribers = await resolve_subscribers("backend")
frontend_subscribers = await resolve_subscribers("frontend")
lines = [
"<b>Ownership runtime state</b>",
"",
"<b>Topics:</b>",
f"• backend: {await resolve_topic_id('backend')}",
f"• frontend: {await resolve_topic_id('frontend')}",
f"• digest: {await resolve_topic_id('digest')}",
"",
"<b>Subscribers:</b>",
f"• backend: {', '.join(map(str, backend_subscribers)) or 'none'}",
f"• frontend: {', '.join(map(str, frontend_subscribers)) or 'none'}",
"",
"<b>Project overrides:</b>",
]
if project_overrides:
lines.extend(
f"{escape(record.project_slug)}{escape(record.group_name)}"
for record in project_overrides
)
else:
lines.append("• none")
lines.extend(["", "<b>Topic overrides:</b>"])
if topic_overrides:
lines.extend(
f"{escape(record.group_name)}{record.topic_id}" for record in topic_overrides
)
else:
lines.append("• none")
lines.extend(["", "<b>Subscriber overrides:</b>"])
if subscriber_overrides:
lines.extend(
f"{escape(record.group_name)}{record.user_id}" for record in subscriber_overrides
)
else:
lines.append("• none")
await message.answer("\n".join(lines), disable_web_page_preview=True)
@router.message(Command("owner"))
async def cmd_owner(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=2) if message.text else []
if len(args) < 3:
await message.answer("Использование: /owner &lt;slug&gt; &lt;backend|frontend&gt;")
return
project_slug = args[1].strip()
group_name = args[2].strip().lower()
await set_project_group(project_slug, group_name)
await message.answer(
f"Проект <b>{escape(project_slug)}</b> привязан к группе {escape(group_name)}."
)
@router.message(Command("owner_reset"))
async def cmd_owner_reset(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /owner_reset &lt;slug&gt;")
return
removed = await clear_project_group(args[1].strip())
if not removed:
await message.answer("Override для проекта не найден.")
return
await message.answer("Override для проекта удалён.")
@router.message(Command("topic"))
async def cmd_topic(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=2) if message.text else []
if len(args) < 3:
await message.answer(
"Использование: /topic &lt;backend|frontend|digest&gt; &lt;topic_id&gt;"
)
return
group_name = args[1].strip().lower()
topic_id = int(args[2].strip())
await set_topic_override(group_name, topic_id)
await message.answer(f"Topic override для <b>{escape(group_name)}</b> сохранён: {topic_id}.")
@router.message(Command("topic_reset"))
async def cmd_topic_reset(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /topic_reset &lt;backend|frontend|digest&gt;")
return
removed = await clear_topic_override(args[1].strip().lower())
if not removed:
await message.answer("Topic override не найден.")
return
await message.answer("Topic override удалён.")
@router.message(Command("mute_add"))
async def cmd_mute_add(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /mute_add &lt;regex&gt;")
return
rule = await add_rule(args[1].strip())
await message.answer(f"Добавлено mute rule #{rule.id}: <code>{escape(rule.pattern)}</code>")
@router.message(Command("mute_list"))
async def cmd_mute_list(message: Message) -> None:
if not await _require_admin(message):
return
rules = await list_rules()
if not rules:
await message.answer("Mute rules не настроены.")
return
lines = ["<b>Mute rules</b>", ""]
for rule in rules:
suffix = f"{escape(rule.description)}" if rule.description else ""
lines.append(f"• #{rule.id} <code>{escape(rule.pattern)}</code>{suffix}")
await message.answer("\n".join(lines), disable_web_page_preview=True)
@router.message(Command("mute_del"))
async def cmd_mute_del(message: Message) -> None:
if not await _require_admin(message):
return
args = message.text.split(maxsplit=1) if message.text else []
if len(args) < 2:
await message.answer("Использование: /mute_del &lt;id&gt;")
return
removed = await remove_rule(int(args[1].strip()))
if not removed:
await message.answer("Mute rule не найдено.")
return
await message.answer("Mute rule удалено.")