Files
tgit/app/access/service.py
ElebobaandClaude Opus 5.5 e8c3a321a3
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s
feat(access): демо-QR — многоразовое приглашение для показа
- invites.max_uses/uses: обычное приглашение — 1 вход, демо-QR — до DEMO_INVITE_MAX_USES (50),
  живёт DEMO_INVITE_TTL_HOURS (3 ч); захват атомарный, UPDATE … WHERE uses < max_uses
- users.guest: вошедшие по демо-QR; «🧹 Отключить гостей демо» отзывает всех и гасит демо-QR
- «👥 Участники»: кнопка «🎤 Демо-QR», гости в списке — 🎟
- миграция 0003 (ADD COLUMN без batch — SQLite не пересоздаёт users); проверена на Postgres
- SPEC A-3a, ADR-017, DEMO.md, .env.example

Refs #55

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:11:53 +03:00

203 lines
8.3 KiB
Python

"""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
max_uses: int = 1
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, demo: bool = False) -> CreatedInvite:
"""A personal one-time invite, or the demo QR (#55): up to `demo_invite_max_uses` entrants, short-lived."""
token = secrets.token_urlsafe(16) # 128 bits, 22 chars of [A-Za-z0-9_-]: fits the 64-char deep link payload
if demo:
max_uses, ttl = self._settings.demo_invite_max_uses, self._settings.demo_invite_ttl_hours
else:
max_uses, ttl = 1, self._settings.invite_ttl_hours
expires_at = self._clock() + timedelta(hours=ttl)
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,
max_uses=max_uses,
)
)
return CreatedInvite(token=token, expires_at=expires_at, max_uses=max_uses)
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.uses >= invite.max_uses or _aware(invite.expires_at) <= now:
raise _Rejected
guest = invite.max_uses > 1
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,
guest=guest,
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.guest = guest
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: when two people race for the last use 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.uses < Invite.max_uses)
.values(uses=Invite.uses + 1, 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
async def revoke_guests(self) -> int:
"""After the talk: cut off everybody who came by the demo QR and expire it — a photo of it lets nobody in."""
now = self._clock()
async with self._sessions.begin() as s:
await s.execute(update(Invite).where(Invite.max_uses > 1, Invite.expires_at > now).values(expires_at=now))
ids = list(
await s.scalars(
select(User.tg_id).where(User.guest.is_(True), User.is_active.is_(True), User.role != Role.ADMIN)
)
)
revoked = 0
for tg_id in ids:
revoked += await self.revoke(tg_id)
return revoked
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)