feat(bot): доступ по QR-приглашениям, раздел «Участники», отзыв доступа
- AccessService: одноразовые приглашения (128 бит, в БД только sha256, TTL 24 ч), атомарный захват приглашения (условный UPDATE — из двух сканов одного QR проходит один), антиперебор (> 5 неудач в час — молчание), отзыв (черновик и FSM удаляются), повторное приглашение - ADMIN_IDS заводятся в БД при старте; дальше — только по приглашению - AccessMiddleware: участники из БД (+ last_seen раз в 5 мин), новичкам — только /start <токен> - «👥 Участники» для админов: список, QR (segno) + ссылка, отзыв с подтверждением - один ответ на неверный/истёкший/использованный токен — без подсказок Refs #25 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+25
-16
@@ -1,15 +1,18 @@
|
||||
"""/start, /help, /menu, main menu buttons and in-section navigation."""
|
||||
"""/start (incl. invite deep links), /help, /menu, main menu buttons and in-section navigation."""
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.filters import Command, CommandStart
|
||||
from aiogram.filters import Command, CommandObject, CommandStart
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from app.access.service import AccessService, RedeemStatus
|
||||
from app.bot.menu import Nav, NavAction, Section, main_menu, section_by_button, section_nav
|
||||
from app.bot.middlewares.access import tg_user
|
||||
from app.db.models import Role, User
|
||||
from app.texts import Texts
|
||||
|
||||
|
||||
def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
|
||||
def build_router(texts: Texts, access: AccessService) -> Router:
|
||||
router = Router(name="common")
|
||||
sections = section_by_button(texts)
|
||||
section_text = {
|
||||
@@ -17,33 +20,39 @@ def build_router(texts: Texts, admin_ids: frozenset[int]) -> Router:
|
||||
Section.STATUS: texts.section.status,
|
||||
Section.LOGS: texts.section.logs,
|
||||
Section.ALERTS: texts.section.alerts,
|
||||
Section.MEMBERS: texts.section.members,
|
||||
}
|
||||
|
||||
def is_admin(message: Message) -> bool:
|
||||
return message.from_user is not None and message.from_user.id in admin_ids
|
||||
|
||||
@router.message(CommandStart())
|
||||
async def start(message: Message, state: FSMContext) -> None:
|
||||
async def start(message: Message, command: CommandObject, state: FSMContext, member: User | None) -> None:
|
||||
if member is None:
|
||||
# Only an outsider with an invite token gets here (AccessMiddleware); a member's /start <x> is a plain start
|
||||
if message.from_user is None or not command.args:
|
||||
return
|
||||
status = await access.redeem(command.args, tg_user(message.from_user))
|
||||
if status is RedeemStatus.THROTTLED:
|
||||
return # brute force: stay silent (SPEC A-6)
|
||||
if status is RedeemStatus.REJECTED:
|
||||
await message.answer(texts.start.invite_rejected)
|
||||
return
|
||||
member = await access.get_member(message.from_user.id)
|
||||
if member is None:
|
||||
return
|
||||
await state.clear()
|
||||
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=is_admin(message)))
|
||||
await message.answer(texts.start.welcome, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
|
||||
|
||||
@router.message(Command("menu"))
|
||||
async def menu(message: Message, state: FSMContext) -> None:
|
||||
async def menu(message: Message, state: FSMContext, member: User) -> None:
|
||||
await state.clear()
|
||||
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=is_admin(message)))
|
||||
await message.answer(texts.menu.placeholder, reply_markup=main_menu(texts, is_admin=member.role is Role.ADMIN))
|
||||
|
||||
@router.message(Command("help"))
|
||||
async def help_(message: Message) -> None:
|
||||
await message.answer(texts.help.text)
|
||||
|
||||
@router.message(F.text.in_(sections))
|
||||
@router.message(F.text.in_({k for k, v in sections.items() if v is not Section.MEMBERS}))
|
||||
async def open_section(message: Message, state: FSMContext) -> None:
|
||||
section = sections[message.text or ""]
|
||||
if section is Section.MEMBERS and not is_admin(message):
|
||||
return
|
||||
await state.clear()
|
||||
await message.answer(section_text[section], reply_markup=section_nav(texts))
|
||||
await message.answer(section_text[sections[message.text or ""]], reply_markup=section_nav(texts))
|
||||
|
||||
@router.callback_query(Nav.filter())
|
||||
async def navigate(query: CallbackQuery, callback_data: Nav, state: FSMContext) -> None:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""«👥 Участники» 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
|
||||
Reference in New Issue
Block a user