diff --git a/migrations/versions/20260330_0003_bot_admins.py b/migrations/versions/20260330_0003_bot_admins.py
new file mode 100644
index 0000000..2b267e4
--- /dev/null
+++ b/migrations/versions/20260330_0003_bot_admins.py
@@ -0,0 +1,37 @@
+"""bot admins
+
+Revision ID: 20260330_0003
+Revises: 20260327_0002
+Create Date: 2026-03-30 11:30:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision: str = "20260330_0003"
+down_revision: str | None = "20260327_0002"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "bot_admins",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("user_id", sa.BigInteger(), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ )
+ op.create_index(op.f("ix_bot_admins_user_id"), "bot_admins", ["user_id"], unique=True)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f("ix_bot_admins_user_id"), table_name="bot_admins")
+ op.drop_table("bot_admins")
diff --git a/src/glitchup_bot/bot/handlers/commands.py b/src/glitchup_bot/bot/handlers/commands.py
index ee3dc9d..5de10d8 100644
--- a/src/glitchup_bot/bot/handlers/commands.py
+++ b/src/glitchup_bot/bot/handlers/commands.py
@@ -1,6 +1,9 @@
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
@@ -18,7 +21,12 @@ from glitchup_bot.bot.keyboards import (
help_result_keyboard,
help_subscriptions_keyboard,
)
-from glitchup_bot.config import settings
+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,
@@ -47,6 +55,20 @@ from glitchup_bot.services.routing import (
router = Router()
logger = logging.getLogger(__name__)
+MAX_PAGE_CHARS = 3000
+MAX_PAGE_LINES = 18
+MAX_PAGINATION_SESSIONS = 200
+
+
+@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:
@@ -57,8 +79,72 @@ 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 _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:
@@ -89,6 +175,9 @@ def _help_text(is_admin: bool) -> str:
"Для админа:",
"• /admin — панель управления",
"• /sync — принудительная синхронизация",
+ "• /admins — список администраторов",
+ "• /admin_add <user_id> или reply на сообщение",
+ "• /admin_del <user_id>",
"• /ownership — текущее распределение routing-настроек",
"• /mute_list — список mute rules",
]
@@ -163,6 +252,7 @@ def _admin_text() -> str:
"",
"• Центр синхронизации — запуск sync и проверка статуса",
"• Сводки и мониторинг — основные обзорные экраны",
+ "• Администраторы — список и управление доступом",
"• Ownership и topics — текущая маршрутизация",
"• Mute rules — правила скрытия шумных событий",
]
@@ -198,6 +288,10 @@ def _admin_guide_text() -> str:
"Через кнопки удобно смотреть состояние системы, "
"а точечные настройки меняются командами.",
"",
+ "• /admin_add 123456 — добавить администратора",
+ "• /admin_add ответом на сообщение — добавить автора сообщения",
+ "• /admin_del 123456 — удалить runtime-администратора",
+ "",
"• /owner slug backend — переназначить проект в группу",
"• /topic backend 123 — сменить topic override",
"• /mute_add payment.*timeout — добавить mute rule",
@@ -218,8 +312,40 @@ def _sync_summary_text(summary) -> str:
)
+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 _require_admin(message: Message) -> bool:
- if _is_admin_user(_sender_id(message)):
+ if await _is_admin_user(_sender_id(message)):
return True
await message.answer("Команда доступна только администраторам.")
@@ -227,7 +353,7 @@ async def _require_admin(message: Message) -> bool:
async def _require_admin_callback(callback: CallbackQuery) -> bool:
- if _is_admin_user(_callback_sender_id(callback)):
+ if await _is_admin_user(_callback_sender_id(callback)):
return True
await callback.answer("Только для администраторов", show_alert=True)
@@ -305,6 +431,49 @@ async def _deliver_text(
)
+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,
@@ -346,9 +515,17 @@ async def _run_summary_action(
target: Message | CallbackQuery,
loader: Callable[[], Awaitable[str]],
*,
- reply_markup=None,
+ back_callback: str,
+ is_admin: bool,
+ admin_mode: bool = False,
) -> None:
- await _deliver_text(target, await loader(), reply_markup=reply_markup)
+ await _deliver_result(
+ target,
+ await loader(),
+ back_callback=back_callback,
+ is_admin=is_admin,
+ admin_mode=admin_mode,
+ )
async def _ownership_text() -> str:
@@ -415,7 +592,7 @@ async def _mute_rules_text() -> str:
@router.message(Command("start"))
async def cmd_start(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
+ is_admin = await _is_admin_user(_sender_id(message))
await message.answer(
_help_text(is_admin),
reply_markup=help_home_keyboard(is_admin),
@@ -425,7 +602,7 @@ async def cmd_start(message: Message) -> None:
@router.message(Command("help"))
async def cmd_help(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
+ is_admin = await _is_admin_user(_sender_id(message))
await message.answer(
_help_text(is_admin),
reply_markup=help_home_keyboard(is_admin),
@@ -449,7 +626,7 @@ async def cmd_admin(message: Message) -> None:
async def cb_help_actions(callback: CallbackQuery) -> None:
data = callback.data or ""
action = data.removeprefix("help:")
- is_admin = _is_admin_user(_callback_sender_id(callback))
+ is_admin = await _is_admin_user(_callback_sender_id(callback))
await callback.answer()
if action == "open":
@@ -491,42 +668,48 @@ async def cb_help_actions(callback: CallbackQuery) -> None:
await _run_summary_action(
callback,
lambda: build_digest(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
return
if action == "today":
await _run_summary_action(
callback,
lambda: build_today_summary(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
return
if action == "top":
await _run_summary_action(
callback,
lambda: build_top_issues(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
return
if action == "stale":
await _run_summary_action(
callback,
lambda: build_stale_issues(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
return
if action == "releases":
await _run_summary_action(
callback,
lambda: build_release_summary(refresh=True),
- reply_markup=help_result_keyboard("help:menu:releases", is_admin),
+ back_callback="help:menu:releases",
+ is_admin=is_admin,
)
return
if action == "sync_status":
await _run_summary_action(
callback,
build_sync_status,
- reply_markup=help_result_keyboard("help:open", is_admin),
+ back_callback="help:open",
+ is_admin=is_admin,
)
return
if action.startswith("sub:"):
@@ -584,96 +767,169 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
reply_markup=admin_overview_keyboard(),
)
return
+ if action == "admins":
+ await _deliver_result(
+ callback,
+ await _admins_text(),
+ back_callback="admin:open",
+ is_admin=True,
+ admin_mode=True,
+ )
+ return
if action == "sync":
summary = await run_manual_sync()
- await _show_callback_screen(
+ await _deliver_result(
callback,
_sync_summary_text(summary),
- reply_markup=admin_result_keyboard("admin:menu:sync"),
+ back_callback="admin:menu:sync",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "sync_status":
await _run_summary_action(
callback,
build_sync_status,
- reply_markup=admin_result_keyboard("admin:menu:sync"),
+ back_callback="admin:menu:sync",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "ownership":
- await _show_callback_screen(
+ await _deliver_result(
callback,
await _ownership_text(),
- reply_markup=admin_result_keyboard("admin:open"),
+ back_callback="admin:open",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "mute_list":
- await _show_callback_screen(
+ await _deliver_result(
callback,
await _mute_rules_text(),
- reply_markup=admin_result_keyboard("admin:open"),
+ back_callback="admin:open",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "releases":
await _run_summary_action(
callback,
lambda: build_release_summary(refresh=True),
- reply_markup=admin_result_keyboard("admin:menu:overview"),
+ 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),
- reply_markup=admin_result_keyboard("admin:menu:overview"),
+ back_callback="admin:menu:overview",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "week":
await _run_summary_action(
callback,
lambda: build_digest(refresh=True),
- reply_markup=admin_result_keyboard("admin:menu:overview"),
+ back_callback="admin:menu:overview",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "top":
await _run_summary_action(
callback,
lambda: build_top_issues(refresh=True),
- reply_markup=admin_result_keyboard("admin:menu:overview"),
+ back_callback="admin:menu:overview",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "stale":
await _run_summary_action(
callback,
lambda: build_stale_issues(refresh=True),
- reply_markup=admin_result_keyboard("admin:menu:overview"),
+ back_callback="admin:menu:overview",
+ is_admin=True,
+ admin_mode=True,
)
return
if action == "guide":
- await _show_callback_screen(
+ await _deliver_result(
callback,
_admin_guide_text(),
- reply_markup=admin_result_keyboard("admin:open"),
+ 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 = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_digest(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
@router.message(Command("today"))
async def cmd_today(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_today_summary(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
@@ -684,41 +940,45 @@ async def cmd_project(message: Message) -> None:
await message.answer("Использование: /project <slug>")
return
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_project_summary(args[1].strip(), refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
@router.message(Command("top"))
async def cmd_top(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_top_issues(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
@router.message(Command("stale"))
async def cmd_stale(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_stale_issues(refresh=True),
- reply_markup=help_result_keyboard("help:menu:monitoring", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:monitoring",
+ is_admin=is_admin,
)
@router.message(Command("releases"))
async def cmd_releases(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_release_summary(refresh=True),
- reply_markup=help_result_keyboard("help:menu:releases", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:releases",
+ is_admin=is_admin,
)
@@ -729,21 +989,23 @@ async def cmd_release(message: Message) -> None:
await message.answer("Использование: /release <version>")
return
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_release_detail(args[1].strip(), refresh=True),
- reply_markup=help_result_keyboard("help:menu:releases", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:menu:releases",
+ is_admin=is_admin,
)
@router.message(Command("sync_status"))
async def cmd_sync_status(message: Message) -> None:
- is_admin = _is_admin_user(_sender_id(message))
- await message.answer(
+ is_admin = await _is_admin_user(_sender_id(message))
+ await _deliver_result(
+ message,
await build_sync_status(),
- reply_markup=help_result_keyboard("help:open", is_admin),
- disable_web_page_preview=True,
+ back_callback="help:open",
+ is_admin=is_admin,
)
@@ -753,10 +1015,78 @@ async def cmd_sync(message: Message) -> None:
return
summary = await run_manual_sync()
- await message.answer(
+ await _deliver_result(
+ message,
_sync_summary_text(summary),
- reply_markup=admin_result_keyboard("admin:menu:sync"),
- disable_web_page_preview=True,
+ 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,
)
@@ -767,7 +1097,7 @@ async def cmd_subscribe(message: Message) -> None:
await message.answer("Использование: /subscribe <backend|frontend>")
return
- is_admin = _is_admin_user(_sender_id(message))
+ is_admin = await _is_admin_user(_sender_id(message))
await _handle_subscription_action(
message,
args[1].strip().lower(),
@@ -784,7 +1114,7 @@ async def cmd_unsubscribe(message: Message) -> None:
await message.answer("Использование: /unsubscribe <backend|frontend>")
return
- is_admin = _is_admin_user(_sender_id(message))
+ is_admin = await _is_admin_user(_sender_id(message))
await _handle_subscription_action(
message,
args[1].strip().lower(),
@@ -799,10 +1129,12 @@ async def cmd_ownership(message: Message) -> None:
if not await _require_admin(message):
return
- await message.answer(
+ await _deliver_result(
+ message,
await _ownership_text(),
- reply_markup=admin_result_keyboard("admin:open"),
- disable_web_page_preview=True,
+ back_callback="admin:open",
+ is_admin=True,
+ admin_mode=True,
)
@@ -897,10 +1229,12 @@ async def cmd_mute_list(message: Message) -> None:
if not await _require_admin(message):
return
- await message.answer(
+ await _deliver_result(
+ message,
await _mute_rules_text(),
- reply_markup=admin_result_keyboard("admin:open"),
- disable_web_page_preview=True,
+ back_callback="admin:open",
+ is_admin=True,
+ admin_mode=True,
)
diff --git a/src/glitchup_bot/bot/keyboards.py b/src/glitchup_bot/bot/keyboards.py
index bdb4e98..de844f9 100644
--- a/src/glitchup_bot/bot/keyboards.py
+++ b/src/glitchup_bot/bot/keyboards.py
@@ -2,6 +2,26 @@ from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
+def _add_pagination_row(
+ builder: InlineKeyboardBuilder,
+ *,
+ page_token: str | None,
+ page_index: int,
+ total_pages: int,
+) -> None:
+ if not page_token or total_pages <= 1:
+ return
+
+ if page_index > 0:
+ builder.button(text="◀", callback_data=f"page:{page_token}:{page_index - 1}")
+ builder.button(
+ text=f"{page_index + 1}/{total_pages}",
+ callback_data=f"page:{page_token}:{page_index}",
+ )
+ if page_index + 1 < total_pages:
+ builder.button(text="▶", callback_data=f"page:{page_token}:{page_index + 1}")
+
+
def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring")
@@ -51,15 +71,34 @@ def help_subscriptions_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
return builder.as_markup()
-def help_result_keyboard(back_callback: str, is_admin: bool) -> InlineKeyboardMarkup:
+def help_result_keyboard(
+ back_callback: str,
+ is_admin: bool,
+ *,
+ page_token: str | None = None,
+ page_index: int = 0,
+ total_pages: int = 1,
+) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
+ _add_pagination_row(
+ builder,
+ page_token=page_token,
+ page_index=page_index,
+ total_pages=total_pages,
+ )
builder.button(text="Назад", callback_data=back_callback)
builder.button(text="Главное меню", callback_data="help:open")
if is_admin:
builder.button(text="Админ-панель", callback_data="admin:open")
- builder.adjust(2, 1)
+ if total_pages > 1:
+ builder.adjust(3, 2, 1)
+ else:
+ builder.adjust(2, 1)
else:
- builder.adjust(2)
+ if total_pages > 1:
+ builder.adjust(3, 2)
+ else:
+ builder.adjust(2)
return builder.as_markup()
@@ -67,11 +106,12 @@ def admin_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
builder.button(text="Центр синхронизации", callback_data="admin:menu:sync")
builder.button(text="Сводки и мониторинг", callback_data="admin:menu:overview")
+ builder.button(text="Администраторы", callback_data="admin:admins")
builder.button(text="Ownership и topics", callback_data="admin:ownership")
builder.button(text="Mute rules", callback_data="admin:mute_list")
builder.button(text="Инструкция", callback_data="admin:guide")
builder.button(text="Пользовательское меню", callback_data="help:open")
- builder.adjust(2, 2, 1, 1)
+ builder.adjust(2, 2, 2, 1)
return builder.as_markup()
@@ -97,10 +137,25 @@ def admin_overview_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup()
-def admin_result_keyboard(back_callback: str) -> InlineKeyboardMarkup:
+def admin_result_keyboard(
+ back_callback: str,
+ *,
+ page_token: str | None = None,
+ page_index: int = 0,
+ total_pages: int = 1,
+) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
+ _add_pagination_row(
+ builder,
+ page_token=page_token,
+ page_index=page_index,
+ total_pages=total_pages,
+ )
builder.button(text="Назад", callback_data=back_callback)
builder.button(text="Админ-панель", callback_data="admin:open")
builder.button(text="Пользовательское меню", callback_data="help:open")
- builder.adjust(2, 1)
+ if total_pages > 1:
+ builder.adjust(3, 2, 1)
+ else:
+ builder.adjust(2, 1)
return builder.as_markup()
diff --git a/src/glitchup_bot/models/__init__.py b/src/glitchup_bot/models/__init__.py
index eeb744f..a78d6c2 100644
--- a/src/glitchup_bot/models/__init__.py
+++ b/src/glitchup_bot/models/__init__.py
@@ -1,3 +1,4 @@
+from glitchup_bot.models.admins import BotAdmin
from glitchup_bot.models.base import Base
from glitchup_bot.models.issues import IssueCache
from glitchup_bot.models.mute_rules import MuteRule
@@ -11,6 +12,7 @@ from glitchup_bot.models.sync import SyncState
__all__ = [
"Base",
+ "BotAdmin",
"GroupSubscriberOverride",
"GroupTopicOverride",
"IssueCache",
diff --git a/src/glitchup_bot/models/admins.py b/src/glitchup_bot/models/admins.py
new file mode 100644
index 0000000..f9e9762
--- /dev/null
+++ b/src/glitchup_bot/models/admins.py
@@ -0,0 +1,14 @@
+from datetime import datetime
+
+from sqlalchemy import BigInteger, DateTime, Integer, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from glitchup_bot.models.base import Base
+
+
+class BotAdmin(Base):
+ __tablename__ = "bot_admins"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ user_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
diff --git a/src/glitchup_bot/services/admins.py b/src/glitchup_bot/services/admins.py
new file mode 100644
index 0000000..17ce61a
--- /dev/null
+++ b/src/glitchup_bot/services/admins.py
@@ -0,0 +1,63 @@
+from sqlalchemy import select
+
+from glitchup_bot.config import settings
+from glitchup_bot.models.admins import BotAdmin
+from glitchup_bot.models.database import get_session_factory
+
+
+async def list_runtime_admins() -> list[BotAdmin]:
+ async with get_session_factory()() as session:
+ result = await session.execute(select(BotAdmin).order_by(BotAdmin.user_id))
+ return list(result.scalars().all())
+
+
+async def list_effective_admins() -> list[tuple[int, str]]:
+ sources_by_user: dict[int, set[str]] = {}
+
+ for user_id in settings.telegram_admin_ids:
+ sources_by_user.setdefault(user_id, set()).add("env")
+
+ for record in await list_runtime_admins():
+ sources_by_user.setdefault(record.user_id, set()).add("runtime")
+
+ return [
+ (user_id, " + ".join(sorted(sources)))
+ for user_id, sources in sorted(sources_by_user.items(), key=lambda item: item[0])
+ ]
+
+
+async def is_admin(user_id: int | None) -> bool:
+ if user_id is None:
+ return False
+ if user_id in settings.telegram_admin_ids:
+ return True
+
+ runtime_admins = await list_runtime_admins()
+ if any(record.user_id == user_id for record in runtime_admins):
+ return True
+
+ return not settings.telegram_admin_ids and not runtime_admins
+
+
+async def add_admin(user_id: int) -> bool:
+ async with get_session_factory()() as session:
+ result = await session.execute(select(BotAdmin).where(BotAdmin.user_id == user_id))
+ record = result.scalar_one_or_none()
+ if record is not None:
+ return False
+
+ session.add(BotAdmin(user_id=user_id))
+ await session.commit()
+ return True
+
+
+async def remove_admin(user_id: int) -> bool:
+ async with get_session_factory()() as session:
+ result = await session.execute(select(BotAdmin).where(BotAdmin.user_id == user_id))
+ record = result.scalar_one_or_none()
+ if record is None:
+ return False
+
+ await session.delete(record)
+ await session.commit()
+ return True