тут всякие фиксы
Some checks failed
CI / Lint (ruff + mypy) (push) Failing after 39s
CI / Run tests (push) Has been skipped
CI / Docker build test (push) Successful in 17s

This commit is contained in:
2026-03-30 18:04:49 +07:00
parent 3f176902a2
commit bdae79db58
6 changed files with 584 additions and 79 deletions

View File

@@ -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")

View File

@@ -1,6 +1,9 @@
import logging import logging
from collections import OrderedDict
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from html import escape from html import escape
from uuid import uuid4
from aiogram import F, Router from aiogram import F, Router
from aiogram.exceptions import TelegramBadRequest from aiogram.exceptions import TelegramBadRequest
@@ -18,7 +21,12 @@ from glitchup_bot.bot.keyboards import (
help_result_keyboard, help_result_keyboard,
help_subscriptions_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 ( from glitchup_bot.services.digest_builder import (
build_digest, build_digest,
build_project_summary, build_project_summary,
@@ -47,6 +55,20 @@ from glitchup_bot.services.routing import (
router = Router() router = Router()
logger = logging.getLogger(__name__) 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: 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 return callback.from_user.id if callback.from_user else None
def _is_admin_user(user_id: int | None) -> bool: async def _is_admin_user(user_id: int | None) -> bool:
return settings.is_admin(user_id) 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: def _help_text(is_admin: bool) -> str:
@@ -89,6 +175,9 @@ def _help_text(is_admin: bool) -> str:
"<b>Для админа:</b>", "<b>Для админа:</b>",
"• /admin — панель управления", "• /admin — панель управления",
"• /sync — принудительная синхронизация", "• /sync — принудительная синхронизация",
"• /admins — список администраторов",
"• /admin_add &lt;user_id&gt; или reply на сообщение",
"• /admin_del &lt;user_id&gt;",
"• /ownership — текущее распределение routing-настроек", "• /ownership — текущее распределение routing-настроек",
"• /mute_list — список mute rules", "• /mute_list — список mute rules",
] ]
@@ -163,6 +252,7 @@ def _admin_text() -> str:
"", "",
"• <b>Центр синхронизации</b> — запуск sync и проверка статуса", "• <b>Центр синхронизации</b> — запуск sync и проверка статуса",
"• <b>Сводки и мониторинг</b> — основные обзорные экраны", "• <b>Сводки и мониторинг</b> — основные обзорные экраны",
"• <b>Администраторы</b> — список и управление доступом",
"• <b>Ownership и topics</b> — текущая маршрутизация", "• <b>Ownership и topics</b> — текущая маршрутизация",
"• <b>Mute rules</b> — правила скрытия шумных событий", "• <b>Mute rules</b> — правила скрытия шумных событий",
] ]
@@ -198,6 +288,10 @@ def _admin_guide_text() -> str:
"Через кнопки удобно смотреть состояние системы, " "Через кнопки удобно смотреть состояние системы, "
"а точечные настройки меняются командами.", "а точечные настройки меняются командами.",
"", "",
"• <code>/admin_add 123456</code> — добавить администратора",
"• <code>/admin_add</code> ответом на сообщение — добавить автора сообщения",
"• <code>/admin_del 123456</code> — удалить runtime-администратора",
"",
"• <code>/owner slug backend</code> — переназначить проект в группу", "• <code>/owner slug backend</code> — переназначить проект в группу",
"• <code>/topic backend 123</code> — сменить topic override", "• <code>/topic backend 123</code> — сменить topic override",
"• <code>/mute_add payment.*timeout</code> — добавить mute rule", "• <code>/mute_add payment.*timeout</code> — добавить 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 = [
"<b>Администраторы</b>",
"",
"Добавлять можно командой <code>/admin_add &lt;user_id&gt;</code> "
"или ответом на сообщение пользователя.",
"",
]
for user_id, source in admins:
source_label = source.replace("env", "из .env")
lines.append(f"• <code>{user_id}</code> — {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: async def _require_admin(message: Message) -> bool:
if _is_admin_user(_sender_id(message)): if await _is_admin_user(_sender_id(message)):
return True return True
await message.answer("Команда доступна только администраторам.") await message.answer("Команда доступна только администраторам.")
@@ -227,7 +353,7 @@ async def _require_admin(message: Message) -> bool:
async def _require_admin_callback(callback: CallbackQuery) -> 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 return True
await callback.answer("Только для администраторов", show_alert=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( async def _handle_subscription_action(
target: Message | CallbackQuery, target: Message | CallbackQuery,
group_name: str, group_name: str,
@@ -346,9 +515,17 @@ async def _run_summary_action(
target: Message | CallbackQuery, target: Message | CallbackQuery,
loader: Callable[[], Awaitable[str]], loader: Callable[[], Awaitable[str]],
*, *,
reply_markup=None, back_callback: str,
is_admin: bool,
admin_mode: bool = False,
) -> None: ) -> 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: async def _ownership_text() -> str:
@@ -415,7 +592,7 @@ async def _mute_rules_text() -> str:
@router.message(Command("start")) @router.message(Command("start"))
async def cmd_start(message: Message) -> None: 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( await message.answer(
_help_text(is_admin), _help_text(is_admin),
reply_markup=help_home_keyboard(is_admin), reply_markup=help_home_keyboard(is_admin),
@@ -425,7 +602,7 @@ async def cmd_start(message: Message) -> None:
@router.message(Command("help")) @router.message(Command("help"))
async def cmd_help(message: Message) -> None: 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( await message.answer(
_help_text(is_admin), _help_text(is_admin),
reply_markup=help_home_keyboard(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: async def cb_help_actions(callback: CallbackQuery) -> None:
data = callback.data or "" data = callback.data or ""
action = data.removeprefix("help:") 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() await callback.answer()
if action == "open": if action == "open":
@@ -491,42 +668,48 @@ async def cb_help_actions(callback: CallbackQuery) -> None:
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_digest(refresh=True), 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 return
if action == "today": if action == "today":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_today_summary(refresh=True), 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 return
if action == "top": if action == "top":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_top_issues(refresh=True), 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 return
if action == "stale": if action == "stale":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_stale_issues(refresh=True), 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 return
if action == "releases": if action == "releases":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_release_summary(refresh=True), 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 return
if action == "sync_status": if action == "sync_status":
await _run_summary_action( await _run_summary_action(
callback, callback,
build_sync_status, build_sync_status,
reply_markup=help_result_keyboard("help:open", is_admin), back_callback="help:open",
is_admin=is_admin,
) )
return return
if action.startswith("sub:"): if action.startswith("sub:"):
@@ -584,96 +767,169 @@ async def cb_admin_actions(callback: CallbackQuery) -> None:
reply_markup=admin_overview_keyboard(), reply_markup=admin_overview_keyboard(),
) )
return 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": if action == "sync":
summary = await run_manual_sync() summary = await run_manual_sync()
await _show_callback_screen( await _deliver_result(
callback, callback,
_sync_summary_text(summary), _sync_summary_text(summary),
reply_markup=admin_result_keyboard("admin:menu:sync"), back_callback="admin:menu:sync",
is_admin=True,
admin_mode=True,
) )
return return
if action == "sync_status": if action == "sync_status":
await _run_summary_action( await _run_summary_action(
callback, callback,
build_sync_status, build_sync_status,
reply_markup=admin_result_keyboard("admin:menu:sync"), back_callback="admin:menu:sync",
is_admin=True,
admin_mode=True,
) )
return return
if action == "ownership": if action == "ownership":
await _show_callback_screen( await _deliver_result(
callback, callback,
await _ownership_text(), await _ownership_text(),
reply_markup=admin_result_keyboard("admin:open"), back_callback="admin:open",
is_admin=True,
admin_mode=True,
) )
return return
if action == "mute_list": if action == "mute_list":
await _show_callback_screen( await _deliver_result(
callback, callback,
await _mute_rules_text(), await _mute_rules_text(),
reply_markup=admin_result_keyboard("admin:open"), back_callback="admin:open",
is_admin=True,
admin_mode=True,
) )
return return
if action == "releases": if action == "releases":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_release_summary(refresh=True), 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 return
if action == "today": if action == "today":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_today_summary(refresh=True), 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 return
if action == "week": if action == "week":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_digest(refresh=True), 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 return
if action == "top": if action == "top":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_top_issues(refresh=True), 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 return
if action == "stale": if action == "stale":
await _run_summary_action( await _run_summary_action(
callback, callback,
lambda: build_stale_issues(refresh=True), 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 return
if action == "guide": if action == "guide":
await _show_callback_screen( await _deliver_result(
callback, callback,
_admin_guide_text(), _admin_guide_text(),
reply_markup=admin_result_keyboard("admin:open"), back_callback="admin:open",
is_admin=True,
admin_mode=True,
) )
return 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")) @router.message(Command("week"))
async def cmd_week(message: Message) -> None: async def cmd_week(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_digest(refresh=True), await build_digest(refresh=True),
reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), back_callback="help:menu:monitoring",
disable_web_page_preview=True, is_admin=is_admin,
) )
@router.message(Command("today")) @router.message(Command("today"))
async def cmd_today(message: Message) -> None: async def cmd_today(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_today_summary(refresh=True), await build_today_summary(refresh=True),
reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), back_callback="help:menu:monitoring",
disable_web_page_preview=True, is_admin=is_admin,
) )
@@ -684,41 +940,45 @@ async def cmd_project(message: Message) -> None:
await message.answer("Использование: /project &lt;slug&gt;") await message.answer("Использование: /project &lt;slug&gt;")
return return
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_project_summary(args[1].strip(), refresh=True), await build_project_summary(args[1].strip(), refresh=True),
reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), back_callback="help:menu:monitoring",
disable_web_page_preview=True, is_admin=is_admin,
) )
@router.message(Command("top")) @router.message(Command("top"))
async def cmd_top(message: Message) -> None: async def cmd_top(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_top_issues(refresh=True), await build_top_issues(refresh=True),
reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), back_callback="help:menu:monitoring",
disable_web_page_preview=True, is_admin=is_admin,
) )
@router.message(Command("stale")) @router.message(Command("stale"))
async def cmd_stale(message: Message) -> None: async def cmd_stale(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_stale_issues(refresh=True), await build_stale_issues(refresh=True),
reply_markup=help_result_keyboard("help:menu:monitoring", is_admin), back_callback="help:menu:monitoring",
disable_web_page_preview=True, is_admin=is_admin,
) )
@router.message(Command("releases")) @router.message(Command("releases"))
async def cmd_releases(message: Message) -> None: async def cmd_releases(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_release_summary(refresh=True), await build_release_summary(refresh=True),
reply_markup=help_result_keyboard("help:menu:releases", is_admin), back_callback="help:menu:releases",
disable_web_page_preview=True, is_admin=is_admin,
) )
@@ -729,21 +989,23 @@ async def cmd_release(message: Message) -> None:
await message.answer("Использование: /release &lt;version&gt;") await message.answer("Использование: /release &lt;version&gt;")
return return
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_release_detail(args[1].strip(), refresh=True), await build_release_detail(args[1].strip(), refresh=True),
reply_markup=help_result_keyboard("help:menu:releases", is_admin), back_callback="help:menu:releases",
disable_web_page_preview=True, is_admin=is_admin,
) )
@router.message(Command("sync_status")) @router.message(Command("sync_status"))
async def cmd_sync_status(message: Message) -> None: async def cmd_sync_status(message: Message) -> None:
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await message.answer( await _deliver_result(
message,
await build_sync_status(), await build_sync_status(),
reply_markup=help_result_keyboard("help:open", is_admin), back_callback="help:open",
disable_web_page_preview=True, is_admin=is_admin,
) )
@@ -753,10 +1015,78 @@ async def cmd_sync(message: Message) -> None:
return return
summary = await run_manual_sync() summary = await run_manual_sync()
await message.answer( await _deliver_result(
message,
_sync_summary_text(summary), _sync_summary_text(summary),
reply_markup=admin_result_keyboard("admin:menu:sync"), back_callback="admin:menu:sync",
disable_web_page_preview=True, 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 &lt;user_id&gt; или ответом на сообщение пользователя."
)
return
added = await add_admin(user_id)
text = (
f"Администратор <code>{user_id}</code> добавлен."
if added
else f"Пользователь <code>{user_id}</code> уже есть среди 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 &lt;user_id&gt;")
return
removed = await remove_admin(user_id)
text = (
f"Runtime-администратор <code>{user_id}</code> удалён."
if removed
else f"Runtime-администратор <code>{user_id}</code> не найден."
)
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 &lt;backend|frontend&gt;") await message.answer("Использование: /subscribe &lt;backend|frontend&gt;")
return return
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _handle_subscription_action( await _handle_subscription_action(
message, message,
args[1].strip().lower(), args[1].strip().lower(),
@@ -784,7 +1114,7 @@ async def cmd_unsubscribe(message: Message) -> None:
await message.answer("Использование: /unsubscribe &lt;backend|frontend&gt;") await message.answer("Использование: /unsubscribe &lt;backend|frontend&gt;")
return return
is_admin = _is_admin_user(_sender_id(message)) is_admin = await _is_admin_user(_sender_id(message))
await _handle_subscription_action( await _handle_subscription_action(
message, message,
args[1].strip().lower(), args[1].strip().lower(),
@@ -799,10 +1129,12 @@ async def cmd_ownership(message: Message) -> None:
if not await _require_admin(message): if not await _require_admin(message):
return return
await message.answer( await _deliver_result(
message,
await _ownership_text(), await _ownership_text(),
reply_markup=admin_result_keyboard("admin:open"), back_callback="admin:open",
disable_web_page_preview=True, is_admin=True,
admin_mode=True,
) )
@@ -897,10 +1229,12 @@ async def cmd_mute_list(message: Message) -> None:
if not await _require_admin(message): if not await _require_admin(message):
return return
await message.answer( await _deliver_result(
message,
await _mute_rules_text(), await _mute_rules_text(),
reply_markup=admin_result_keyboard("admin:open"), back_callback="admin:open",
disable_web_page_preview=True, is_admin=True,
admin_mode=True,
) )

View File

@@ -2,6 +2,26 @@ from aiogram.types import InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder 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: def help_home_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring") builder.button(text="Обзор и метрики", callback_data="help:menu:monitoring")
@@ -51,15 +71,34 @@ def help_subscriptions_keyboard(is_admin: bool) -> InlineKeyboardMarkup:
return builder.as_markup() 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() 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=back_callback)
builder.button(text="Главное меню", callback_data="help:open") builder.button(text="Главное меню", callback_data="help:open")
if is_admin: if is_admin:
builder.button(text="Админ-панель", callback_data="admin:open") 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: else:
builder.adjust(2) if total_pages > 1:
builder.adjust(3, 2)
else:
builder.adjust(2)
return builder.as_markup() return builder.as_markup()
@@ -67,11 +106,12 @@ def admin_home_keyboard() -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text="Центр синхронизации", callback_data="admin:menu:sync") builder.button(text="Центр синхронизации", callback_data="admin:menu:sync")
builder.button(text="Сводки и мониторинг", callback_data="admin:menu:overview") 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="Ownership и topics", callback_data="admin:ownership")
builder.button(text="Mute rules", callback_data="admin:mute_list") builder.button(text="Mute rules", callback_data="admin:mute_list")
builder.button(text="Инструкция", callback_data="admin:guide") builder.button(text="Инструкция", callback_data="admin:guide")
builder.button(text="Пользовательское меню", callback_data="help:open") builder.button(text="Пользовательское меню", callback_data="help:open")
builder.adjust(2, 2, 1, 1) builder.adjust(2, 2, 2, 1)
return builder.as_markup() return builder.as_markup()
@@ -97,10 +137,25 @@ def admin_overview_keyboard() -> InlineKeyboardMarkup:
return builder.as_markup() 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() 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=back_callback)
builder.button(text="Админ-панель", callback_data="admin:open") builder.button(text="Админ-панель", callback_data="admin:open")
builder.button(text="Пользовательское меню", callback_data="help: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() return builder.as_markup()

View File

@@ -1,3 +1,4 @@
from glitchup_bot.models.admins import BotAdmin
from glitchup_bot.models.base import Base from glitchup_bot.models.base import Base
from glitchup_bot.models.issues import IssueCache from glitchup_bot.models.issues import IssueCache
from glitchup_bot.models.mute_rules import MuteRule from glitchup_bot.models.mute_rules import MuteRule
@@ -11,6 +12,7 @@ from glitchup_bot.models.sync import SyncState
__all__ = [ __all__ = [
"Base", "Base",
"BotAdmin",
"GroupSubscriberOverride", "GroupSubscriberOverride",
"GroupTopicOverride", "GroupTopicOverride",
"IssueCache", "IssueCache",

View File

@@ -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())

View File

@@ -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