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:
2026-10-15 19:03:50 +03:00
co-authored by Claude Opus 5.5
parent 4e5930b4c0
commit 219bebd5a2
16 changed files with 655 additions and 44 deletions
+25 -16
View File
@@ -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: