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