diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py
index 8ff4bc3..d56eb25 100644
--- a/src/glitchup_bot/bot/handlers/commands.py
+++ b/src/glitchup_bot/bot/handlers/commands.py
@@ -11,6 +11,7 @@ from aiogram.filters import Command
from aiogram.types import CallbackQuery, Message
from glitchup_bot.bot.keyboards import (
+ admin_admins_keyboard,
admin_home_keyboard,
admin_recipient_group_keyboard,
admin_recipients_keyboard,
@@ -58,6 +59,7 @@ logger = logging.getLogger(__name__)
MAX_PAGE_CHARS = 3000
MAX_PAGE_LINES = 18
MAX_PAGINATION_SESSIONS = 200
+PENDING_ADMIN_ACTIONS: dict[int, str] = {}
PENDING_RECIPIENT_ACTIONS: dict[int, tuple[str, str]] = {}
PENDING_SETTING_ACTIONS: dict[int, tuple[str, str]] = {}
@@ -798,68 +800,11 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
reply_markup=admin_routing_keyboard(),
)
return
- if action == "hint:sync":
- await _deliver_result(
+ if action == "menu:admins":
+ await _show_callback_screen(
callback,
- _admin_hint_text("sync"),
- back_callback="admin:menu:sync",
- is_admin=True,
- admin_mode=True,
- )
- return
- if action == "hint:recipients":
- await _deliver_result(
- callback,
- _admin_hint_text("recipients"),
- back_callback="admin:menu:recipients",
- is_admin=True,
- admin_mode=True,
- )
- return
- if action == "hint:routing":
- await _deliver_result(
- callback,
- _admin_hint_text("routing"),
- back_callback="admin:menu:routing",
- is_admin=True,
- admin_mode=True,
- )
- return
- if action == "hint:settings":
- await _deliver_result(
- callback,
- _admin_hint_text("settings"),
- back_callback="admin:menu:settings",
- is_admin=True,
- admin_mode=True,
- )
- return
- if action == "hint:mute":
- await _deliver_result(
- callback,
- _admin_hint_text("mute"),
- back_callback="admin:open",
- is_admin=True,
- admin_mode=True,
- )
- return
- if action == "hint:admins":
- await _deliver_result(
- callback,
- _admin_hint_text("admins"),
- back_callback="admin:open",
- is_admin=True,
- admin_mode=True,
- )
- return
- if action.startswith("hint:recipient_group:"):
- group_name = action.rsplit(":", 1)[1]
- await _deliver_result(
- callback,
- _admin_hint_text("recipient_group", group_name),
- back_callback=f"admin:recipients:{group_name}",
- is_admin=True,
- admin_mode=True,
+ await _admins_text(),
+ reply_markup=admin_admins_keyboard(),
)
return
if action == "menu:settings":
@@ -925,11 +870,37 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
await _deliver_result(
callback,
await _admins_text(),
- back_callback="admin:open",
+ back_callback="admin:menu:admins",
is_admin=True,
admin_mode=True,
)
return
+ if action == "admins:add":
+ admin_id = _callback_sender_id(callback)
+ if admin_id is not None:
+ PENDING_ADMIN_ACTIONS[admin_id] = "add"
+ await _show_callback_screen(
+ callback,
+ (
+ "Добавление администратора\n\n"
+ "Отправьте следующим сообщением Telegram ID, который нужно добавить."
+ ),
+ reply_markup=admin_result_keyboard("admin:menu:admins"),
+ )
+ return
+ if action == "admins:del":
+ admin_id = _callback_sender_id(callback)
+ if admin_id is not None:
+ PENDING_ADMIN_ACTIONS[admin_id] = "del"
+ await _show_callback_screen(
+ callback,
+ (
+ "Удаление администратора\n\n"
+ "Отправьте следующим сообщением Telegram ID, который нужно удалить."
+ ),
+ reply_markup=admin_result_keyboard("admin:menu:admins"),
+ )
+ return
if action == "settings:sync_enabled":
runtime = await get_runtime_settings()
await set_runtime_setting("sync_enabled", "false" if runtime.sync_enabled else "true")
@@ -938,8 +909,8 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
await reload_scheduler()
await _show_callback_screen(
callback,
- await _runtime_settings_text(),
- reply_markup=admin_settings_keyboard(),
+ _admin_sync_text(),
+ reply_markup=admin_sync_keyboard(),
)
return
if action == "settings:sync_interval":
@@ -949,7 +920,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
await _show_callback_screen(
callback,
_setting_prompt_text("sync_interval_minutes", ""),
- reply_markup=admin_result_keyboard("admin:menu:settings"),
+ reply_markup=admin_result_keyboard("admin:menu:sync"),
)
return
if action == "settings:digest_day":
@@ -959,7 +930,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
await _show_callback_screen(
callback,
_setting_prompt_text("digest_cron_day", ""),
- reply_markup=admin_result_keyboard("admin:menu:settings"),
+ reply_markup=admin_result_keyboard("admin:menu:sync"),
)
return
if action == "settings:digest_time":
@@ -969,7 +940,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
await _show_callback_screen(
callback,
_setting_prompt_text("digest_time", ""),
- reply_markup=admin_result_keyboard("admin:menu:settings"),
+ reply_markup=admin_result_keyboard("admin:menu:sync"),
)
return
if action == "settings:digest_timezone":
@@ -979,7 +950,7 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
await _show_callback_screen(
callback,
_setting_prompt_text("digest_timezone", ""),
- reply_markup=admin_result_keyboard("admin:menu:settings"),
+ reply_markup=admin_result_keyboard("admin:menu:sync"),
)
return
if action == "settings:bot_title":
@@ -1217,7 +1188,7 @@ async def cmd_admins(message: Message) -> None:
await _deliver_result(
message,
await _admins_text(),
- back_callback="admin:open",
+ back_callback="admin:menu:admins",
is_admin=True,
admin_mode=True,
)
@@ -1244,7 +1215,7 @@ async def cmd_admin_add(message: Message) -> None:
await _deliver_result(
message,
text,
- back_callback="admin:open",
+ back_callback="admin:menu:admins",
is_admin=True,
admin_mode=True,
)
@@ -1269,7 +1240,7 @@ async def cmd_admin_del(message: Message) -> None:
await _deliver_result(
message,
text,
- back_callback="admin:open",
+ back_callback="admin:menu:admins",
is_admin=True,
admin_mode=True,
)
@@ -1306,14 +1277,14 @@ async def cmd_pending_recipient_input(message: Message) -> None:
await reload_scheduler()
text = f"Интервал sync обновлён: {escape(raw_value)} мин."
- back_callback = "admin:menu:settings"
+ back_callback = "admin:menu:sync"
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"
+ back_callback = "admin:menu:sync"
elif setting_key == "digest_time":
if ":" not in raw_value:
await message.answer("Нужно указать время в формате HH:MM.")
@@ -1328,14 +1299,14 @@ async def cmd_pending_recipient_input(message: Message) -> None:
await reload_scheduler()
text = f"Время weekly digest обновлено: {escape(raw_value)}."
- back_callback = "admin:menu:settings"
+ back_callback = "admin:menu:sync"
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"
+ back_callback = "admin:menu:sync"
else:
await set_runtime_setting(setting_key, raw_value)
text = f"Настройка {escape(setting_key)} обновлена."
@@ -1350,6 +1321,40 @@ async def cmd_pending_recipient_input(message: Message) -> None:
)
return
+ if user_id in PENDING_ADMIN_ACTIONS:
+ if not raw_value.lstrip("-").isdigit():
+ await message.answer("Нужен числовой Telegram ID.")
+ return
+
+ action = PENDING_ADMIN_ACTIONS.pop(user_id)
+ target_id = int(raw_value)
+ if action == "add":
+ added = await add_admin(target_id)
+ text = (
+ f"Администратор {target_id} добавлен."
+ if added
+ else (
+ f"Пользователь {target_id} уже есть среди "
+ "runtime-администраторов."
+ )
+ )
+ else:
+ removed = await remove_admin(target_id)
+ text = (
+ f"Runtime-администратор {target_id} удалён."
+ if removed
+ else f"Runtime-администратор {target_id} не найден."
+ )
+
+ await _deliver_result(
+ message,
+ text,
+ back_callback="admin:menu:admins",
+ is_admin=True,
+ admin_mode=True,
+ )
+ return
+
if user_id not in PENDING_RECIPIENT_ACTIONS:
return
@@ -1385,6 +1390,95 @@ async def cmd_pending_recipient_input(message: Message) -> None:
)
+def _admin_routing_text() -> str:
+ return "\n".join(
+ [
+ "Topic и routing",
+ "",
+ "Здесь можно смотреть текущую схему доставки и менять topic override без env.",
+ (
+ "Routing определяет, к какой группе относится проект, а topic override "
+ "определяет тему Telegram для backend, frontend и digest."
+ ),
+ ]
+ )
+
+
+def _admin_sync_text() -> str:
+ return "\n".join(
+ [
+ "Синхронизация",
+ "",
+ "Синхронизация подтягивает актуальные issues из GlitchTip в локальный кэш бота.",
+ (
+ "Здесь удобно запускать ручной sync, смотреть статус и менять "
+ "расписание, не трогая .env."
+ ),
+ ]
+ )
+
+
+def _admin_recipients_text() -> str:
+ return "\n".join(
+ [
+ "Получатели уведомлений",
+ "",
+ "Выберите группу и назначайте Telegram ID через кнопки.",
+ (
+ "Получатели — это пользователи, которым бот отправляет уведомления "
+ "по backend или frontend."
+ ),
+ ]
+ )
+
+
+async def _runtime_settings_text() -> str:
+ runtime = await get_runtime_settings()
+ return "\n".join(
+ [
+ "Настройки бота",
+ "",
+ "Здесь собраны тексты интерфейса, которые можно менять без правки .env.",
+ f"• название: {escape(runtime.bot_title)}",
+ f"• описание: {escape(runtime.bot_purpose)}",
+ f"• подсказка админу: {escape(runtime.bot_admin_hint)}",
+ ]
+ )
+
+
+def _recipient_group_text(group_name: str) -> str:
+ return "\n".join(
+ [
+ f"{escape(group_name.capitalize())} получатели",
+ "",
+ "Здесь можно посмотреть список, добавить новый Telegram ID или удалить существующий.",
+ (
+ "Удаление отсюда прекращает доставку уведомлений этому пользователю "
+ "по выбранной группе."
+ ),
+ ]
+ )
+
+
+async def _admins_text() -> str:
+ admins = await list_effective_admins()
+ lines = [
+ "Администраторы",
+ "",
+ "Администраторы могут менять настройки бота, получателей и расписание.",
+ "Добавление и удаление можно делать прямо кнопками ниже по Telegram ID.",
+ ]
+ if not admins:
+ lines.extend(["", "Список пока пуст."])
+ return "\n".join(lines)
+
+ lines.append("")
+ for user_id, source in admins:
+ source_label = source.replace("env", "из .env")
+ lines.append(f"• {user_id} — {source_label}")
+ return "\n".join(lines)
+
+
@router.message(Command("subscribe"))
async def cmd_subscribe(message: Message) -> None:
await message.answer("Самоподписка отключена. Получателей настраивает администратор.")
diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py
index 7d00e3f..9478b23 100644
--- a/src/glitchup_bot/bot/keyboards.py
+++ b/src/glitchup_bot/bot/keyboards.py
@@ -71,13 +71,11 @@ def admin_home_keyboard() -> InlineKeyboardMarkup:
builder.button(text="Получатели", callback_data="admin:menu:recipients")
builder.button(text="Topic и routing", callback_data="admin:menu:routing")
builder.button(text="Настройки бота", callback_data="admin:menu:settings")
- builder.button(text="Администраторы", callback_data="admin:admins")
- builder.button(text="Что такое админы?", callback_data="admin:hint:admins")
+ builder.button(text="Администраторы", callback_data="admin:menu:admins")
builder.button(text="Mute rules", callback_data="admin:mute_list")
- builder.button(text="Что такое mute?", callback_data="admin:hint:mute")
builder.button(text="Инструкция", callback_data="admin:guide")
builder.button(text="Назад", callback_data="help:open")
- builder.adjust(2, 2, 2, 2, 1)
+ builder.adjust(2, 2, 2, 2)
return builder.as_markup()
@@ -85,9 +83,13 @@ def admin_sync_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Запустить sync", callback_data="admin:sync")
builder.button(text="Статус sync", callback_data="admin:sync_status")
- builder.button(text="Что это?", callback_data="admin:hint:sync")
+ builder.button(text="Автосинк: вкл/выкл", callback_data="admin:settings:sync_enabled")
+ builder.button(text="Интервал sync", callback_data="admin:settings:sync_interval")
+ builder.button(text="День отчёта", callback_data="admin:settings:digest_day")
+ builder.button(text="Время отчёта", callback_data="admin:settings:digest_time")
+ builder.button(text="Часовой пояс", callback_data="admin:settings:digest_timezone")
builder.button(text="Назад", callback_data="admin:open")
- builder.adjust(2, 1, 1)
+ builder.adjust(2, 2, 2, 1, 1)
return builder.as_markup()
@@ -97,7 +99,6 @@ def admin_routing_keyboard() -> InlineKeyboardMarkup:
builder.button(text="Topic backend", callback_data="admin:settings:topic:backend")
builder.button(text="Topic frontend", callback_data="admin:settings:topic:frontend")
builder.button(text="Topic digest", callback_data="admin:settings:topic:digest")
- builder.button(text="Что это?", callback_data="admin:hint:routing")
builder.button(text="Назад", callback_data="admin:open")
builder.adjust(2, 2, 1, 1)
return builder.as_markup()
@@ -105,17 +106,11 @@ def admin_routing_keyboard() -> InlineKeyboardMarkup:
def admin_settings_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
- builder.button(text="Автосинк: вкл/выкл", callback_data="admin:settings:sync_enabled")
- builder.button(text="Интервал sync", callback_data="admin:settings:sync_interval")
- builder.button(text="День отчёта", callback_data="admin:settings:digest_day")
- builder.button(text="Время отчёта", callback_data="admin:settings:digest_time")
- builder.button(text="Часовой пояс", callback_data="admin:settings:digest_timezone")
builder.button(text="Название бота", callback_data="admin:settings:bot_title")
builder.button(text="Описание бота", callback_data="admin:settings:bot_purpose")
builder.button(text="Подсказка админу", callback_data="admin:settings:bot_admin_hint")
- builder.button(text="Что это?", callback_data="admin:hint:settings")
builder.button(text="Назад", callback_data="admin:open")
- builder.adjust(2, 2, 2, 2, 1, 1)
+ builder.adjust(2, 1, 1)
return builder.as_markup()
@@ -123,9 +118,8 @@ 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:hint:recipients")
builder.button(text="Назад", callback_data="admin:open")
- builder.adjust(2, 1, 1)
+ builder.adjust(2, 1)
return builder.as_markup()
@@ -134,9 +128,18 @@ def admin_recipient_group_keyboard(group_name: str) -> InlineKeyboardMarkup:
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=f"admin:hint:recipient_group:{group_name}")
builder.button(text="Назад", callback_data="admin:menu:recipients")
- builder.adjust(2, 1, 1, 1)
+ builder.adjust(2, 1, 1)
+ return builder.as_markup()
+
+
+def admin_admins_keyboard() -> InlineKeyboardMarkup:
+ builder = InlineKeyboardBuilder()
+ builder.button(text="Список", callback_data="admin:admins")
+ builder.button(text="Добавить ID", callback_data="admin:admins:add")
+ builder.button(text="Удалить ID", callback_data="admin:admins:del")
+ builder.button(text="Назад", callback_data="admin:open")
+ builder.adjust(2, 1, 1)
return builder.as_markup()