Files
tgit/app/bot/routers/members.py
T
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

130 lines
5.9 KiB
Python

"""«👥 Участники» 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
from aiogram import Bot, F, Router
from aiogram.filters.callback_data import CallbackData
from aiogram.fsm.context import FSMContext
from aiogram.types import BufferedInputFile, CallbackQuery, InlineKeyboardMarkup, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from app.access.qr import invite_link, qr_png
from app.access.service import AccessService
from app.bot.menu import Nav, NavAction
from app.db.models import Role, User
from app.texts import Texts
class MembersAction(StrEnum):
INVITE = "inv"
ASK_REVOKE = "ask"
REVOKE = "rev"
LIST = "list"
DEMO_INVITE = "demo"
ASK_REVOKE_GUESTS = "askg"
REVOKE_GUESTS = "revg"
class MembersCb(CallbackData, prefix="mem"):
action: MembersAction
tg_id: int = 0
def _fmt(dt: datetime | None, never: str) -> str:
return dt.strftime("%d.%m %H:%M") if dt else never
def render_members(texts: Texts, members: list[User]) -> tuple[str, InlineKeyboardMarkup]:
t = texts.members
lines = [t.title.format(count=len(members)), ""]
for m in members:
lines.append(
t.line.format(
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),
seen=_fmt(m.last_seen_at, t.never),
)
)
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(
text=t.revoke.format(name=m.full_name),
callback_data=MembersCb(action=MembersAction.ASK_REVOKE, tg_id=m.tg_id),
)
kb.button(text=texts.menu.back, callback_data=Nav(action=NavAction.BACK))
kb.adjust(1)
return "\n".join(lines), kb.as_markup()
def build_router(texts: Texts, access: AccessService) -> Router:
router = Router(name="members")
t = texts.members
# Every handler here is admin-only; a member who somehow has the button (e.g. was demoted) gets nothing.
def is_admin(member: User | None) -> bool:
return member is not None and member.role is Role.ADMIN
@router.message(F.text == texts.menu.members)
async def open_members(message: Message, state: FSMContext, member: User) -> None:
if not is_admin(member):
return
await state.clear()
text, kb = render_members(texts, await access.list_members())
await message.answer(text, reply_markup=kb)
@router.callback_query(MembersCb.filter())
async def on_action(query: CallbackQuery, callback_data: MembersCb, bot: Bot, member: User) -> None:
if not is_admin(member) or not isinstance(query.message, Message):
await query.answer()
return
msg = query.message
match callback_data.action:
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.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)
name = target.full_name if target else str(callback_data.tg_id)
kb = InlineKeyboardBuilder()
kb.button(
text=t.confirm_yes, callback_data=MembersCb(action=MembersAction.REVOKE, tg_id=callback_data.tg_id)
)
kb.button(text=t.confirm_no, callback_data=MembersCb(action=MembersAction.LIST))
await msg.edit_text(t.confirm.format(name=name), reply_markup=kb.as_markup())
case MembersAction.REVOKE:
target = await access.get_member(callback_data.tg_id)
name = target.full_name if target else str(callback_data.tg_id)
ok = await access.revoke(callback_data.tg_id)
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)
await query.answer()
return router