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: