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:
@@ -1,6 +1,7 @@
|
||||
"""Access gate on every update — messages and button presses alike (SPEC A-5).
|
||||
"""Access gate on every update — messages and button presses alike (SPEC A-5, ADR-011).
|
||||
|
||||
Until invites land (#25) the only members are the bootstrap admins from `ADMIN_IDS`.
|
||||
Members (active users in the DB) pass with `member` injected into handler data. Outsiders pass only with
|
||||
`/start <token>` — the invite deep link; everything else gets one "invite only" reply a day, then silence.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -8,18 +9,28 @@ from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject
|
||||
from aiogram.types import CallbackQuery, Message, TelegramObject, User
|
||||
|
||||
from app.access.service import AccessService, TgUser
|
||||
from app.texts import Texts
|
||||
|
||||
IsMember = Callable[[int], Awaitable[bool]]
|
||||
|
||||
NO_ACCESS_REPLY_EVERY = 24 * 3600.0
|
||||
|
||||
|
||||
def tg_user(user: User) -> TgUser:
|
||||
return TgUser(tg_id=user.id, full_name=user.full_name, username=user.username)
|
||||
|
||||
|
||||
def is_invite_link(event: TelegramObject) -> bool:
|
||||
if not isinstance(event, Message) or not event.text:
|
||||
return False
|
||||
parts = event.text.split(maxsplit=1)
|
||||
return len(parts) == 2 and parts[0].split("@")[0] == "/start"
|
||||
|
||||
|
||||
class AccessMiddleware(BaseMiddleware):
|
||||
def __init__(self, is_member: IsMember, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self._is_member = is_member
|
||||
def __init__(self, access: AccessService, texts: Texts, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
self._access = access
|
||||
self._texts = texts
|
||||
self._clock = clock
|
||||
# tg_id -> when we last told this outsider "invite only"; answering every message would let them spam us.
|
||||
@@ -31,11 +42,18 @@ class AccessMiddleware(BaseMiddleware):
|
||||
event: TelegramObject,
|
||||
data: dict[str, Any],
|
||||
) -> Any:
|
||||
user = data.get("event_from_user")
|
||||
if user is not None and await self._is_member(user.id):
|
||||
user: User | None = data.get("event_from_user")
|
||||
if user is None:
|
||||
return None
|
||||
member = await self._access.get_member(user.id)
|
||||
if member is not None:
|
||||
await self._access.touch(member, tg_user(user))
|
||||
data["member"] = member
|
||||
return await handler(event, data)
|
||||
if user is not None:
|
||||
await self._refuse(event, user.id)
|
||||
if is_invite_link(event):
|
||||
data["member"] = None
|
||||
return await handler(event, data)
|
||||
await self._refuse(event, user.id)
|
||||
return None
|
||||
|
||||
async def _refuse(self, event: TelegramObject, tg_id: int) -> None:
|
||||
|
||||
Reference in New Issue
Block a user