"""«👥 Участники» for admins: list, invite by QR, 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" 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 "👤", 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)) 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: invite = await access.create_invite(member.tg_id) 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")) 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.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