"""Access gate on every update — messages and button presses alike (SPEC A-5, ADR-011). Members (active users in the DB) pass with `member` injected into handler data. Outsiders pass only with `/start ` — the invite deep link; everything else gets one "invite only" reply a day, then silence. """ import time from collections.abc import Awaitable, Callable from typing import Any from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject, User from app.access.service import AccessService, TgUser from app.texts import Texts 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, 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. self._last_refusal: dict[int, float] = {} async def __call__( self, handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: dict[str, Any], ) -> Any: 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 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: if isinstance(event, CallbackQuery): await event.answer() # stop the spinner on a stale button, say nothing return now = self._clock() last = self._last_refusal.get(tg_id) if last is not None and now - last < NO_ACCESS_REPLY_EVERY: return self._last_refusal[tg_id] = now if isinstance(event, Message): await event.answer(self._texts.start.no_access)