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
+11 -12
View File
@@ -7,9 +7,10 @@ import logging
from aiogram import Bot, Dispatcher
from app import __version__
from app.access.service import AccessService
from app.bot.fsm import make_storage
from app.bot.middlewares.access import AccessMiddleware
from app.bot.routers.common import build_router
from app.bot.routers import common, members
from app.config import Settings
from app.db.maintenance import cleanup_loop
from app.db.migrate import upgrade
@@ -21,18 +22,14 @@ from app.texts import Texts, load_texts
log = logging.getLogger("tgit")
def build_dispatcher(settings: Settings, texts: Texts, sessions: SessionMaker) -> Dispatcher:
admin_ids = settings.access.admin_ids
async def is_member(tg_id: int) -> bool:
return tg_id in admin_ids
def build_dispatcher(texts: Texts, sessions: SessionMaker, access: AccessService) -> Dispatcher:
dp = Dispatcher(storage=make_storage(sessions))
access = AccessMiddleware(is_member, texts)
gate = AccessMiddleware(access, texts)
# Outer middleware: runs before filters, so nothing — not even a stale button — reaches a handler unchecked.
dp.message.outer_middleware(access)
dp.callback_query.outer_middleware(access)
dp.include_router(build_router(texts, admin_ids))
dp.message.outer_middleware(gate)
dp.callback_query.outer_middleware(gate)
dp.include_router(members.build_router(texts, access))
dp.include_router(common.build_router(texts, access))
return dp
@@ -41,7 +38,9 @@ async def run(settings: Settings) -> None:
engine = make_engine(settings.database.url)
await upgrade(engine)
sessions = make_sessionmaker(engine)
dp = build_dispatcher(settings, texts, sessions)
access = AccessService(sessions, settings.access)
await access.bootstrap_admins()
dp = build_dispatcher(texts, sessions, access)
rt = settings.runtime
background = [
asyncio.create_task(heartbeat(rt.heartbeat_file, rt.heartbeat_interval)),