import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from html import escape
from uuid import uuid4
from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from glitchup_bot.bot.keyboards import (
admin_home_keyboard,
admin_recipient_group_keyboard,
admin_recipients_keyboard,
admin_result_keyboard,
admin_routing_keyboard,
admin_settings_keyboard,
admin_sync_keyboard,
help_home_keyboard,
help_result_keyboard,
)
from glitchup_bot.services.admins import (
add_admin,
is_admin,
list_effective_admins,
remove_admin,
)
from glitchup_bot.services.digest_builder import (
build_digest,
build_project_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,
)
from glitchup_bot.services.runtime_settings import get_runtime_settings, set_runtime_setting
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]] = {}
PENDING_SETTING_ACTIONS: dict[int, tuple[str, str]] = {}
@dataclass(slots=True)
class PaginationSession:
pages: list[str]
back_callback: str
is_admin: bool
admin_mode: bool
PAGINATION_SESSIONS: OrderedDict[str, PaginationSession] = OrderedDict()
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
async def _is_admin_user(user_id: int | None) -> bool:
return await is_admin(user_id)
def _paginate_text(text: str) -> list[str]:
if len(text) <= MAX_PAGE_CHARS and text.count("\n") + 1 <= MAX_PAGE_LINES:
return [text]
pages: list[str] = []
current_lines: list[str] = []
current_len = 0
for line in text.splitlines():
projected_len = current_len + len(line) + (1 if current_lines else 0)
if current_lines and (
projected_len > MAX_PAGE_CHARS or len(current_lines) >= MAX_PAGE_LINES
):
pages.append("\n".join(current_lines))
current_lines = [line]
current_len = len(line)
continue
current_lines.append(line)
current_len = projected_len
if current_lines:
pages.append("\n".join(current_lines))
return pages or [text]
def _store_pagination_session(session: PaginationSession) -> str:
token = uuid4().hex[:12]
PAGINATION_SESSIONS[token] = session
PAGINATION_SESSIONS.move_to_end(token)
while len(PAGINATION_SESSIONS) > MAX_PAGINATION_SESSIONS:
PAGINATION_SESSIONS.popitem(last=False)
return token
def _result_keyboard(
*,
back_callback: str,
is_admin: bool,
admin_mode: bool,
page_token: str | None = None,
page_index: int = 0,
total_pages: int = 1,
):
if admin_mode:
return admin_result_keyboard(
back_callback,
page_token=page_token,
page_index=page_index,
total_pages=total_pages,
)
return help_result_keyboard(
back_callback,
is_admin,
page_token=page_token,
page_index=page_index,
total_pages=total_pages,
)
def _help_text(is_admin: bool) -> str:
return "\n".join(
[
"GlitchUp Bot",
"",
"Выберите нужную сводку кнопками ниже.",
"Пользователю доступны только базовые экраны.",
"",
"• Сегодня",
"• Неделя",
"• Самые шумные",
"• Давно висят",
*(["", "• Для управления откройте админ-панель"] if is_admin else []),
]
)
async def _start_text(is_admin: bool) -> str:
runtime = await get_runtime_settings()
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 = [
f"{escape(runtime.bot_title)}",
f"Синхронизация: {escape(sync_value)}",
"",
escape(runtime.bot_purpose),
]
if is_admin:
lines.extend(["", escape(runtime.bot_admin_hint)])
return "\n".join(lines)
def _admin_routing_text() -> str:
return "\n".join(
[
"Topic и routing",
"",
"Здесь можно смотреть текущую схему и менять topic override без env.",
]
)
def _admin_text() -> str:
return "\n".join(
[
"Админ-панель GlitchUp Bot",
"",
"Здесь только практичные разделы:",
"• синхронизация",
"• получатели по backend/frontend",
"• topic и routing",
"• runtime-настройки бота",
"• администраторы",
"• mute rules",
]
)
def _admin_sync_text() -> str:
return "\n".join(
[
"Синхронизация",
"",
"Отсюда удобно запускать ручной sync и проверять, "
"когда данные обновлялись в последний раз.",
]
)
def _admin_recipients_text() -> str:
return "\n".join(
[
"Получатели уведомлений",
"",
"Выберите группу и назначайте Telegram ID через кнопки.",
"Самоподписка пользователей отключена.",
]
)
async def _runtime_settings_text() -> str:
runtime = await get_runtime_settings()
lines = [
"Настройки бота",
"",
f"• автосинк: {'включён' if runtime.sync_enabled else 'выключен'}",
f"• интервал sync: {runtime.sync_interval_minutes} мин",
(
"• отчёт: "
f"{runtime.digest_cron_day} "
f"{runtime.digest_cron_hour:02d}:{runtime.digest_cron_minute:02d} "
f"{runtime.digest_timezone}"
),
f"• название: {escape(runtime.bot_title)}",
f"• описание: {escape(runtime.bot_purpose)}",
f"• подсказка админу: {escape(runtime.bot_admin_hint)}",
]
return "\n".join(lines)
def _recipient_group_text(group_name: str) -> str:
return "\n".join(
[
f"{escape(group_name.capitalize())} получатели",
"",
"Можно посмотреть список, добавить новый Telegram ID или удалить существующий.",
]
)
def _admin_guide_text() -> str:
return "\n".join(
[
"Подсказка по админке",
"",
"Почти всё вынесено в кнопки и короткие сценарии.",
"",
"• /admin_add 123456 — добавить администратора",
"• /admin_add ответом на сообщение — добавить автора сообщения",
"• /admin_del 123456 — удалить runtime-администратора",
"",
"• /owner slug backend — переназначить проект в группу",
"• /topic backend 123 — сменить topic override",
"• /mute_add payment.*timeout — добавить mute rule",
]
)
def _sync_summary_text(summary) -> str:
return "\n".join(
[
"Sync завершён",
"",
f"• проектов: {summary.project_count}",
f"• issues: {summary.issue_count}",
f"• помечено resolved: {summary.resolved_count}",
f"• время: {escape(summary.synced_at.isoformat())}",
]
)
async def _admins_text() -> str:
admins = await list_effective_admins()
if not admins:
return "Список администраторов пуст."
lines = [
"Администраторы",
"",
"Добавлять можно командой /admin_add <user_id> "
"или ответом на сообщение пользователя.",
"",
]
for user_id, source in admins:
source_label = source.replace("env", "из .env")
lines.append(f"• {user_id} — {source_label}")
return "\n".join(lines)
def _extract_target_user_id(message: Message) -> int | None:
args = message.text.split(maxsplit=1) if message.text else []
if len(args) >= 2:
raw_value = args[1].strip()
return int(raw_value) if raw_value.lstrip("-").isdigit() else None
reply = message.reply_to_message
if reply and reply.from_user:
return reply.from_user.id
return None
async def _recipients_text(group_name: str) -> str:
subscribers = await resolve_subscribers(group_name)
title = "Backend" if group_name == "backend" else "Frontend"
if not subscribers:
return f"{title} получатели\n\nСписок пуст."
lines = [f"{title} получатели", ""]
for user_id in subscribers:
lines.append(f"• {user_id}")
return "\n".join(lines)
def _setting_prompt_text(setting_key: str, target: str) -> str:
prompts = {
"topic": f"Отправьте новым сообщением числовой topic ID для {escape(target)}.",
"sync_interval_minutes": "Отправьте интервал синхронизации в минутах.",
"digest_cron_day": "Отправьте день отчёта, например mon.",
"digest_time": "Отправьте время отчёта в формате HH:MM.",
"digest_timezone": "Отправьте timezone, например Asia/Krasnoyarsk.",
"bot_title": "Отправьте новое название бота.",
"bot_purpose": "Отправьте короткое описание, для чего нужен бот.",
"bot_admin_hint": "Отправьте текст подсказки для администратора на /start.",
}
return prompts[setting_key]
async def _require_admin(message: Message) -> bool:
if await _is_admin_user(_sender_id(message)):
return True
await message.answer("Команда доступна только администраторам.")
return False
async def _require_admin_callback(callback: CallbackQuery) -> bool:
if await _is_admin_user(_callback_sender_id(callback)):
return True
await callback.answer("Только для администраторов", show_alert=True)
return False
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 _show_callback_screen(
callback: CallbackQuery,
text: str,
*,
reply_markup=None,
disable_web_page_preview: bool = True,
) -> None:
if callback.message is None:
return
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
except TelegramBadRequest as exc:
if "message is not modified" in str(exc).lower():
return
await callback.message.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
async def _deliver_text(
target: Message | CallbackQuery,
text: str,
*,
reply_markup=None,
disable_web_page_preview: bool = True,
) -> None:
if isinstance(target, CallbackQuery):
await _show_callback_screen(
target,
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
return
await _answer_text(
target,
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
async def _deliver_result(
target: Message | CallbackQuery,
text: str,
*,
back_callback: str,
is_admin: bool,
admin_mode: bool = False,
) -> None:
pages = _paginate_text(text)
if len(pages) == 1:
await _deliver_text(
target,
pages[0],
reply_markup=_result_keyboard(
back_callback=back_callback,
is_admin=is_admin,
admin_mode=admin_mode,
),
)
return
token = _store_pagination_session(
PaginationSession(
pages=pages,
back_callback=back_callback,
is_admin=is_admin,
admin_mode=admin_mode,
)
)
await _deliver_text(
target,
pages[0],
reply_markup=_result_keyboard(
back_callback=back_callback,
is_admin=is_admin,
admin_mode=admin_mode,
page_token=token,
page_index=0,
total_pages=len(pages),
),
)
async def _handle_subscription_action(
target: Message | CallbackQuery,
group_name: str,
action: str,
user_id: int | None,
*,
reply_markup=None,
) -> None:
if user_id is None:
await _deliver_text(
target,
"Не удалось определить пользователя.",
reply_markup=reply_markup,
)
return
if action == "subscribe":
await add_subscriber(group_name, user_id)
await _deliver_text(
target,
f"Подписка на {escape(group_name)} включена.",
reply_markup=reply_markup,
)
return
removed = await remove_subscriber(group_name, user_id)
if not removed:
await _deliver_text(target, "Runtime-подписка не найдена.", reply_markup=reply_markup)
return
await _deliver_text(
target,
f"Подписка на {escape(group_name)} отключена.",
reply_markup=reply_markup,
)
async def _run_summary_action(
target: Message | CallbackQuery,
loader: Callable[[], Awaitable[str]],
*,
back_callback: str,
is_admin: bool,
admin_mode: bool = False,
) -> None:
await _deliver_result(
target,
await loader(),
back_callback=back_callback,
is_admin=is_admin,
admin_mode=admin_mode,
)
async def _ownership_text() -> str:
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 = [
"Ownership runtime state",
"",
"Topics:",
f"• backend: {await resolve_topic_id('backend')}",
f"• frontend: {await resolve_topic_id('frontend')}",
f"• digest: {await resolve_topic_id('digest')}",
"",
"Subscribers:",
f"• backend: {', '.join(map(str, backend_subscribers)) or 'none'}",
f"• frontend: {', '.join(map(str, frontend_subscribers)) or 'none'}",
"",
"Project overrides:",
]
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(["", "Topic overrides:"])
if topic_overrides:
lines.extend(
f"• {escape(record.group_name)} → {record.topic_id}" for record in topic_overrides
)
else:
lines.append("• none")
lines.extend(["", "Subscriber overrides:"])
if subscriber_overrides:
lines.extend(
f"• {escape(record.group_name)} → {record.user_id}" for record in subscriber_overrides
)
else:
lines.append("• none")
return "\n".join(lines)
async def _mute_rules_text() -> str:
rules = await list_rules()
if not rules:
return "Mute rules не настроены."
lines = ["Mute rules", ""]
for rule in rules:
suffix = f" — {escape(rule.description)}" if rule.description else ""
lines.append(f"• #{rule.id} {escape(rule.pattern)}{suffix}")
return "\n".join(lines)
@router.message(Command("start"))
async def cmd_start(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await message.answer(
await _start_text(is_admin),
reply_markup=help_home_keyboard(is_admin),
disable_web_page_preview=True,
)
@router.message(Command("help"))
async def cmd_help(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await message.answer(
_help_text(is_admin),
reply_markup=help_home_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_home_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:")
is_admin = await _is_admin_user(_callback_sender_id(callback))
await callback.answer()
if action == "open":
await _show_callback_screen(
callback,
_help_text(is_admin),
reply_markup=help_home_keyboard(is_admin),
)
return
if action == "week":
await _run_summary_action(
callback,
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=False),
back_callback="help:open",
is_admin=is_admin,
)
return
if action == "top":
await _run_summary_action(
callback,
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=False),
back_callback="help:open",
is_admin=is_admin,
)
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 _show_callback_screen(
callback,
_admin_text(),
reply_markup=admin_home_keyboard(),
)
@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 == "menu:sync":
await _show_callback_screen(
callback,
_admin_sync_text(),
reply_markup=admin_sync_keyboard(),
)
return
if action == "menu:recipients":
await _show_callback_screen(
callback,
_admin_recipients_text(),
reply_markup=admin_recipients_keyboard(),
)
return
if action == "menu:routing":
await _show_callback_screen(
callback,
_admin_routing_text(),
reply_markup=admin_routing_keyboard(),
)
return
if action == "menu:settings":
await _show_callback_screen(
callback,
await _runtime_settings_text(),
reply_markup=admin_settings_keyboard(),
)
return
if action in {"recipients:backend", "recipients:frontend"}:
group_name = action.split(":", 1)[1]
await _show_callback_screen(
callback,
_recipient_group_text(group_name),
reply_markup=admin_recipient_group_keyboard(group_name),
)
return
if action.startswith("recipients:list:"):
group_name = action.rsplit(":", 1)[1]
await _deliver_result(
callback,
await _recipients_text(group_name),
back_callback=f"admin:recipients:{group_name}",
is_admin=True,
admin_mode=True,
)
return
if action.startswith("recipients:add:"):
group_name = action.rsplit(":", 1)[1]
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_RECIPIENT_ACTIONS[admin_id] = ("add", group_name)
await _show_callback_screen(
callback,
"\n".join(
[
f"{escape(group_name.capitalize())}",
"",
"Отправьте следующим сообщением Telegram ID, который нужно добавить.",
]
),
reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"),
)
return
if action.startswith("recipients:del:"):
group_name = action.rsplit(":", 1)[1]
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_RECIPIENT_ACTIONS[admin_id] = ("del", group_name)
await _show_callback_screen(
callback,
"\n".join(
[
f"{escape(group_name.capitalize())}",
"",
"Отправьте следующим сообщением Telegram ID, который нужно удалить.",
]
),
reply_markup=admin_result_keyboard(f"admin:recipients:{group_name}"),
)
return
if action == "admins":
await _deliver_result(
callback,
await _admins_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
return
if action == "settings:sync_enabled":
runtime = await get_runtime_settings()
await set_runtime_setting("sync_enabled", "false" if runtime.sync_enabled else "true")
from glitchup_bot.tasks.scheduler import reload_scheduler
await reload_scheduler()
await _show_callback_screen(
callback,
await _runtime_settings_text(),
reply_markup=admin_settings_keyboard(),
)
return
if action == "settings:sync_interval":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("sync_interval_minutes", "")
await _show_callback_screen(
callback,
_setting_prompt_text("sync_interval_minutes", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action == "settings:digest_day":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("digest_cron_day", "")
await _show_callback_screen(
callback,
_setting_prompt_text("digest_cron_day", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action == "settings:digest_time":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("digest_time", "")
await _show_callback_screen(
callback,
_setting_prompt_text("digest_time", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action == "settings:digest_timezone":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("digest_timezone", "")
await _show_callback_screen(
callback,
_setting_prompt_text("digest_timezone", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action == "settings:bot_title":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("bot_title", "")
await _show_callback_screen(
callback,
_setting_prompt_text("bot_title", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action == "settings:bot_purpose":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("bot_purpose", "")
await _show_callback_screen(
callback,
_setting_prompt_text("bot_purpose", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action == "settings:bot_admin_hint":
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("bot_admin_hint", "")
await _show_callback_screen(
callback,
_setting_prompt_text("bot_admin_hint", ""),
reply_markup=admin_result_keyboard("admin:menu:settings"),
)
return
if action.startswith("settings:topic:"):
group_name = action.rsplit(":", 1)[1]
admin_id = _callback_sender_id(callback)
if admin_id is not None:
PENDING_SETTING_ACTIONS[admin_id] = ("topic", group_name)
await _show_callback_screen(
callback,
_setting_prompt_text("topic", group_name),
reply_markup=admin_result_keyboard("admin:menu:routing"),
)
return
if action == "sync":
summary = await run_manual_sync()
await _deliver_result(
callback,
_sync_summary_text(summary),
back_callback="admin:menu:sync",
is_admin=True,
admin_mode=True,
)
return
if action == "sync_status":
await _run_summary_action(
callback,
build_sync_status,
back_callback="admin:menu:sync",
is_admin=True,
admin_mode=True,
)
return
if action == "ownership":
await _deliver_result(
callback,
await _ownership_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
return
if action == "mute_list":
await _deliver_result(
callback,
await _mute_rules_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
return
if action == "guide":
await _deliver_result(
callback,
_admin_guide_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
return
@router.callback_query(F.data.startswith("page:"))
async def cb_paginated_results(callback: CallbackQuery) -> None:
payload = (callback.data or "").split(":")
if len(payload) != 3:
await callback.answer()
return
_, token, raw_index = payload
session = PAGINATION_SESSIONS.get(token)
if session is None:
await callback.answer("Эта навигация устарела. Открой раздел заново.", show_alert=True)
return
if session.admin_mode and not await _is_admin_user(_callback_sender_id(callback)):
await callback.answer("Только для администраторов", show_alert=True)
return
if not raw_index.isdigit():
await callback.answer()
return
page_index = int(raw_index)
if page_index < 0 or page_index >= len(session.pages):
await callback.answer()
return
await callback.answer()
PAGINATION_SESSIONS.move_to_end(token)
await _show_callback_screen(
callback,
session.pages[page_index],
reply_markup=_result_keyboard(
back_callback=session.back_callback,
is_admin=session.is_admin,
admin_mode=session.admin_mode,
page_token=token,
page_index=page_index,
total_pages=len(session.pages),
),
)
@router.message(Command("week"))
async def cmd_week(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
await build_digest(refresh=False),
back_callback="help:open",
is_admin=is_admin,
)
@router.message(Command("today"))
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=False),
back_callback="help:open",
is_admin=is_admin,
)
@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
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
await build_project_summary(args[1].strip(), refresh=False),
back_callback="help:open",
is_admin=is_admin,
)
@router.message(Command("top"))
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=False),
back_callback="help:open",
is_admin=is_admin,
)
@router.message(Command("stale"))
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=False),
back_callback="help:open",
is_admin=is_admin,
)
@router.message(Command("releases"))
async def cmd_releases(message: Message) -> None:
await message.answer("Раздел релизов скрыт из упрощённого интерфейса.")
@router.message(Command("release"))
async def cmd_release(message: Message) -> None:
await message.answer("Раздел релизов скрыт из упрощённого интерфейса.")
@router.message(Command("sync_status"))
async def cmd_sync_status(message: Message) -> None:
is_admin = await _is_admin_user(_sender_id(message))
await _deliver_result(
message,
await build_sync_status(),
back_callback="help:open",
is_admin=is_admin,
)
@router.message(Command("sync"))
async def cmd_sync(message: Message) -> None:
if not await _require_admin(message):
return
summary = await run_manual_sync()
await _deliver_result(
message,
_sync_summary_text(summary),
back_callback="admin:menu:sync",
is_admin=True,
admin_mode=True,
)
@router.message(Command("admins"))
async def cmd_admins(message: Message) -> None:
if not await _require_admin(message):
return
await _deliver_result(
message,
await _admins_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
@router.message(Command("admin_add"))
async def cmd_admin_add(message: Message) -> None:
if not await _require_admin(message):
return
user_id = _extract_target_user_id(message)
if user_id is None:
await message.answer(
"Использование: /admin_add <user_id> или ответом на сообщение пользователя."
)
return
added = await add_admin(user_id)
text = (
f"Администратор {user_id} добавлен."
if added
else f"Пользователь {user_id} уже есть среди runtime-администраторов."
)
await _deliver_result(
message,
text,
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
@router.message(Command("admin_del"))
async def cmd_admin_del(message: Message) -> None:
if not await _require_admin(message):
return
user_id = _extract_target_user_id(message)
if user_id is None:
await message.answer("Использование: /admin_del <user_id>")
return
removed = await remove_admin(user_id)
text = (
f"Runtime-администратор {user_id} удалён."
if removed
else f"Runtime-администратор {user_id} не найден."
)
await _deliver_result(
message,
text,
back_callback="admin:open",
is_admin=True,
admin_mode=True,
)
@router.message(F.text)
async def cmd_pending_recipient_input(message: Message) -> None:
user_id = _sender_id(message)
if user_id is None:
return
if not await _require_admin(message):
return
raw_value = (message.text or "").strip()
if raw_value.startswith("/"):
return
if user_id in PENDING_SETTING_ACTIONS:
setting_key, target = PENDING_SETTING_ACTIONS.pop(user_id)
if setting_key == "topic":
if not raw_value.lstrip("-").isdigit():
await message.answer("Нужен числовой topic ID.")
return
await set_topic_override(target, int(raw_value))
text = f"Topic для {escape(target)} обновлён: {escape(raw_value)}."
back_callback = "admin:menu:routing"
elif setting_key == "sync_interval_minutes":
if not raw_value.isdigit():
await message.answer("Нужно указать интервал в минутах числом.")
return
await set_runtime_setting("sync_interval_minutes", raw_value)
from glitchup_bot.tasks.scheduler import reload_scheduler
await reload_scheduler()
text = f"Интервал sync обновлён: {escape(raw_value)} мин."
back_callback = "admin:menu:settings"
elif setting_key == "digest_cron_day":
await set_runtime_setting("digest_cron_day", raw_value)
from glitchup_bot.tasks.scheduler import reload_scheduler
await reload_scheduler()
text = f"День weekly digest обновлён: {escape(raw_value)}."
back_callback = "admin:menu:settings"
elif setting_key == "digest_time":
if ":" not in raw_value:
await message.answer("Нужно указать время в формате HH:MM.")
return
hour, minute = raw_value.split(":", 1)
if not (hour.isdigit() and minute.isdigit()):
await message.answer("Нужно указать время в формате HH:MM.")
return
await set_runtime_setting("digest_cron_hour", hour)
await set_runtime_setting("digest_cron_minute", minute)
from glitchup_bot.tasks.scheduler import reload_scheduler
await reload_scheduler()
text = f"Время weekly digest обновлено: {escape(raw_value)}."
back_callback = "admin:menu:settings"
elif setting_key == "digest_timezone":
await set_runtime_setting("digest_timezone", raw_value)
from glitchup_bot.tasks.scheduler import reload_scheduler
await reload_scheduler()
text = f"Timezone обновлён: {escape(raw_value)}."
back_callback = "admin:menu:settings"
else:
await set_runtime_setting(setting_key, raw_value)
text = f"Настройка {escape(setting_key)} обновлена."
back_callback = "admin:menu:settings"
await _deliver_result(
message,
text,
back_callback=back_callback,
is_admin=True,
admin_mode=True,
)
return
if user_id not in PENDING_RECIPIENT_ACTIONS:
return
if not raw_value.lstrip("-").isdigit():
await message.answer("Нужен числовой Telegram ID.")
return
action, group_name = PENDING_RECIPIENT_ACTIONS.pop(user_id)
target_id = int(raw_value)
if action == "add":
await add_subscriber(group_name, target_id)
text = (
f"Telegram ID {target_id} добавлен в группу "
f"{escape(group_name)}."
)
else:
removed = await remove_subscriber(group_name, target_id)
text = (
f"Telegram ID {target_id} удалён из группы "
f"{escape(group_name)}."
if removed
else f"Telegram ID {target_id} не найден в группе "
f"{escape(group_name)}."
)
await _deliver_result(
message,
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:
await message.answer("Самоподписка отключена. Получателей настраивает администратор.")
@router.message(Command("ownership"))
async def cmd_ownership(message: Message) -> None:
if not await _require_admin(message):
return
await _deliver_result(
message,
await _ownership_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=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"Проект {escape(project_slug)} привязан к группе {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 для {escape(group_name)} сохранён: {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}: {escape(rule.pattern)}")
@router.message(Command("mute_list"))
async def cmd_mute_list(message: Message) -> None:
if not await _require_admin(message):
return
await _deliver_result(
message,
await _mute_rules_text(),
back_callback="admin:open",
is_admin=True,
admin_mode=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 удалено.")