feat(bot): доступ по QR-приглашениям, раздел «Участники», отзыв доступа

- AccessService: одноразовые приглашения (128 бит, в БД только sha256, TTL 24 ч),
  атомарный захват приглашения (условный UPDATE — из двух сканов одного QR проходит один),
  антиперебор (> 5 неудач в час — молчание), отзыв (черновик и FSM удаляются), повторное приглашение
- ADMIN_IDS заводятся в БД при старте; дальше — только по приглашению
- AccessMiddleware: участники из БД (+ last_seen раз в 5 мин), новичкам — только /start <токен>
- «👥 Участники» для админов: список, QR (segno) + ссылка, отзыв с подтверждением
- один ответ на неверный/истёкший/использованный токен — без подсказок

Refs #25

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 19:03:50 +03:00
co-authored by Claude Opus 5.5
parent 4e5930b4c0
commit 219bebd5a2
16 changed files with 655 additions and 44 deletions
+29 -11
View File
@@ -1,6 +1,7 @@
"""Access gate on every update — messages and button presses alike (SPEC A-5).
"""Access gate on every update — messages and button presses alike (SPEC A-5, ADR-011).
Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`.
Members (active users in the DB) pass with `member` injected into handler data. Outsiders pass only with
`/start <token>` — the invite deep link; everything else gets one "invite only" reply a day, then silence.
"""
import time
@@ -8,18 +9,28 @@ from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import CallbackQuery, Message, TelegramObject
from aiogram.types import CallbackQuery, Message, TelegramObject, User
from app.access.service import AccessService, TgUser
from app.texts import Texts
IsMember = Callable[[int], Awaitable[bool]]
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
def tg_user(user: User) -> TgUser:
return TgUser(tg_id=user.id, full_name=user.full_name, username=user.username)
def is_invite_link(event: TelegramObject) -> bool:
if not isinstance(event, Message) or not event.text:
return False
parts = event.text.split(maxsplit=1)
return len(parts) == 2 and parts[0].split("@")[0] == "/start"
class AccessMiddleware(BaseMiddleware):
def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
self._is_member = is_member
def __init__(self, access: AccessService, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
self._access = access
self._texts = texts
self._clock = clock
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
@@ -31,11 +42,18 @@ class AccessMiddleware(BaseMiddleware):
event: TelegramObject,
data: dict[str, Any],
) -> Any:
user = data.get("event_from_user")
if user is not None and await self._is_member(user.id):
user: User | None = data.get("event_from_user")
if user is None:
return None
member = await self._access.get_member(user.id)
if member is not None:
await self._access.touch(member, tg_user(user))
data["member"] = member
return await handler(event, data)
if user is not None:
await self._refuse(event, user.id)
if is_invite_link(event):
data["member"] = None
return await handler(event, data)
await self._refuse(event, user.id)
return None
async def _refuse(self, event: TelegramObject, tg_id: int) -> None:
+25 -16
View File
@@ -1,15 +1,18 @@
"""/start, /help, /menu, main menu buttons and in-section navigation."""
"""/start (incl. invite deep links), /help, /menu, main menu buttons and in-section navigation."""
from aiogram import F, Router
from aiogram.filters import Command, CommandStart
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from app.access.service import AccessService, RedeemStatus
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
from app.bot.middlewares.access import tg_user
from app.db.models import Role, User
from app.texts import Texts
def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
def build_router(texts: Texts, access: AccessService) -> Router:
router = Router(name="common")
sections = section_by_button(texts)
section_text = {
@@ -17,33 +20,39 @@ def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
Section.STATUS: texts.section.status,
Section.LOGS: texts.section.logs,
Section.ALERTS: texts.section.alerts,
Section.MEMBERS: texts.section.members,
}
def is_admin(message: Message) -> bool:
return message.from_user is not None and message.from_user.id in admin_ids
@router.message(CommandStart())
async def start(message: Message, state: FSMContext) -> None:
async def start(message: Message, command: CommandObject, state: FSMContext, member: User | None) -> None:
if member is None:
# Only an outsider with an invite token gets here (AccessMiddleware); a member's /start <x> is a plain start
if message.from_user is None or not command.args:
return
status = await access.redeem(command.args, tg_user(message.from_user))
if status is RedeemStatus.THROTTLED:
return # brute force: stay silent (SPEC A-6)
if status is RedeemStatus.REJECTED:
await message.answer(texts.start.invite_rejected)
return
member = await access.get_member(message.from_user.id)
if member is None:
return
await state.clear()
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message)))
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
@router.message(Command("menu"))
async def menu(message: Message, state: FSMContext) -> None:
async def menu(message: Message, state: FSMContext, member: User) -> None:
await state.clear()
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message)))
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
@router.message(Command("help"))
async def help_(message: Message) -> None:
await message.answer(texts.help.text)
@router.message(F.text.in_(sections))
@router.message(F.text.in_({k for k, v in sections.items() if v is not Section.MEMBERS}))
async def open_section(message: Message, state: FSMContext) -> None:
section = sections[message.text or ""]
if section is Section.MEMBERS and not is_admin(message):
return
await state.clear()
await message.answer(section_text[section], reply_markup=section_nav(texts))
await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts))
@router.callback_query(Nav.filter())
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
+111
View File
@@ -0,0 +1,111 @@
"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7)."""
from datetime import datetime
from enum import StrEnum
from aiogram import Bot, F, Router
from aiogram.filters.callback_data import CallbackData
from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.access.qr import invite_link, qr_png
from app.access.service import AccessService
from app.bot.menu import Nav, NavAction
from app.db.models import Role, User
from app.texts import Texts
class MembersAction(StrEnum):
INVITE = "inv"
ASK_REVOKE = "ask"
REVOKE = "rev"
LIST = "list"
class MembersCb(CallbackData, prefix="mem"):
action: MembersAction
tg_id: int = 0
def _fmt(dt: datetime | None, never: str) -> str:
return dt.strftime("%d.%m %H:%M") if dt else never
def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboardMarkup]:
t = texts.members
lines = [t.title.format(count=len(members)), ""]
for m in members:
lines.append(
t.line.format(
icon="👑" if m.role is Role.ADMIN else "👤",
name=m.full_name,
username=f" @{m.username}" if m.username else "",
joined=_fmt(m.joined_at, t.never),
seen=_fmt(m.last_seen_at, t.never),
)
)
kb = InlineKeyboardBuilder()
kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE))
for m in members:
if m.role is not Role.ADMIN:
kb.button(
text=t.revoke.format(name=m.full_name),
callback_data=MembersCb(action=MembersAction.ASK_REVOKE, tg_id=m.tg_id),
)
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.adjust(1)
return "\n".join(lines), kb.as_markup()
def build_router(texts: Texts, access: AccessService) -> Router:
router = Router(name="members")
t = texts.members
# Every handler here is admin-only; a member who somehow has the button (e.g. was demoted) gets nothing.
def is_admin(member: User | None) -> bool:
return member is not None and member.role is Role.ADMIN
@router.message(F.text == texts.menu.members)
async def open_members(message: Message, state: FSMContext, member: User) -> None:
if not is_admin(member):
return
await state.clear()
text, kb = render_members(texts, await access.list_members())
await message.answer(text, reply_markup=kb)
@router.callback_query(MembersCb.filter())
async def on_action(query: CallbackQuery, callback_data: MembersCb, bot: Bot, member: User) -> None:
if not is_admin(member) or not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
match callback_data.action:
case MembersAction.INVITE:
invite = await access.create_invite(member.tg_id)
me = await bot.me()
link = invite_link(me.username or "", invite.token)
caption = t.invite_caption.format(link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"))
await msg.answer_photo(BufferedInputFile(qr_png(link), "invite.png"), caption=caption)
case MembersAction.ASK_REVOKE:
target = await access.get_member(callback_data.tg_id)
name = target.full_name if target else str(callback_data.tg_id)
kb = InlineKeyboardBuilder()
kb.button(
text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE, tg_id=callback_data.tg_id)
)
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
await msg.edit_text(t.confirm.format(name=name), reply_markup=kb.as_markup())
case MembersAction.REVOKE:
target = await access.get_member(callback_data.tg_id)
name = target.full_name if target else str(callback_data.tg_id)
ok = await access.revoke(callback_data.tg_id)
text, kb_markup = render_members(texts, await access.list_members())
note = t.revoked.format(name=name) if ok else t.not_revoked
await msg.edit_text(f"{note}\n\n{text}", reply_markup=kb_markup)
case MembersAction.LIST:
text, kb_markup = render_members(texts, await access.list_members())
await msg.edit_text(text, reply_markup=kb_markup)
await query.answer()
return router