"""Force-join gate. The user must be a member of EVERY channel/group in GATE_CHANNELS. The bot must be an ADMIN of each of them, otherwise Telegram will not tell it who the members are. A misconfiguration (bot not admin / wrong username) BLOCKS users and alerts the admin loudly, instead of silently letting everybody in. Temporary network trouble does not punish students.""" import asyncio import logging import time from aiogram import Bot from aiogram.enums import ChatMemberStatus from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from app.config import get_settings from app.core.notify import notify_admins log = logging.getLogger(__name__) _OK = {ChatMemberStatus.CREATOR, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.MEMBER} _cache: dict[int, float] = {} _alerted: dict[str, float] = {} _TTL = 1800 # seconds a positive result is trusted (/start and the "I joined" button always re-check) def channels() -> list[str]: return get_settings().gate_list def channel_url(handle: str) -> str: return f"https://t.me/{handle.lstrip('@')}" async def _alert(bot: Bot, handle: str, exc: Exception) -> None: now = time.monotonic() if now - _alerted.get(handle, 0) < 600: return _alerted[handle] = now log.error("Gate cannot verify %s: %r", handle, exc) await notify_admins( bot, f"⚠️ The join-gate cannot check {handle}: {type(exc).__name__}: {exc}\n" f"Make the bot an ADMIN of {handle} and check the username (run /gatecheck). " "Until it is fixed, users are NOT let through.", ) async def _is_in(bot: Bot, handle: str, user_id: int) -> bool: try: member = await bot.get_chat_member(handle, user_id) return member.status in _OK except (TelegramBadRequest, TelegramForbiddenError) as exc: # configuration problem -> stay closed await _alert(bot, handle, exc) return False except Exception as exc: # network hiccup / flood wait: do not punish students log.warning("Gate check for %s hit a temporary error: %r", handle, exc) return True async def missing(bot: Bot, user_id: int, *, fresh: bool = False) -> list[str]: """Channels the user still has to join (empty list = allowed in).""" now = time.time() if not fresh and _cache.get(user_id, 0) > now: return [] handles = channels() results = await asyncio.gather(*[_is_in(bot, h, user_id) for h in handles]) left = [h for h, ok in zip(handles, results) if not ok] if left: _cache.pop(user_id, None) else: _cache[user_id] = now + _TTL return left async def is_member(bot: Bot, user_id: int, *, fresh: bool = False) -> bool: return not await missing(bot, user_id, fresh=fresh)