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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user