initial commit
This commit is contained in:
618
src/glitchup_bot/bot/handlers/commands.py
Normal file
618
src/glitchup_bot/bot/handlers/commands.py
Normal 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 <slug> — сводка по проекту",
|
||||
"• /top — самые шумные issues",
|
||||
"• /stale — старые незакрытые issues",
|
||||
"• /releases — список релизов с issues",
|
||||
"• /release <version> — детали по релизу",
|
||||
"• /sync_status — статус последней синхронизации",
|
||||
"• /subscribe <backend|frontend> — подписка на DM",
|
||||
"• /unsubscribe <backend|frontend> — отписка от DM",
|
||||
]
|
||||
|
||||
if is_admin:
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<b>Админ-команды:</b>",
|
||||
"• /admin — открыть панель управления",
|
||||
"• /sync — принудительный sync",
|
||||
"• /ownership — показать overrides",
|
||||
"• /owner <slug> <backend|frontend>",
|
||||
"• /owner_reset <slug>",
|
||||
"• /topic <backend|frontend|digest> <topic_id>",
|
||||
"• /topic_reset <backend|frontend|digest>",
|
||||
"• /mute_add <regex>",
|
||||
"• /mute_list",
|
||||
"• /mute_del <id>",
|
||||
]
|
||||
)
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"<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 <slug> <backend|frontend>",
|
||||
"• /owner_reset <slug>",
|
||||
"• /topic <backend|frontend|digest> <topic_id>",
|
||||
"• /topic_reset <backend|frontend|digest>",
|
||||
"• /mute_add <regex>",
|
||||
"• /mute_del <id>",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
async def _answer_text(
|
||||
target: Message | CallbackQuery,
|
||||
text: str,
|
||||
*,
|
||||
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 <slug>")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
await build_project_summary(args[1].strip(), refresh=True),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("top"))
|
||||
async def cmd_top(message: Message) -> None:
|
||||
await message.answer(await build_top_issues(refresh=True), disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.message(Command("stale"))
|
||||
async def cmd_stale(message: Message) -> None:
|
||||
await message.answer(await build_stale_issues(refresh=True), disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.message(Command("releases"))
|
||||
async def cmd_releases(message: Message) -> None:
|
||||
await message.answer(await build_release_summary(refresh=True), disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.message(Command("release"))
|
||||
async def cmd_release(message: Message) -> None:
|
||||
args = message.text.split(maxsplit=1) if message.text else []
|
||||
if len(args) < 2:
|
||||
await message.answer("Использование: /release <version>")
|
||||
return
|
||||
|
||||
await message.answer(
|
||||
await build_release_detail(args[1].strip(), refresh=True),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("sync_status"))
|
||||
async def cmd_sync_status(message: Message) -> None:
|
||||
await message.answer(await build_sync_status())
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
async def cmd_sync(message: Message) -> None:
|
||||
if not await _require_admin(message):
|
||||
return
|
||||
|
||||
summary = await run_manual_sync()
|
||||
await message.answer(
|
||||
"<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 <backend|frontend>")
|
||||
return
|
||||
|
||||
await _handle_subscription_action(
|
||||
message,
|
||||
args[1].strip().lower(),
|
||||
"subscribe",
|
||||
_sender_id(message),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("unsubscribe"))
|
||||
async def cmd_unsubscribe(message: Message) -> None:
|
||||
args = message.text.split(maxsplit=1) if message.text else []
|
||||
if len(args) < 2:
|
||||
await message.answer("Использование: /unsubscribe <backend|frontend>")
|
||||
return
|
||||
|
||||
await _handle_subscription_action(
|
||||
message,
|
||||
args[1].strip().lower(),
|
||||
"unsubscribe",
|
||||
_sender_id(message),
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("ownership"))
|
||||
async def cmd_ownership(message: Message) -> None:
|
||||
if not await _require_admin(message):
|
||||
return
|
||||
|
||||
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 <slug> <backend|frontend>")
|
||||
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 <slug>")
|
||||
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 <backend|frontend|digest> <topic_id>"
|
||||
)
|
||||
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 <backend|frontend|digest>")
|
||||
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 <regex>")
|
||||
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 <id>")
|
||||
return
|
||||
|
||||
removed = await remove_rule(int(args[1].strip()))
|
||||
if not removed:
|
||||
await message.answer("Mute rule не найдено.")
|
||||
return
|
||||
|
||||
await message.answer("Mute rule удалено.")
|
||||
Reference in New Issue
Block a user