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
+174
View File
@@ -0,0 +1,174 @@
"""Invites, members and revocation (SPEC §3.1, ADR-011)."""
import hashlib
import secrets
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from typing import Any, cast
from sqlalchemy import CursorResult, delete, func, select, update
from app.config import AccessSettings
from app.db.models import AccessAttempt, Draft, FsmState, Invite, Role, User, utcnow
from app.db.session import SessionMaker
LAST_SEEN_EVERY = timedelta(minutes=5)
ATTEMPT_WINDOW = timedelta(hours=1)
class RedeemStatus(StrEnum):
OK = "ok"
ALREADY_MEMBER = "already_member"
REJECTED = "rejected" # unknown, used or expired — one answer, no hints which tokens existed
THROTTLED = "throttled"
@dataclass(frozen=True)
class TgUser:
tg_id: int
full_name: str
username: str | None = None
@dataclass(frozen=True)
class CreatedInvite:
token: str
expires_at: datetime
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
class AccessService:
def __init__(self, sessions: SessionMaker, settings: AccessSettings, clock: Callable[[], datetime] = utcnow):
self._sessions = sessions
self._settings = settings
self._clock = clock
async def bootstrap_admins(self) -> None:
"""ADMIN_IDS become active admins in the DB; everybody else gets in only by invite (SPEC A-8)."""
async with self._sessions.begin() as s:
for tg_id in self._settings.admin_ids:
user = await s.get(User, tg_id)
if user is None:
s.add(User(tg_id=tg_id, full_name=f"admin {tg_id}", role=Role.ADMIN, joined_at=self._clock()))
else:
user.role, user.is_active = Role.ADMIN, True
async def get_member(self, tg_id: int) -> User | None:
async with self._sessions() as s:
user = await s.get(User, tg_id)
return user if user is not None and user.is_active else None
async def touch(self, member: User, seen: TgUser) -> None:
"""Refresh name and last-seen, at most every few minutes — not a write per update."""
now = self._clock()
last = member.last_seen_at
if (
last is not None
and now - _aware(last) < LAST_SEEN_EVERY
and (member.full_name, member.username) == (seen.full_name, seen.username)
):
return
async with self._sessions.begin() as s:
await s.execute(
update(User)
.where(User.tg_id == member.tg_id)
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
)
async def create_invite(self, admin_id: int) -> CreatedInvite:
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours)
async with self._sessions.begin() as s:
s.add(
Invite(
token_hash=hash_token(token), created_by=admin_id, created_at=self._clock(), expires_at=expires_at
)
)
return CreatedInvite(token=token, expires_at=expires_at)
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
now = self._clock()
try:
async with self._sessions.begin() as s:
existing = await s.get(User, who.tg_id)
if existing is not None and existing.is_active:
return RedeemStatus.ALREADY_MEMBER
failed = await s.scalar(
select(func.count())
.select_from(AccessAttempt)
.where(
AccessAttempt.tg_id == who.tg_id,
AccessAttempt.ok.is_(False),
AccessAttempt.at > now - ATTEMPT_WINDOW,
)
)
if (failed or 0) >= self._settings.max_failed_attempts:
return RedeemStatus.THROTTLED
invite = await s.scalar(select(Invite).where(Invite.token_hash == hash_token(token)))
if invite is None or invite.used_by is not None or _aware(invite.expires_at) <= now:
raise _Rejected
if existing is None:
s.add(
User(
tg_id=who.tg_id,
full_name=who.full_name,
username=who.username,
role=Role.MEMBER,
invited_by=invite.created_by,
joined_at=now,
last_seen_at=now,
)
)
else: # revoked earlier, invited again
existing.is_active, existing.role, existing.invited_by = True, Role.MEMBER, invite.created_by
existing.full_name, existing.username, existing.last_seen_at = who.full_name, who.username, now
await s.flush() # the user row must exist before invites.used_by points at it
# Claim atomically: of two people scanning the same QR only one UPDATE matches the row,
# the loser's transaction (with its freshly created user) is rolled back.
result = await s.execute(
update(Invite)
.where(Invite.id == invite.id, Invite.used_by.is_(None))
.values(used_by=who.tg_id, used_at=now)
)
if cast(CursorResult[Any], result).rowcount != 1:
raise _Rejected
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=True))
except _Rejected:
async with self._sessions.begin() as s:
s.add(AccessAttempt(tg_id=who.tg_id, at=now, ok=False))
return RedeemStatus.REJECTED
return RedeemStatus.OK
async def list_members(self) -> list[User]:
async with self._sessions() as s:
rows = await s.scalars(select(User).where(User.is_active.is_(True)).order_by(User.role, User.joined_at))
return list(rows)
async def revoke(self, tg_id: int) -> bool:
"""Cut a member off at once: their updates are ignored from now on, draft and dialog state are dropped."""
async with self._sessions.begin() as s:
user = await s.get(User, tg_id)
if user is None or not user.is_active or user.role is Role.ADMIN:
return False
user.is_active = False
await s.execute(delete(Draft).where(Draft.user_id == tg_id))
# FSM keys are "bot:chat:user:…"; in a private chat chat == user
await s.execute(delete(FsmState).where(FsmState.key.like(f"%:{tg_id}:{tg_id}:%")))
return True
class _Rejected(Exception):
pass
def _aware(value: datetime) -> datetime:
# SQLite hands back naive datetimes even for timezone=True columns; everything we store is UTC.
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)