feat(access): демо-QR — многоразовое приглашение для показа
CI / ruff · mypy · pytest (pull_request) Successful in 1h3m54s

- 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>
This commit is contained in:
2026-10-22 18:11:53 +03:00
co-authored by Claude Opus 5.5
parent 7a12b3525a
commit e8c3a321a3
14 changed files with 247 additions and 15 deletions
+36 -8
View File
@@ -36,6 +36,7 @@ class TgUser:
class CreatedInvite:
token: str
expires_at: datetime
max_uses: int = 1
def hash_token(token: str) -> str:
@@ -80,16 +81,25 @@ class AccessService:
.values(last_seen_at=now, full_name=seen.full_name, username=seen.username)
)
async def create_invite(self, admin_id: int) -> CreatedInvite:
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
expires_at = self._clock() + timedelta(hours=self._settings.invite_ttl_hours)
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
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)
return CreatedInvite(token=token, expires_at=expires_at, max_uses=max_uses)
async def redeem(self, token: str, who: TgUser) -> RedeemStatus:
now = self._clock()
@@ -111,8 +121,9 @@ class AccessService:
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:
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(
@@ -122,21 +133,23 @@ class AccessService:
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: of two people scanning the same QR only one UPDATE matches the row,
# 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.used_by.is_(None))
.values(used_by=who.tg_id, used_at=now)
.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
@@ -164,6 +177,21 @@ class AccessService:
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
+23 -5
View File
@@ -1,4 +1,4 @@
"""«👥 Участники» for admins: list, invite by QR, revoke access (SPEC A-1, A-7)."""
"""«👥 Участники» for admins: list, invite by QR (personal or the demo one, #55), revoke access (SPEC A-1, A-7)."""
from datetime import datetime
from enum import StrEnum
@@ -21,6 +21,9 @@ class MembersAction(StrEnum):
ASK_REVOKE = "ask"
REVOKE = "rev"
LIST = "list"
DEMO_INVITE = "demo"
ASK_REVOKE_GUESTS = "askg"
REVOKE_GUESTS = "revg"
class MembersCb(CallbackData, prefix="mem"):
@@ -38,7 +41,7 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa
for m in members:
lines.append(
t.line.format(
icon="👑" if m.role is Role.ADMIN else "👤",
icon="👑" if m.role is Role.ADMIN else "🎟" if m.guest else "👤",
name=m.full_name,
username=f" @{m.username}" if m.username else "",
joined=_fmt(m.joined_at, t.never),
@@ -47,6 +50,9 @@ def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboa
)
kb = InlineKeyboardBuilder()
kb.button(text=t.invite, callback_data=MembersCb(action=MembersAction.INVITE))
kb.button(text=t.demo_invite, callback_data=MembersCb(action=MembersAction.DEMO_INVITE))
if any(m.guest and m.role is not Role.ADMIN for m in members):
kb.button(text=t.revoke_guests, callback_data=MembersCb(action=MembersAction.ASK_REVOKE_GUESTS))
for m in members:
if m.role is not Role.ADMIN:
kb.button(
@@ -81,11 +87,14 @@ def build_router(texts: Texts, access: AccessService) -> Router:
return
msg = query.message
match callback_data.action:
case MembersAction.INVITE:
invite = await access.create_invite(member.tg_id)
case MembersAction.INVITE | MembersAction.DEMO_INVITE:
demo = callback_data.action is MembersAction.DEMO_INVITE
invite = await access.create_invite(member.tg_id, demo=demo)
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"))
caption = (t.demo_invite_caption if demo else t.invite_caption).format(
link=link, expires=invite.expires_at.strftime("%d.%m %H:%M"), max_uses=invite.max_uses
)
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)
@@ -103,6 +112,15 @@ def build_router(texts: Texts, access: AccessService) -> Router:
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.ASK_REVOKE_GUESTS:
kb = InlineKeyboardBuilder()
kb.button(text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE_GUESTS))
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
await msg.edit_text(t.confirm_guests, reply_markup=kb.as_markup())
case MembersAction.REVOKE_GUESTS:
count = await access.revoke_guests()
text, kb_markup = render_members(texts, await access.list_members())
await msg.edit_text(f"{t.guests_revoked.format(count=count)}\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)
+3
View File
@@ -23,6 +23,9 @@ class AccessSettings(BaseSettings):
# Bootstrap admins (SPEC A-8). Comma-separated in the env: ADMIN_IDS=1,2
admin_ids: Annotated[frozenset[int], NoDecode] = frozenset()
invite_ttl_hours: float = Field(default=24.0, gt=0)
# Demo QR for the audience at the talk (#55): many entrants, short life
demo_invite_max_uses: int = Field(default=50, ge=2)
demo_invite_ttl_hours: float = Field(default=3.0, gt=0)
# More failed /start <token> per hour than this — silence (SPEC A-6)
max_failed_attempts: int = Field(default=5, ge=1)
@@ -0,0 +1,33 @@
"""multi-use demo invite (#55): invites.max_uses/uses, users.guest
Revision ID: 0003
Revises: 0002
Create Date: 2026-10-22 12:00:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0003"
down_revision: str | None = "0002"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# Plain ADD COLUMN, not batch: on SQLite a batch rebuilds `users`, which invites reference, and the FKs fail
op.add_column("invites", sa.Column("max_uses", sa.Integer(), server_default="1", nullable=False))
op.add_column("invites", sa.Column("uses", sa.Integer(), server_default="0", nullable=False))
op.add_column("users", sa.Column("guest", sa.Boolean(), server_default=sa.false(), nullable=False))
# Invites redeemed before this migration are spent
op.execute("UPDATE invites SET uses = 1 WHERE used_by IS NOT NULL")
def downgrade() -> None:
with op.batch_alter_table("users", schema=None) as batch_op:
batch_op.drop_column("guest")
with op.batch_alter_table("invites", schema=None) as batch_op:
batch_op.drop_column("uses")
batch_op.drop_column("max_uses")
+6
View File
@@ -22,6 +22,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
false,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -128,6 +129,8 @@ class User(Base):
full_name: Mapped[str] = mapped_column(String(256))
role: Mapped[Role] = mapped_column(enum(Role, "role"), default=Role.MEMBER)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
# Came in by a multi-use demo invite: cut off all at once after the talk (#55)
guest: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
invited_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
joined_at: Mapped[datetime] = mapped_column(default=utcnow)
last_seen_at: Mapped[datetime | None]
@@ -142,6 +145,9 @@ class Invite(Base):
created_by: Mapped[int] = mapped_column(TgId, ForeignKey("users.tg_id"))
created_at: Mapped[datetime] = mapped_column(default=utcnow)
expires_at: Mapped[datetime]
# 1 — a personal invite; more — the demo QR shown to an audience (#55). used_by/used_at — the latest entrant.
max_uses: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
uses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
used_by: Mapped[int | None] = mapped_column(TgId, ForeignKey("users.tg_id"))
used_at: Mapped[datetime | None]
+5
View File
@@ -49,6 +49,11 @@ class MembersTexts(_Strict):
revoked: str
not_revoked: str
invite_caption: str
demo_invite: str
demo_invite_caption: str
revoke_guests: str
confirm_guests: str
guests_revoked: str
class CommitTexts(_Strict):