Add public mode with channel membership requirements

Completes TODO #2. PUBLIC_MODE swaps the ALLOWED_USERS allowlist for a
membership check against REQUIRED_CHATS, with REQUIRE_MODE choosing
between needing every chat or just one.

- Stop is_admin() falling back to "everyone is an admin" when
  ADMIN_USERS is unset once PUBLIC_MODE is on. That fallback is the
  original single-owner convenience, but under public mode it handed
  moderation, /edit and /delete to anyone who found the bot. Public
  mode now grants admin to nobody until ADMIN_USERS is set, and says
  so at startup. Private deployments are unaffected.
- Cache membership answers: inline search queries on every keystroke,
  so an uncached check meant several API calls per typed character.
  Grants last five minutes, refusals thirty seconds, so a user who has
  just joined is not locked out for the full window.
- Fail closed when the membership check errors, and validate
  REQUIRED_CHATS at startup so a chat the bot cannot read is reported
  rather than silently refusing every user.
- Refused users get a join prompt with real links instead of silence;
  inline shows a tappable bar that opens the bot and explains why.
  A private bot stays silent as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-09-01 21:50:01 +00:00
parent e99d9adf97
commit 82b5da41b8
6 changed files with 385 additions and 11 deletions
+170 -10
View File
@@ -1,9 +1,11 @@
import asyncio
import logging
import os
import time
import uuid
from aiogram import Bot, Dispatcher, F
from aiogram.exceptions import TelegramAPIError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
@@ -23,6 +25,7 @@ from aiogram.types import (
InlineQueryResultCachedVideo,
InlineQueryResultCachedVoice,
InlineQueryResultArticle,
InlineQueryResultsButton,
InputTextMessageContent,
Message,
)
@@ -41,9 +44,33 @@ ADMIN_USERS: set[int] = {
int(uid.strip()) for uid in os.environ.get("ADMIN_USERS", "").split(",") if uid.strip()
}
def _env_flag(name: str) -> bool:
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes"}
# Off by default: with ALLOWED_USERS unset, is_allowed() lets everyone in, so
# opening submissions implicitly would accept media from the whole world.
ACCEPT_SUBMISSIONS = os.environ.get("ACCEPT_SUBMISSIONS", "").strip().lower() in {"1", "true", "yes"}
ACCEPT_SUBMISSIONS = _env_flag("ACCEPT_SUBMISSIONS")
# Public mode swaps the ALLOWED_USERS allowlist for "is this user in the chats
# I require?". With no REQUIRED_CHATS the bot is simply open to everyone.
PUBLIC_MODE = _env_flag("PUBLIC_MODE")
def _parse_chat(raw: str) -> int | str:
"""Telegram takes either @username or a numeric id."""
raw = raw.strip()
try:
return int(raw)
except ValueError:
return raw
REQUIRED_CHATS: list[int | str] = [
_parse_chat(c) for c in os.environ.get("REQUIRED_CHATS", "").split(",") if c.strip()
]
# "all" (default): must be in every listed chat. "any": one is enough.
REQUIRE_MODE = os.environ.get("REQUIRE_MODE", "all").strip().lower()
bot = Bot(token=BOT_TOKEN)
dp = Dispatcher(storage=MemoryStorage())
@@ -69,12 +96,126 @@ def is_allowed(user_id: int) -> bool:
def is_admin(user_id: int) -> bool:
# If no ADMIN_USERS set, fall back to ALLOWED_USERS behaviour (backward compat)
if ADMIN_USERS:
return user_id in ADMIN_USERS
# An unconfigured private bot treats its users as admins, which is the
# original single-owner behaviour. That fallback must not survive into
# public mode, where it would hand admin rights to anyone who found the
# bot. check_admin_config() flags the missing setting at startup.
if PUBLIC_MODE:
return False
return not ALLOWED_USERS or user_id in ALLOWED_USERS
# Inline queries fire on every keystroke, so an uncached membership check would
# mean several API calls per typed character. Denials expire quickly so that a
# user who has just joined is not left waiting for the full window.
MEMBERSHIP_TTL_ALLOWED = 300.0
MEMBERSHIP_TTL_DENIED = 30.0
ACCEPTED_MEMBER_STATUSES = {"creator", "administrator", "member"}
_membership_cache: dict[tuple[int, int | str], tuple[bool, float]] = {}
async def _fetch_membership(user_id: int, chat: int | str) -> bool:
try:
member = await bot.get_chat_member(chat, user_id)
except TelegramAPIError:
# Usually the bot is not an admin in that chat, or the user is unknown
# to it. Fail closed; check_required_chats() reports setup problems at
# startup so this does not fail closed silently forever.
log.warning("membership check failed for user %s in chat %s", user_id, chat, exc_info=True)
return False
if member.status in ACCEPTED_MEMBER_STATUSES:
return True
# A restricted user is still a member unless they have actually left.
if member.status == "restricted":
return bool(getattr(member, "is_member", False))
return False
async def is_member_of(user_id: int, chat: int | str) -> bool:
key = (user_id, chat)
cached = _membership_cache.get(key)
now = time.monotonic()
if cached is not None and cached[1] > now:
return cached[0]
result = await _fetch_membership(user_id, chat)
ttl = MEMBERSHIP_TTL_ALLOWED if result else MEMBERSHIP_TTL_DENIED
_membership_cache[key] = (result, now + ttl)
return result
async def passes_membership(user_id: int) -> bool:
if not REQUIRED_CHATS:
return True
if REQUIRE_MODE == "any":
for chat in REQUIRED_CHATS:
if await is_member_of(user_id, chat):
return True
return False
for chat in REQUIRED_CHATS:
if not await is_member_of(user_id, chat):
return False
return True
async def has_access(user_id: int) -> bool:
"""Public mode replaces the allowlist with the membership requirement."""
if is_admin(user_id):
return True
if PUBLIC_MODE:
return await passes_membership(user_id)
return is_allowed(user_id)
# Resolved once at startup so the join prompt can show real links.
REQUIRED_CHAT_LINKS: list[tuple[str, str | None]] = []
def check_admin_config() -> None:
if PUBLIC_MODE and not ADMIN_USERS:
log.error(
"PUBLIC_MODE is on but ADMIN_USERS is empty — nobody can moderate, "
"add or delete. Set ADMIN_USERS to your Telegram user ID."
)
async def check_required_chats() -> None:
"""Verify the bot can actually see each required chat, and remember how to
link to it. A misconfigured chat would otherwise lock out every user with
nothing in the logs to explain why."""
REQUIRED_CHAT_LINKS.clear()
for chat in REQUIRED_CHATS:
try:
info = await bot.get_chat(chat)
except TelegramAPIError:
log.error(
"REQUIRED_CHATS: cannot read %s — add the bot to that chat as an "
"admin, or users will not be able to pass the membership check.",
chat, exc_info=True,
)
REQUIRED_CHAT_LINKS.append((str(chat), None))
continue
url = f"https://t.me/{info.username}" if info.username else info.invite_link
REQUIRED_CHAT_LINKS.append((info.title or str(chat), url))
if url is None:
log.warning("REQUIRED_CHATS: no public link for %s; users will only see its name", chat)
def join_prompt() -> str:
if not REQUIRED_CHAT_LINKS:
return "You do not have access to this bot."
joiner = "any one of" if REQUIRE_MODE == "any" else "all of"
lines = [f"To use this bot you need to join {joiner} these:"]
for title, url in REQUIRED_CHAT_LINKS:
lines.append(f"• <a href=\"{url}\">{title}</a>" if url else f"• {title}")
return "\n".join(lines)
async def register_commands() -> None:
"""Publish Telegram's command menu with role-appropriate suggestions."""
if not ADMIN_USERS:
@@ -120,11 +261,19 @@ def menu_text(user_id: int) -> str:
)
async def deny(msg: Message) -> None:
"""A public bot tells the user how to get in; a private one stays silent,
as it always has, rather than confirming to strangers that it exists."""
if PUBLIC_MODE:
await msg.answer(join_prompt(), parse_mode="HTML")
# ── /start ───────────────────────────────────────────────────────────────────
@dp.message(Command("start"))
async def cmd_start(msg: Message):
if not is_allowed(msg.from_user.id):
if not await has_access(msg.from_user.id):
await deny(msg)
return
await msg.answer(
f"{menu_text(msg.from_user.id)}\n/menu — show this list again",
@@ -136,7 +285,8 @@ async def cmd_start(msg: Message):
@dp.message(Command("menu"))
async def cmd_menu(msg: Message):
if not is_allowed(msg.from_user.id):
if not await has_access(msg.from_user.id):
await deny(msg)
return
await msg.answer(menu_text(msg.from_user.id), parse_mode="HTML")
@@ -145,7 +295,7 @@ async def cmd_menu(msg: Message):
@dp.message(Command("cancel"))
async def cmd_cancel(msg: Message, state: FSMContext):
if not is_allowed(msg.from_user.id):
if not await has_access(msg.from_user.id):
return
await state.clear()
await msg.answer("Cancelled.")
@@ -205,7 +355,7 @@ async def on_media(msg: Message, state: FSMContext):
user_id = msg.from_user.id
if is_admin(user_id):
status = "approved"
elif ACCEPT_SUBMISSIONS and is_allowed(user_id):
elif ACCEPT_SUBMISSIONS and await has_access(user_id):
status = "pending"
else:
return
@@ -289,7 +439,8 @@ async def list_page(page: int) -> tuple[str, InlineKeyboardMarkup | None]:
@dp.message(Command("list"))
async def cmd_list(msg: Message):
if not is_allowed(msg.from_user.id):
if not await has_access(msg.from_user.id):
await deny(msg)
return
text, keyboard = await list_page(0)
await msg.answer(text, parse_mode="HTML", reply_markup=keyboard)
@@ -297,7 +448,7 @@ async def cmd_list(msg: Message):
@dp.callback_query(F.data.startswith("list:"))
async def on_list_page(callback: CallbackQuery):
if not is_allowed(callback.from_user.id):
if not await has_access(callback.from_user.id):
await callback.answer("Not allowed.", show_alert=True)
return
@@ -559,8 +710,15 @@ INLINE_BUILDERS = {
@dp.inline_query()
async def inline_handler(query: InlineQuery):
if not is_allowed(query.from_user.id):
await query.answer([], cache_time=1)
if not await has_access(query.from_user.id):
# A bar above the results beats silently returning nothing: tapping it
# opens the bot in private chat, where /start explains what to join.
button = None
if PUBLIC_MODE and REQUIRED_CHATS:
button = InlineQueryResultsButton(
text="Tap to see what you need to join", start_parameter="join"
)
await query.answer([], cache_time=1, is_personal=True, button=button)
return
try:
@@ -594,6 +752,8 @@ async def inline_handler(query: InlineQuery):
async def main():
await db.init_db()
check_admin_config()
await check_required_chats()
await register_commands()
log.info("Bot starting…")
await dp.start_polling(bot)