From 801a7520d5213c35f2300aeaacd9d9719ede3b8b Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 1 May 2026 22:59:01 +0700 Subject: [PATCH 01/20] feat: add bio-bait spam detection with profile bio scanning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects two related spam vectors common in Indonesian Telegram groups: 1. Bait phrases in messages (e.g. "cek bio aku", "liat byoh", "open my bio"). Spammers obfuscate the word "bio" with misspellings, separators (b.i.o, b1o), and Cyrillic look-alikes (ะฌั–ะพ). The handler normalizes (NFKC + lowercase + zero-width strip) and canonicalizes obfuscated variants back to "bio" before matching a small set of imperative + bio + possessive patterns. 2. Promo/scam links inside the user's Telegram profile bio. Some spammers send innocuous group messages while their bio carries t.me/+invite links, non-whitelisted t.me/{user} links, or multiple non-whitelisted @mentions (sometimes paired with promo hint words like VIP, BCL, ASP, open). The user's bio is fetched once per hour via bot.get_chat() and cached in bot_data. On match the handler deletes the message, restricts the user, clears the cached bio, and posts a notification (separate templates for message-bait vs bio-link cases) to the warning topic. - New handler: src/bot/handlers/bio_bait.py (registered at group=2, shifts contact/new_user/duplicate/message handlers to 3/4/5/6). - New config: bio_bait_enabled (Settings + GroupConfig, default True). - New templates: BIO_BAIT_SPAM_NOTIFICATION (+ NO_RESTRICT) and BIO_LINK_SPAM_NOTIFICATION (+ NO_RESTRICT) in constants.py. - Tests: tests/test_bio_bait.py covers normalization, true positives (incl. Cyrillic / obfuscated forms), false positives (biology, bioinformatics, "bio aku ada di README"), bio-link detection, per-user TTL cache, all handler branches. 626 tests pass, bio_bait.py at 100% coverage, ruff clean. --- src/bot/config.py | 1 + src/bot/constants.py | 32 +++ src/bot/group_config.py | 2 + src/bot/handlers/bio_bait.py | 356 +++++++++++++++++++++++++++++ src/bot/main.py | 29 ++- tests/test_bio_bait.py | 419 +++++++++++++++++++++++++++++++++++ 6 files changed, 831 insertions(+), 8 deletions(-) create mode 100644 src/bot/handlers/bio_bait.py create mode 100644 tests/test_bio_bait.py diff --git a/src/bot/config.py b/src/bot/config.py index 285d8c5..dd9f818 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -85,6 +85,7 @@ class Settings(BaseSettings): duplicate_spam_threshold: int = 2 duplicate_spam_min_length: int = 20 duplicate_spam_similarity: float = 0.95 + bio_bait_enabled: bool = True groups_config_path: str = "groups.json" logfire_token: str | None = None logfire_service_name: str = "pythonid-bot" diff --git a/src/bot/constants.py b/src/bot/constants.py index bfd6b75..2ca1895 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -278,6 +278,38 @@ def format_hours_display(hours: int) -> str: "๐Ÿ“Œ [Peraturan Grup]({rules_link})" ) +# Bio bait spam notification (e.g. "cek bio aku" / "lihat byoh") +BIO_BAIT_SPAM_NOTIFICATION = ( + "๐Ÿšซ *Spam Bio Bait Terdeteksi*\n\n" + "Pesan dari {user_mention} telah dihapus karena berisi ajakan " + "untuk mengecek bio/profil, pola yang umum dipakai untuk spam/promosi/scam.\n\n" + "Pengguna telah dibatasi.\n\n" + "๐Ÿ“Œ [Peraturan Grup]({rules_link})" +) + +BIO_BAIT_SPAM_NOTIFICATION_NO_RESTRICT = ( + "๐Ÿšซ *Spam Bio Bait Terdeteksi*\n\n" + "Pesan dari {user_mention} telah dihapus karena berisi ajakan " + "untuk mengecek bio/profil, pola yang umum dipakai untuk spam/promosi/scam.\n\n" + "๐Ÿ“Œ [Peraturan Grup]({rules_link})" +) + +# Bio profile link spam (user's profile bio contains promo/scam links) +BIO_LINK_SPAM_NOTIFICATION = ( + "๐Ÿšซ *Spam Bio Profil Terdeteksi*\n\n" + "Pesan dari {user_mention} telah dihapus karena akun ini memiliki " + "bio profil dengan tautan/mention Telegram mencurigakan.\n\n" + "Pengguna telah dibatasi.\n\n" + "๐Ÿ“Œ [Peraturan Grup]({rules_link})" +) + +BIO_LINK_SPAM_NOTIFICATION_NO_RESTRICT = ( + "๐Ÿšซ *Spam Bio Profil Terdeteksi*\n\n" + "Pesan dari {user_mention} telah dihapus karena akun ini memiliki " + "bio profil dengan tautan/mention Telegram mencurigakan.\n\n" + "๐Ÿ“Œ [Peraturan Grup]({rules_link})" +) + # Whitelisted URL domains for new user probation # These domains are allowed even during probation period # Matches exact domain or subdomains (e.g., "github.com" matches "www.github.com") diff --git a/src/bot/group_config.py b/src/bot/group_config.py index 224d70a..154504d 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -41,6 +41,7 @@ class GroupConfig(BaseModel): duplicate_spam_threshold: int = 2 duplicate_spam_min_length: int = 20 duplicate_spam_similarity: float = 0.95 + bio_bait_enabled: bool = True @field_validator("group_id") @classmethod @@ -193,6 +194,7 @@ def build_group_registry(settings: object) -> GroupRegistry: duplicate_spam_threshold=settings.duplicate_spam_threshold, duplicate_spam_min_length=settings.duplicate_spam_min_length, duplicate_spam_similarity=settings.duplicate_spam_similarity, + bio_bait_enabled=settings.bio_bait_enabled, ) registry.register(config) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py new file mode 100644 index 0000000..758e80d --- /dev/null +++ b/src/bot/handlers/bio_bait.py @@ -0,0 +1,356 @@ +""" +Bio bait spam detection handler. + +Spammers commonly post short messages telling other members to check their +profile bio, where the bio itself contains a link to a Telegram channel/group +(typically scam/promo/gambling). To evade keyword filters they obfuscate the +word "bio" with misspellings, separators, and Cyrillic look-alikes +(e.g. "byooh", "b.i.o", "ะฌั–ะพ", "b1o", "bioohh"). + +This handler covers TWO related vectors: + +1. Bait phrase in the message text (e.g. "cek bio aku", "liat byoh"). +2. The user's *Telegram profile bio* itself contains promo/scam links + (e.g. "VIP BCL t.me/+KVUG7Nzphek0N2M1"). In this case the group message + may be innocuous; the spam is in the bio. We fetch the bio once per + hour per user and cache it. + +On match the handler deletes the message, restricts the user, and posts a +notification to the warning topic. +""" + +import logging +import re +import unicodedata +from time import monotonic + +from telegram import Update +from telegram.ext import ApplicationHandlerStop, ContextTypes + +from bot.constants import ( + BIO_BAIT_SPAM_NOTIFICATION, + BIO_BAIT_SPAM_NOTIFICATION_NO_RESTRICT, + BIO_LINK_SPAM_NOTIFICATION, + BIO_LINK_SPAM_NOTIFICATION_NO_RESTRICT, + RESTRICTED_PERMISSIONS, + WHITELISTED_TELEGRAM_PATHS, +) +from bot.group_config import get_group_config_for_update +from bot.handlers.anti_spam import is_url_whitelisted +from bot.services.telegram_utils import get_user_mention + +logger = logging.getLogger(__name__) + +# Maximum normalized text length to consider as bait. Real bait is short. +BIO_BAIT_MAX_LENGTH = 80 + +# Per-user bio cache (TTL in seconds). Stored in context.bot_data. +USER_BIO_CACHE_KEY = "user_bio_cache" +USER_BIO_CACHE_TTL_SECONDS = 3600 + +# Strip common zero-width characters used to break keyword filters. +ZERO_WIDTH_RE = re.compile(r"[\u200b-\u200f\u2060-\u2064\ufeff]") + +# Canonicalize obfuscated "bio" variants to a plain "bio" token. +# Covers: bio, b1o, b!o, b.i.o, b i o, b-i-o, bioh, bioo, bioohh, plus +# Cyrillic look-alikes ัŒ and ั–. (Input is already lowercased.) +BIO_OBFUSCATED_RE = re.compile( + r"\b[bัŒ][\s._\-]*[i1!ั–][\s._\-]*[o0ะพ](?:[\s._\-]*h+)?\b" +) + +# Canonicalize "byo / byoh / byooh" variants. +BYO_OBFUSCATED_RE = re.compile( + r"\b[bัŒ][\s._\-]*y[\s._\-]*[o0ะพ](?:[\s._\-]*h+)?\b" +) + +# Catch elongated forms after partial canonicalization, e.g. "biooo", "byoooh". +BIO_ELONGATED_RE = re.compile(r"\bb(?:i|y)o+h*\b") + +# Common Indonesian first-person possessives + English equivalents. +_BIO_OWNER_RE = r"\b(?:aku|gw|gue|saya|ku|ane|me|my)\b" +# Optional address particle that often follows bait phrases. +_BIO_SUFFIX_RE = r"(?:\s+\b(?:dong|ya|kak|bro|sis)\b)?" + +# Bait phrase patterns matched against the normalized text. +# Each requires either: +# (a) imperative cue + bio (with optional address particle), OR +# (b) bio + first-person possessive at end of message, OR +# (c) imperative cue + profil/profile + possessive, OR +# (d) imperative cue + my + profile/bio. +BIO_BAIT_PATTERNS = ( + re.compile( + r"\b(?:cek|check|liat|lihat|buka|open|view|see|kunjungi|kunjungin)\b" + rf"(?:\s+\w+){{0,2}}\s+\bbio\b{_BIO_SUFFIX_RE}" + ), + re.compile( + rf"\bbio\b\s+{_BIO_OWNER_RE}" + rf"(?:\s+\b(?:update|updated|baru|new)\b)?" + rf"{_BIO_SUFFIX_RE}$" + ), + re.compile( + r"\b(?:cek|check|liat|lihat|buka|open|view|see)\b" + r"\s+\b(?:profil|profile)\b" + rf"\s+{_BIO_OWNER_RE}{_BIO_SUFFIX_RE}" + ), + re.compile( + r"\b(?:cek|check|liat|lihat|buka|open|view|see)\b" + r"\s+\bmy\b" + r"\s+\b(?:profile|bio)\b" + ), +) + +# Telegram private invite links (e.g. t.me/+KVUG7Nzphek0N2M1). +TELEGRAM_INVITE_LINK_RE = re.compile( + r"(?:https?://)?(?:t\.me|telegram\.me)/\+[A-Za-z0-9_-]{8,}", + re.IGNORECASE, +) + +# Telegram public channel/user links (e.g. t.me/somechannel). +TELEGRAM_LINK_RE = re.compile( + r"((?:https?://)?(?:t\.me|telegram\.me)/[A-Za-z][A-Za-z0-9_]{4,31}(?:/[^\s]+)?)", + re.IGNORECASE, +) + +# Bare @username mentions. +TELEGRAM_USERNAME_RE = re.compile(r"(? str: + """ + Normalize text for bio-bait detection. + + Applies NFKC, lowercases, strips zero-width characters, canonicalizes + obfuscated bio/byo variants to "bio", strips remaining punctuation, + and collapses whitespace. + + Args: + text: Raw message text or caption. + + Returns: + Normalized text suitable for regex matching. + """ + text = unicodedata.normalize("NFKC", text).lower() + text = ZERO_WIDTH_RE.sub("", text) + text = BIO_OBFUSCATED_RE.sub(" bio ", text) + text = BYO_OBFUSCATED_RE.sub(" bio ", text) + text = BIO_ELONGATED_RE.sub(" bio ", text) + text = re.sub(r"[^\w\s]", " ", text, flags=re.UNICODE) + text = re.sub(r"\s+", " ", text).strip() + return text + + +def is_bio_bait_spam(text: str) -> bool: + """ + Check whether the given text matches any bio bait pattern. + + Args: + text: Raw message text or caption. + + Returns: + bool: True if text matches a bait pattern within the length cap. + """ + normalized = normalize_bio_bait_text(text) + if not normalized: + return False + if len(normalized) > BIO_BAIT_MAX_LENGTH: + return False + return any(pattern.search(normalized) for pattern in BIO_BAIT_PATTERNS) + + +def has_suspicious_bio_links(bio: str) -> bool: + """ + Check whether a user's bio text contains suspicious Telegram promo refs. + + Triggers on: + - Any t.me/+... private invite link. + - Any non-whitelisted t.me/{username} link. + - Two or more non-whitelisted bare @mentions. + - A single non-whitelisted @mention combined with a promo hint word. + + Args: + bio: Raw bio string from the user's profile. + + Returns: + bool: True if the bio is considered spammy. + """ + if not bio: + return False + + normalized = unicodedata.normalize("NFKC", bio) + lowered = normalized.lower() + + if TELEGRAM_INVITE_LINK_RE.search(normalized): + return True + + for match in TELEGRAM_LINK_RE.finditer(normalized): + if not is_url_whitelisted(match.group(1)): + return True + + mentions = { + m.group(1).lower() + for m in TELEGRAM_USERNAME_RE.finditer(normalized) + if m.group(1).lower() not in WHITELISTED_TELEGRAM_PATHS + } + if len(mentions) >= 2: + return True + if mentions and any(hint in lowered for hint in BIO_PROMO_HINTS): + return True + + return False + + +def _get_user_bio_cache( + context: ContextTypes.DEFAULT_TYPE, +) -> dict[int, tuple[float, str | None]]: + """Get or initialize the per-user bio cache stored in bot_data.""" + return context.bot_data.setdefault(USER_BIO_CACHE_KEY, {}) + + +def clear_cached_user_bio( + context: ContextTypes.DEFAULT_TYPE, user_id: int +) -> None: + """Remove a user's bio cache entry (call after restriction).""" + _get_user_bio_cache(context).pop(user_id, None) + + +async def get_cached_user_bio( + context: ContextTypes.DEFAULT_TYPE, user_id: int +) -> str | None: + """ + Fetch the user's profile bio with a per-user TTL cache. + + Returns the cached bio if the entry is still fresh. Otherwise calls + bot.get_chat(user_id) and stores the result. Errors are swallowed and + cause this function to return None for that call. + """ + cache = _get_user_bio_cache(context) + now = monotonic() + + cached = cache.get(user_id) + if cached and cached[0] > now: + return cached[1] + + try: + chat = await context.bot.get_chat(user_id) + bio = (getattr(chat, "bio", None) or "").strip() or None + except Exception: + logger.debug("Failed to fetch user bio: user_id=%s", user_id, exc_info=True) + return None + + cache[user_id] = (now + USER_BIO_CACHE_TTL_SECONDS, bio) + return bio + + +async def handle_bio_bait_spam( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """ + Handle bio-bait spam (phrase in message OR promo links in user's bio). + + Skips bots and admins. On match, deletes the message, restricts the + user, and notifies the warning topic. Always raises ApplicationHandlerStop + after handling a detected message to prevent downstream handlers from + re-processing it. + + Args: + update: Telegram update containing the message. + context: Bot context with helper methods. + """ + if not update.message or not update.message.from_user: + return + + group_config = get_group_config_for_update(update) + if group_config is None: + return + + if not group_config.bio_bait_enabled: + return + + user = update.message.from_user + if user.is_bot: + return + + admin_ids = context.bot_data.get("group_admin_ids", {}).get(group_config.group_id, []) + if user.id in admin_ids: + return + + text = update.message.text or update.message.caption or "" + + detection_reason: str | None = None + if text and is_bio_bait_spam(text): + detection_reason = "message_bait" + else: + user_bio = await get_cached_user_bio(context, user.id) + if user_bio and has_suspicious_bio_links(user_bio): + detection_reason = "bio_links" + + if detection_reason is None: + return + + user_mention = get_user_mention(user) + logger.info( + f"Bio bait spam detected: user_id={user.id}, " + f"group_id={group_config.group_id}, reason={detection_reason}" + ) + + try: + await update.message.delete() + logger.info(f"Deleted bio bait spam from user_id={user.id}") + except Exception: + logger.error( + f"Failed to delete bio bait spam: user_id={user.id}", + exc_info=True, + ) + + restricted = False + try: + await context.bot.restrict_chat_member( + chat_id=group_config.group_id, + user_id=user.id, + permissions=RESTRICTED_PERMISSIONS, + ) + restricted = True + clear_cached_user_bio(context, user.id) + logger.info(f"Restricted user_id={user.id} for bio bait spam") + except Exception: + logger.error( + f"Failed to restrict user for bio bait spam: user_id={user.id}", + exc_info=True, + ) + + try: + if detection_reason == "bio_links": + template = ( + BIO_LINK_SPAM_NOTIFICATION if restricted + else BIO_LINK_SPAM_NOTIFICATION_NO_RESTRICT + ) + else: + template = ( + BIO_BAIT_SPAM_NOTIFICATION if restricted + else BIO_BAIT_SPAM_NOTIFICATION_NO_RESTRICT + ) + notification_text = template.format( + user_mention=user_mention, + rules_link=group_config.rules_link, + ) + await context.bot.send_message( + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, + text=notification_text, + parse_mode="Markdown", + ) + logger.info(f"Sent bio bait spam notification for user_id={user.id}") + except Exception: + logger.error( + f"Failed to send bio bait spam notification: user_id={user.id}", + exc_info=True, + ) + + raise ApplicationHandlerStop diff --git a/src/bot/main.py b/src/bot/main.py index c6aba20..4eebd0e 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -19,6 +19,7 @@ from bot.group_config import get_group_registry, init_group_registry from bot.handlers import captcha from bot.handlers.anti_spam import handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam +from bot.handlers.bio_bait import handle_bio_bait_spam from bot.handlers.duplicate_spam import handle_duplicate_spam from bot.handlers.dm import handle_dm from bot.handlers.message import handle_message @@ -356,15 +357,27 @@ def main() -> None: ) logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") + # Handler: Bio bait spam handler - catches "cek bio aku" / "lihat byoh" style + # messages where spammers point users to their profile bio (which contains + # external promo/scam links). + application.add_handler( + MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + handle_bio_bait_spam, + ), + group=2, + ) + logger.info("Registered handler: bio_bait_spam_handler (group=2)") + # Handler: Contact spam handler - blocks contact card sharing for all members application.add_handler( MessageHandler( filters.ChatType.GROUPS & filters.CONTACT, handle_contact_spam, ), - group=2, + group=3, ) - logger.info("Registered handler: contact_spam_handler (group=2)") + logger.info("Registered handler: contact_spam_handler (group=3)") # Handler 9: New-user anti-spam handler - checks for forwards/links from users on probation application.add_handler( @@ -372,9 +385,9 @@ def main() -> None: filters.ChatType.GROUPS, handle_new_user_spam, ), - group=3, + group=4, ) - logger.info("Registered handler: anti_spam_handler (group=3)") + logger.info("Registered handler: anti_spam_handler (group=4)") # Handler 10: Duplicate message spam handler - detects repeated identical messages application.add_handler( @@ -382,9 +395,9 @@ def main() -> None: filters.ChatType.GROUPS & ~filters.COMMAND, handle_duplicate_spam, ), - group=4, + group=5, ) - logger.info("Registered handler: duplicate_spam_handler (group=4)") + logger.info("Registered handler: duplicate_spam_handler (group=5)") # Handler 11: Group message handler - monitors messages in monitored # groups and warns/restricts users with incomplete profiles @@ -393,9 +406,9 @@ def main() -> None: filters.ChatType.GROUPS & ~filters.COMMAND, handle_message, ), - group=5, + group=6, ) - logger.info("Registered handler: message_handler (group=5)") + logger.info("Registered handler: message_handler (group=6)") # Register auto-restriction job to run every 5 minutes if application.job_queue: diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py new file mode 100644 index 0000000..1e1c85f --- /dev/null +++ b/tests/test_bio_bait.py @@ -0,0 +1,419 @@ +"""Tests for the bio bait spam detection handler.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram import Chat, Message, User +from telegram.ext import ApplicationHandlerStop + +from bot.group_config import GroupConfig +from bot.handlers.bio_bait import ( + BIO_BAIT_MAX_LENGTH, + USER_BIO_CACHE_KEY, + USER_BIO_CACHE_TTL_SECONDS, + clear_cached_user_bio, + get_cached_user_bio, + handle_bio_bait_spam, + has_suspicious_bio_links, + is_bio_bait_spam, + normalize_bio_bait_text, +) + + +class TestNormalizeBioBaitText: + """Tests for the normalize_bio_bait_text function.""" + + def test_lowercase(self): + assert normalize_bio_bait_text("CEK BIO") == "cek bio" + + def test_strip_zero_width(self): + result = normalize_bio_bait_text("cek b\u200bi\u200bo aku") + assert "bio" in result + assert "aku" in result + + def test_canonicalize_b1o(self): + assert "bio" in normalize_bio_bait_text("cek b1o aku") + + def test_canonicalize_b_dot_i_dot_o(self): + assert "bio" in normalize_bio_bait_text("cek b.i.o aku") + + def test_canonicalize_spaced(self): + assert "bio" in normalize_bio_bait_text("cek b i o aku") + + def test_canonicalize_byoh(self): + assert "bio" in normalize_bio_bait_text("liat byoh") + + def test_canonicalize_bioohh(self): + assert "bio" in normalize_bio_bait_text("cek bioohh aku") + + def test_canonicalize_cyrillic(self): + # Cyrillic ะฌ + ั– + ะพ, gets lowercased then matched. + assert "bio" in normalize_bio_bait_text("cek ะฌั–ะพ aku") + + def test_strip_punctuation(self): + assert normalize_bio_bait_text("cek bio, aku!") == "cek bio aku" + + def test_collapse_whitespace(self): + assert normalize_bio_bait_text("cek bio aku") == "cek bio aku" + + def test_empty_string(self): + assert normalize_bio_bait_text("") == "" + + +class TestIsBioBaitSpam: + """Tests for the is_bio_bait_spam function.""" + + @pytest.mark.parametrize("text", [ + "cek bio", + "lihat bio aku", + "liat byoh", + "buka b1o aku", + "cek b!o aku", + "b.i.o aku", + "b i o aku", + "bioooo aku", + "ะฌั–ะพ aku", + "open my bio", + "check my profile", + "cek\nbio aku", + "lihat profil aku", + "cek bioohh aku", + "cek bio kak", + "lihat bio dong", + "bio aku update", + "bio aku updated", + "bio aku baru", + ]) + def test_detects_bait(self, text): + assert is_bio_bait_spam(text) is True + + @pytest.mark.parametrize("text", [ + "biology itu menarik banget", + "bioinformatics adalah bidang yang luas", + "biome dan biodiversity penting", + "DM aku", + "pm aku", + "profile picture saya rusak", + "halo semua", + "info ada di sini bro", + "thank you my bro", + "bio aku ada di README", + "bio aku untuk eksperimen regex", + "", + ]) + def test_does_not_detect_safe(self, text): + assert is_bio_bait_spam(text) is False + + def test_too_long_not_detected(self): + text = "cek bio aku " + ("padding " * 30) + assert is_bio_bait_spam(text) is False + + def test_length_cap_constant(self): + assert BIO_BAIT_MAX_LENGTH > 0 + + +class TestHasSuspiciousBioLinks: + """Tests for has_suspicious_bio_links.""" + + def test_empty_bio(self): + assert has_suspicious_bio_links("") is False + + def test_invite_link(self): + bio = "VIP BCL t.me/+KVUG7Nzphek0N2M1 ASP" + assert has_suspicious_bio_links(bio) is True + + def test_invite_link_with_https(self): + assert has_suspicious_bio_links("https://t.me/+abcdefghij") is True + + def test_non_whitelisted_public_link(self): + assert has_suspicious_bio_links("Join t.me/somerandomscamchannel") is True + + def test_whitelisted_public_link_alone(self): + # A bio mentioning the official group is fine. + assert has_suspicious_bio_links("Member of t.me/pythonid") is False + + def test_single_bare_mention_not_enough(self): + assert has_suspicious_bio_links("Contact: @somerandomname") is False + + def test_two_non_whitelisted_mentions(self): + assert has_suspicious_bio_links("@channel_one @channel_two") is True + + def test_single_mention_with_promo_hint(self): + assert has_suspicious_bio_links("VIP @channel_one") is True + + def test_whitelisted_mention_alone(self): + assert has_suspicious_bio_links("@pythonid") is False + + def test_plain_bio_no_links(self): + assert has_suspicious_bio_links("Just a Python developer from Indonesia.") is False + + +class TestUserBioCache: + """Tests for get_cached_user_bio / clear_cached_user_bio.""" + + @pytest.fixture + def context(self): + ctx = MagicMock() + ctx.bot_data = {} + ctx.bot = MagicMock() + ctx.bot.get_chat = AsyncMock() + return ctx + + async def test_fetch_and_cache(self, context): + chat = MagicMock() + chat.bio = " hello world " + context.bot.get_chat.return_value = chat + + bio = await get_cached_user_bio(context, 42) + assert bio == "hello world" + assert 42 in context.bot_data[USER_BIO_CACHE_KEY] + + async def test_cache_hit_skips_api(self, context): + chat = MagicMock() + chat.bio = "first" + context.bot.get_chat.return_value = chat + + await get_cached_user_bio(context, 7) + await get_cached_user_bio(context, 7) + assert context.bot.get_chat.call_count == 1 + + async def test_empty_bio_cached_as_none(self, context): + chat = MagicMock() + chat.bio = "" + context.bot.get_chat.return_value = chat + + bio = await get_cached_user_bio(context, 9) + assert bio is None + assert context.bot_data[USER_BIO_CACHE_KEY][9][1] is None + + async def test_missing_bio_attribute_cached_as_none(self, context): + chat = MagicMock(spec=[]) # no bio attribute + context.bot.get_chat.return_value = chat + + bio = await get_cached_user_bio(context, 11) + assert bio is None + + async def test_get_chat_error_returns_none(self, context): + context.bot.get_chat = AsyncMock(side_effect=Exception("boom")) + bio = await get_cached_user_bio(context, 13) + assert bio is None + # Failures are NOT cached so we retry next time. + assert 13 not in context.bot_data.get(USER_BIO_CACHE_KEY, {}) + + def test_clear_cache(self, context): + context.bot_data[USER_BIO_CACHE_KEY] = {42: (123.0, "x")} + clear_cached_user_bio(context, 42) + assert 42 not in context.bot_data[USER_BIO_CACHE_KEY] + + def test_clear_cache_missing(self, context): + # Should not raise even if the entry doesn't exist. + clear_cached_user_bio(context, 999) + + def test_ttl_constant_positive(self): + assert USER_BIO_CACHE_TTL_SECONDS > 0 + + +class TestHandleBioBaitSpam: + """Tests for the handle_bio_bait_spam handler.""" + + @pytest.fixture + def group_config(self): + return GroupConfig( + group_id=-100, + warning_topic_id=999, + bio_bait_enabled=True, + ) + + @pytest.fixture + def mock_update(self): + update = MagicMock() + update.message = MagicMock(spec=Message) + update.message.from_user = MagicMock(spec=User) + update.message.from_user.id = 42 + update.message.from_user.is_bot = False + update.message.from_user.full_name = "Test User" + update.message.from_user.username = "testuser" + update.message.text = "cek bio aku" + update.message.caption = None + update.message.message_id = 100 + update.message.delete = AsyncMock() + update.effective_chat = MagicMock(spec=Chat) + update.effective_chat.id = -100 + return update + + @pytest.fixture + def mock_context(self): + context = MagicMock() + context.bot_data = {"group_admin_ids": {-100: [1, 2]}} + context.bot = MagicMock() + context.bot.restrict_chat_member = AsyncMock() + context.bot.send_message = AsyncMock() + # Default: empty bio so bio-link branch won't trigger unintentionally. + chat = MagicMock() + chat.bio = "" + context.bot.get_chat = AsyncMock(return_value=chat) + return context + + async def test_skips_no_message(self, mock_context, group_config): + update = MagicMock() + update.message = None + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(update, mock_context) + + async def test_skips_no_user(self, mock_context, group_config): + update = MagicMock() + update.message = MagicMock(spec=Message) + update.message.from_user = None + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(update, mock_context) + + async def test_skips_unmonitored_group(self, mock_update, mock_context): + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=None): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + + async def test_skips_when_disabled(self, mock_update, mock_context, group_config): + group_config.bio_bait_enabled = False + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + + async def test_skips_bots(self, mock_update, mock_context, group_config): + mock_update.message.from_user.is_bot = True + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + + async def test_skips_admins(self, mock_update, mock_context, group_config): + mock_update.message.from_user.id = 1 + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + + async def test_skips_innocuous_message_with_clean_bio( + self, mock_update, mock_context, group_config + ): + mock_update.message.text = "halo semua, ada yang tahu cara install python?" + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + + async def test_detects_message_bait_and_restricts( + self, mock_update, mock_context, group_config + ): + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + + mock_update.message.delete.assert_called_once() + mock_context.bot.restrict_chat_member.assert_called_once() + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "Bio Bait" in call_kwargs["text"] + assert "dibatasi" in call_kwargs["text"] + + async def test_uses_caption_when_no_text(self, mock_update, mock_context, group_config): + mock_update.message.text = None + mock_update.message.caption = "lihat bio aku" + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_called_once() + + async def test_detects_via_bio_links_with_innocuous_message( + self, mock_update, mock_context, group_config + ): + mock_update.message.text = "halo" + chat = MagicMock() + chat.bio = "VIP BCL t.me/+KVUG7Nzphek0N2M1" + mock_context.bot.get_chat = AsyncMock(return_value=chat) + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + + mock_update.message.delete.assert_called_once() + mock_context.bot.restrict_chat_member.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "Bio Profil" in call_kwargs["text"] + + async def test_no_text_no_bad_bio_does_nothing( + self, mock_update, mock_context, group_config + ): + mock_update.message.text = None + mock_update.message.caption = None + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + + async def test_no_text_with_bad_bio_triggers_restriction( + self, mock_update, mock_context, group_config + ): + mock_update.message.text = None + mock_update.message.caption = None + chat = MagicMock() + chat.bio = "VIP t.me/+abcdefghij" + mock_context.bot.get_chat = AsyncMock(return_value=chat) + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_called_once() + + async def test_restriction_clears_bio_cache( + self, mock_update, mock_context, group_config + ): + mock_update.message.text = "halo" + chat = MagicMock() + chat.bio = "VIP t.me/+abcdefghij" + mock_context.bot.get_chat = AsyncMock(return_value=chat) + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + + cache = mock_context.bot_data.get(USER_BIO_CACHE_KEY, {}) + assert mock_update.message.from_user.id not in cache + + async def test_delete_failure_continues(self, mock_update, mock_context, group_config): + mock_update.message.delete = AsyncMock(side_effect=Exception("Delete failed")) + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + mock_context.bot.restrict_chat_member.assert_called_once() + mock_context.bot.send_message.assert_called_once() + + async def test_restrict_failure_uses_no_restrict_template( + self, mock_update, mock_context, group_config + ): + mock_context.bot.restrict_chat_member = AsyncMock(side_effect=Exception("Restrict failed")) + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + mock_context.bot.send_message.assert_called_once() + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "dibatasi" not in call_kwargs["text"] + + async def test_restrict_failure_for_bio_link_uses_no_restrict_template( + self, mock_update, mock_context, group_config + ): + mock_update.message.text = "halo" + chat = MagicMock() + chat.bio = "VIP t.me/+abcdefghij" + mock_context.bot.get_chat = AsyncMock(return_value=chat) + mock_context.bot.restrict_chat_member = AsyncMock(side_effect=Exception("fail")) + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + call_kwargs = mock_context.bot.send_message.call_args.kwargs + assert "Bio Profil" in call_kwargs["text"] + assert "dibatasi" not in call_kwargs["text"] + + async def test_notification_failure_still_raises_stop( + self, mock_update, mock_context, group_config + ): + mock_context.bot.send_message = AsyncMock(side_effect=Exception("Send failed")) + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) From b764bed242a1e44418ddca6e7284fefabe26a804 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 1 May 2026 23:03:22 +0700 Subject: [PATCH 02/20] chore: sanitize spam example references Replace real-looking Telegram invite hashes and @username from spam examples in code comments and tests with obvious placeholders so the repository does not propagate (or appear to endorse) actual scam links. --- src/bot/handlers/bio_bait.py | 8 ++++---- tests/test_bio_bait.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index 758e80d..12efed3 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -11,9 +11,9 @@ 1. Bait phrase in the message text (e.g. "cek bio aku", "liat byoh"). 2. The user's *Telegram profile bio* itself contains promo/scam links - (e.g. "VIP BCL t.me/+KVUG7Nzphek0N2M1"). In this case the group message - may be innocuous; the spam is in the bio. We fetch the bio once per - hour per user and cache it. + (private t.me/+ invite links and/or non-whitelisted @mentions). In + this case the group message may be innocuous; the spam is in the bio. + We fetch the bio once per hour per user and cache it. On match the handler deletes the message, restricts the user, and posts a notification to the warning topic. @@ -99,7 +99,7 @@ ), ) -# Telegram private invite links (e.g. t.me/+KVUG7Nzphek0N2M1). +# Telegram private invite links (t.me/+). TELEGRAM_INVITE_LINK_RE = re.compile( r"(?:https?://)?(?:t\.me|telegram\.me)/\+[A-Za-z0-9_-]{8,}", re.IGNORECASE, diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index 1e1c85f..1ad321f 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -119,11 +119,11 @@ def test_empty_bio(self): assert has_suspicious_bio_links("") is False def test_invite_link(self): - bio = "VIP BCL t.me/+KVUG7Nzphek0N2M1 ASP" + bio = "VIP promo t.me/+exampleinvitehash ASP" assert has_suspicious_bio_links(bio) is True def test_invite_link_with_https(self): - assert has_suspicious_bio_links("https://t.me/+abcdefghij") is True + assert has_suspicious_bio_links("https://t.me/+exampleinvitehash") is True def test_non_whitelisted_public_link(self): assert has_suspicious_bio_links("Join t.me/somerandomscamchannel") is True @@ -325,7 +325,7 @@ async def test_detects_via_bio_links_with_innocuous_message( ): mock_update.message.text = "halo" chat = MagicMock() - chat.bio = "VIP BCL t.me/+KVUG7Nzphek0N2M1" + chat.bio = "VIP promo t.me/+exampleinvitehash" mock_context.bot.get_chat = AsyncMock(return_value=chat) with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): @@ -352,7 +352,7 @@ async def test_no_text_with_bad_bio_triggers_restriction( mock_update.message.text = None mock_update.message.caption = None chat = MagicMock() - chat.bio = "VIP t.me/+abcdefghij" + chat.bio = "VIP t.me/+exampleinvitehash" mock_context.bot.get_chat = AsyncMock(return_value=chat) with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): @@ -365,7 +365,7 @@ async def test_restriction_clears_bio_cache( ): mock_update.message.text = "halo" chat = MagicMock() - chat.bio = "VIP t.me/+abcdefghij" + chat.bio = "VIP t.me/+exampleinvitehash" mock_context.bot.get_chat = AsyncMock(return_value=chat) with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): @@ -399,7 +399,7 @@ async def test_restrict_failure_for_bio_link_uses_no_restrict_template( ): mock_update.message.text = "halo" chat = MagicMock() - chat.bio = "VIP t.me/+abcdefghij" + chat.bio = "VIP t.me/+exampleinvitehash" mock_context.bot.get_chat = AsyncMock(return_value=chat) mock_context.bot.restrict_chat_member = AsyncMock(side_effect=Exception("fail")) From 7097a1e7911aa04aa504d5058278911c9e5c40f1 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Thu, 14 May 2026 23:04:03 +0700 Subject: [PATCH 03/20] fix: narrow promo hints and word-boundary matching for bio link detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove generic promo hints (open, ready, available) that FP on developer bios - Use word-boundary regex for promo hint matching (prevents 'vip' inside 'advancement') - Fix Cyrillic 4-char obfuscation: allow ัŒ as filler between b and i in BIO_OBFUSCATED_RE - Add tests: word boundary, generic words removed, strong hints still work, Cyrillic filler --- src/bot/handlers/bio_bait.py | 15 ++++++--------- tests/test_bio_bait.py | 24 ++++++++++++++++++++---- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index 12efed3..fba6baf 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -55,7 +55,7 @@ # Covers: bio, b1o, b!o, b.i.o, b i o, b-i-o, bioh, bioo, bioohh, plus # Cyrillic look-alikes ัŒ and ั–. (Input is already lowercased.) BIO_OBFUSCATED_RE = re.compile( - r"\b[bัŒ][\s._\-]*[i1!ั–][\s._\-]*[o0ะพ](?:[\s._\-]*h+)?\b" + r"\b[bัŒ][ัŒ\s._\-]*[i1!ั–][\s._\-]*[o0ะพ](?:[\s._\-]*h+)?\b" ) # Canonicalize "byo / byoh / byooh" variants. @@ -118,9 +118,12 @@ # escalate a bio to suspicious. Single mentions alone are not enough. BIO_PROMO_HINTS = frozenset({ "vip", "join", "promo", "channel", "grup", "group", "asp", "bcl", - "open", "available", "ready", }) +# Word-boundary regex for promo hints to avoid substring false positives. +_BIO_PROMO_HINTS_RE = re.compile( + r"\b(?:" + "|".join(sorted(BIO_PROMO_HINTS)) + r")\b" +) def normalize_bio_bait_text(text: str) -> str: """ @@ -145,7 +148,6 @@ def normalize_bio_bait_text(text: str) -> str: text = re.sub(r"\s+", " ", text).strip() return text - def is_bio_bait_spam(text: str) -> bool: """ Check whether the given text matches any bio bait pattern. @@ -163,7 +165,6 @@ def is_bio_bait_spam(text: str) -> bool: return False return any(pattern.search(normalized) for pattern in BIO_BAIT_PATTERNS) - def has_suspicious_bio_links(bio: str) -> bool: """ Check whether a user's bio text contains suspicious Telegram promo refs. @@ -200,26 +201,23 @@ def has_suspicious_bio_links(bio: str) -> bool: } if len(mentions) >= 2: return True - if mentions and any(hint in lowered for hint in BIO_PROMO_HINTS): + if mentions and _BIO_PROMO_HINTS_RE.search(lowered): return True return False - def _get_user_bio_cache( context: ContextTypes.DEFAULT_TYPE, ) -> dict[int, tuple[float, str | None]]: """Get or initialize the per-user bio cache stored in bot_data.""" return context.bot_data.setdefault(USER_BIO_CACHE_KEY, {}) - def clear_cached_user_bio( context: ContextTypes.DEFAULT_TYPE, user_id: int ) -> None: """Remove a user's bio cache entry (call after restriction).""" _get_user_bio_cache(context).pop(user_id, None) - async def get_cached_user_bio( context: ContextTypes.DEFAULT_TYPE, user_id: int ) -> str | None: @@ -247,7 +245,6 @@ async def get_cached_user_bio( cache[user_id] = (now + USER_BIO_CACHE_TTL_SECONDS, bio) return bio - async def handle_bio_bait_spam( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index 1ad321f..ab78f6d 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -19,7 +19,6 @@ normalize_bio_bait_text, ) - class TestNormalizeBioBaitText: """Tests for the normalize_bio_bait_text function.""" @@ -50,6 +49,10 @@ def test_canonicalize_cyrillic(self): # Cyrillic ะฌ + ั– + ะพ, gets lowercased then matched. assert "bio" in normalize_bio_bait_text("cek ะฌั–ะพ aku") + def test_canonicalize_cyrillic_ัŒ_filler(self): + # Latin b + Cyrillic ัŒ + Cyrillic ั– + Cyrillic ะพ + assert "bio" in normalize_bio_bait_text("bัŒั–ะพ aku") + def test_strip_punctuation(self): assert normalize_bio_bait_text("cek bio, aku!") == "cek bio aku" @@ -59,7 +62,6 @@ def test_collapse_whitespace(self): def test_empty_string(self): assert normalize_bio_bait_text("") == "" - class TestIsBioBaitSpam: """Tests for the is_bio_bait_spam function.""" @@ -73,6 +75,7 @@ class TestIsBioBaitSpam: "b i o aku", "bioooo aku", "ะฌั–ะพ aku", + "bัŒั–ะพ aku", "open my bio", "check my profile", "cek\nbio aku", @@ -111,7 +114,6 @@ def test_too_long_not_detected(self): def test_length_cap_constant(self): assert BIO_BAIT_MAX_LENGTH > 0 - class TestHasSuspiciousBioLinks: """Tests for has_suspicious_bio_links.""" @@ -147,6 +149,21 @@ def test_whitelisted_mention_alone(self): def test_plain_bio_no_links(self): assert has_suspicious_bio_links("Just a Python developer from Indonesia.") is False + def test_promo_hint_word_boundary(self): + """'vip' should not match inside other words like 'advancement'.""" + assert has_suspicious_bio_links("advancement @some_user") is False + + def test_generic_words_no_longer_trigger(self): + """'open', 'ready', 'available' removed from promo hints.""" + assert has_suspicious_bio_links("Open source @my_github") is False + assert has_suspicious_bio_links("Ready @my_youtube") is False + assert has_suspicious_bio_links("Available @my_handle") is False + + def test_strong_promo_hints_still_work(self): + """'vip', 'promo', 'join' etc. still trigger with mention.""" + assert has_suspicious_bio_links("VIP @scam_channel") is True + assert has_suspicious_bio_links("promo @scam_channel") is True + assert has_suspicious_bio_links("join @scam_channel") is True class TestUserBioCache: """Tests for get_cached_user_bio / clear_cached_user_bio.""" @@ -212,7 +229,6 @@ def test_clear_cache_missing(self, context): def test_ttl_constant_positive(self): assert USER_BIO_CACHE_TTL_SECONDS > 0 - class TestHandleBioBaitSpam: """Tests for the handle_bio_bait_spam handler.""" From 811c4ce0b289d897fb80090d082d5a9857a1239d Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Thu, 14 May 2026 23:41:55 +0700 Subject: [PATCH 04/20] feat: add bio-bait monitor-only mode with owner alerts and metrics --- .env.example | 11 +++ groups.json.example | 10 ++- src/bot/config.py | 5 ++ src/bot/constants.py | 13 ++++ src/bot/group_config.py | 6 +- src/bot/handlers/bio_bait.py | 129 +++++++++++++++++++++++++++++++++-- tests/test_bio_bait.py | 44 ++++++++++++ tests/test_config.py | 22 ++++++ tests/test_group_config.py | 10 +++ 9 files changed, 240 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 4cb6079..06b548f 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,17 @@ DUPLICATE_SPAM_MIN_LENGTH=20 # 0.95 catches minor edits, 0.97 only near-exact copies, 0.90 is more aggressive DUPLICATE_SPAM_SIMILARITY=0.95 +# Enable/disable bio bait detection (true/false) +BIO_BAIT_ENABLED=true + +# Monitor-only mode for bio bait detection (true/false) +# When true: no delete/restrict/warning-topic notification, only metrics + owner alert +BIO_BAIT_MONITOR_ONLY=false + +# Owner/admin chat ID to receive bio bait monitor alerts (optional) +# Example: 57747812 +# BIO_BAIT_ALERT_CHAT_ID=57747812 + # Path to groups.json for multi-group support (optional) # If this file exists, per-group settings are loaded from it instead of the # GROUP_ID/WARNING_TOPIC_ID/etc. fields above. See groups.json.example. diff --git a/groups.json.example b/groups.json.example index b2f99ba..d14caf2 100644 --- a/groups.json.example +++ b/groups.json.example @@ -15,7 +15,10 @@ "duplicate_spam_window_seconds": 120, "duplicate_spam_threshold": 2, "duplicate_spam_min_length": 20, - "duplicate_spam_similarity": 0.95 + "duplicate_spam_similarity": 0.95, + "bio_bait_enabled": true, + "bio_bait_monitor_only": false, + "bio_bait_alert_chat_id": null }, { "group_id": -1009876543210, @@ -33,6 +36,9 @@ "duplicate_spam_window_seconds": 60, "duplicate_spam_threshold": 2, "duplicate_spam_min_length": 20, - "duplicate_spam_similarity": 0.90 + "duplicate_spam_similarity": 0.90, + "bio_bait_enabled": true, + "bio_bait_monitor_only": false, + "bio_bait_alert_chat_id": null } ] diff --git a/src/bot/config.py b/src/bot/config.py index dd9f818..96338b7 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -86,6 +86,8 @@ class Settings(BaseSettings): duplicate_spam_min_length: int = 20 duplicate_spam_similarity: float = 0.95 bio_bait_enabled: bool = True + bio_bait_monitor_only: bool = False + bio_bait_alert_chat_id: int | None = None groups_config_path: str = "groups.json" logfire_token: str | None = None logfire_service_name: str = "pythonid-bot" @@ -128,6 +130,9 @@ def model_post_init(self, __context): logger.debug(f"captcha_timeout_seconds: {self.captcha_timeout_seconds}") logger.debug(f"new_user_probation_hours: {self.new_user_probation_hours}") logger.debug(f"new_user_violation_threshold: {self.new_user_violation_threshold}") + logger.debug(f"bio_bait_enabled: {self.bio_bait_enabled}") + logger.debug(f"bio_bait_monitor_only: {self.bio_bait_monitor_only}") + logger.debug(f"bio_bait_alert_chat_id: {self.bio_bait_alert_chat_id}") logger.debug(f"telegram_bot_token: {'***' + self.telegram_bot_token[-4:]}") # Mask sensitive token logger.debug(f"logfire_enabled: {self.logfire_enabled}") logger.debug(f"logfire_environment: {self.logfire_environment}") diff --git a/src/bot/constants.py b/src/bot/constants.py index 2ca1895..e1a5bfc 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -310,6 +310,19 @@ def format_hours_display(hours: int) -> str: "๐Ÿ“Œ [Peraturan Grup]({rules_link})" ) +# Monitor-only alert for owner/admin chat when bio bait match is detected. +# Sent without parse_mode to preserve raw message/bio content for forensic review. +BIO_BAIT_MONITOR_ALERT = ( + "[BIO BAIT MONITOR]\n" + "Reason: {reason}\n" + "Group ID: {group_id}\n" + "User ID: {user_id}\n" + "User: {user_name}\n" + "Username: {username}\n" + "Message:\n{message_text}\n\n" + "Profile Bio:\n{profile_bio}" +) + # Whitelisted URL domains for new user probation # These domains are allowed even during probation period # Matches exact domain or subdomains (e.g., "github.com" matches "www.github.com") diff --git a/src/bot/group_config.py b/src/bot/group_config.py index 154504d..925e26d 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -42,6 +42,8 @@ class GroupConfig(BaseModel): duplicate_spam_min_length: int = 20 duplicate_spam_similarity: float = 0.95 bio_bait_enabled: bool = True + bio_bait_monitor_only: bool = False + bio_bait_alert_chat_id: int | None = None @field_validator("group_id") @classmethod @@ -194,7 +196,9 @@ def build_group_registry(settings: object) -> GroupRegistry: duplicate_spam_threshold=settings.duplicate_spam_threshold, duplicate_spam_min_length=settings.duplicate_spam_min_length, duplicate_spam_similarity=settings.duplicate_spam_similarity, - bio_bait_enabled=settings.bio_bait_enabled, + bio_bait_enabled=getattr(settings, "bio_bait_enabled", True), + bio_bait_monitor_only=getattr(settings, "bio_bait_monitor_only", False), + bio_bait_alert_chat_id=getattr(settings, "bio_bait_alert_chat_id", None), ) registry.register(config) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index fba6baf..6cc599a 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -28,6 +28,7 @@ from telegram.ext import ApplicationHandlerStop, ContextTypes from bot.constants import ( + BIO_BAIT_MONITOR_ALERT, BIO_BAIT_SPAM_NOTIFICATION, BIO_BAIT_SPAM_NOTIFICATION_NO_RESTRICT, BIO_LINK_SPAM_NOTIFICATION, @@ -48,6 +49,12 @@ USER_BIO_CACHE_KEY = "user_bio_cache" USER_BIO_CACHE_TTL_SECONDS = 3600 +# Bio bait metrics stored in bot_data. +BIO_BAIT_METRICS_KEY = "bio_bait_metrics" + +# Telegram hard limit per message text. +MAX_TELEGRAM_MESSAGE_LENGTH = 4096 + # Strip common zero-width characters used to break keyword filters. ZERO_WIDTH_RE = re.compile(r"[\u200b-\u200f\u2060-\u2064\ufeff]") @@ -218,6 +225,79 @@ def clear_cached_user_bio( """Remove a user's bio cache entry (call after restriction).""" _get_user_bio_cache(context).pop(user_id, None) + +def _get_bio_bait_metrics(context: ContextTypes.DEFAULT_TYPE) -> dict[str, int]: + """Get or initialize bio bait metrics stored in bot_data.""" + return context.bot_data.setdefault(BIO_BAIT_METRICS_KEY, {}) + + +def _increment_bio_bait_metric( + context: ContextTypes.DEFAULT_TYPE, + metric_name: str, +) -> None: + """Increment a named bio bait metric counter.""" + metrics = _get_bio_bait_metrics(context) + metrics[metric_name] = metrics.get(metric_name, 0) + 1 + + +def record_bio_bait_detection_metrics( + context: ContextTypes.DEFAULT_TYPE, + detection_reason: str, + monitor_only: bool, +) -> None: + """Record counters for a bio bait detection event.""" + _increment_bio_bait_metric(context, "detections_total") + _increment_bio_bait_metric(context, f"detections_{detection_reason}") + if monitor_only: + _increment_bio_bait_metric(context, "monitor_only_matches") + else: + _increment_bio_bait_metric(context, "enforced_matches") + + +def _chunk_telegram_text(text: str, max_length: int = MAX_TELEGRAM_MESSAGE_LENGTH) -> list[str]: + """Split text into Telegram-safe chunks.""" + if len(text) <= max_length: + return [text] + return [text[i : i + max_length] for i in range(0, len(text), max_length)] + + +async def send_monitor_alert_to_owner( + context: ContextTypes.DEFAULT_TYPE, + alert_chat_id: int, + group_id: int, + user_id: int, + user_name: str, + username: str | None, + detection_reason: str, + message_text: str, + profile_bio: str | None, +) -> bool: + """Send monitor-only detection details to owner/admin chat ID.""" + reason_label = "message_bait" if detection_reason == "message_bait" else "bio_links" + alert_text = BIO_BAIT_MONITOR_ALERT.format( + reason=reason_label, + group_id=group_id, + user_id=user_id, + user_name=user_name, + username=f"@{username}" if username else "-", + message_text=message_text or "(kosong)", + profile_bio=profile_bio or "(kosong)", + ) + + try: + for chunk in _chunk_telegram_text(alert_text): + await context.bot.send_message(chat_id=alert_chat_id, text=chunk) + return True + except Exception: + logger.error( + "Failed to send bio bait monitor alert: user_id=%s, group_id=%s", + user_id, + group_id, + exc_info=True, + ) + return False + + async def get_cached_user_bio( context: ContextTypes.DEFAULT_TYPE, user_id: int ) -> str | None: @@ -251,10 +331,10 @@ async def handle_bio_bait_spam( """ Handle bio-bait spam (phrase in message OR promo links in user's bio). - Skips bots and admins. On match, deletes the message, restricts the - user, and notifies the warning topic. Always raises ApplicationHandlerStop - after handling a detected message to prevent downstream handlers from - re-processing it. + Skips bots and admins. In enforcement mode, deletes the message, + restricts the user, notifies the warning topic, and raises + ApplicationHandlerStop. In monitor-only mode, only records metrics and + optionally sends owner alerts without affecting user message flow. Args: update: Telegram update containing the message. @@ -281,6 +361,7 @@ async def handle_bio_bait_spam( text = update.message.text or update.message.caption or "" detection_reason: str | None = None + user_bio: str | None = None if text and is_bio_bait_spam(text): detection_reason = "message_bait" else: @@ -291,12 +372,46 @@ async def handle_bio_bait_spam( if detection_reason is None: return - user_mention = get_user_mention(user) logger.info( - f"Bio bait spam detected: user_id={user.id}, " - f"group_id={group_config.group_id}, reason={detection_reason}" + "Bio bait spam detected: user_id=%s, group_id=%s, reason=%s", + user.id, + group_config.group_id, + detection_reason, ) + monitor_only = group_config.bio_bait_monitor_only + record_bio_bait_detection_metrics(context, detection_reason, monitor_only) + + alert_chat_id = group_config.bio_bait_alert_chat_id + if alert_chat_id is not None: + if user_bio is None: + user_bio = await get_cached_user_bio(context, user.id) + sent = await send_monitor_alert_to_owner( + context=context, + alert_chat_id=alert_chat_id, + group_id=group_config.group_id, + user_id=user.id, + user_name=user.full_name, + username=user.username, + detection_reason=detection_reason, + message_text=text, + profile_bio=user_bio, + ) + if sent: + _increment_bio_bait_metric(context, "owner_alert_sent") + else: + _increment_bio_bait_metric(context, "owner_alert_failed") + + if monitor_only: + logger.info( + "Bio bait monitor-only mode: no delete/restrict (user_id=%s, group_id=%s)", + user.id, + group_config.group_id, + ) + return + + user_mention = get_user_mention(user) + try: await update.message.delete() logger.info(f"Deleted bio bait spam from user_id={user.id}") diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index ab78f6d..eb4d6e6 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -9,6 +9,7 @@ from bot.group_config import GroupConfig from bot.handlers.bio_bait import ( BIO_BAIT_MAX_LENGTH, + BIO_BAIT_METRICS_KEY, USER_BIO_CACHE_KEY, USER_BIO_CACHE_TTL_SECONDS, clear_cached_user_bio, @@ -433,3 +434,46 @@ async def test_notification_failure_still_raises_stop( with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): with pytest.raises(ApplicationHandlerStop): await handle_bio_bait_spam(mock_update, mock_context) + + async def test_monitor_only_collects_metrics_and_sends_owner_alert( + self, mock_update, mock_context, group_config + ): + group_config.bio_bait_monitor_only = True + group_config.bio_bait_alert_chat_id = 57747812 + mock_update.message.text = "cek bio aku" + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + + mock_update.message.delete.assert_not_called() + mock_context.bot.restrict_chat_member.assert_not_called() + mock_context.bot.send_message.assert_called_once() + + kwargs = mock_context.bot.send_message.call_args.kwargs + assert kwargs["chat_id"] == 57747812 + assert "message_thread_id" not in kwargs + assert "cek bio aku" in kwargs["text"] + + metrics = mock_context.bot_data[BIO_BAIT_METRICS_KEY] + assert metrics["detections_total"] == 1 + assert metrics["detections_message_bait"] == 1 + assert metrics["monitor_only_matches"] == 1 + assert metrics["owner_alert_sent"] == 1 + + async def test_monitor_only_alert_failure_still_collects_metrics( + self, mock_update, mock_context, group_config + ): + group_config.bio_bait_monitor_only = True + group_config.bio_bait_alert_chat_id = 57747812 + mock_context.bot.send_message = AsyncMock(side_effect=Exception("Send failed")) + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + + mock_update.message.delete.assert_not_called() + mock_context.bot.restrict_chat_member.assert_not_called() + + metrics = mock_context.bot_data[BIO_BAIT_METRICS_KEY] + assert metrics["detections_total"] == 1 + assert metrics["monitor_only_matches"] == 1 + assert metrics["owner_alert_failed"] == 1 diff --git a/tests/test_config.py b/tests/test_config.py index 32752d0..7fe2263 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -153,6 +153,28 @@ def test_duplicate_spam_from_env(self, monkeypatch): assert settings.duplicate_spam_threshold == 5 assert settings.duplicate_spam_min_length == 50 + def test_bio_bait_monitor_defaults(self, monkeypatch): + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + + settings = Settings(_env_file=None) + + assert settings.bio_bait_monitor_only is False + assert settings.bio_bait_alert_chat_id is None + + def test_bio_bait_monitor_from_env(self, monkeypatch): + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("BIO_BAIT_MONITOR_ONLY", "true") + monkeypatch.setenv("BIO_BAIT_ALERT_CHAT_ID", "57747812") + + settings = Settings(_env_file=None) + + assert settings.bio_bait_monitor_only is True + assert settings.bio_bait_alert_chat_id == 57747812 + class TestSettingsValidation: def test_group_id_must_be_negative(self, monkeypatch): diff --git a/tests/test_group_config.py b/tests/test_group_config.py index f7ae56d..447f4bb 100644 --- a/tests/test_group_config.py +++ b/tests/test_group_config.py @@ -28,6 +28,8 @@ def test_minimal_config(self): assert gc.restrict_failed_users is False assert gc.warning_threshold == 3 assert gc.captcha_enabled is False + assert gc.bio_bait_monitor_only is False + assert gc.bio_bait_alert_chat_id is None def test_full_config(self): gc = GroupConfig( @@ -41,10 +43,14 @@ def test_full_config(self): new_user_probation_hours=168, new_user_violation_threshold=2, rules_link="https://t.me/mygroup/rules", + bio_bait_monitor_only=True, + bio_bait_alert_chat_id=57747812, ) assert gc.restrict_failed_users is True assert gc.warning_threshold == 5 assert gc.captcha_timeout_seconds == 180 + assert gc.bio_bait_monitor_only is True + assert gc.bio_bait_alert_chat_id == 57747812 def test_group_id_must_be_negative(self): with pytest.raises(ValidationError, match="group_id must be negative"): @@ -277,6 +283,8 @@ def test_falls_back_to_env(self): settings.duplicate_spam_window_seconds = 300 settings.duplicate_spam_threshold = 5 settings.duplicate_spam_min_length = 50 + settings.bio_bait_monitor_only = True + settings.bio_bait_alert_chat_id = 57747812 registry = build_group_registry(settings) @@ -289,6 +297,8 @@ def test_falls_back_to_env(self): assert gc.duplicate_spam_window_seconds == 300 assert gc.duplicate_spam_threshold == 5 assert gc.duplicate_spam_min_length == 50 + assert gc.bio_bait_monitor_only is True + assert gc.bio_bait_alert_chat_id == 57747812 class TestGetGroupConfigForUpdate: From f36a3a0a6ed7aef59992a38bc3272f13e895ef7f Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Mon, 18 May 2026 22:54:43 +0700 Subject: [PATCH 05/20] fix: bio-bait review - trusted bypass, monitor-only alert semantics, warning-topic guard, narrowed filter Changes: - Use is_user_admin_or_trusted() for admin/trusted bypass (consistent with other spam handlers) - Move owner alert block inside monitor_only check (enforcement mode no longer sends alerts) - Add warning-topic guard: skip alert when alert_chat_id matches monitored group, log, increment owner_alert_skipped_warning_topic metric - Narrow main.py handler filter to TEXT|CAPTION to reduce unnecessary handler invocations - Add 5 tests covering: trusted bypass, enforcement no-alert, same-group skip, enforcement metrics (message_bait + bio_links) --- src/bot/handlers/bio_bait.py | 53 +++++++------ src/bot/main.py | 2 +- tests/test_bio_bait.py | 141 +++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+), 24 deletions(-) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index 6cc599a..d58ddce 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -38,7 +38,7 @@ ) from bot.group_config import get_group_config_for_update from bot.handlers.anti_spam import is_url_whitelisted -from bot.services.telegram_utils import get_user_mention +from bot.services.telegram_utils import get_user_mention, is_user_admin_or_trusted logger = logging.getLogger(__name__) @@ -354,8 +354,7 @@ async def handle_bio_bait_spam( if user.is_bot: return - admin_ids = context.bot_data.get("group_admin_ids", {}).get(group_config.group_id, []) - if user.id in admin_ids: + if is_user_admin_or_trusted(context, group_config.group_id, user.id): return text = update.message.text or update.message.caption or "" @@ -382,27 +381,35 @@ async def handle_bio_bait_spam( monitor_only = group_config.bio_bait_monitor_only record_bio_bait_detection_metrics(context, detection_reason, monitor_only) - alert_chat_id = group_config.bio_bait_alert_chat_id - if alert_chat_id is not None: - if user_bio is None: - user_bio = await get_cached_user_bio(context, user.id) - sent = await send_monitor_alert_to_owner( - context=context, - alert_chat_id=alert_chat_id, - group_id=group_config.group_id, - user_id=user.id, - user_name=user.full_name, - username=user.username, - detection_reason=detection_reason, - message_text=text, - profile_bio=user_bio, - ) - if sent: - _increment_bio_bait_metric(context, "owner_alert_sent") - else: - _increment_bio_bait_metric(context, "owner_alert_failed") - if monitor_only: + alert_chat_id = group_config.bio_bait_alert_chat_id + if alert_chat_id is not None: + # Warning-topic guard: skip owner alert if target equals monitored group + if alert_chat_id == group_config.group_id: + logger.warning( + "Skipping bio bait monitor alert: alert_chat_id matches monitored group (warning topic). group_id=%s", + group_config.group_id, + ) + _increment_bio_bait_metric(context, "owner_alert_skipped_warning_topic") + else: + if user_bio is None: + user_bio = await get_cached_user_bio(context, user.id) + sent = await send_monitor_alert_to_owner( + context=context, + alert_chat_id=alert_chat_id, + group_id=group_config.group_id, + user_id=user.id, + user_name=user.full_name, + username=user.username, + detection_reason=detection_reason, + message_text=text, + profile_bio=user_bio, + ) + if sent: + _increment_bio_bait_metric(context, "owner_alert_sent") + else: + _increment_bio_bait_metric(context, "owner_alert_failed") + logger.info( "Bio bait monitor-only mode: no delete/restrict (user_id=%s, group_id=%s)", user.id, diff --git a/src/bot/main.py b/src/bot/main.py index 4eebd0e..c9d0935 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -362,7 +362,7 @@ def main() -> None: # external promo/scam links). application.add_handler( MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND, + filters.ChatType.GROUPS & ~filters.COMMAND & (filters.TEXT | filters.CAPTION), handle_bio_bait_spam, ), group=2, diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index eb4d6e6..dbc006c 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -477,3 +477,144 @@ async def test_monitor_only_alert_failure_still_collects_metrics( assert metrics["detections_total"] == 1 assert metrics["monitor_only_matches"] == 1 assert metrics["owner_alert_failed"] == 1 + +class TestBioBaitReviewFixes: + """Tests for pending bio-bait review fixes (trusted bypass, monitor semantics, + warning-topic guard, metrics).""" + + @pytest.fixture + def group_config(self): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=999, + bio_bait_enabled=True, + ) + + @pytest.fixture + def mock_update(self): + update = MagicMock() + update.message = MagicMock(spec=Message) + update.message.from_user = MagicMock(spec=User) + update.message.from_user.id = 42 + update.message.from_user.is_bot = False + update.message.from_user.full_name = "Test User" + update.message.from_user.username = "testuser" + update.message.text = "cek bio aku" + update.message.caption = None + update.message.message_id = 100 + update.message.delete = AsyncMock() + update.effective_chat = MagicMock(spec=Chat) + update.effective_chat.id = -1001234567890 + return update + + @pytest.fixture + def mock_context(self): + context = MagicMock() + context.bot_data = {} + context.bot_data["group_admin_ids"] = {-1001234567890: [1, 2]} + context.bot = MagicMock() + context.bot.restrict_chat_member = AsyncMock() + context.bot.send_message = AsyncMock() + chat = MagicMock() + chat.bio = "" + context.bot.get_chat = AsyncMock(return_value=chat) + return context + + # โ”€โ”€ (a) trusted user bypass โ”€โ”€ + + async def test_trusted_user_bypasses_bio_bait( + self, mock_update, mock_context, group_config + ): + """Trusted user (not admin) should bypass bio bait detection.""" + mock_context.bot_data["trusted_user_ids"] = {42} + mock_context.bot_data["group_admin_ids"] = {-1001234567890: [1, 2]} + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + mock_update.message.delete.assert_not_called() + mock_context.bot.restrict_chat_member.assert_not_called() + mock_context.bot.send_message.assert_not_called() + + # โ”€โ”€ (b) enforcement mode + alert_chat_id does NOT send owner alert โ”€โ”€ + + async def test_enforcement_mode_alert_chat_id_does_not_send_owner_alert( + self, mock_update, mock_context, group_config + ): + """In enforcement mode, owner alert should NOT be sent even if alert_chat_id is set.""" + group_config.bio_bait_monitor_only = False + group_config.bio_bait_alert_chat_id = 57747812 + mock_update.message.text = "cek bio aku" + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + + # Only the warning-topic notification should be sent, not the owner alert + assert mock_context.bot.send_message.call_count == 1 + kwargs = mock_context.bot.send_message.call_args.kwargs + assert kwargs.get("message_thread_id") == 999 # warning topic + + metrics = mock_context.bot_data.get(BIO_BAIT_METRICS_KEY, {}) + assert "owner_alert_sent" not in metrics + assert "owner_alert_failed" not in metrics + + # โ”€โ”€ (c) monitor-only + alert target = same group => alert skipped โ”€โ”€ + + async def test_monitor_only_alert_target_same_group_skipped( + self, mock_update, mock_context, group_config + ): + """When monitor-only and alert_chat_id equals the monitored group, + owner alert should be skipped and skip metric incremented.""" + group_config.bio_bait_monitor_only = True + group_config.bio_bait_alert_chat_id = -1001234567890 # same as group_id + mock_update.message.text = "cek bio aku" + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + await handle_bio_bait_spam(mock_update, mock_context) + + # No send_message calls at all + mock_context.bot.send_message.assert_not_called() + mock_update.message.delete.assert_not_called() + mock_context.bot.restrict_chat_member.assert_not_called() + + metrics = mock_context.bot_data[BIO_BAIT_METRICS_KEY] + assert metrics["owner_alert_skipped_warning_topic"] == 1 + assert "owner_alert_sent" not in metrics + assert "owner_alert_failed" not in metrics + + # โ”€โ”€ (d) enforcement metrics โ”€โ”€ + + async def test_enforcement_message_bait_records_enforced_matches( + self, mock_update, mock_context, group_config + ): + """Enforcement mode with message bait should record enforced_matches metric.""" + group_config.bio_bait_monitor_only = False + mock_update.message.text = "cek bio aku" + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + + metrics = mock_context.bot_data[BIO_BAIT_METRICS_KEY] + assert metrics["detections_total"] == 1 + assert metrics["detections_message_bait"] == 1 + assert metrics["enforced_matches"] == 1 + + async def test_enforcement_bio_links_records_enforced_matches_and_bio_links( + self, mock_update, mock_context, group_config + ): + """Enforcement mode with bio_links detection should record enforced_matches + and detections_bio_links.""" + group_config.bio_bait_monitor_only = False + mock_update.message.text = "halo" + chat = MagicMock() + chat.bio = "VIP t.me/+exampleinvitehash777xyz" + mock_context.bot.get_chat = AsyncMock(return_value=chat) + + with patch("bot.handlers.bio_bait.get_group_config_for_update", return_value=group_config): + with pytest.raises(ApplicationHandlerStop): + await handle_bio_bait_spam(mock_update, mock_context) + + metrics = mock_context.bot_data[BIO_BAIT_METRICS_KEY] + assert metrics["detections_total"] == 1 + assert metrics["detections_bio_links"] == 1 + assert metrics["enforced_matches"] == 1 From 6d67a41c5e1ab167b0f71e6949eeb1dbc36b9d1f Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Mon, 18 May 2026 23:43:43 +0700 Subject: [PATCH 06/20] Fix bio-bait routing: remove TEXT|CAPTION filter restriction Non-text messages (photo without caption, sticker, etc.) were blocked by the bio-bait handler filter requiring TEXT or CAPTION. This meant users posting media with no text could bypass bio-link detection. Changes: - Add BIO_BAIT_FILTER constant to bio_bait.py using only GROUPS & ~COMMAND - Register handler with BIO_BAIT_FILTER in main.py instead of inline filter - Add TestBioBaitRegistrationFilter regression tests: - Non-text group message passes filter - Text group message still passes filter - Command messages still excluded Duplicate_spam and message handler filters left unchanged. --- src/bot/handlers/bio_bait.py | 8 +++- src/bot/main.py | 4 +- tests/test_bio_bait.py | 77 ++++++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index d58ddce..f9359ff 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -25,7 +25,7 @@ from time import monotonic from telegram import Update -from telegram.ext import ApplicationHandlerStop, ContextTypes +from telegram.ext import ApplicationHandlerStop, ContextTypes, filters as _filters from bot.constants import ( BIO_BAIT_MONITOR_ALERT, @@ -40,6 +40,12 @@ from bot.handlers.anti_spam import is_url_whitelisted from bot.services.telegram_utils import get_user_mention, is_user_admin_or_trusted +# Filter for bio-bait handler registration in main.py. +# Must NOT restrict to TEXT|CAPTION so non-text messages (e.g. photos +# without caption) reach the handler for bio-link detection. +BIO_BAIT_FILTER = _filters.ChatType.GROUPS & ~_filters.COMMAND + + logger = logging.getLogger(__name__) # Maximum normalized text length to consider as bait. Real bait is short. diff --git a/src/bot/main.py b/src/bot/main.py index c9d0935..12334f2 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -19,7 +19,7 @@ from bot.group_config import get_group_registry, init_group_registry from bot.handlers import captcha from bot.handlers.anti_spam import handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam -from bot.handlers.bio_bait import handle_bio_bait_spam +from bot.handlers.bio_bait import BIO_BAIT_FILTER, handle_bio_bait_spam from bot.handlers.duplicate_spam import handle_duplicate_spam from bot.handlers.dm import handle_dm from bot.handlers.message import handle_message @@ -362,7 +362,7 @@ def main() -> None: # external promo/scam links). application.add_handler( MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND & (filters.TEXT | filters.CAPTION), + BIO_BAIT_FILTER, handle_bio_bait_spam, ), group=2, diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index dbc006c..2282c40 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -618,3 +618,80 @@ async def test_enforcement_bio_links_records_enforced_matches_and_bio_links( assert metrics["detections_total"] == 1 assert metrics["detections_bio_links"] == 1 assert metrics["enforced_matches"] == 1 + + +class TestBioBaitRegistrationFilter: + """Tests for bio-bait handler filter registration. + + The handler filter MUST accept non-text messages (e.g., photo without + caption) so bio-link detection works for users who post media with + no text. If the filter includes TEXT|CAPTION, non-text messages never + reach the handler โ€” this test catches that regression. + """ + + def test_filter_accepts_non_text_group_message(self): + """Non-text group message must pass bio-bait filter.""" + from datetime import datetime + + from telegram import Chat, Message, Update, User + + from bot.handlers.bio_bait import BIO_BAIT_FILTER + + user = User(id=42, is_bot=False, first_name="Test") + chat = Chat(id=-100, type=Chat.GROUP, title="Test") + msg = Message( + message_id=1, + date=datetime.now(), + chat=chat, + from_user=user, + ) + update = Update(update_id=1, message=msg) + + assert BIO_BAIT_FILTER.check_update(update) is True, ( + "Bio-bait filter MUST accept non-text messages for bio-link detection" + ) + + def test_filter_accepts_text_group_message(self): + """Text group message must still pass bio-bait filter.""" + from datetime import datetime + + from telegram import Chat, Message, Update, User + + from bot.handlers.bio_bait import BIO_BAIT_FILTER + + user = User(id=42, is_bot=False, first_name="Test") + chat = Chat(id=-100, type=Chat.GROUP, title="Test") + msg = Message( + message_id=2, + date=datetime.now(), + chat=chat, + from_user=user, + text="cek bio aku", + ) + update = Update(update_id=2, message=msg) + + assert BIO_BAIT_FILTER.check_update(update) is True + + def test_filter_excludes_group_commands(self): + """Command messages must be excluded by bio-bait filter.""" + from datetime import datetime + + from telegram import Chat, Message, MessageEntity, Update, User + + from bot.handlers.bio_bait import BIO_BAIT_FILTER + + user = User(id=42, is_bot=False, first_name="Test") + chat = Chat(id=-100, type=Chat.GROUP, title="Test") + msg = Message( + message_id=3, + date=datetime.now(), + chat=chat, + from_user=user, + text="/start", + entities=[MessageEntity(type="bot_command", offset=0, length=6)], + ) + update = Update(update_id=3, message=msg) + + assert BIO_BAIT_FILTER.check_update(update) is False, ( + "Bio-bait filter MUST exclude commands" + ) From 8e3bb01c98a2daeeb9c3ea869381acf72b0d7714 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Tue, 19 May 2026 00:31:51 +0700 Subject: [PATCH 07/20] fix: bio-bait review - cache eviction, f-string logging, shared whitelist - Move is_url_whitelisted to services/telegram_utils.py (eliminates cross-handler dependency) - Add hard cache cap (2000) with LRU eviction for bio cache - Tighten pattern 1 to require end-of-string anchor (prevents false positives like 'open source bio library') - Count @mentions by occurrence instead of unique set (catches repeated same-mention spam) - Convert all logging to f-strings in bio_bait.py - Add tests for cache eviction, duplicate mentions, and benign phrases --- src/bot/handlers/anti_spam.py | 63 +----------------------------- src/bot/handlers/bio_bait.py | 62 ++++++++++++----------------- src/bot/services/telegram_utils.py | 49 +++++++++++++++++++++++ tests/test_anti_spam.py | 4 +- tests/test_bio_bait.py | 33 ++++++++++++++++ 5 files changed, 109 insertions(+), 102 deletions(-) diff --git a/src/bot/handlers/anti_spam.py b/src/bot/handlers/anti_spam.py index bad836d..107b3ca 100644 --- a/src/bot/handlers/anti_spam.py +++ b/src/bot/handlers/anti_spam.py @@ -9,7 +9,6 @@ import logging from datetime import UTC, datetime -from urllib.parse import urlparse from telegram import Message, MessageEntity, Update from telegram.ext import ApplicationHandlerStop, ContextTypes @@ -22,13 +21,11 @@ NEW_USER_SPAM_RESTRICTION, NEW_USER_SPAM_WARNING, RESTRICTED_PERMISSIONS, - WHITELISTED_URL_DOMAINS, - WHITELISTED_TELEGRAM_PATHS, format_hours_display, ) from bot.database.service import get_database from bot.group_config import get_group_config_for_update -from bot.services.telegram_utils import get_user_mention, is_user_admin_or_trusted +from bot.services.telegram_utils import get_user_mention, is_user_admin_or_trusted, is_url_whitelisted logger = logging.getLogger(__name__) @@ -144,64 +141,6 @@ def extract_urls(message: Message) -> list[str]: return urls -def is_url_whitelisted(url: str) -> bool: - """ - Check if a URL's domain matches any whitelisted domain. - - Uses suffix-based set lookups for O(hostname labels) performance. - Checks if the URL's hostname exactly matches or is a subdomain of - a whitelisted domain. - - Args: - url: URL to check. - - Returns: - bool: True if URL's domain is whitelisted. - """ - try: - # Add scheme if missing for proper parsing - if not url.startswith(('http://', 'https://')): - url = 'https://' + url - - parsed = urlparse(url) - hostname = parsed.netloc.lower() - - # Remove port if present - if ':' in hostname: - hostname = hostname.rsplit(':', 1)[0] - - # Specific logic for Telegram links - # Check against WHITELISTED_TELEGRAM_PATHS instead of WHITELISTED_URL_DOMAINS - if hostname in {"t.me", "telegram.me"}: - path = parsed.path - if not path or path == "/": - return False - - # Extract the first segment of the path (the username/channel name) - # e.g., "/PythonID/123" -> "pythonid" - parts = path.strip("/").split("/") - if not parts: - return False - - first_segment = parts[0].lower() - return first_segment in WHITELISTED_TELEGRAM_PATHS - - # Check suffixes of the hostname against the set - # e.g., "sub.example.github.com" checks: - # "sub.example.github.com", "example.github.com", "github.com", "com" - while hostname: - if hostname in WHITELISTED_URL_DOMAINS: - return True - dot_idx = hostname.find('.') - if dot_idx == -1: - return False - hostname = hostname[dot_idx + 1:] - - return False - except Exception: - return False - - def has_non_whitelisted_link(message: Message) -> bool: """ Check if a message contains non-whitelisted URLs. diff --git a/src/bot/handlers/bio_bait.py b/src/bot/handlers/bio_bait.py index f9359ff..57be5ee 100644 --- a/src/bot/handlers/bio_bait.py +++ b/src/bot/handlers/bio_bait.py @@ -37,8 +37,7 @@ WHITELISTED_TELEGRAM_PATHS, ) from bot.group_config import get_group_config_for_update -from bot.handlers.anti_spam import is_url_whitelisted -from bot.services.telegram_utils import get_user_mention, is_user_admin_or_trusted +from bot.services.telegram_utils import get_user_mention, is_user_admin_or_trusted, is_url_whitelisted # Filter for bio-bait handler registration in main.py. # Must NOT restrict to TEXT|CAPTION so non-text messages (e.g. photos @@ -54,6 +53,7 @@ # Per-user bio cache (TTL in seconds). Stored in context.bot_data. USER_BIO_CACHE_KEY = "user_bio_cache" USER_BIO_CACHE_TTL_SECONDS = 3600 +USER_BIO_CACHE_MAX_SIZE = 2000 # Bio bait metrics stored in bot_data. BIO_BAIT_METRICS_KEY = "bio_bait_metrics" @@ -86,14 +86,16 @@ # Bait phrase patterns matched against the normalized text. # Each requires either: -# (a) imperative cue + bio (with optional address particle), OR +# (a) imperative cue + bio + ownership cue OR end-of-string, OR # (b) bio + first-person possessive at end of message, OR # (c) imperative cue + profil/profile + possessive, OR # (d) imperative cue + my + profile/bio. BIO_BAIT_PATTERNS = ( re.compile( r"\b(?:cek|check|liat|lihat|buka|open|view|see|kunjungi|kunjungin)\b" - rf"(?:\s+\w+){{0,2}}\s+\bbio\b{_BIO_SUFFIX_RE}" + rf"(?:\s+\w+){{0,2}}\s+\bbio\b" + rf"(?:\s+{_BIO_OWNER_RE})?" # optional ownership + rf"{_BIO_SUFFIX_RE}$" # must be at end of message ), re.compile( rf"\bbio\b\s+{_BIO_OWNER_RE}" @@ -207,14 +209,13 @@ def has_suspicious_bio_links(bio: str) -> bool: if not is_url_whitelisted(match.group(1)): return True - mentions = { - m.group(1).lower() - for m in TELEGRAM_USERNAME_RE.finditer(normalized) + mention_count = sum( + 1 for m in TELEGRAM_USERNAME_RE.finditer(normalized) if m.group(1).lower() not in WHITELISTED_TELEGRAM_PATHS - } - if len(mentions) >= 2: + ) + if mention_count >= 2: return True - if mentions and _BIO_PROMO_HINTS_RE.search(lowered): + if mention_count == 1 and _BIO_PROMO_HINTS_RE.search(lowered): return True return False @@ -295,12 +296,7 @@ async def send_monitor_alert_to_owner( await context.bot.send_message(chat_id=alert_chat_id, text=chunk) return True except Exception: - logger.error( - "Failed to send bio bait monitor alert: user_id=%s, group_id=%s", - user_id, - group_id, - exc_info=True, - ) + logger.error(f"Failed to send bio bait monitor alert: user_id={user_id}, group_id={group_id}") return False @@ -321,11 +317,16 @@ async def get_cached_user_bio( if cached and cached[0] > now: return cached[1] + if len(cache) >= USER_BIO_CACHE_MAX_SIZE: + sorted_keys = sorted(cache, key=lambda k: cache[k][0]) + for k in sorted_keys[: USER_BIO_CACHE_MAX_SIZE // 2]: + del cache[k] + try: chat = await context.bot.get_chat(user_id) bio = (getattr(chat, "bio", None) or "").strip() or None except Exception: - logger.debug("Failed to fetch user bio: user_id=%s", user_id, exc_info=True) + logger.debug(f"Failed to fetch user bio: user_id={user_id}", exc_info=True) return None cache[user_id] = (now + USER_BIO_CACHE_TTL_SECONDS, bio) @@ -378,10 +379,7 @@ async def handle_bio_bait_spam( return logger.info( - "Bio bait spam detected: user_id=%s, group_id=%s, reason=%s", - user.id, - group_config.group_id, - detection_reason, + f"Bio bait spam detected: user_id={user.id}, group_id={group_config.group_id}, reason={detection_reason}" ) monitor_only = group_config.bio_bait_monitor_only @@ -393,8 +391,7 @@ async def handle_bio_bait_spam( # Warning-topic guard: skip owner alert if target equals monitored group if alert_chat_id == group_config.group_id: logger.warning( - "Skipping bio bait monitor alert: alert_chat_id matches monitored group (warning topic). group_id=%s", - group_config.group_id, + f"Skipping bio bait monitor alert: alert_chat_id matches monitored group (warning topic). group_id={group_config.group_id}" ) _increment_bio_bait_metric(context, "owner_alert_skipped_warning_topic") else: @@ -417,9 +414,7 @@ async def handle_bio_bait_spam( _increment_bio_bait_metric(context, "owner_alert_failed") logger.info( - "Bio bait monitor-only mode: no delete/restrict (user_id=%s, group_id=%s)", - user.id, - group_config.group_id, + f"Bio bait monitor-only mode: no delete/restrict (user_id={user.id}, group_id={group_config.group_id})" ) return @@ -429,10 +424,7 @@ async def handle_bio_bait_spam( await update.message.delete() logger.info(f"Deleted bio bait spam from user_id={user.id}") except Exception: - logger.error( - f"Failed to delete bio bait spam: user_id={user.id}", - exc_info=True, - ) + logger.error(f"Failed to delete bio bait spam: user_id={user.id}", exc_info=True) restricted = False try: @@ -445,10 +437,7 @@ async def handle_bio_bait_spam( clear_cached_user_bio(context, user.id) logger.info(f"Restricted user_id={user.id} for bio bait spam") except Exception: - logger.error( - f"Failed to restrict user for bio bait spam: user_id={user.id}", - exc_info=True, - ) + logger.error(f"Failed to restrict user for bio bait spam: user_id={user.id}", exc_info=True) try: if detection_reason == "bio_links": @@ -473,9 +462,6 @@ async def handle_bio_bait_spam( ) logger.info(f"Sent bio bait spam notification for user_id={user.id}") except Exception: - logger.error( - f"Failed to send bio bait spam notification: user_id={user.id}", - exc_info=True, - ) + logger.error(f"Failed to send bio bait spam notification: user_id={user.id}", exc_info=True) raise ApplicationHandlerStop diff --git a/src/bot/services/telegram_utils.py b/src/bot/services/telegram_utils.py index 8c48abd..b6bb1c9 100644 --- a/src/bot/services/telegram_utils.py +++ b/src/bot/services/telegram_utils.py @@ -6,12 +6,14 @@ """ import logging +from urllib.parse import urlparse from telegram import Bot, Chat, Message, User from telegram.constants import ChatMemberStatus from telegram.error import BadRequest, Forbidden from telegram.helpers import escape_markdown, mention_markdown +from bot.constants import WHITELISTED_TELEGRAM_PATHS, WHITELISTED_URL_DOMAINS from bot.database.service import get_database logger = logging.getLogger(__name__) @@ -156,6 +158,53 @@ def extract_forwarded_user(message: Message) -> tuple[int, str] | None: return user_id, user_name +def is_url_whitelisted(url: str) -> bool: + """ + Check if a URL's domain matches any whitelisted domain. + + Uses suffix-based set lookups for O(hostname labels) performance. + Checks if the URL's hostname exactly matches or is a subdomain of + a whitelisted domain. + + Args: + url: URL to check. + + Returns: + bool: True if URL's domain is whitelisted. + """ + try: + if not url.startswith(('http://', 'https://')): + url = 'https://' + url + + parsed = urlparse(url) + hostname = parsed.netloc.lower() + + if ':' in hostname: + hostname = hostname.rsplit(':', 1)[0] + + if hostname in {"t.me", "telegram.me"}: + path = parsed.path + if not path or path == "/": + return False + parts = path.strip("/").split("/") + if not parts: + return False + first_segment = parts[0].lower() + return first_segment in WHITELISTED_TELEGRAM_PATHS + + while hostname: + if hostname in WHITELISTED_URL_DOMAINS: + return True + dot_idx = hostname.find('.') + if dot_idx == -1: + return False + hostname = hostname[dot_idx + 1:] + + return False + except Exception: + return False + + def _get_trusted_ids(bot_data: dict) -> set[int]: """ Return the cached set of trusted user IDs from ``bot_data``. diff --git a/tests/test_anti_spam.py b/tests/test_anti_spam.py index 7b6dc43..e84f075 100644 --- a/tests/test_anti_spam.py +++ b/tests/test_anti_spam.py @@ -22,8 +22,8 @@ has_non_whitelisted_link, has_story, is_forwarded, - is_url_whitelisted, ) +from bot.services.telegram_utils import is_url_whitelisted class TestIsForwarded: @@ -295,7 +295,7 @@ def test_malformed_url_exception_handled(self): def test_urlparse_exception_returns_false(self): """Test that exceptions during URL parsing return False.""" - with patch("bot.handlers.anti_spam.urlparse", side_effect=ValueError("parse error")): + with patch("bot.services.telegram_utils.urlparse", side_effect=ValueError("parse error")): assert is_url_whitelisted("https://github.com/user/repo") is False diff --git a/tests/test_bio_bait.py b/tests/test_bio_bait.py index 2282c40..838c5a0 100644 --- a/tests/test_bio_bait.py +++ b/tests/test_bio_bait.py @@ -11,6 +11,7 @@ BIO_BAIT_MAX_LENGTH, BIO_BAIT_METRICS_KEY, USER_BIO_CACHE_KEY, + USER_BIO_CACHE_MAX_SIZE, USER_BIO_CACHE_TTL_SECONDS, clear_cached_user_bio, get_cached_user_bio, @@ -104,6 +105,11 @@ def test_detects_bait(self, text): "bio aku ada di README", "bio aku untuk eksperimen regex", "", + # Pattern 1 ownership: must end with bio + optional cue + "open source bio library", + "view bio data structure", + "cek bio di website", + "lihat bio orang lain", ]) def test_does_not_detect_safe(self, text): assert is_bio_bait_spam(text) is False @@ -141,6 +147,10 @@ def test_single_bare_mention_not_enough(self): def test_two_non_whitelisted_mentions(self): assert has_suspicious_bio_links("@channel_one @channel_two") is True + def test_duplicate_mention_counts(self): + """Same @mention repeated counts as 2, not 1.""" + assert has_suspicious_bio_links("@scamch @scamch") is True + def test_single_mention_with_promo_hint(self): assert has_suspicious_bio_links("VIP @channel_one") is True @@ -227,6 +237,29 @@ def test_clear_cache_missing(self, context): # Should not raise even if the entry doesn't exist. clear_cached_user_bio(context, 999) + async def test_cache_eviction(self, context): + """Cache eviction removes oldest entries when at max size.""" + from time import monotonic + + cache = context.bot_data.setdefault(USER_BIO_CACHE_KEY, {}) + now = monotonic() + # Fill cache to max + for i in range(USER_BIO_CACHE_MAX_SIZE): + cache[i] = (now + 3600, f"bio_{i}") + + # Next fetch should trigger eviction + chat = MagicMock() + chat.bio = "new bio" + context.bot.get_chat = AsyncMock(return_value=chat) + + bio = await get_cached_user_bio(context, 99999) + assert bio == "new bio" + # Cache should be roughly half size after eviction + assert len(cache) <= USER_BIO_CACHE_MAX_SIZE // 2 + 2 + + def test_max_size_constant(self): + assert USER_BIO_CACHE_MAX_SIZE > 0 + def test_ttl_constant_positive(self): assert USER_BIO_CACHE_TTL_SECONDS > 0 From 2260aed0efaa64eff7114f80f24d5187172ff274 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 15:10:45 +0700 Subject: [PATCH 08/20] docs: add built-in plugin loader design spec --- .../specs/2026-05-22-plugin-system-design.md | 219 ++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-22-plugin-system-design.md diff --git a/docs/superpowers/specs/2026-05-22-plugin-system-design.md b/docs/superpowers/specs/2026-05-22-plugin-system-design.md new file mode 100644 index 0000000..c905a55 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-plugin-system-design.md @@ -0,0 +1,219 @@ +# Built-in Plugin Loader Design (Zero-Behavior Migration) + +## Context + +Current bot wiring in `src/bot/main.py` contains long, tightly-coupled registration flow for commands, callbacks, message handlers, and jobs. This makes feature growth harder and boundaries unclear. + +Goal for this iteration is architectural cleanup without runtime behavior changes. + +## Goals + +- Introduce built-in plugin loader architecture. +- Keep zero behavior change by default when all plugins enabled. +- Support per-group and single-group plugin toggles using existing config sources (`groups.json` + `.env` fallback). +- Validate plugin config strictly (unknown plugin key fails startup). +- Keep handler order, handler groups, callback patterns, job intervals, and allowed updates unchanged. + +## Non-Goals + +- No third-party/external plugin packages. +- No runtime plugin discovery from filesystem/packages. +- No behavior refactor inside existing handlers. +- No profile monitor splitting (`require_photo` and `require_username`) in this iteration. + +## Plugin Granularity + +Fine-grained plugin units (one plugin per existing feature unit), including: + +- `topic_guard` +- `verify` +- `unverify` +- `check` +- `trust` +- `untrust` +- `trusted_list` +- `check_forwarded_message` +- `verify_callback` +- `unverify_callback` +- `warn_callback` +- `trust_callback` +- `untrust_callback` +- `captcha` +- `dm` +- `inline_keyboard_spam` +- `bio_bait_spam` +- `contact_spam` +- `new_user_spam` +- `duplicate_spam` +- `profile_monitor` +- `auto_restrict_job` +- `refresh_admin_ids_job` + +This preserves selective disable use cases such as disabling `profile_monitor` in specific groups while keeping anti-spam protections active. + +## Proposed Architecture + +### New modules + +- `src/bot/plugins/base.py` + - Defines plugin interface/protocol with stable registration contract. + +- `src/bot/plugins/definitions.py` + - Static manifest of built-in plugins in deterministic order. + - Order must mirror existing `main.py` registration behavior. + +- `src/bot/plugins/config.py` + - Plugin config parsing + validation. + - Resolves effective toggle state per group. + +- `src/bot/plugins/manager.py` + - Loads manifest. + - Validates uniqueness and plugin keys. + - Registers plugins into application. + +- `src/bot/plugins/builtin/*.py` + - Thin wrappers around current registration logic. + - Each wrapper owns only registration binding and optional enabled-gating. + +### Existing modules updated + +- `src/bot/main.py` + - Replace direct handler/job registration wall with plugin manager call. + - Keep startup/init/logging/error-handling/post-init behavior intact. + +- `src/bot/group_config.py` + - Add per-group plugin override field. + - Validate override key/value types. + +- `src/bot/config.py` + - Add single-group plugin default override support via env. + +## Configuration Model + +Use existing source model (Option 2): + +- Multi-group from `groups.json` +- Single-group fallback from `.env` + +Add default-plus-override semantics similar to dedicated `plugins.json` pattern: + +1. Start with default enabled `true` for all plugins. +2. Apply `.env` global defaults (single-group fallback path). +3. Apply per-group `groups.json` overrides. +4. Validate all keys against manifest; unknown key fails startup. + +### `groups.json` extension + +Each group object can include: + +```json +{ + "plugins": { + "profile_monitor": false, + "captcha": true, + "duplicate_spam": true + } +} +``` + +### `.env` extension (single-group fallback) + +Use JSON object string: + +```env +PLUGINS_DEFAULT={"profile_monitor": false, "captcha": true} +``` + +If not present, all plugins remain enabled by default. + +## Registration and Runtime Flow + +1. `main.py` initializes settings, group registry, database (unchanged). +2. `PluginManager` loads static manifest and validates duplicate names. +3. `PluginConfigResolver` computes effective per-group enabled matrix. +4. Plugins register in fixed order. +5. Runtime checks apply group-specific enablement where applicable. + +### Zero-behavior guarantees + +- Handler order preserved exactly. +- Handler group numbers preserved exactly. +- Callback regex patterns preserved exactly. +- Job intervals and first-run delays preserved exactly. +- `allowed_updates` list preserved exactly. +- Existing feature logic untouched except for plugin-enabled guard checks. + +## Error Handling + +Fail-fast startup errors: + +- Unknown plugin key in `.env`/`groups.json`. +- Non-boolean plugin toggle values. +- Duplicate plugin names in manifest. + +Safe defaults: + +- Missing key means enabled (`true`). + +Runtime behavior: + +- Disabled plugin returns immediately for affected group context. +- Existing non-monitored group checks remain unchanged. + +## Testing Strategy (TDD) + +### New test files + +- `tests/test_plugin_config.py` + - unknown plugin key -> startup failure + - non-bool value -> startup failure + - missing keys -> default true + - merge semantics (`.env` defaults + `groups.json` override) + +- `tests/test_plugin_manager.py` + - deterministic manifest order + - duplicate plugin name failure + - plugin registration behavior with toggles + +- `tests/test_main_plugins_bootstrap.py` + - `main.py` delegates registration to plugin manager + - all-enabled baseline registration parity checks + +### Existing tests impact + +- Minimal updates where wrapper-level gating changes execution path. +- No expected user-visible behavior change. + +### Verification commands + +```bash +uv run pytest tests/test_plugin_config.py tests/test_plugin_manager.py tests/test_main_plugins_bootstrap.py +uv run pytest +uv run ruff check . +``` + +## Rollout Plan + +1. Add plugin interface + manifest + manager. +2. Move current registration wiring into plugin wrappers preserving order. +3. Add config parsing/validation for toggles. +4. Integrate manager in `main.py`. +5. Add/adjust tests and verify parity. + +## Risks and Mitigations + +- **Risk:** accidental registration order drift. + - **Mitigation:** explicit static manifest order tests. + +- **Risk:** command/callback plugins difficult to scope per group. + - **Mitigation:** keep existing command semantics; only apply group toggles where group context is resolvable and meaningful. + +- **Risk:** config fragility from JSON string in `.env`. + - **Mitigation:** strict validation + clear startup error messages. + +## Success Criteria + +- Plugin system exists with fine-grained built-ins. +- Per-group/plugin toggles functional via `groups.json` and `.env` fallback model. +- Unknown plugin keys fail startup. +- All tests pass and bot behavior remains unchanged when all toggles enabled. From 60dcd589e61dad1dd6ae9084c46b36b1abf8cab7 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 15:21:47 +0700 Subject: [PATCH 09/20] chore: ignore local worktrees directory --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6cee43e..10d075c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ data/ .vscode # AGENTS.md +.worktrees/ From 2a6ab583cf132583cbe970e7e671e2f383b1b13f Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 15:28:58 +0700 Subject: [PATCH 10/20] feat(config): add strict plugin toggle validation --- src/bot/config.py | 40 ++++++- src/bot/group_config.py | 45 ++++++- tests/test_plugin_config.py | 233 ++++++++++++++++++++++++++++++++++++ 3 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 tests/test_plugin_config.py diff --git a/src/bot/config.py b/src/bot/config.py index 96338b7..707f2eb 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -6,14 +6,18 @@ (production, staging) via the BOT_ENV environment variable. """ +import json import logging import os from datetime import timedelta from functools import lru_cache from pathlib import Path +from pydantic import field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from bot.group_config import KNOWN_PLUGINS + logger = logging.getLogger(__name__) @@ -32,7 +36,7 @@ def get_env_file() -> str | None: "staging": ".env.staging", } env_file = env_files.get(env, ".env") - + # Return path only if file exists, otherwise return None # Pydantic will load from environment variables if no .env file if Path(env_file).exists(): @@ -94,6 +98,7 @@ class Settings(BaseSettings): logfire_environment: str = "production" logfire_enabled: bool = True log_level: str = "INFO" + plugins_default: dict[str, bool] = {} model_config = SettingsConfigDict( env_file=get_env_file(), @@ -101,6 +106,35 @@ class Settings(BaseSettings): extra="ignore", ) + @field_validator("plugins_default", mode="before") + @classmethod + def parse_and_validate_plugins_default(cls, v: object) -> dict[str, bool]: + """Parse PLUGINS_DEFAULT env var as JSON object and validate keys/values.""" + if isinstance(v, dict): + parsed = v + elif isinstance(v, str): + if not v.strip(): + return {} + try: + parsed = json.loads(v) + except json.JSONDecodeError: + raise ValueError("PLUGINS_DEFAULT must be a valid JSON string") + if not isinstance(parsed, dict): + raise ValueError("PLUGINS_DEFAULT must be a JSON object") + elif isinstance(v, list): + raise ValueError("PLUGINS_DEFAULT must be a JSON object, got array") + else: + return {} + for key, val in parsed.items(): + if key not in KNOWN_PLUGINS: + raise ValueError(f"Unknown plugin key in PLUGINS_DEFAULT: '{key}'") + if not isinstance(val, bool): + raise ValueError( + f"Plugin '{key}' in PLUGINS_DEFAULT must be a boolean, " + f"got {type(val).__name__}" + ) + return parsed + def model_post_init(self, __context): """Validate and log non-sensitive configuration values after initialization.""" if self.group_id >= 0: @@ -118,7 +152,7 @@ def model_post_init(self, __context): env = os.getenv("BOT_ENV", "production") if self.logfire_environment == "production" and env == "staging": self.logfire_environment = "staging" - + logger.info("Configuration loaded successfully") logger.debug(f"group_id: {self.group_id}") logger.debug(f"warning_topic_id: {self.warning_topic_id}") @@ -161,4 +195,4 @@ def get_settings() -> Settings: Returns: Settings: Application configuration instance. """ - return Settings() + return Settings() \ No newline at end of file diff --git a/src/bot/group_config.py b/src/bot/group_config.py index 925e26d..6685246 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -17,6 +17,34 @@ logger = logging.getLogger(__name__) +# Set of all known built-in plugin names. +# Must match the plugin manifest order from the plugin system design spec. +KNOWN_PLUGINS: frozenset[str] = frozenset({ + "topic_guard", + "verify", + "unverify", + "check", + "trust", + "untrust", + "trusted_list", + "check_forwarded_message", + "verify_callback", + "unverify_callback", + "warn_callback", + "trust_callback", + "untrust_callback", + "captcha", + "dm", + "inline_keyboard_spam", + "bio_bait_spam", + "contact_spam", + "new_user_spam", + "duplicate_spam", + "profile_monitor", + "auto_restrict_job", + "refresh_admin_ids_job", +}) + class GroupConfig(BaseModel): """ @@ -44,6 +72,7 @@ class GroupConfig(BaseModel): bio_bait_enabled: bool = True bio_bait_monitor_only: bool = False bio_bait_alert_chat_id: int | None = None + plugins: dict[str, bool] | None = None @field_validator("group_id") @classmethod @@ -80,6 +109,20 @@ def probation_hours_must_be_non_negative(cls, v: int) -> int: raise ValueError("new_user_probation_hours must be >= 0") return v + @field_validator("plugins", mode="before") + @classmethod + def validate_plugins(cls, v: dict | None) -> dict | None: + if v is None: + return v + if not isinstance(v, dict): + raise ValueError("plugins must be a dict or None") + for key, val in v.items(): + if key not in KNOWN_PLUGINS: + raise ValueError(f"Unknown plugin key: '{key}'") + if not isinstance(val, bool): + raise ValueError(f"Plugin '{key}' value must be a boolean, got {type(val).__name__}") + return v + @property def probation_timedelta(self) -> timedelta: return timedelta(hours=self.new_user_probation_hours) @@ -265,4 +308,4 @@ def get_group_registry() -> GroupRegistry: def reset_group_registry() -> None: """Reset the group registry singleton (for testing).""" global _registry - _registry = None + _registry = None \ No newline at end of file diff --git a/tests/test_plugin_config.py b/tests/test_plugin_config.py new file mode 100644 index 0000000..7badf4c --- /dev/null +++ b/tests/test_plugin_config.py @@ -0,0 +1,233 @@ +"""Tests for plugin config validation in GroupConfig and Settings.""" + +import json + +import pytest +from pydantic import ValidationError +from pydantic_settings.exceptions import SettingsError + +from bot.config import Settings +from bot.group_config import GroupConfig, KNOWN_PLUGINS + + +class TestKnownPlugins: + """Verify the KNOWN_PLUGINS set matches design spec.""" + + def test_known_plugins_contains_all_expected(self): + assert "topic_guard" in KNOWN_PLUGINS + assert "verify" in KNOWN_PLUGINS + assert "unverify" in KNOWN_PLUGINS + assert "check" in KNOWN_PLUGINS + assert "trust" in KNOWN_PLUGINS + assert "untrust" in KNOWN_PLUGINS + assert "trusted_list" in KNOWN_PLUGINS + assert "check_forwarded_message" in KNOWN_PLUGINS + assert "verify_callback" in KNOWN_PLUGINS + assert "unverify_callback" in KNOWN_PLUGINS + assert "warn_callback" in KNOWN_PLUGINS + assert "trust_callback" in KNOWN_PLUGINS + assert "untrust_callback" in KNOWN_PLUGINS + assert "captcha" in KNOWN_PLUGINS + assert "dm" in KNOWN_PLUGINS + assert "inline_keyboard_spam" in KNOWN_PLUGINS + assert "bio_bait_spam" in KNOWN_PLUGINS + assert "contact_spam" in KNOWN_PLUGINS + assert "new_user_spam" in KNOWN_PLUGINS + assert "duplicate_spam" in KNOWN_PLUGINS + assert "profile_monitor" in KNOWN_PLUGINS + assert "auto_restrict_job" in KNOWN_PLUGINS + assert "refresh_admin_ids_job" in KNOWN_PLUGINS + + def test_known_plugins_is_frozen_set(self): + assert isinstance(KNOWN_PLUGINS, frozenset) + + +class TestGroupConfigPlugins: + """Tests for GroupConfig.plugins field validation.""" + + def test_plugins_defaults_to_none(self): + """Default plugins is None (all enabled).""" + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=42) + assert gc.plugins is None + + def test_plugins_valid_dict(self): + """Valid plugin dict with bool values passes.""" + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={"profile_monitor": False, "captcha": True}, + ) + assert gc.plugins == {"profile_monitor": False, "captcha": True} + + def test_plugins_empty_dict(self): + """Empty dict is valid (no overrides).""" + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={}, + ) + assert gc.plugins == {} + + def test_plugins_unknown_key_raises(self): + """Unknown plugin key raises ValueError.""" + with pytest.raises(ValidationError) as excinfo: + GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={"nonexistent_plugin": True}, + ) + assert "Unknown plugin" in str(excinfo.value) + + def test_plugins_unknown_key_in_mixed_dict_raises(self): + """Even with valid keys present, unknown key still fails.""" + with pytest.raises(ValidationError) as excinfo: + GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={"captcha": True, "fake_plugin": False}, + ) + assert "Unknown plugin" in str(excinfo.value) + + def test_plugins_non_bool_value_raises(self): + """Non-bool value raises ValueError.""" + with pytest.raises(ValidationError) as excinfo: + GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={"captcha": "yes"}, + ) + assert "must be a boolean" in str(excinfo.value).lower() or "bool" in str(excinfo.value).lower() + + def test_plugins_string_value_raises(self): + """String 'true' or 'false' not coerced to bool.""" + with pytest.raises(ValidationError) as excinfo: + GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={"captcha": "true"}, + ) + assert "must be a boolean" in str(excinfo.value).lower() or "bool" in str(excinfo.value).lower() + + def test_plugins_int_value_raises(self): + """Integer 0/1 not coerced to bool.""" + with pytest.raises(ValidationError) as excinfo: + GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins={"captcha": 1}, + ) + assert "must be a boolean" in str(excinfo.value).lower() or "bool" in str(excinfo.value).lower() + + def test_plugins_all_off(self): + """All known plugins set to False is valid.""" + all_off = {name: False for name in KNOWN_PLUGINS} + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins=all_off, + ) + assert gc.plugins == all_off + + def test_plugins_all_on(self): + """All known plugins set to True is valid.""" + all_on = {name: True for name in KNOWN_PLUGINS} + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + plugins=all_on, + ) + assert gc.plugins == all_on + + def test_plugins_loaded_from_json(self): + """Ensure plugins field works when loading from dict (e.g., groups.json).""" + data = { + "group_id": -1001234567890, + "warning_topic_id": 42, + "plugins": {"captcha": False, "dm": True}, + } + gc = GroupConfig(**data) + assert gc.plugins == {"captcha": False, "dm": True} + + +class TestSettingsPluginsDefault: + """Tests for Settings.plugins_default field validation.""" + + def test_plugins_default_defaults_to_empty(self, monkeypatch): + """Default plugins_default is empty dict when env var not set.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + + settings = Settings(_env_file=None) + assert settings.plugins_default == {} + + def test_plugins_default_valid_json(self, monkeypatch): + """Valid JSON string sets plugins_default correctly.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"profile_monitor": false, "captcha": true}') + + settings = Settings(_env_file=None) + assert settings.plugins_default == {"profile_monitor": False, "captcha": True} + + def test_plugins_default_unknown_key_raises(self, monkeypatch): + """Unknown plugin key in PLUGINS_DEFAULT raises ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"bogus_plugin": true}') + + with pytest.raises((ValueError, SettingsError), match="Unknown plugin"): + Settings(_env_file=None) + + def test_plugins_default_non_bool_raises(self, monkeypatch): + """Non-bool value in PLUGINS_DEFAULT raises ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"captcha": "yes"}') + + with pytest.raises((ValueError, SettingsError), match="must be a boolean|bool"): + Settings(_env_file=None) + + def test_plugins_default_invalid_json_raises(self, monkeypatch): + """Invalid JSON string in PLUGINS_DEFAULT raises SettingsError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", "not valid json") + + with pytest.raises(SettingsError, match="error parsing value"): + Settings(_env_file=None) + + def test_plugins_default_json_array_raises(self, monkeypatch): + """JSON array (not object) raises ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '["captcha", "dm"]') + + with pytest.raises((ValueError, SettingsError), match="must be a JSON|got array"): + Settings(_env_file=None) + + def test_plugins_default_empty_json_object(self, monkeypatch): + """Empty JSON object {} is valid.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", "{}") + + settings = Settings(_env_file=None) + assert settings.plugins_default == {} + + def test_plugins_default_full_set(self, monkeypatch): + """All known plugins in PLUGINS_DEFAULT is valid.""" + full_set = {name: True for name in KNOWN_PLUGINS} + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", json.dumps(full_set)) + + settings = Settings(_env_file=None) + assert settings.plugins_default == full_set \ No newline at end of file From 77ba0e61b3462ce28acbff296debb8ee40fff0a8 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 15:42:04 +0700 Subject: [PATCH 11/20] fix(config): apply plugins_default in single-group fallback --- src/bot/config.py | 4 +-- src/bot/group_config.py | 16 +++------ tests/test_group_config.py | 72 +++++++++++++++++++++++++++++++++++--- 3 files changed, 73 insertions(+), 19 deletions(-) diff --git a/src/bot/config.py b/src/bot/config.py index 707f2eb..bbca41b 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -20,7 +20,6 @@ logger = logging.getLogger(__name__) - def get_env_file() -> str | None: """ Determine which .env file to load based on BOT_ENV environment variable. @@ -46,7 +45,6 @@ def get_env_file() -> str | None: logger.debug(f"No .env file found at {env_file}, loading from environment variables") return None - class Settings(BaseSettings): """ Application settings loaded from environment variables. @@ -170,6 +168,7 @@ def model_post_init(self, __context): logger.debug(f"telegram_bot_token: {'***' + self.telegram_bot_token[-4:]}") # Mask sensitive token logger.debug(f"logfire_enabled: {self.logfire_enabled}") logger.debug(f"logfire_environment: {self.logfire_environment}") + logger.debug(f"plugins_default: {self.plugins_default}") @property def probation_timedelta(self) -> timedelta: @@ -183,7 +182,6 @@ def warning_time_threshold_timedelta(self) -> timedelta: def captcha_timeout_timedelta(self) -> timedelta: return timedelta(seconds=self.captcha_timeout_seconds) - @lru_cache def get_settings() -> Settings: """ diff --git a/src/bot/group_config.py b/src/bot/group_config.py index 6685246..265deca 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -18,7 +18,7 @@ logger = logging.getLogger(__name__) # Set of all known built-in plugin names. -# Must match the plugin manifest order from the plugin system design spec. +# Must match the plugin manifest from the plugin system design spec. KNOWN_PLUGINS: frozenset[str] = frozenset({ "topic_guard", "verify", @@ -45,7 +45,6 @@ "refresh_admin_ids_job", }) - class GroupConfig(BaseModel): """ Per-group configuration settings. @@ -111,9 +110,9 @@ def probation_hours_must_be_non_negative(cls, v: int) -> int: @field_validator("plugins", mode="before") @classmethod - def validate_plugins(cls, v: dict | None) -> dict | None: + def validate_plugins(cls, v: object) -> dict[str, bool] | None: if v is None: - return v + return None if not isinstance(v, dict): raise ValueError("plugins must be a dict or None") for key, val in v.items(): @@ -135,7 +134,6 @@ def warning_time_threshold_timedelta(self) -> timedelta: def captcha_timeout_timedelta(self) -> timedelta: return timedelta(seconds=self.captcha_timeout_seconds) - class GroupRegistry: """ Registry of monitored groups. @@ -161,7 +159,6 @@ def all_groups(self) -> list[GroupConfig]: def is_monitored(self, group_id: int) -> bool: return group_id in self._groups - def load_groups_from_json(path: str) -> list[GroupConfig]: """ Parse a groups.json file into a list of GroupConfig objects. @@ -197,7 +194,6 @@ def load_groups_from_json(path: str) -> list[GroupConfig]: return configs - def build_group_registry(settings: object) -> GroupRegistry: """ Build a GroupRegistry from settings. @@ -242,12 +238,12 @@ def build_group_registry(settings: object) -> GroupRegistry: bio_bait_enabled=getattr(settings, "bio_bait_enabled", True), bio_bait_monitor_only=getattr(settings, "bio_bait_monitor_only", False), bio_bait_alert_chat_id=getattr(settings, "bio_bait_alert_chat_id", None), + plugins=getattr(settings, "plugins_default", None), ) registry.register(config) return registry - def get_group_config_for_update(update: Update) -> GroupConfig | None: """ Get the GroupConfig for the group in the given Update. @@ -268,11 +264,9 @@ def get_group_config_for_update(update: Update) -> GroupConfig | None: logger.error("Group registry not initialized; skipping update") return None - # Module-level singleton _registry: GroupRegistry | None = None - def init_group_registry(settings: object) -> GroupRegistry: """ Initialize the global group registry singleton. @@ -289,7 +283,6 @@ def init_group_registry(settings: object) -> GroupRegistry: _registry = build_group_registry(settings) return _registry - def get_group_registry() -> GroupRegistry: """ Get the global group registry singleton. @@ -304,7 +297,6 @@ def get_group_registry() -> GroupRegistry: raise RuntimeError("Group registry not initialized. Call init_group_registry() first.") return _registry - def reset_group_registry() -> None: """Reset the group registry singleton (for testing).""" global _registry diff --git a/tests/test_group_config.py b/tests/test_group_config.py index 447f4bb..ebaf6f9 100644 --- a/tests/test_group_config.py +++ b/tests/test_group_config.py @@ -19,7 +19,6 @@ reset_group_registry, ) - class TestGroupConfig: def test_minimal_config(self): gc = GroupConfig(group_id=-1001234567890, warning_topic_id=42) @@ -115,6 +114,29 @@ def test_duplicate_spam_custom_values(self): assert gc.duplicate_spam_threshold == 5 assert gc.duplicate_spam_min_length == 50 + def test_plugins_none_by_default(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42) + assert gc.plugins is None + + def test_plugins_valid_dict(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42, plugins={"captcha": True}) + assert gc.plugins == {"captcha": True} + + def test_plugins_empty_dict(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42, plugins={}) + assert gc.plugins == {} + + def test_plugins_rejects_unknown_key(self): + with pytest.raises(ValidationError, match="Unknown plugin key"): + GroupConfig(group_id=-1, warning_topic_id=42, plugins={"unknown": True}) + + def test_plugins_rejects_non_bool(self): + with pytest.raises(ValidationError, match="value must be a boolean"): + GroupConfig(group_id=-1, warning_topic_id=42, plugins={"captcha": "yes"}) + + def test_plugins_rejects_non_dict(self): + with pytest.raises(ValidationError, match="plugins must be a dict"): + GroupConfig(group_id=-1, warning_topic_id=42, plugins=[1, 2, 3]) class TestGroupRegistry: def test_register_and_get(self): @@ -158,7 +180,6 @@ def test_empty_registry(self): assert registry.get(-100) is None assert registry.is_monitored(-100) is False - class TestLoadGroupsFromJson: def test_load_valid_json(self): data = [ @@ -246,6 +267,16 @@ def test_invalid_group_config(self): with pytest.raises(ValidationError, match="group_id must be negative"): load_groups_from_json(f.name) + def test_load_with_plugins(self): + data = [ + {"group_id": -100, "warning_topic_id": 1, "plugins": {"captcha": True}}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + configs = load_groups_from_json(f.name) + + assert configs[0].plugins == {"captcha": True} class TestBuildGroupRegistry: def test_builds_from_json_file(self): @@ -285,6 +316,7 @@ def test_falls_back_to_env(self): settings.duplicate_spam_min_length = 50 settings.bio_bait_monitor_only = True settings.bio_bait_alert_chat_id = 57747812 + settings.plugins_default = {} registry = build_group_registry(settings) @@ -300,6 +332,37 @@ def test_falls_back_to_env(self): assert gc.bio_bait_monitor_only is True assert gc.bio_bait_alert_chat_id == 57747812 + def test_single_group_fallback_wires_plugins_default(self): + plugins = {"captcha": True, "dm": False} + settings = MagicMock() + settings.groups_config_path = "/nonexistent/groups.json" + settings.group_id = -100 + settings.warning_topic_id = 1 + settings.restrict_failed_users = False + settings.warning_threshold = 3 + settings.warning_time_threshold_minutes = 180 + settings.captcha_enabled = False + settings.captcha_timeout_seconds = 120 + settings.new_user_probation_hours = 72 + settings.new_user_violation_threshold = 3 + settings.rules_link = "https://t.me/test/rules" + settings.contact_spam_restrict = True + settings.duplicate_spam_enabled = True + settings.duplicate_spam_window_seconds = 120 + settings.duplicate_spam_threshold = 2 + settings.duplicate_spam_min_length = 20 + settings.duplicate_spam_similarity = 0.95 + settings.bio_bait_enabled = True + settings.bio_bait_monitor_only = False + settings.bio_bait_alert_chat_id = None + settings.plugins_default = plugins + + registry = build_group_registry(settings) + + assert len(registry.all_groups()) == 1 + gc = registry.get(-100) + assert gc is not None + assert gc.plugins == plugins class TestGetGroupConfigForUpdate: def test_returns_none_when_registry_not_initialized(self): @@ -345,7 +408,6 @@ def test_returns_none_when_no_effective_chat(self): result = get_group_config_for_update(update) assert result is None - class TestSingleton: def setup_method(self): reset_group_registry() @@ -374,6 +436,7 @@ def test_init_and_get(self): settings.duplicate_spam_window_seconds = 120 settings.duplicate_spam_threshold = 3 settings.duplicate_spam_min_length = 20 + settings.plugins_default = {} registry = init_group_registry(settings) assert registry is get_group_registry() @@ -396,9 +459,10 @@ def test_reset_clears_registry(self): settings.duplicate_spam_window_seconds = 120 settings.duplicate_spam_threshold = 3 settings.duplicate_spam_min_length = 20 + settings.plugins_default = {} init_group_registry(settings) reset_group_registry() with pytest.raises(RuntimeError, match="not initialized"): - get_group_registry() + get_group_registry() \ No newline at end of file From 707857f0c68bc92e434f951126b88a945e6a9750 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 15:52:32 +0700 Subject: [PATCH 12/20] feat(plugins): add plugin contracts and toggle resolver --- src/bot/plugins/__init__.py | 14 ++++++ src/bot/plugins/base.py | 33 +++++++++++++ src/bot/plugins/config.py | 59 ++++++++++++++++++++++ src/bot/plugins/definitions.py | 48 ++++++++++++++++++ tests/test_plugin_manager.py | 90 ++++++++++++++++++++++++++++++++++ 5 files changed, 244 insertions(+) create mode 100644 src/bot/plugins/__init__.py create mode 100644 src/bot/plugins/base.py create mode 100644 src/bot/plugins/config.py create mode 100644 src/bot/plugins/definitions.py create mode 100644 tests/test_plugin_manager.py diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py new file mode 100644 index 0000000..eadfcb9 --- /dev/null +++ b/src/bot/plugins/__init__.py @@ -0,0 +1,14 @@ +"""Plugin system for PythonID bot. + +Provides base contracts, toggle resolution, and plugin definitions +for modular handler registration. +""" + +from bot.plugins.base import PluginProtocol +from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles + +__all__ = [ + "PluginProtocol", + "is_plugin_enabled", + "resolve_plugin_toggles", +] \ No newline at end of file diff --git a/src/bot/plugins/base.py b/src/bot/plugins/base.py new file mode 100644 index 0000000..bf5ce24 --- /dev/null +++ b/src/bot/plugins/base.py @@ -0,0 +1,33 @@ +"""Base plugin contracts for the PythonID bot plugin system.""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from telegram.ext import BaseHandler + + +@runtime_checkable +class PluginProtocol(Protocol): + """Protocol that all built-in plugins must satisfy. + + Attributes: + name: Canonical plugin identifier (must match KNOWN_PLUGINS). + description: Human-readable description of plugin purpose. + handler_group: PTB handler group integer for registration ordering. + """ + + name: str + description: str + handler_group: int + + def register(self, application: object) -> list[BaseHandler]: + """Register handlers onto the PTB Application. + + Args: + application: PTB Application instance. + + Returns: + List of registered BaseHandler instances. + """ + ... \ No newline at end of file diff --git a/src/bot/plugins/config.py b/src/bot/plugins/config.py new file mode 100644 index 0000000..879f2d5 --- /dev/null +++ b/src/bot/plugins/config.py @@ -0,0 +1,59 @@ +"""Plugin toggle resolution. + +Provides deterministic resolution of plugin enabled/disabled state +from environment-level defaults and per-group overrides. +""" + +from __future__ import annotations + +from bot.group_config import KNOWN_PLUGINS + + +def resolve_plugin_toggles( + defaults: dict[str, bool], + overrides: dict[str, bool] | None, +) -> dict[str, bool]: + """Resolve plugin enabled/disabled state for all known plugins. + + Resolution order (first match wins): + 1. Group ``overrides`` (explicit per-group plugin config) + 2. Environment ``defaults`` (PLUGINS_DEFAULT env var) + 3. ``True`` (all plugins enabled by default) + + Args: + defaults: Env-wide default toggles from Settings.plugins_default. + overrides: Per-group overrides from GroupConfig.plugins (or None). + + Returns: + Dict mapping every ``KNOWN_PLUGINS`` name to its resolved bool. + """ + result: dict[str, bool] = {} + + for name in KNOWN_PLUGINS: + # Priority 1: group override (if present) + if overrides is not None and name in overrides: + result[name] = overrides[name] + # Priority 2: env default (if present) + elif name in defaults: + result[name] = defaults[name] + # Priority 3: True (default) + else: + result[name] = True + + return result + + +def is_plugin_enabled(toggles: dict[str, bool], name: str) -> bool: + """Check if a single plugin is enabled from a resolved toggle dict. + + Args: + toggles: Resolved toggle dict from ``resolve_plugin_toggles``. + name: Plugin name to check. + + Returns: + True if plugin is enabled. + + Raises: + KeyError: If ``name`` is not in ``toggles``. + """ + return toggles[name] \ No newline at end of file diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py new file mode 100644 index 0000000..71d115d --- /dev/null +++ b/src/bot/plugins/definitions.py @@ -0,0 +1,48 @@ +"""Plugin definitions and manifest for the PythonID bot. + +Provides the canonical mapping from plugin names to human-readable +metadata. The plugin names must stay in sync with ``KNOWN_PLUGINS`` +in ``bot.group_config``, which is the authoritative source. +""" + +from __future__ import annotations + +PluginManifest = list[dict[str, str]] +"""Type alias for a list of plugin descriptor dicts.""" + +# Human-readable metadata for each known built-in plugin. +# ``name`` must be present in ``KNOWN_PLUGINS``. +_PLUGIN_DEFINITIONS: PluginManifest = [ + {"name": "topic_guard", "handler_group": "-1", "description": "Intercept warning-topic messages before other handlers"}, + {"name": "verify", "handler_group": "0", "description": "Admin /verify command"}, + {"name": "unverify", "handler_group": "0", "description": "Admin /unverify command"}, + {"name": "check", "handler_group": "0", "description": "Admin /check command"}, + {"name": "trust", "handler_group": "0", "description": "Admin /trust command"}, + {"name": "untrust", "handler_group": "0", "description": "Admin /untrust command"}, + {"name": "trusted_list", "handler_group": "0", "description": "Admin /trusted list command"}, + {"name": "check_forwarded_message", "handler_group": "0", "description": "Handle forwarded messages for /check context"}, + {"name": "verify_callback", "handler_group": "0", "description": "Captcha verify button callback"}, + {"name": "unverify_callback", "handler_group": "0", "description": "Admin unverify button callback"}, + {"name": "warn_callback", "handler_group": "0", "description": "Admin warn button callback"}, + {"name": "trust_callback", "handler_group": "0", "description": "Admin trust button callback"}, + {"name": "untrust_callback", "handler_group": "0", "description": "Admin untrust button callback"}, + {"name": "captcha", "handler_group": "0", "description": "Captcha verification for new members"}, + {"name": "dm", "handler_group": "0", "description": "Direct message unrestriction flow"}, + {"name": "inline_keyboard_spam", "handler_group": "1", "description": "Block inline keyboard URL spam"}, + {"name": "bio_bait_spam", "handler_group": "1", "description": "Detect and alert on bio bait patterns"}, + {"name": "contact_spam", "handler_group": "2", "description": "Block contact card sharing"}, + {"name": "new_user_spam", "handler_group": "3", "description": "Probation enforcement for new users"}, + {"name": "duplicate_spam", "handler_group": "4", "description": "Repeated message detection"}, + {"name": "profile_monitor", "handler_group": "5", "description": "Profile compliance monitoring"}, + {"name": "auto_restrict_job", "handler_group": "6", "description": "Periodic auto-restriction job (every 5 min)"}, + {"name": "refresh_admin_ids_job", "handler_group": "6", "description": "Periodic admin cache refresh job (every 10 min)"}, +] + + +def get_plugin_definitions() -> PluginManifest: + """Return a copy of all built-in plugin definitions. + + Returns: + List of plugin descriptor dicts with keys: name, handler_group, description. + """ + return list(_PLUGIN_DEFINITIONS) \ No newline at end of file diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py new file mode 100644 index 0000000..42828d2 --- /dev/null +++ b/tests/test_plugin_manager.py @@ -0,0 +1,90 @@ +"""Tests for plugin toggle resolver and plugin contracts.""" + +from bot.group_config import KNOWN_PLUGINS +from bot.plugins import base +from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles + + +class TestResolvePluginToggles: + """Resolver: defaults True, group override wins.""" + + def test_defaults_true_when_no_overrides(self): + """All plugins True when no overrides specified.""" + toggles = resolve_plugin_toggles({}, None) + for name in KNOWN_PLUGINS: + assert toggles[name] is True + + def test_env_default_applied_when_no_group_overrides(self): + """Env defaults apply to listed plugins; others stay True.""" + env_defaults = {"captcha": False, "dm": False} + toggles = resolve_plugin_toggles(env_defaults, None) + assert toggles["captcha"] is False + assert toggles["dm"] is False + assert toggles["verify"] is True + assert toggles["profile_monitor"] is True + + def test_group_override_wins_over_env_default(self): + """Group override takes precedence over env default.""" + toggles = resolve_plugin_toggles( + {"captcha": False, "dm": True}, + {"captcha": True}, + ) + assert toggles["captcha"] is True # group wins + assert toggles["dm"] is True # from env + + def test_group_override_empty_falls_to_env(self): + """Empty group overrides dict falls through to env defaults.""" + toggles = resolve_plugin_toggles({"captcha": False}, {}) + assert toggles["captcha"] is False + assert toggles["verify"] is True # not in env either + + def test_empty_env_and_empty_group_all_true(self): + """Empty env defaults + empty group overrides = all True.""" + toggles = resolve_plugin_toggles({}, {}) + for name in KNOWN_PLUGINS: + assert toggles[name] is True + + def test_result_contains_all_known_plugins(self): + """Returned dict always has all KNOWN_PLUGINS keys.""" + toggles = resolve_plugin_toggles({}, None) + assert set(toggles.keys()) == KNOWN_PLUGINS + + def test_is_plugin_enabled_convenience(self): + """is_plugin_enabled returns correct bool for a single plugin.""" + toggles = resolve_plugin_toggles({"captcha": False}, None) + assert is_plugin_enabled(toggles, "captcha") is False + assert is_plugin_enabled(toggles, "verify") is True + + def test_group_override_false_overrides_env_true(self): + """Group override False wins over env default True.""" + toggles = resolve_plugin_toggles( + {"captcha": True}, + {"captcha": False}, + ) + assert toggles["captcha"] is False + + def test_partial_group_override(self): + """Only overridden plugins use group value; rest use env or True.""" + toggles = resolve_plugin_toggles( + {"captcha": False, "dm": True, "verify": False}, + {"captcha": True}, + ) + assert toggles["captcha"] is True # group override + assert toggles["dm"] is True # from env + assert toggles["verify"] is False # from env + assert toggles["profile_monitor"] is True # default True + + +class TestPluginContracts: + """Verify plugin base contracts are importable and well-typed.""" + + def test_plugin_protocol_exists(self): + """Plugin protocol is exported from base module.""" + assert hasattr(base, "PluginProtocol") + + def test_plugin_protocol_has_fields(self): + """Plugin protocol defines expected fields as annotations + register method.""" + assert "name" in base.PluginProtocol.__annotations__ + assert "description" in base.PluginProtocol.__annotations__ + assert "handler_group" in base.PluginProtocol.__annotations__ + assert hasattr(base.PluginProtocol, "register") \ No newline at end of file From a633ac9ceb18f008da81fbac477dfcbde1b4456c Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 15:57:56 +0700 Subject: [PATCH 13/20] fix(plugins): align manifest types and export definitions --- src/bot/plugins/__init__.py | 3 ++ src/bot/plugins/definitions.py | 54 ++++++++++++++++++---------------- tests/test_plugin_manager.py | 41 ++++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 28 deletions(-) diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py index eadfcb9..53c7342 100644 --- a/src/bot/plugins/__init__.py +++ b/src/bot/plugins/__init__.py @@ -6,9 +6,12 @@ from bot.plugins.base import PluginProtocol from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles +from bot.plugins.definitions import PluginManifest, get_plugin_definitions __all__ = [ "PluginProtocol", + "PluginManifest", + "get_plugin_definitions", "is_plugin_enabled", "resolve_plugin_toggles", ] \ No newline at end of file diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py index 71d115d..fe98da4 100644 --- a/src/bot/plugins/definitions.py +++ b/src/bot/plugins/definitions.py @@ -7,42 +7,44 @@ from __future__ import annotations -PluginManifest = list[dict[str, str]] +import copy + +PluginManifest = list[dict[str, str | int]] """Type alias for a list of plugin descriptor dicts.""" # Human-readable metadata for each known built-in plugin. # ``name`` must be present in ``KNOWN_PLUGINS``. _PLUGIN_DEFINITIONS: PluginManifest = [ - {"name": "topic_guard", "handler_group": "-1", "description": "Intercept warning-topic messages before other handlers"}, - {"name": "verify", "handler_group": "0", "description": "Admin /verify command"}, - {"name": "unverify", "handler_group": "0", "description": "Admin /unverify command"}, - {"name": "check", "handler_group": "0", "description": "Admin /check command"}, - {"name": "trust", "handler_group": "0", "description": "Admin /trust command"}, - {"name": "untrust", "handler_group": "0", "description": "Admin /untrust command"}, - {"name": "trusted_list", "handler_group": "0", "description": "Admin /trusted list command"}, - {"name": "check_forwarded_message", "handler_group": "0", "description": "Handle forwarded messages for /check context"}, - {"name": "verify_callback", "handler_group": "0", "description": "Captcha verify button callback"}, - {"name": "unverify_callback", "handler_group": "0", "description": "Admin unverify button callback"}, - {"name": "warn_callback", "handler_group": "0", "description": "Admin warn button callback"}, - {"name": "trust_callback", "handler_group": "0", "description": "Admin trust button callback"}, - {"name": "untrust_callback", "handler_group": "0", "description": "Admin untrust button callback"}, - {"name": "captcha", "handler_group": "0", "description": "Captcha verification for new members"}, - {"name": "dm", "handler_group": "0", "description": "Direct message unrestriction flow"}, - {"name": "inline_keyboard_spam", "handler_group": "1", "description": "Block inline keyboard URL spam"}, - {"name": "bio_bait_spam", "handler_group": "1", "description": "Detect and alert on bio bait patterns"}, - {"name": "contact_spam", "handler_group": "2", "description": "Block contact card sharing"}, - {"name": "new_user_spam", "handler_group": "3", "description": "Probation enforcement for new users"}, - {"name": "duplicate_spam", "handler_group": "4", "description": "Repeated message detection"}, - {"name": "profile_monitor", "handler_group": "5", "description": "Profile compliance monitoring"}, - {"name": "auto_restrict_job", "handler_group": "6", "description": "Periodic auto-restriction job (every 5 min)"}, - {"name": "refresh_admin_ids_job", "handler_group": "6", "description": "Periodic admin cache refresh job (every 10 min)"}, + {"name": "topic_guard", "handler_group": -1, "description": "Intercept warning-topic messages before other handlers"}, + {"name": "verify", "handler_group": 0, "description": "Admin /verify command"}, + {"name": "unverify", "handler_group": 0, "description": "Admin /unverify command"}, + {"name": "check", "handler_group": 0, "description": "Admin /check command"}, + {"name": "trust", "handler_group": 0, "description": "Admin /trust command"}, + {"name": "untrust", "handler_group": 0, "description": "Admin /untrust command"}, + {"name": "trusted_list", "handler_group": 0, "description": "Admin /trusted list command"}, + {"name": "check_forwarded_message", "handler_group": 0, "description": "Handle forwarded messages for /check context"}, + {"name": "verify_callback", "handler_group": 0, "description": "Captcha verify button callback"}, + {"name": "unverify_callback", "handler_group": 0, "description": "Admin unverify button callback"}, + {"name": "warn_callback", "handler_group": 0, "description": "Admin warn button callback"}, + {"name": "trust_callback", "handler_group": 0, "description": "Admin trust button callback"}, + {"name": "untrust_callback", "handler_group": 0, "description": "Admin untrust button callback"}, + {"name": "captcha", "handler_group": 0, "description": "Captcha verification for new members"}, + {"name": "dm", "handler_group": 0, "description": "Direct message unrestriction flow"}, + {"name": "inline_keyboard_spam", "handler_group": 1, "description": "Block inline keyboard URL spam"}, + {"name": "bio_bait_spam", "handler_group": 1, "description": "Detect and alert on bio bait patterns"}, + {"name": "contact_spam", "handler_group": 2, "description": "Block contact card sharing"}, + {"name": "new_user_spam", "handler_group": 3, "description": "Probation enforcement for new users"}, + {"name": "duplicate_spam", "handler_group": 4, "description": "Repeated message detection"}, + {"name": "profile_monitor", "handler_group": 5, "description": "Profile compliance monitoring"}, + {"name": "auto_restrict_job", "handler_group": 6, "description": "Periodic auto-restriction job (every 5 min)"}, + {"name": "refresh_admin_ids_job", "handler_group": 6, "description": "Periodic admin cache refresh job (every 10 min)"}, ] def get_plugin_definitions() -> PluginManifest: - """Return a copy of all built-in plugin definitions. + """Return a deep copy of all built-in plugin definitions. Returns: List of plugin descriptor dicts with keys: name, handler_group, description. """ - return list(_PLUGIN_DEFINITIONS) \ No newline at end of file + return copy.deepcopy(_PLUGIN_DEFINITIONS) \ No newline at end of file diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 42828d2..12d6813 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -1,8 +1,9 @@ -"""Tests for plugin toggle resolver and plugin contracts.""" +"""Tests for plugin toggle resolver, plugin contracts, and definitions.""" from bot.group_config import KNOWN_PLUGINS from bot.plugins import base from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles +from bot.plugins.definitions import get_plugin_definitions class TestResolvePluginToggles: @@ -87,4 +88,40 @@ def test_plugin_protocol_has_fields(self): assert "name" in base.PluginProtocol.__annotations__ assert "description" in base.PluginProtocol.__annotations__ assert "handler_group" in base.PluginProtocol.__annotations__ - assert hasattr(base.PluginProtocol, "register") \ No newline at end of file + assert hasattr(base.PluginProtocol, "register") + + +class TestPluginDefinitions: + """Verify plugin definitions match KNOWN_PLUGINS and have correct types.""" + + def test_names_match_known_plugins(self): + """Every definition name is in KNOWN_PLUGINS and every KNOWN_PLUGINS has a definition.""" + defs = get_plugin_definitions() + def_names = {d["name"] for d in defs} + assert def_names == KNOWN_PLUGINS + + def test_each_definition_has_required_keys(self): + """Each definition dict contains name, handler_group, description.""" + for d in get_plugin_definitions(): + assert "name" in d + assert "handler_group" in d + assert "description" in d + + def test_handler_group_is_int(self): + """handler_group value is int, not str.""" + for d in get_plugin_definitions(): + assert isinstance(d["handler_group"], int), f"{d['name']}: handler_group={d['handler_group']!r}" + + def test_returned_copy_isolation(self): + """Mutating returned list or dicts doesn't affect internal definitions.""" + defs1 = get_plugin_definitions() + defs2 = get_plugin_definitions() + # List-level isolation: clearing defs1 doesn't affect defs2 + defs1.clear() + assert len(defs2) > 0 + # Dict-level isolation: mutating a dict in defs2 doesn't affect future calls + defs2[0]["name"] = "hacked" + defs3 = get_plugin_definitions() + assert defs3[0]["name"] != "hacked" + # Calling again still works + assert len(defs3) == len(KNOWN_PLUGINS) \ No newline at end of file From 31eb3e3e18d89efe5de908d026cb53200146d659 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 16:13:24 +0700 Subject: [PATCH 14/20] feat(plugins): add built-in wrappers with fixed registration order --- src/bot/plugins/builtin/__init__.py | 26 +++ src/bot/plugins/builtin/captcha.py | 36 ++++ src/bot/plugins/builtin/commands.py | 113 +++++++++++++ src/bot/plugins/builtin/dm.py | 40 +++++ src/bot/plugins/builtin/jobs.py | 52 ++++++ src/bot/plugins/builtin/profile_monitor.py | 40 +++++ src/bot/plugins/builtin/spam.py | 84 ++++++++++ src/bot/plugins/builtin/topic_guard.py | 40 +++++ src/bot/plugins/definitions.py | 23 ++- tests/test_plugin_manager.py | 184 ++++++++++++++++++++- 10 files changed, 630 insertions(+), 8 deletions(-) create mode 100644 src/bot/plugins/builtin/__init__.py create mode 100644 src/bot/plugins/builtin/captcha.py create mode 100644 src/bot/plugins/builtin/commands.py create mode 100644 src/bot/plugins/builtin/dm.py create mode 100644 src/bot/plugins/builtin/jobs.py create mode 100644 src/bot/plugins/builtin/profile_monitor.py create mode 100644 src/bot/plugins/builtin/spam.py create mode 100644 src/bot/plugins/builtin/topic_guard.py diff --git a/src/bot/plugins/builtin/__init__.py b/src/bot/plugins/builtin/__init__.py new file mode 100644 index 0000000..c8bc81f --- /dev/null +++ b/src/bot/plugins/builtin/__init__.py @@ -0,0 +1,26 @@ +"""Built-in plugin wrappers for the PythonID bot. + +Each submodule exports a single ``plugin`` object satisfying +``PluginProtocol`` that knows how to register its handlers onto +a PTB ``Application`` instance. +""" + +from bot.plugins.builtin import ( + captcha, + commands, + dm, + jobs, + profile_monitor, + spam, + topic_guard, +) + +__all__ = [ + "captcha", + "commands", + "dm", + "jobs", + "profile_monitor", + "spam", + "topic_guard", +] \ No newline at end of file diff --git a/src/bot/plugins/builtin/captcha.py b/src/bot/plugins/builtin/captcha.py new file mode 100644 index 0000000..0bd4a68 --- /dev/null +++ b/src/bot/plugins/builtin/captcha.py @@ -0,0 +1,36 @@ +"""Built-in plugin: captcha. + +Wraps ``bot.handlers.captcha`` handlers for new member verification. +All register at group=0 via ``captcha.get_handlers()``. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from bot.handlers import captcha + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _CaptchaPlugin: + """Plugin wrapper for captcha handlers.""" + + name: str = "captcha" + description: str = "Captcha verification for new members" + handler_group: int = 0 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register captcha handlers onto application.""" + handlers = captcha.get_handlers() + for h in handlers: + application.add_handler(h) + logger.info("Registered handler: captcha_handlers (group=0)") + return handlers + + +plugin = _CaptchaPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/commands.py b/src/bot/plugins/builtin/commands.py new file mode 100644 index 0000000..5ff013f --- /dev/null +++ b/src/bot/plugins/builtin/commands.py @@ -0,0 +1,113 @@ +"""Built-in plugin: commands. + +Wraps all command and callback handlers (verify, unverify, check, trust, +untrust, trusted_list, check_forwarded_message, and their callbacks). +All register at group=0. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from telegram.ext import CallbackQueryHandler, CommandHandler, MessageHandler, filters + +from bot.handlers.check import handle_check_command, handle_check_forwarded_message, handle_warn_callback +from bot.handlers.trust import ( + handle_trust_callback, + handle_trust_command, + handle_trusted_list_command, + handle_untrust_callback, + handle_untrust_command, +) +from bot.handlers.verify import ( + handle_unverify_callback, + handle_unverify_command, + handle_verify_callback, + handle_verify_command, +) + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _CommandsPlugin: + """Plugin wrapper for command and callback handlers.""" + + name: str = "commands" + description: str = "Admin commands and callback handlers (verify, unverify, check, trust)" + handler_group: int = 0 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register all command and callback handlers onto application.""" + handlers: list[BaseHandler] = [] + + h = CommandHandler("verify", handle_verify_command) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: verify_command (group=0)") + + h = CommandHandler("unverify", handle_unverify_command) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: unverify_command (group=0)") + + h = CommandHandler("check", handle_check_command) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: check_command (group=0)") + + h = CommandHandler("trust", handle_trust_command) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: trust_command (group=0)") + + h = CommandHandler("untrust", handle_untrust_command) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: untrust_command (group=0)") + + h = CommandHandler("trusted", handle_trusted_list_command) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: trusted_list_command (group=0)") + + h = MessageHandler( + filters.FORWARDED & filters.ChatType.PRIVATE, + handle_check_forwarded_message, + ) + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: check_forwarded_message (group=0)") + + h = CallbackQueryHandler(handle_verify_callback, pattern=r"^verify:\d+$") + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: verify_callback (group=0)") + + h = CallbackQueryHandler(handle_unverify_callback, pattern=r"^unverify:\d+$") + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: unverify_callback (group=0)") + + h = CallbackQueryHandler(handle_warn_callback, pattern=r"^warn:\d+:") + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: warn_callback (group=0)") + + h = CallbackQueryHandler(handle_trust_callback, pattern=r"^trust:\d+$") + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: trust_callback (group=0)") + + h = CallbackQueryHandler(handle_untrust_callback, pattern=r"^untrust:\d+$") + application.add_handler(h) + handlers.append(h) + logger.info("Registered handler: untrust_callback (group=0)") + + return handlers + + +plugin = _CommandsPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/dm.py b/src/bot/plugins/builtin/dm.py new file mode 100644 index 0000000..6320878 --- /dev/null +++ b/src/bot/plugins/builtin/dm.py @@ -0,0 +1,40 @@ +"""Built-in plugin: dm. + +Wraps ``bot.handlers.dm.handle_dm`` for DM unrestriction flow. +Registers at group=0 with PRIVATE & TEXT filter. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from telegram.ext import MessageHandler, filters + +from bot.handlers.dm import handle_dm + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _DmPlugin: + """Plugin wrapper for DM handler.""" + + name: str = "dm" + description: str = "Direct message unrestriction flow" + handler_group: int = 0 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register DM handler onto application.""" + handler: BaseHandler = MessageHandler( + filters.ChatType.PRIVATE & filters.TEXT, + handle_dm, + ) + application.add_handler(handler) + logger.info("Registered handler: dm_handler (group=0)") + return [handler] + + +plugin = _DmPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/jobs.py b/src/bot/plugins/builtin/jobs.py new file mode 100644 index 0000000..befd748 --- /dev/null +++ b/src/bot/plugins/builtin/jobs.py @@ -0,0 +1,52 @@ +"""Built-in plugin: jobs. + +Wraps periodic JobQueue jobs (auto_restrict_job, refresh_admin_ids_job). +Register repeating jobs via application.job_queue. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from bot.services.scheduler import auto_restrict_expired_warnings +from bot.main import refresh_admin_ids + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _JobsPlugin: + """Plugin wrapper for periodic job handlers.""" + + name: str = "jobs" + description: str = "Periodic JobQueue tasks (auto-restrict, admin cache refresh)" + handler_group: int = 6 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register repeating jobs onto application.job_queue.""" + handlers: list[BaseHandler] = [] + + if application.job_queue: + application.job_queue.run_repeating( + auto_restrict_expired_warnings, + interval=300, + first=300, + name="auto_restrict_job", + ) + logger.info("JobQueue registered: auto_restrict_job (every 5 minutes, first run in 5 minutes)") + + application.job_queue.run_repeating( + refresh_admin_ids, + interval=600, + first=600, + name="refresh_admin_ids_job", + ) + logger.info("JobQueue registered: refresh_admin_ids_job (every 10 minutes)") + + return handlers + + +plugin = _JobsPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/profile_monitor.py b/src/bot/plugins/builtin/profile_monitor.py new file mode 100644 index 0000000..4097356 --- /dev/null +++ b/src/bot/plugins/builtin/profile_monitor.py @@ -0,0 +1,40 @@ +"""Built-in plugin: profile_monitor. + +Wraps ``bot.handlers.message.handle_message`` for profile compliance +monitoring. Registers at group=6 with GROUPS & ~COMMAND filter. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from telegram.ext import MessageHandler, filters + +from bot.handlers.message import handle_message + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _ProfileMonitorPlugin: + """Plugin wrapper for profile compliance monitor.""" + + name: str = "profile_monitor" + description: str = "Profile compliance monitoring" + handler_group: int = 6 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register profile monitor handler onto application.""" + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + handle_message, + ) + application.add_handler(handler, group=6) + logger.info("Registered handler: message_handler (group=6)") + return [handler] + + +plugin = _ProfileMonitorPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/spam.py b/src/bot/plugins/builtin/spam.py new file mode 100644 index 0000000..fda82cb --- /dev/null +++ b/src/bot/plugins/builtin/spam.py @@ -0,0 +1,84 @@ +"""Built-in plugin: spam. + +Wraps all anti-spam handlers (inline_keyboard_spam, bio_bait_spam, +contact_spam, new_user_spam, duplicate_spam) with their respective +filter and group patterns matching main.py. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from telegram.ext import MessageHandler, filters + +from bot.handlers.anti_spam import handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam +from bot.handlers.bio_bait import BIO_BAIT_FILTER, handle_bio_bait_spam +from bot.handlers.duplicate_spam import handle_duplicate_spam + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _SpamPlugin: + """Plugin wrapper for all anti-spam handlers.""" + + name: str = "spam" + description: str = "Anti-spam handlers (inline keyboards, bio bait, contact, probation, duplicates)" + handler_group: int = 1 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register all spam handlers onto application with their respective groups.""" + handlers: list[BaseHandler] = [] + + # Inline keyboard spam - group 1 + h: BaseHandler = MessageHandler( + filters.ChatType.GROUPS, + handle_inline_keyboard_spam, + ) + application.add_handler(h, group=1) + handlers.append(h) + logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") + + # Bio bait spam - group 2 + h = MessageHandler( + BIO_BAIT_FILTER, + handle_bio_bait_spam, + ) + application.add_handler(h, group=2) + handlers.append(h) + logger.info("Registered handler: bio_bait_spam_handler (group=2)") + + # Contact spam - group 3 + h = MessageHandler( + filters.ChatType.GROUPS & filters.CONTACT, + handle_contact_spam, + ) + application.add_handler(h, group=3) + handlers.append(h) + logger.info("Registered handler: contact_spam_handler (group=3)") + + # New user spam (probation) - group 4 + h = MessageHandler( + filters.ChatType.GROUPS, + handle_new_user_spam, + ) + application.add_handler(h, group=4) + handlers.append(h) + logger.info("Registered handler: anti_spam_handler (group=4)") + + # Duplicate spam - group 5 + h = MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + handle_duplicate_spam, + ) + application.add_handler(h, group=5) + handlers.append(h) + logger.info("Registered handler: duplicate_spam_handler (group=5)") + + return handlers + + +plugin = _SpamPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/topic_guard.py b/src/bot/plugins/builtin/topic_guard.py new file mode 100644 index 0000000..5a616f6 --- /dev/null +++ b/src/bot/plugins/builtin/topic_guard.py @@ -0,0 +1,40 @@ +"""Built-in plugin: topic_guard. + +Wraps ``bot.handlers.topic_guard.guard_warning_topic`` with same +filter/group pattern (MessageHandler, MESSAGE|EDITED_MESSAGE, group=-1). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from telegram.ext import MessageHandler, filters + +from bot.handlers.topic_guard import guard_warning_topic + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + + +class _TopicGuardPlugin: + """Plugin wrapper for topic_guard handler.""" + + name: str = "topic_guard" + description: str = "Intercept warning-topic messages before other handlers" + handler_group: int = -1 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register topic_guard handler onto application.""" + handler: BaseHandler = MessageHandler( + filters.UpdateType.MESSAGE | filters.UpdateType.EDITED_MESSAGE, + guard_warning_topic, + ) + application.add_handler(handler, group=-1) + logger.info("Registered handler: topic_guard (group=-1, message + edited_message)") + return [handler] + + +plugin = _TopicGuardPlugin() \ No newline at end of file diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py index fe98da4..81745af 100644 --- a/src/bot/plugins/definitions.py +++ b/src/bot/plugins/definitions.py @@ -14,6 +14,8 @@ # Human-readable metadata for each known built-in plugin. # ``name`` must be present in ``KNOWN_PLUGINS``. +# Order matches main.py registration order (topic_guard first). +# handler_group values match the PTB group argument used in main.py. _PLUGIN_DEFINITIONS: PluginManifest = [ {"name": "topic_guard", "handler_group": -1, "description": "Intercept warning-topic messages before other handlers"}, {"name": "verify", "handler_group": 0, "description": "Admin /verify command"}, @@ -31,15 +33,26 @@ {"name": "captcha", "handler_group": 0, "description": "Captcha verification for new members"}, {"name": "dm", "handler_group": 0, "description": "Direct message unrestriction flow"}, {"name": "inline_keyboard_spam", "handler_group": 1, "description": "Block inline keyboard URL spam"}, - {"name": "bio_bait_spam", "handler_group": 1, "description": "Detect and alert on bio bait patterns"}, - {"name": "contact_spam", "handler_group": 2, "description": "Block contact card sharing"}, - {"name": "new_user_spam", "handler_group": 3, "description": "Probation enforcement for new users"}, - {"name": "duplicate_spam", "handler_group": 4, "description": "Repeated message detection"}, - {"name": "profile_monitor", "handler_group": 5, "description": "Profile compliance monitoring"}, + {"name": "bio_bait_spam", "handler_group": 2, "description": "Detect and alert on bio bait patterns"}, + {"name": "contact_spam", "handler_group": 3, "description": "Block contact card sharing"}, + {"name": "new_user_spam", "handler_group": 4, "description": "Probation enforcement for new users"}, + {"name": "duplicate_spam", "handler_group": 5, "description": "Repeated message detection"}, + {"name": "profile_monitor", "handler_group": 6, "description": "Profile compliance monitoring"}, {"name": "auto_restrict_job", "handler_group": 6, "description": "Periodic auto-restriction job (every 5 min)"}, {"name": "refresh_admin_ids_job", "handler_group": 6, "description": "Periodic admin cache refresh job (every 10 min)"}, ] +# Deterministic registration order matching main.py. +# topic_guard first (group=-1), refresh_admin_ids_job last. +MANIFEST_ORDER: tuple[str, ...] = tuple(d["name"] for d in _PLUGIN_DEFINITIONS) # type: ignore[arg-type] +"""Canonical handler registration order for all known built-in plugins. + +Order matches ``main.py`` registration sequence: +topic_guard (group=-1) first, then group-0 commands/callbacks/captcha/dm, +then spam handlers (groups 1-5), then profile_monitor (group 6), +then job plugins last. +""" + def get_plugin_definitions() -> PluginManifest: """Return a deep copy of all built-in plugin definitions. diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 12d6813..2ed605e 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -1,9 +1,9 @@ -"""Tests for plugin toggle resolver, plugin contracts, and definitions.""" +"""Tests for plugin toggle resolver, plugin contracts, definitions, and built-in wrappers.""" from bot.group_config import KNOWN_PLUGINS from bot.plugins import base from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles -from bot.plugins.definitions import get_plugin_definitions +from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions class TestResolvePluginToggles: @@ -124,4 +124,182 @@ def test_returned_copy_isolation(self): defs3 = get_plugin_definitions() assert defs3[0]["name"] != "hacked" # Calling again still works - assert len(defs3) == len(KNOWN_PLUGINS) \ No newline at end of file + assert len(defs3) == len(KNOWN_PLUGINS) + + +class TestManifestOrder: + """MANIFEST_ORDER defines deterministic handler registration order matching main.py.""" + + @staticmethod + def _expected_order() -> tuple[str, ...]: + """Canonical registration order derived from main.py.""" + return ( + "topic_guard", + "verify", + "unverify", + "check", + "trust", + "untrust", + "trusted_list", + "check_forwarded_message", + "verify_callback", + "unverify_callback", + "warn_callback", + "trust_callback", + "untrust_callback", + "captcha", + "dm", + "inline_keyboard_spam", + "bio_bait_spam", + "contact_spam", + "new_user_spam", + "duplicate_spam", + "profile_monitor", + "auto_restrict_job", + "refresh_admin_ids_job", + ) + + def test_manifest_order_is_tuple_of_strings(self): + """MANIFEST_ORDER is a tuple of plugin name strings.""" + assert isinstance(MANIFEST_ORDER, tuple) + assert len(MANIFEST_ORDER) > 0 + for name in MANIFEST_ORDER: + assert isinstance(name, str) + + def test_manifest_order_matches_registration_order(self): + """Order matches the canonical order from main.py.""" + assert MANIFEST_ORDER == self._expected_order() + + def test_manifest_order_contains_all_known_plugins(self): + """Every KNOWN_PLUGINS name appears exactly once in MANIFEST_ORDER.""" + assert set(MANIFEST_ORDER) == KNOWN_PLUGINS + assert len(MANIFEST_ORDER) == len(KNOWN_PLUGINS) + + def test_manifest_order_first_is_topic_guard(self): + """topic_guard is first (group=-1 runs before all others).""" + assert MANIFEST_ORDER[0] == "topic_guard" + + def test_manifest_order_last_is_refresh_admin_ids_job(self): + """refresh_admin_ids_job is last (final registration in main.py).""" + assert MANIFEST_ORDER[-1] == "refresh_admin_ids_job" + + def test_manifest_order_no_duplicates(self): + """No duplicate names in MANIFEST_ORDER.""" + assert len(MANIFEST_ORDER) == len(set(MANIFEST_ORDER)) + + def test_manifest_order_topic_guard_in_group_negative_one(self): + """The topic_guard entry from definitions has handler_group=-1.""" + defs = {d["name"]: d for d in get_plugin_definitions()} + assert defs["topic_guard"]["handler_group"] == -1 + + def test_manifest_order_bio_bait_spam_in_group_two(self): + """bio_bait_spam entry has handler_group=2 (matches main.py).""" + defs = {d["name"]: d for d in get_plugin_definitions()} + assert defs["bio_bait_spam"]["handler_group"] == 2 + + def test_manifest_order_contact_spam_in_group_three(self): + """contact_spam entry has handler_group=3 (matches main.py).""" + defs = {d["name"]: d for d in get_plugin_definitions()} + assert defs["contact_spam"]["handler_group"] == 3 + + def test_manifest_order_new_user_spam_in_group_four(self): + """new_user_spam entry has handler_group=4 (matches main.py).""" + defs = {d["name"]: d for d in get_plugin_definitions()} + assert defs["new_user_spam"]["handler_group"] == 4 + + def test_manifest_order_duplicate_spam_in_group_five(self): + """duplicate_spam entry has handler_group=5 (matches main.py).""" + defs = {d["name"]: d for d in get_plugin_definitions()} + assert defs["duplicate_spam"]["handler_group"] == 5 + + def test_manifest_order_profile_monitor_in_group_six(self): + """profile_monitor entry has handler_group=6 (matches main.py).""" + defs = {d["name"]: d for d in get_plugin_definitions()} + assert defs["profile_monitor"]["handler_group"] == 6 + + +class TestBuiltinModules: + """Verify built-in wrapper modules exist and export plugin objects.""" + + def test_builtin_init_module_exists(self): + """builtin/__init__.py is importable.""" + import bot.plugins.builtin # noqa: F811 + assert hasattr(bot.plugins.builtin, "__file__") + + def test_topic_guard_module_has_plugin(self): + """builtin/topic_guard.py exports a plugin object.""" + import bot.plugins.builtin.topic_guard # noqa: F811 + plugin = bot.plugins.builtin.topic_guard.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "topic_guard" + + def test_commands_module_has_plugin(self): + """builtin/commands.py exports a plugin object.""" + import bot.plugins.builtin.commands # noqa: F811 + plugin = bot.plugins.builtin.commands.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "commands" + + def test_captcha_module_has_plugin(self): + """builtin/captcha.py exports a plugin object.""" + import bot.plugins.builtin.captcha # noqa: F811 + plugin = bot.plugins.builtin.captcha.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "captcha" + + def test_dm_module_has_plugin(self): + """builtin/dm.py exports a plugin object.""" + import bot.plugins.builtin.dm # noqa: F811 + plugin = bot.plugins.builtin.dm.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "dm" + + def test_spam_module_has_plugin(self): + """builtin/spam.py exports a plugin object.""" + import bot.plugins.builtin.spam # noqa: F811 + plugin = bot.plugins.builtin.spam.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "spam" + + def test_profile_monitor_module_has_plugin(self): + """builtin/profile_monitor.py exports a plugin object.""" + import bot.plugins.builtin.profile_monitor # noqa: F811 + plugin = bot.plugins.builtin.profile_monitor.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "profile_monitor" + + def test_jobs_module_has_plugin(self): + """builtin/jobs.py exports a plugin object.""" + import bot.plugins.builtin.jobs # noqa: F811 + plugin = bot.plugins.builtin.jobs.plugin + assert isinstance(plugin, base.PluginProtocol) + assert plugin.name == "jobs" + + def test_each_plugin_satisfies_protocol(self): + """Every builtin plugin object satisfies PluginProtocol with correct fields.""" + import bot.plugins.builtin.captcha as captcha_mod + import bot.plugins.builtin.commands as commands_mod + import bot.plugins.builtin.dm as dm_mod + import bot.plugins.builtin.jobs as jobs_mod + import bot.plugins.builtin.profile_monitor as pm_mod + import bot.plugins.builtin.spam as spam_mod + import bot.plugins.builtin.topic_guard as tg_mod + + plugin_map = { + "topic_guard": tg_mod.plugin, + "commands": commands_mod.plugin, + "captcha": captcha_mod.plugin, + "dm": dm_mod.plugin, + "spam": spam_mod.plugin, + "profile_monitor": pm_mod.plugin, + "jobs": jobs_mod.plugin, + } + + for name, plugin in plugin_map.items(): + assert isinstance(plugin, base.PluginProtocol), f"{name} fails PluginProtocol" + assert isinstance(plugin.name, str) + assert len(plugin.name) > 0 + assert isinstance(plugin.handler_group, int) + assert isinstance(plugin.description, str) + assert len(plugin.description) > 0 + assert callable(getattr(plugin, "register", None)) \ No newline at end of file From f0d2b99bbbebf08c98ce20336bc0719da375b1d8 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 16:32:37 +0700 Subject: [PATCH 15/20] feat(main): register handlers and jobs via plugin manager --- src/bot/main.py | 231 +----------- src/bot/plugins/builtin/captcha.py | 22 +- src/bot/plugins/builtin/commands.py | 182 +++++---- src/bot/plugins/builtin/dm.py | 26 +- src/bot/plugins/builtin/jobs.py | 59 ++- src/bot/plugins/builtin/profile_monitor.py | 26 +- src/bot/plugins/builtin/spam.py | 113 +++--- src/bot/plugins/builtin/topic_guard.py | 26 +- src/bot/plugins/manager.py | 127 +++++++ tests/test_main_plugins_bootstrap.py | 417 +++++++++++++++++++++ 10 files changed, 854 insertions(+), 375 deletions(-) create mode 100644 src/bot/plugins/manager.py create mode 100644 tests/test_main_plugins_bootstrap.py diff --git a/src/bot/main.py b/src/bot/main.py index 12334f2..219aa6c 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -1,51 +1,22 @@ """ Main entry point for the PythonID bot. -This module initializes the bot application, registers all message handlers, -and starts the polling loop. Handler registration order matters: -1. Topic guard (group -1): Runs first to delete unauthorized messages -2. DM handler: Processes private messages for unrestriction flow -3. Message handler: Monitors group messages for profile compliance +This module initializes the bot application, registers all message handlers +via the plugin system, and starts the polling loop. """ import logging import logfire from telegram.error import NetworkError, TimedOut -from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters +from telegram.ext import Application, ContextTypes from bot.config import get_settings from bot.database.service import get_database, init_database from bot.group_config import get_group_registry, init_group_registry -from bot.handlers import captcha -from bot.handlers.anti_spam import handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam -from bot.handlers.bio_bait import BIO_BAIT_FILTER, handle_bio_bait_spam -from bot.handlers.duplicate_spam import handle_duplicate_spam -from bot.handlers.dm import handle_dm -from bot.handlers.message import handle_message -from bot.handlers.topic_guard import guard_warning_topic -from bot.handlers.verify import ( - handle_unverify_callback, - handle_unverify_command, - handle_verify_callback, - handle_verify_command, -) -from bot.handlers.check import ( - handle_check_command, - handle_check_forwarded_message, - handle_warn_callback, -) -from bot.handlers.trust import ( - handle_trust_callback, - handle_trust_command, - handle_trusted_list_command, - handle_untrust_callback, - handle_untrust_command, -) -from bot.services.scheduler import auto_restrict_expired_warnings +from bot.plugins.manager import PluginManager from bot.services.telegram_utils import fetch_group_admin_ids - def configure_logging() -> None: """ Configure logging with Logfire integration. @@ -114,10 +85,8 @@ def configure_logging() -> None: else: logger.info("Logfire disabled - console output only") - logger = logging.getLogger(__name__) - async def refresh_admin_ids(context: ContextTypes.DEFAULT_TYPE) -> None: """ Periodically refresh cached admin IDs for all monitored groups. @@ -144,7 +113,6 @@ async def refresh_admin_ids(context: ContextTypes.DEFAULT_TYPE) -> None: context.bot_data["admin_ids"] = list(all_admin_ids) logger.info(f"Refreshed admin IDs: {len(all_admin_ids)} unique admin(s) across {len(group_admin_ids)} group(s)") - async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """ Handle errors in the bot. @@ -164,7 +132,6 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N logger.error("Unhandled exception:", exc_info=context.error) - async def post_init(application: Application) -> None: # type: ignore[type-arg] """ Post-initialization callback to fetch and cache group admin IDs. @@ -211,7 +178,6 @@ async def post_init(application: Application) -> None: # type: ignore[type-arg] from bot.services.captcha_recovery import recover_pending_captchas await recover_pending_captchas(application) - def main() -> None: """ Initialize and run the bot. @@ -221,9 +187,8 @@ def main() -> None: 2. Loads configuration from environment 3. Initializes the group registry (from groups.json or .env fallback) 4. Initializes the SQLite database - 5. Registers message handlers in priority order - 6. Starts JobQueue for periodic tasks - 7. Starts the bot polling loop + 5. Registers all handlers and jobs via PluginManager in MANIFEST_ORDER + 6. Starts the bot polling loop """ # Configure logging first configure_logging() @@ -249,190 +214,16 @@ def main() -> None: application.add_error_handler(error_handler) logger.info("Application built successfully") - # Handler 1: Topic guard - runs first (group -1) to delete unauthorized - # messages in the warning topic before other handlers process them - application.add_handler( - MessageHandler( - filters.UpdateType.MESSAGE | filters.UpdateType.EDITED_MESSAGE, - guard_warning_topic, - ), - group=-1, - ) - logger.info("Registered handler: topic_guard (group=-1, message + edited_message)") - - # Handler 2: /verify command - allows admins to whitelist users in DM - application.add_handler( - CommandHandler("verify", handle_verify_command) - ) - logger.info("Registered handler: verify_command (group=0)") + # Register all handlers and jobs via PluginManager in deterministic order + pm = PluginManager() + plugin_handlers = pm.register_all(application) - # Handler 3: /unverify command - allows admins to remove users from whitelist in DM - application.add_handler( - CommandHandler("unverify", handle_unverify_command) - ) - logger.info("Registered handler: unverify_command (group=0)") - - # Handler: /check command - allows admins to check user profiles in DM - application.add_handler( - CommandHandler("check", handle_check_command) - ) - logger.info("Registered handler: check_command (group=0)") - - # Handler: /trust command - allows admins to trust users for spam bypass in DM - application.add_handler( - CommandHandler("trust", handle_trust_command) - ) - logger.info("Registered handler: trust_command (group=0)") - - # Handler: /untrust command - allows admins to remove users from trusted list in DM - application.add_handler( - CommandHandler("untrust", handle_untrust_command) - ) - logger.info("Registered handler: untrust_command (group=0)") - - # Handler: /trusted command - list all trusted users in DM - application.add_handler( - CommandHandler("trusted", handle_trusted_list_command) - ) - logger.info("Registered handler: trusted_list_command (group=0)") - - # Handler: Forwarded message handler - allows admins to check profiles via forward - application.add_handler( - MessageHandler( - filters.FORWARDED & filters.ChatType.PRIVATE, - handle_check_forwarded_message - ) - ) - logger.info("Registered handler: check_forwarded_message (group=0)") - - # Handler 5: Callback handlers for verify/unverify buttons - application.add_handler( - CallbackQueryHandler(handle_verify_callback, pattern=r"^verify:\d+$") - ) - logger.info("Registered handler: verify_callback (group=0)") - application.add_handler( - CallbackQueryHandler(handle_unverify_callback, pattern=r"^unverify:\d+$") - ) - logger.info("Registered handler: unverify_callback (group=0)") - application.add_handler( - CallbackQueryHandler(handle_warn_callback, pattern=r"^warn:\d+:") - ) - logger.info("Registered handler: warn_callback (group=0)") - application.add_handler( - CallbackQueryHandler(handle_trust_callback, pattern=r"^trust:\d+$") - ) - logger.info("Registered handler: trust_callback (group=0)") - application.add_handler( - CallbackQueryHandler(handle_untrust_callback, pattern=r"^untrust:\d+$") - ) - logger.info("Registered handler: untrust_callback (group=0)") - - # Handler 6: Captcha handlers - new member verification - for handler in captcha.get_handlers(): - application.add_handler(handler) - logger.info("Registered handler: captcha_handlers (group=0)") - - # Handler 7: DM handler - processes private messages (including /start) - # for the unrestriction flow. Must be registered before group handler - # to prevent group handler from catching private messages first. - application.add_handler( - MessageHandler( - filters.ChatType.PRIVATE & filters.TEXT, - handle_dm, - ) - ) - logger.info("Registered handler: dm_handler (group=0)") - - # Handler 8: Inline keyboard spam handler - catches messages with - # non-whitelisted URL buttons in inline keyboards (spam from bots/forwards). - # Each spam handler runs in its own group so they all independently process - # every group message. They raise ApplicationHandlerStop to prevent later - # groups from running when spam IS detected. - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS, - handle_inline_keyboard_spam, - ), - group=1, - ) - logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") - - # Handler: Bio bait spam handler - catches "cek bio aku" / "lihat byoh" style - # messages where spammers point users to their profile bio (which contains - # external promo/scam links). - application.add_handler( - MessageHandler( - BIO_BAIT_FILTER, - handle_bio_bait_spam, - ), - group=2, - ) - logger.info("Registered handler: bio_bait_spam_handler (group=2)") - - # Handler: Contact spam handler - blocks contact card sharing for all members - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS & filters.CONTACT, - handle_contact_spam, - ), - group=3, - ) - logger.info("Registered handler: contact_spam_handler (group=3)") - - # Handler 9: New-user anti-spam handler - checks for forwards/links from users on probation - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS, - handle_new_user_spam, - ), - group=4, - ) - logger.info("Registered handler: anti_spam_handler (group=4)") - - # Handler 10: Duplicate message spam handler - detects repeated identical messages - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND, - handle_duplicate_spam, - ), - group=5, - ) - logger.info("Registered handler: duplicate_spam_handler (group=5)") - - # Handler 11: Group message handler - monitors messages in monitored - # groups and warns/restricts users with incomplete profiles - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND, - handle_message, - ), - group=6, - ) - logger.info("Registered handler: message_handler (group=6)") - - # Register auto-restriction job to run every 5 minutes - if application.job_queue: - application.job_queue.run_repeating( - auto_restrict_expired_warnings, - interval=300, - first=300, - name="auto_restrict_job" - ) - logger.info("JobQueue registered: auto_restrict_job (every 5 minutes, first run in 5 minutes)") - - application.job_queue.run_repeating( - refresh_admin_ids, - interval=600, - first=600, - name="refresh_admin_ids_job" - ) - logger.info("JobQueue registered: refresh_admin_ids_job (every 10 minutes)") + logger.info(f"Registered {sum(len(h) for h in plugin_handlers.values())} handler(s) across {len(plugin_handlers)} plugin(s)") logger.info(f"Starting bot polling for {group_count} group(s)") logger.info("All handlers registered successfully") application.run_polling(allowed_updates=["message", "edited_message", "callback_query", "chat_member"]) - if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/bot/plugins/builtin/captcha.py b/src/bot/plugins/builtin/captcha.py index 0bd4a68..7a16de8 100644 --- a/src/bot/plugins/builtin/captcha.py +++ b/src/bot/plugins/builtin/captcha.py @@ -2,6 +2,9 @@ Wraps ``bot.handlers.captcha`` handlers for new member verification. All register at group=0 via ``captcha.get_handlers()``. + +Also exposes individual registrar function ``register_captcha`` for +fine-grained plugin registration. """ from __future__ import annotations @@ -17,6 +20,19 @@ logger = logging.getLogger(__name__) +# --- Individual registrar function --- + +def register_captcha(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register captcha handlers onto application.""" + handlers = captcha.get_handlers() + for h in handlers: + application.add_handler(h) + logger.info("Registered handler: captcha_handlers (group=0)") + return handlers + + +# --- Coarse plugin class (keeps existing API) --- + class _CaptchaPlugin: """Plugin wrapper for captcha handlers.""" @@ -26,11 +42,7 @@ class _CaptchaPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register captcha handlers onto application.""" - handlers = captcha.get_handlers() - for h in handlers: - application.add_handler(h) - logger.info("Registered handler: captcha_handlers (group=0)") - return handlers + return register_captcha(application) plugin = _CaptchaPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/commands.py b/src/bot/plugins/builtin/commands.py index 5ff013f..a7846ba 100644 --- a/src/bot/plugins/builtin/commands.py +++ b/src/bot/plugins/builtin/commands.py @@ -3,6 +3,9 @@ Wraps all command and callback handlers (verify, unverify, check, trust, untrust, trusted_list, check_forwarded_message, and their callbacks). All register at group=0. + +Also exposes individual registrar functions (register_verify, +register_unverify, etc.) for fine-grained plugin registration. """ from __future__ import annotations @@ -33,6 +36,109 @@ logger = logging.getLogger(__name__) +# --- Individual registrar functions --- + +def register_verify(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /verify command handler.""" + handler: BaseHandler = CommandHandler("verify", handle_verify_command) + application.add_handler(handler) + logger.info("Registered handler: verify_command (group=0)") + return [handler] + + +def register_unverify(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /unverify command handler.""" + handler: BaseHandler = CommandHandler("unverify", handle_unverify_command) + application.add_handler(handler) + logger.info("Registered handler: unverify_command (group=0)") + return [handler] + + +def register_check(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /check command handler.""" + handler: BaseHandler = CommandHandler("check", handle_check_command) + application.add_handler(handler) + logger.info("Registered handler: check_command (group=0)") + return [handler] + + +def register_trust(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /trust command handler.""" + handler: BaseHandler = CommandHandler("trust", handle_trust_command) + application.add_handler(handler) + logger.info("Registered handler: trust_command (group=0)") + return [handler] + + +def register_untrust(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /untrust command handler.""" + handler: BaseHandler = CommandHandler("untrust", handle_untrust_command) + application.add_handler(handler) + logger.info("Registered handler: untrust_command (group=0)") + return [handler] + + +def register_trusted_list(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register /trusted command handler.""" + handler: BaseHandler = CommandHandler("trusted", handle_trusted_list_command) + application.add_handler(handler) + logger.info("Registered handler: trusted_list_command (group=0)") + return [handler] + + +def register_check_forwarded_message(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register forwarded message handler for /check context.""" + handler: BaseHandler = MessageHandler( + filters.FORWARDED & filters.ChatType.PRIVATE, + handle_check_forwarded_message, + ) + application.add_handler(handler) + logger.info("Registered handler: check_forwarded_message (group=0)") + return [handler] + + +def register_verify_callback(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register verify callback handler.""" + handler: BaseHandler = CallbackQueryHandler(handle_verify_callback, pattern=r"^verify:\d+$") + application.add_handler(handler) + logger.info("Registered handler: verify_callback (group=0)") + return [handler] + + +def register_unverify_callback(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register unverify callback handler.""" + handler: BaseHandler = CallbackQueryHandler(handle_unverify_callback, pattern=r"^unverify:\d+$") + application.add_handler(handler) + logger.info("Registered handler: unverify_callback (group=0)") + return [handler] + + +def register_warn_callback(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register warn callback handler.""" + handler: BaseHandler = CallbackQueryHandler(handle_warn_callback, pattern=r"^warn:\d+:") + application.add_handler(handler) + logger.info("Registered handler: warn_callback (group=0)") + return [handler] + + +def register_trust_callback(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register trust callback handler.""" + handler: BaseHandler = CallbackQueryHandler(handle_trust_callback, pattern=r"^trust:\d+$") + application.add_handler(handler) + logger.info("Registered handler: trust_callback (group=0)") + return [handler] + + +def register_untrust_callback(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register untrust callback handler.""" + handler: BaseHandler = CallbackQueryHandler(handle_untrust_callback, pattern=r"^untrust:\d+$") + application.add_handler(handler) + logger.info("Registered handler: untrust_callback (group=0)") + return [handler] + + +# --- Coarse plugin class (keeps existing API) --- + class _CommandsPlugin: """Plugin wrapper for command and callback handlers.""" @@ -43,70 +149,18 @@ class _CommandsPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register all command and callback handlers onto application.""" handlers: list[BaseHandler] = [] - - h = CommandHandler("verify", handle_verify_command) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: verify_command (group=0)") - - h = CommandHandler("unverify", handle_unverify_command) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: unverify_command (group=0)") - - h = CommandHandler("check", handle_check_command) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: check_command (group=0)") - - h = CommandHandler("trust", handle_trust_command) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: trust_command (group=0)") - - h = CommandHandler("untrust", handle_untrust_command) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: untrust_command (group=0)") - - h = CommandHandler("trusted", handle_trusted_list_command) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: trusted_list_command (group=0)") - - h = MessageHandler( - filters.FORWARDED & filters.ChatType.PRIVATE, - handle_check_forwarded_message, - ) - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: check_forwarded_message (group=0)") - - h = CallbackQueryHandler(handle_verify_callback, pattern=r"^verify:\d+$") - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: verify_callback (group=0)") - - h = CallbackQueryHandler(handle_unverify_callback, pattern=r"^unverify:\d+$") - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: unverify_callback (group=0)") - - h = CallbackQueryHandler(handle_warn_callback, pattern=r"^warn:\d+:") - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: warn_callback (group=0)") - - h = CallbackQueryHandler(handle_trust_callback, pattern=r"^trust:\d+$") - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: trust_callback (group=0)") - - h = CallbackQueryHandler(handle_untrust_callback, pattern=r"^untrust:\d+$") - application.add_handler(h) - handlers.append(h) - logger.info("Registered handler: untrust_callback (group=0)") - + handlers.extend(register_verify(application)) + handlers.extend(register_unverify(application)) + handlers.extend(register_check(application)) + handlers.extend(register_trust(application)) + handlers.extend(register_untrust(application)) + handlers.extend(register_trusted_list(application)) + handlers.extend(register_check_forwarded_message(application)) + handlers.extend(register_verify_callback(application)) + handlers.extend(register_unverify_callback(application)) + handlers.extend(register_warn_callback(application)) + handlers.extend(register_trust_callback(application)) + handlers.extend(register_untrust_callback(application)) return handlers diff --git a/src/bot/plugins/builtin/dm.py b/src/bot/plugins/builtin/dm.py index 6320878..07b7bb5 100644 --- a/src/bot/plugins/builtin/dm.py +++ b/src/bot/plugins/builtin/dm.py @@ -2,6 +2,9 @@ Wraps ``bot.handlers.dm.handle_dm`` for DM unrestriction flow. Registers at group=0 with PRIVATE & TEXT filter. + +Also exposes individual registrar function ``register_dm`` for +fine-grained plugin registration. """ from __future__ import annotations @@ -19,6 +22,21 @@ logger = logging.getLogger(__name__) +# --- Individual registrar function --- + +def register_dm(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register DM handler onto application.""" + handler: BaseHandler = MessageHandler( + filters.ChatType.PRIVATE & filters.TEXT, + handle_dm, + ) + application.add_handler(handler) + logger.info("Registered handler: dm_handler (group=0)") + return [handler] + + +# --- Coarse plugin class (keeps existing API) --- + class _DmPlugin: """Plugin wrapper for DM handler.""" @@ -28,13 +46,7 @@ class _DmPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register DM handler onto application.""" - handler: BaseHandler = MessageHandler( - filters.ChatType.PRIVATE & filters.TEXT, - handle_dm, - ) - application.add_handler(handler) - logger.info("Registered handler: dm_handler (group=0)") - return [handler] + return register_dm(application) plugin = _DmPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/jobs.py b/src/bot/plugins/builtin/jobs.py index befd748..e33f466 100644 --- a/src/bot/plugins/builtin/jobs.py +++ b/src/bot/plugins/builtin/jobs.py @@ -2,6 +2,9 @@ Wraps periodic JobQueue jobs (auto_restrict_job, refresh_admin_ids_job). Register repeating jobs via application.job_queue. + +Also exposes individual registrar functions (register_auto_restrict_job, +register_refresh_admin_ids_job) for fine-grained plugin registration. """ from __future__ import annotations @@ -10,7 +13,6 @@ from typing import TYPE_CHECKING from bot.services.scheduler import auto_restrict_expired_warnings -from bot.main import refresh_admin_ids if TYPE_CHECKING: from telegram.ext import Application, BaseHandler @@ -18,6 +20,41 @@ logger = logging.getLogger(__name__) +# --- Individual registrar functions --- + +def register_auto_restrict_job(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register auto-restrict repeating job (every 5 minutes).""" + handlers: list[BaseHandler] = [] + if application.job_queue: + application.job_queue.run_repeating( + auto_restrict_expired_warnings, + interval=300, + first=300, + name="auto_restrict_job", + ) + logger.info("JobQueue registered: auto_restrict_job (every 5 minutes, first run in 5 minutes)") + return handlers + + +def register_refresh_admin_ids_job(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register admin cache refresh job (every 10 minutes).""" + # Lazy import to avoid circular dependency (main -> manager -> jobs -> main) + from bot.main import refresh_admin_ids + + handlers: list[BaseHandler] = [] + if application.job_queue: + application.job_queue.run_repeating( + refresh_admin_ids, + interval=600, + first=600, + name="refresh_admin_ids_job", + ) + logger.info("JobQueue registered: refresh_admin_ids_job (every 10 minutes)") + return handlers + + +# --- Coarse plugin class (keeps existing API) --- + class _JobsPlugin: """Plugin wrapper for periodic job handlers.""" @@ -28,24 +65,8 @@ class _JobsPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register repeating jobs onto application.job_queue.""" handlers: list[BaseHandler] = [] - - if application.job_queue: - application.job_queue.run_repeating( - auto_restrict_expired_warnings, - interval=300, - first=300, - name="auto_restrict_job", - ) - logger.info("JobQueue registered: auto_restrict_job (every 5 minutes, first run in 5 minutes)") - - application.job_queue.run_repeating( - refresh_admin_ids, - interval=600, - first=600, - name="refresh_admin_ids_job", - ) - logger.info("JobQueue registered: refresh_admin_ids_job (every 10 minutes)") - + handlers.extend(register_auto_restrict_job(application)) + handlers.extend(register_refresh_admin_ids_job(application)) return handlers diff --git a/src/bot/plugins/builtin/profile_monitor.py b/src/bot/plugins/builtin/profile_monitor.py index 4097356..3e4736f 100644 --- a/src/bot/plugins/builtin/profile_monitor.py +++ b/src/bot/plugins/builtin/profile_monitor.py @@ -2,6 +2,9 @@ Wraps ``bot.handlers.message.handle_message`` for profile compliance monitoring. Registers at group=6 with GROUPS & ~COMMAND filter. + +Also exposes individual registrar function ``register_profile_monitor`` +for fine-grained plugin registration. """ from __future__ import annotations @@ -19,6 +22,21 @@ logger = logging.getLogger(__name__) +# --- Individual registrar function --- + +def register_profile_monitor(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register profile monitor handler onto application (group=6).""" + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + handle_message, + ) + application.add_handler(handler, group=6) + logger.info("Registered handler: message_handler (group=6)") + return [handler] + + +# --- Coarse plugin class (keeps existing API) --- + class _ProfileMonitorPlugin: """Plugin wrapper for profile compliance monitor.""" @@ -28,13 +46,7 @@ class _ProfileMonitorPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register profile monitor handler onto application.""" - handler: BaseHandler = MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND, - handle_message, - ) - application.add_handler(handler, group=6) - logger.info("Registered handler: message_handler (group=6)") - return [handler] + return register_profile_monitor(application) plugin = _ProfileMonitorPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/spam.py b/src/bot/plugins/builtin/spam.py index fda82cb..26db41d 100644 --- a/src/bot/plugins/builtin/spam.py +++ b/src/bot/plugins/builtin/spam.py @@ -3,6 +3,9 @@ Wraps all anti-spam handlers (inline_keyboard_spam, bio_bait_spam, contact_spam, new_user_spam, duplicate_spam) with their respective filter and group patterns matching main.py. + +Also exposes individual registrar functions (register_inline_keyboard_spam, +register_bio_bait_spam, etc.) for fine-grained plugin registration. """ from __future__ import annotations @@ -22,6 +25,65 @@ logger = logging.getLogger(__name__) +# --- Individual registrar functions --- + +def register_inline_keyboard_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register inline keyboard spam handler (group=1).""" + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS, + handle_inline_keyboard_spam, + ) + application.add_handler(handler, group=1) + logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") + return [handler] + + +def register_bio_bait_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register bio bait spam handler (group=2).""" + handler: BaseHandler = MessageHandler( + BIO_BAIT_FILTER, + handle_bio_bait_spam, + ) + application.add_handler(handler, group=2) + logger.info("Registered handler: bio_bait_spam_handler (group=2)") + return [handler] + + +def register_contact_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register contact spam handler (group=3).""" + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & filters.CONTACT, + handle_contact_spam, + ) + application.add_handler(handler, group=3) + logger.info("Registered handler: contact_spam_handler (group=3)") + return [handler] + + +def register_new_user_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register new user spam handler (probation, group=4).""" + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS, + handle_new_user_spam, + ) + application.add_handler(handler, group=4) + logger.info("Registered handler: anti_spam_handler (group=4)") + return [handler] + + +def register_duplicate_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register duplicate message spam handler (group=5).""" + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + handle_duplicate_spam, + ) + application.add_handler(handler, group=5) + logger.info("Registered handler: duplicate_spam_handler (group=5)") + return [handler] + + +# --- Coarse plugin class (keeps existing API) --- + class _SpamPlugin: """Plugin wrapper for all anti-spam handlers.""" @@ -32,52 +94,11 @@ class _SpamPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register all spam handlers onto application with their respective groups.""" handlers: list[BaseHandler] = [] - - # Inline keyboard spam - group 1 - h: BaseHandler = MessageHandler( - filters.ChatType.GROUPS, - handle_inline_keyboard_spam, - ) - application.add_handler(h, group=1) - handlers.append(h) - logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") - - # Bio bait spam - group 2 - h = MessageHandler( - BIO_BAIT_FILTER, - handle_bio_bait_spam, - ) - application.add_handler(h, group=2) - handlers.append(h) - logger.info("Registered handler: bio_bait_spam_handler (group=2)") - - # Contact spam - group 3 - h = MessageHandler( - filters.ChatType.GROUPS & filters.CONTACT, - handle_contact_spam, - ) - application.add_handler(h, group=3) - handlers.append(h) - logger.info("Registered handler: contact_spam_handler (group=3)") - - # New user spam (probation) - group 4 - h = MessageHandler( - filters.ChatType.GROUPS, - handle_new_user_spam, - ) - application.add_handler(h, group=4) - handlers.append(h) - logger.info("Registered handler: anti_spam_handler (group=4)") - - # Duplicate spam - group 5 - h = MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND, - handle_duplicate_spam, - ) - application.add_handler(h, group=5) - handlers.append(h) - logger.info("Registered handler: duplicate_spam_handler (group=5)") - + handlers.extend(register_inline_keyboard_spam(application)) + handlers.extend(register_bio_bait_spam(application)) + handlers.extend(register_contact_spam(application)) + handlers.extend(register_new_user_spam(application)) + handlers.extend(register_duplicate_spam(application)) return handlers diff --git a/src/bot/plugins/builtin/topic_guard.py b/src/bot/plugins/builtin/topic_guard.py index 5a616f6..9a50664 100644 --- a/src/bot/plugins/builtin/topic_guard.py +++ b/src/bot/plugins/builtin/topic_guard.py @@ -2,6 +2,9 @@ Wraps ``bot.handlers.topic_guard.guard_warning_topic`` with same filter/group pattern (MessageHandler, MESSAGE|EDITED_MESSAGE, group=-1). + +Also exposes individual registrar function ``register_topic_guard`` for +fine-grained plugin registration. """ from __future__ import annotations @@ -19,6 +22,21 @@ logger = logging.getLogger(__name__) +# --- Individual registrar function --- + +def register_topic_guard(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """Register topic_guard handler onto application (group=-1).""" + handler: BaseHandler = MessageHandler( + filters.UpdateType.MESSAGE | filters.UpdateType.EDITED_MESSAGE, + guard_warning_topic, + ) + application.add_handler(handler, group=-1) + logger.info("Registered handler: topic_guard (group=-1, message + edited_message)") + return [handler] + + +# --- Coarse plugin class (keeps existing API) --- + class _TopicGuardPlugin: """Plugin wrapper for topic_guard handler.""" @@ -28,13 +46,7 @@ class _TopicGuardPlugin: def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register topic_guard handler onto application.""" - handler: BaseHandler = MessageHandler( - filters.UpdateType.MESSAGE | filters.UpdateType.EDITED_MESSAGE, - guard_warning_topic, - ) - application.add_handler(handler, group=-1) - logger.info("Registered handler: topic_guard (group=-1, message + edited_message)") - return [handler] + return register_topic_guard(application) plugin = _TopicGuardPlugin() \ No newline at end of file diff --git a/src/bot/plugins/manager.py b/src/bot/plugins/manager.py new file mode 100644 index 0000000..3cd1b56 --- /dev/null +++ b/src/bot/plugins/manager.py @@ -0,0 +1,127 @@ +"""Plugin manager for deterministic handler/job registration. + +Provides ``PluginManager`` which maps each fine-grained plugin name +(from ``MANIFEST_ORDER``) to an individual registrar callable, then +calls them in canonical order via ``register_all()``. + +Usage inside ``main.py``:: + + pm = PluginManager() + plugin_handlers = pm.register_all(application) + # plugin_handlers dict stored in application.bot_data["plugin_handlers"] +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Callable + +from bot.plugins.builtin import captcha as captcha_mod +from bot.plugins.builtin import commands +from bot.plugins.builtin import dm as dm_mod +from bot.plugins.builtin import jobs as jobs_mod +from bot.plugins.builtin import profile_monitor as pm_mod +from bot.plugins.builtin import spam as spam_mod +from bot.plugins.builtin import topic_guard as tg_mod +from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions + +if TYPE_CHECKING: + from telegram.ext import Application, BaseHandler + +logger = logging.getLogger(__name__) + +# Type alias for a registrar callable. +# Accepts an Application, returns a list of registered BaseHandler instances. +# Use string forward ref because BaseHandler is only imported under TYPE_CHECKING. +Registrar = Callable[..., list["BaseHandler"]] + + +class PluginManager: + """Manages deterministic handler/job registration from MANIFEST_ORDER. + + Builds an internal registry mapping each manifest-level plugin name + to its individual registrar function. ``register_all()`` iterates + ``MANIFEST_ORDER`` and invokes each registrar in order. + + After registration, metadata (handler group, handler instances) is + stored in ``application.bot_data["plugin_handlers"]`` for later + gating (e.g., Task 5 selective disable). + """ + + def __init__(self) -> None: + # Build registry: manifest name -> registrar callable + self._registry: dict[str, Registrar] = self._build_registry() + + @staticmethod + def _build_registry() -> dict[str, Registrar]: + """Return dict mapping each manifest name to its registrar. + + Each registrar is a module-level function that accepts an + ``Application`` and returns ``list[BaseHandler]``. + """ + return { + # topic_guard + "topic_guard": tg_mod.register_topic_guard, + # commands (group=0) + "verify": commands.register_verify, + "unverify": commands.register_unverify, + "check": commands.register_check, + "trust": commands.register_trust, + "untrust": commands.register_untrust, + "trusted_list": commands.register_trusted_list, + "check_forwarded_message": commands.register_check_forwarded_message, + "verify_callback": commands.register_verify_callback, + "unverify_callback": commands.register_unverify_callback, + "warn_callback": commands.register_warn_callback, + "trust_callback": commands.register_trust_callback, + "untrust_callback": commands.register_untrust_callback, + # captcha + "captcha": captcha_mod.register_captcha, + # dm + "dm": dm_mod.register_dm, + # spam + "inline_keyboard_spam": spam_mod.register_inline_keyboard_spam, + "bio_bait_spam": spam_mod.register_bio_bait_spam, + "contact_spam": spam_mod.register_contact_spam, + "new_user_spam": spam_mod.register_new_user_spam, + "duplicate_spam": spam_mod.register_duplicate_spam, + # profile_monitor + "profile_monitor": pm_mod.register_profile_monitor, + # jobs + "auto_restrict_job": jobs_mod.register_auto_restrict_job, + "refresh_admin_ids_job": jobs_mod.register_refresh_admin_ids_job, + } + + def register_all( + self, + application: Application, # type: ignore[type-arg] + ) -> dict[str, list[BaseHandler]]: + """Register all built-in plugins in MANIFEST_ORDER. + + Args: + application: PTB Application instance. + + Returns: + Dict mapping each plugin name to the list of handler instances + returned by its registrar. Also stored in + ``application.bot_data["plugin_handlers"]``. + """ + result: dict[str, list[BaseHandler]] = {} + defs_by_name = {d["name"]: d for d in get_plugin_definitions()} + + for name in MANIFEST_ORDER: + registrar = self._registry[name] + handlers = registrar(application) + result[name] = handlers + logger.info("Registered plugin: %s (group=%d, %d handler(s))", name, defs_by_name[name]["handler_group"], len(handlers)) # type: ignore[arg-type] + + # Store metadata for later gating + metadata: dict[str, dict] = {} + for name in MANIFEST_ORDER: + metadata[name] = { + "handler_group": defs_by_name[name]["handler_group"], # type: ignore[arg-type] + "handlers": result[name], + } + application.bot_data["plugin_handlers"] = metadata # type: ignore[index] + + return result \ No newline at end of file diff --git a/tests/test_main_plugins_bootstrap.py b/tests/test_main_plugins_bootstrap.py new file mode 100644 index 0000000..f7674f7 --- /dev/null +++ b/tests/test_main_plugins_bootstrap.py @@ -0,0 +1,417 @@ +"""Tests for main.py using PluginManager for handler+job registration. + +Verifies that: +1. PluginManager maps every MANIFEST_ORDER name to a registrar callable. +2. register_all() registers handlers in MANIFEST_ORDER. +3. Plugin metadata is stored in bot_data. +4. main() uses PluginManager.register_all instead of manual registration wall. +""" + +import sys +from unittest.mock import MagicMock, patch + +import bot.plugins.manager as pm_module +from bot.plugins.definitions import MANIFEST_ORDER + + +class TestPluginManagerRegistry: + """PluginManager must map every MANIFEST_ORDER name to a registrar.""" + + def test_manager_importable(self): + """PluginManager class is importable from bot.plugins.manager.""" + from bot.plugins.manager import PluginManager + assert PluginManager is not None + + def test_manager_has_register_all_method(self): + """PluginManager.register_all exists and is callable.""" + from bot.plugins.manager import PluginManager + pm = PluginManager() + assert hasattr(pm, "register_all") + assert callable(pm.register_all) + + def test_manager_builds_registry_with_all_manifest_names(self): + """PluginManager._registry has all MANIFEST_ORDER names.""" + from bot.plugins.manager import PluginManager + pm = PluginManager() + for name in MANIFEST_ORDER: + assert name in pm._registry, f"Missing registrar for {name}" + + def test_each_registrar_is_callable(self): + """Each entry in registry is a callable.""" + from bot.plugins.manager import PluginManager + pm = PluginManager() + for name in MANIFEST_ORDER: + assert callable(pm._registry[name]), f"{name} registrar not callable" + + def test_registry_size_matches_manifest(self): + """Registry size equals MANIFEST_ORDER length.""" + from bot.plugins.manager import PluginManager + pm = PluginManager() + assert len(pm._registry) == len(MANIFEST_ORDER) + + +class TestRegisterAll: + """PluginManager.register_all registers handlers in MANIFEST_ORDER.""" + + def test_register_all_calls_each_registrar(self): + """register_all calls every registrar exactly once.""" + from bot.plugins.manager import PluginManager + + app = MagicMock() + app.bot_data = {} + job_queue = MagicMock() + job_queue.run_repeating = MagicMock() + app.job_queue = job_queue + app.add_handler = MagicMock() + + pm = PluginManager() + for name in MANIFEST_ORDER: + original = pm._registry[name] + wrapped = MagicMock(wraps=original) + pm._registry[name] = wrapped + + result = pm.register_all(app) + + for name in MANIFEST_ORDER: + pm._registry[name].assert_called_once_with(app) + + assert set(result.keys()) == set(MANIFEST_ORDER) + + def test_register_all_returns_handler_lists(self): + """register_all returns dict mapping name to list of handlers.""" + from bot.plugins.manager import PluginManager + + app = MagicMock() + app.bot_data = {} + job_queue = MagicMock() + job_queue.run_repeating = MagicMock() + app.job_data = {} + app.job_queue = job_queue + app.add_handler = MagicMock() + + pm = PluginManager() + result = pm.register_all(app) + + for name in MANIFEST_ORDER: + assert isinstance(result[name], list) + + def test_register_all_stores_metadata_in_bot_data(self): + """register_all stores registration results in bot_data['plugin_handlers'].""" + from bot.plugins.manager import PluginManager + + app = MagicMock() + app.bot_data = {} + job_queue = MagicMock() + job_queue.run_repeating = MagicMock() + app.job_data = {} + app.job_queue = job_queue + app.add_handler = MagicMock() + + pm = PluginManager() + pm.register_all(app) + + assert "plugin_handlers" in app.bot_data + metadata = app.bot_data["plugin_handlers"] + assert set(metadata.keys()) == set(MANIFEST_ORDER) + + def test_register_all_stores_metadata_with_handler_group(self): + """bot_data['plugin_handlers'][name] includes handler_group.""" + from bot.plugins.definitions import get_plugin_definitions + from bot.plugins.manager import PluginManager + + defs_by_name = {d["name"]: d for d in get_plugin_definitions()} + + app = MagicMock() + app.bot_data = {} + job_queue = MagicMock() + job_queue.run_repeating = MagicMock() + app.job_data = {} + app.job_queue = job_queue + app.add_handler = MagicMock() + + pm = PluginManager() + pm.register_all(app) + + for name in MANIFEST_ORDER: + assert app.bot_data["plugin_handlers"][name]["handler_group"] == defs_by_name[name]["handler_group"] + + def test_register_all_stores_handlers_in_metadata(self): + """bot_data['plugin_handlers'][name] includes handlers list.""" + from bot.plugins.manager import PluginManager + + app = MagicMock() + app.bot_data = {} + job_queue = MagicMock() + job_queue.run_repeating = MagicMock() + app.job_data = {} + app.job_queue = job_queue + app.add_handler = MagicMock() + + pm = PluginManager() + result = pm.register_all(app) + + for name in MANIFEST_ORDER: + assert app.bot_data["plugin_handlers"][name]["handlers"] == result[name] + + +class TestMainUsesPluginManager: + """main() must use PluginManager.register_all instead of manual registration.""" + + def test_main_calls_register_all(self): + """main() calls PluginManager.register_all.""" + # Remove from cache to force fresh import inside patch context + sys.modules.pop("bot.main", None) + + with patch.object(pm_module, "PluginManager") as mock_plugin_cls: + mock_pm = MagicMock() + mock_pm.register_all.return_value = {} + mock_plugin_cls.return_value = mock_pm + + with patch("bot.main.configure_logging"): + with patch("bot.main.init_group_registry") as mock_init_reg: + mock_reg = MagicMock() + mock_reg.all_groups.return_value = [] + mock_init_reg.return_value = mock_reg + with patch("bot.main.init_database"): + with patch("bot.main.Application") as mock_app_cls: + mock_app = MagicMock() + mock_app.bot_data = {} + mock_app_cls.builder.return_value.token.return_value.post_init.return_value.build.return_value = mock_app + + class FakeSettings: + logfire_environment = "test" + database_path = ":memory:" + telegram_bot_token = "test" + groups_config_path = "nonexistent.json" + group_id = -100999 + warning_topic_id = 42 + restrict_failed_users = True + warning_threshold = 3 + warning_time_threshold_minutes = 10080 + captcha_enabled = False + captcha_timeout_seconds = 120 + new_user_probation_hours = 48 + new_user_violation_threshold = 3 + rules_link = "https://t.me/rules" + contact_spam_restrict = False + duplicate_spam_enabled = False + duplicate_spam_window_seconds = 30 + duplicate_spam_threshold = 3 + duplicate_spam_min_length = 10 + duplicate_spam_similarity = 0.8 + bio_bait_enabled = True + bio_bait_monitor_only = False + bio_bait_alert_chat_id = None + plugins_default = {} + log_level = "INFO" + logfire_enabled = False + logfire_token = None + logfire_service_name = "pythonid-bot" + + with patch("bot.main.get_settings", return_value=FakeSettings()): + from bot.main import main + main() + + mock_pm.register_all.assert_called_once() + + +class TestRefactoredBuiltinModules: + """Builtin modules expose individual registrar functions for each manifest name.""" + + def test_commands_has_verify_registrar(self): + """bot.plugins.builtin.commands has register_verify function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_verify") + assert callable(commands.register_verify) + + def test_commands_has_unverify_registrar(self): + """bot.plugins.builtin.commands has register_unverify function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_unverify") + assert callable(commands.register_unverify) + + def test_commands_has_check_registrar(self): + """bot.plugins.builtin.commands has register_check function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_check") + assert callable(commands.register_check) + + def test_commands_has_trust_registrar(self): + """bot.plugins.builtin.commands has register_trust function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_trust") + assert callable(commands.register_trust) + + def test_commands_has_untrust_registrar(self): + """bot.plugins.builtin.commands has register_untrust function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_untrust") + assert callable(commands.register_untrust) + + def test_commands_has_trusted_list_registrar(self): + """bot.plugins.builtin.commands has register_trusted_list function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_trusted_list") + assert callable(commands.register_trusted_list) + + def test_commands_has_check_forwarded_message_registrar(self): + """bot.plugins.builtin.commands has register_check_forwarded_message function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_check_forwarded_message") + assert callable(commands.register_check_forwarded_message) + + def test_commands_has_verify_callback_registrar(self): + """bot.plugins.builtin.commands has register_verify_callback function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_verify_callback") + assert callable(commands.register_verify_callback) + + def test_commands_has_unverify_callback_registrar(self): + """bot.plugins.builtin.commands has register_unverify_callback function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_unverify_callback") + assert callable(commands.register_unverify_callback) + + def test_commands_has_warn_callback_registrar(self): + """bot.plugins.builtin.commands has register_warn_callback function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_warn_callback") + assert callable(commands.register_warn_callback) + + def test_commands_has_trust_callback_registrar(self): + """bot.plugins.builtin.commands has register_trust_callback function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_trust_callback") + assert callable(commands.register_trust_callback) + + def test_commands_has_untrust_callback_registrar(self): + """bot.plugins.builtin.commands has register_untrust_callback function.""" + from bot.plugins.builtin import commands + assert hasattr(commands, "register_untrust_callback") + assert callable(commands.register_untrust_callback) + + def test_spam_has_inline_keyboard_spam_registrar(self): + """bot.plugins.builtin.spam has register_inline_keyboard_spam function.""" + from bot.plugins.builtin import spam + assert hasattr(spam, "register_inline_keyboard_spam") + assert callable(spam.register_inline_keyboard_spam) + + def test_spam_has_bio_bait_spam_registrar(self): + """bot.plugins.builtin.spam has register_bio_bait_spam function.""" + from bot.plugins.builtin import spam + assert hasattr(spam, "register_bio_bait_spam") + assert callable(spam.register_bio_bait_spam) + + def test_spam_has_contact_spam_registrar(self): + """bot.plugins.builtin.spam has register_contact_spam function.""" + from bot.plugins.builtin import spam + assert hasattr(spam, "register_contact_spam") + assert callable(spam.register_contact_spam) + + def test_spam_has_new_user_spam_registrar(self): + """bot.plugins.builtin.spam has register_new_user_spam function.""" + from bot.plugins.builtin import spam + assert hasattr(spam, "register_new_user_spam") + assert callable(spam.register_new_user_spam) + + def test_spam_has_duplicate_spam_registrar(self): + """bot.plugins.builtin.spam has register_duplicate_spam function.""" + from bot.plugins.builtin import spam + assert hasattr(spam, "register_duplicate_spam") + assert callable(spam.register_duplicate_spam) + + def test_captcha_has_registrar(self): + """bot.plugins.builtin.captcha has register_captcha function.""" + from bot.plugins.builtin import captcha as captcha_mod + assert hasattr(captcha_mod, "register_captcha") + assert callable(captcha_mod.register_captcha) + + def test_dm_has_registrar(self): + """bot.plugins.builtin.dm has register_dm function.""" + from bot.plugins.builtin import dm as dm_mod + assert hasattr(dm_mod, "register_dm") + assert callable(dm_mod.register_dm) + + def test_profile_monitor_has_registrar(self): + """bot.plugins.builtin.profile_monitor has register_profile_monitor function.""" + from bot.plugins.builtin import profile_monitor as pm_mod + assert hasattr(pm_mod, "register_profile_monitor") + assert callable(pm_mod.register_profile_monitor) + + def test_jobs_has_auto_restrict_registrar(self): + """bot.plugins.builtin.jobs has register_auto_restrict_job function.""" + from bot.plugins.builtin import jobs as jobs_mod + assert hasattr(jobs_mod, "register_auto_restrict_job") + assert callable(jobs_mod.register_auto_restrict_job) + + def test_jobs_has_refresh_admin_ids_registrar(self): + """bot.plugins.builtin.jobs has register_refresh_admin_ids_job function.""" + from bot.plugins.builtin import jobs as jobs_mod + assert hasattr(jobs_mod, "register_refresh_admin_ids_job") + assert callable(jobs_mod.register_refresh_admin_ids_job) + + def test_topic_guard_has_registrar(self): + """bot.plugins.builtin.topic_guard has register_topic_guard function.""" + from bot.plugins.builtin import topic_guard as tg_mod + assert hasattr(tg_mod, "register_topic_guard") + assert callable(tg_mod.register_topic_guard) + + +class TestIndividualRegistrars: + """Individual registrar functions correctly register their handlers.""" + + def test_verify_registrar_adds_handler(self): + """register_verify adds a CommandHandler to the app.""" + from bot.plugins.builtin.commands import register_verify + + app = MagicMock() + app.bot_data = {} + app.add_handler = MagicMock() + handlers = register_verify(app) + assert len(handlers) >= 1 + app.add_handler.assert_called() + + def test_topic_guard_registrar_adds_handler(self): + """register_topic_guard adds handler to group=-1.""" + from bot.plugins.builtin.topic_guard import register_topic_guard + + app = MagicMock() + app.bot_data = {} + app.add_handler = MagicMock() + handlers = register_topic_guard(app) + assert len(handlers) >= 1 + app.add_handler.assert_called() + + def test_captcha_registrar_adds_handlers(self): + """register_captcha adds handlers via captcha.get_handlers().""" + from bot.plugins.builtin.captcha import register_captcha + + app = MagicMock() + app.bot_data = {} + app.add_handler = MagicMock() + handlers = register_captcha(app) + assert len(handlers) >= 1 + app.add_handler.assert_called() + + def test_auto_restrict_job_registrar_schedules_job(self): + """register_auto_restrict_job calls job_queue.run_repeating.""" + from bot.plugins.builtin.jobs import register_auto_restrict_job + + app = MagicMock() + app.bot_data = {} + job_queue = MagicMock() + job_queue.run_repeating = MagicMock() + app.job_queue = job_queue + register_auto_restrict_job(app) + app.job_queue.run_repeating.assert_called_once() + + def test_inline_keyboard_spam_registrar_adds_handler(self): + """register_inline_keyboard_spam adds handler to group=1.""" + from bot.plugins.builtin.spam import register_inline_keyboard_spam + + app = MagicMock() + app.bot_data = {} + app.add_handler = MagicMock() + handlers = register_inline_keyboard_spam(app) + assert len(handlers) >= 1 + app.add_handler.assert_called_with(app.add_handler.call_args[0][0], group=1) \ No newline at end of file From 90cfb1b1dacda7124a543a10eebe923787b95460 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 16:46:17 +0700 Subject: [PATCH 16/20] feat(plugins): enforce per-group plugin enable map at runtime --- src/bot/main.py | 17 +++- src/bot/plugins/config.py | 31 +++++- src/bot/plugins/manager.py | 65 ++++++++++++- tests/test_plugin_manager.py | 180 ++++++++++++++++++++++++++++++++++- 4 files changed, 284 insertions(+), 9 deletions(-) diff --git a/src/bot/main.py b/src/bot/main.py index 219aa6c..00043d3 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -17,6 +17,7 @@ from bot.plugins.manager import PluginManager from bot.services.telegram_utils import fetch_group_admin_ids + def configure_logging() -> None: """ Configure logging with Logfire integration. @@ -85,8 +86,10 @@ def configure_logging() -> None: else: logger.info("Logfire disabled - console output only") + logger = logging.getLogger(__name__) + async def refresh_admin_ids(context: ContextTypes.DEFAULT_TYPE) -> None: """ Periodically refresh cached admin IDs for all monitored groups. @@ -113,6 +116,7 @@ async def refresh_admin_ids(context: ContextTypes.DEFAULT_TYPE) -> None: context.bot_data["admin_ids"] = list(all_admin_ids) logger.info(f"Refreshed admin IDs: {len(all_admin_ids)} unique admin(s) across {len(group_admin_ids)} group(s)") + async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """ Handle errors in the bot. @@ -132,6 +136,7 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N logger.error("Unhandled exception:", exc_info=context.error) + async def post_init(application: Application) -> None: # type: ignore[type-arg] """ Post-initialization callback to fetch and cache group admin IDs. @@ -176,8 +181,10 @@ async def post_init(application: Application) -> None: # type: ignore[type-arg] if has_captcha: logger.info("Recovering pending captcha verifications from database") from bot.services.captcha_recovery import recover_pending_captchas + await recover_pending_captchas(application) + def main() -> None: """ Initialize and run the bot. @@ -188,7 +195,8 @@ def main() -> None: 3. Initializes the group registry (from groups.json or .env fallback) 4. Initializes the SQLite database 5. Registers all handlers and jobs via PluginManager in MANIFEST_ORDER - 6. Starts the bot polling loop + 6. Computes per-group effective plugin toggle map for runtime gating + 7. Starts the bot polling loop """ # Configure logging first configure_logging() @@ -220,10 +228,15 @@ def main() -> None: logger.info(f"Registered {sum(len(h) for h in plugin_handlers.values())} handler(s) across {len(plugin_handlers)} plugin(s)") + # Compute and store per-group effective plugin toggle map for runtime gating + pm.compute_effective_map(settings, registry, application) + logger.info("Computed per-group effective plugin toggle map") + logger.info(f"Starting bot polling for {group_count} group(s)") logger.info("All handlers registered successfully") application.run_polling(allowed_updates=["message", "edited_message", "callback_query", "chat_member"]) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/src/bot/plugins/config.py b/src/bot/plugins/config.py index 879f2d5..62c60f6 100644 --- a/src/bot/plugins/config.py +++ b/src/bot/plugins/config.py @@ -8,7 +8,6 @@ from bot.group_config import KNOWN_PLUGINS - def resolve_plugin_toggles( defaults: dict[str, bool], overrides: dict[str, bool] | None, @@ -42,7 +41,6 @@ def resolve_plugin_toggles( return result - def is_plugin_enabled(toggles: dict[str, bool], name: str) -> bool: """Check if a single plugin is enabled from a resolved toggle dict. @@ -56,4 +54,31 @@ def is_plugin_enabled(toggles: dict[str, bool], name: str) -> bool: Raises: KeyError: If ``name`` is not in ``toggles``. """ - return toggles[name] \ No newline at end of file + return toggles[name] + + +def is_plugin_enabled_for_group( + effective_map: dict[int, dict[str, bool]], + group_id: int, + plugin_name: str, +) -> bool: + """Check if a plugin is enabled for a specific group using the effective map. + + Safe defaults: + - Unknown group_id => True (allow through) + - Missing plugin key in group toggles => True (strict defaults) + + Args: + effective_map: Per-group plugin toggle map from + ``compute_effective_plugin_map``, stored in + ``bot_data["plugin_effective_map"]``. + group_id: Telegram group ID to check. + plugin_name: Plugin name from ``MANIFEST_ORDER`` / ``KNOWN_PLUGINS``. + + Returns: + True if plugin is enabled for the given group. + """ + group_toggles = effective_map.get(group_id) + if group_toggles is None: + return True # Unknown group => safe default + return group_toggles.get(plugin_name, True) # Missing key => safe default \ No newline at end of file diff --git a/src/bot/plugins/manager.py b/src/bot/plugins/manager.py index 3cd1b56..04afe3d 100644 --- a/src/bot/plugins/manager.py +++ b/src/bot/plugins/manager.py @@ -9,6 +9,10 @@ pm = PluginManager() plugin_handlers = pm.register_all(application) # plugin_handlers dict stored in application.bot_data["plugin_handlers"] + + # After init_group_registry: + pm.compute_effective_map(settings, get_group_registry(), application) + # Per-group toggles stored in application.bot_data["plugin_effective_map"] """ from __future__ import annotations @@ -23,6 +27,7 @@ from bot.plugins.builtin import profile_monitor as pm_mod from bot.plugins.builtin import spam as spam_mod from bot.plugins.builtin import topic_guard as tg_mod +from bot.plugins.config import resolve_plugin_toggles from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions if TYPE_CHECKING: @@ -36,6 +41,36 @@ Registrar = Callable[..., list["BaseHandler"]] +def compute_effective_plugin_map( + plugins_default: dict[str, bool], + registry: object, +) -> dict[int, dict[str, bool]]: + """Compute per-group effective plugin toggle maps for all registry groups. + + For each group in the registry, resolves plugin enabled/disabled state + using ``resolve_plugin_toggles`` with env defaults and per-group overrides. + + Args: + plugins_default: Env-wide default toggles from Settings.plugins_default. + registry: GroupRegistry instance with all monitored groups. + + Returns: + Dict mapping group_id -> resolved toggle dict (all KNOWN_PLUGINS keys). + Empty dict if registry has no groups. + """ + from bot.group_config import GroupRegistry + + if not isinstance(registry, GroupRegistry): + logger.warning("compute_effective_plugin_map: registry is not a GroupRegistry") + return {} + + result: dict[int, dict[str, bool]] = {} + for gc in registry.all_groups(): + result[gc.group_id] = resolve_plugin_toggles(plugins_default, gc.plugins) + + return result + + class PluginManager: """Manages deterministic handler/job registration from MANIFEST_ORDER. @@ -124,4 +159,32 @@ def register_all( } application.bot_data["plugin_handlers"] = metadata # type: ignore[index] - return result \ No newline at end of file + return result + + def compute_effective_map( + self, + settings: object, + registry: object, + application: Application, # type: ignore[type-arg] + ) -> dict[int, dict[str, bool]]: + """Compute and store per-group effective plugin toggle map. + + Resolves plugin enabled/disabled state for every group in the + registry and stores the result in + ``application.bot_data["plugin_effective_map"]``. + + Args: + settings: Application Settings instance (must have + ``plugins_default`` attribute). + registry: GroupRegistry instance. + application: PTB Application instance. + + Returns: + Dict mapping group_id -> resolved toggle dict. Also stored + in ``bot_data["plugin_effective_map"]``. + """ + plugins_default = getattr(settings, "plugins_default", {}) + effective_map = compute_effective_plugin_map(plugins_default, registry) + application.bot_data["plugin_effective_map"] = effective_map # type: ignore[index] + logger.info("Computed effective plugin map for %d group(s)", len(effective_map)) + return effective_map \ No newline at end of file diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 2ed605e..cb9b48f 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -1,9 +1,13 @@ """Tests for plugin toggle resolver, plugin contracts, definitions, and built-in wrappers.""" -from bot.group_config import KNOWN_PLUGINS +from unittest.mock import MagicMock + + +from bot.group_config import KNOWN_PLUGINS, GroupConfig, GroupRegistry from bot.plugins import base -from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles +from bot.plugins.config import is_plugin_enabled, is_plugin_enabled_for_group, resolve_plugin_toggles from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions +from bot.plugins.manager import PluginManager, compute_effective_plugin_map class TestResolvePluginToggles: @@ -302,4 +306,174 @@ def test_each_plugin_satisfies_protocol(self): assert isinstance(plugin.handler_group, int) assert isinstance(plugin.description, str) assert len(plugin.description) > 0 - assert callable(getattr(plugin, "register", None)) \ No newline at end of file + assert callable(getattr(plugin, "register", None)) + + +class TestComputeEffectivePluginMap: + """compute_effective_plugin_map: per-group toggle dict from registry + env defaults.""" + + def _make_registry(self, *group_configs: GroupConfig) -> GroupRegistry: + reg = GroupRegistry() + for gc in group_configs: + reg.register(gc) + return reg + + def test_empty_registry_returns_empty_map(self): + """Empty registry => empty map (no groups = nothing to compute).""" + reg = self._make_registry() + result = compute_effective_plugin_map({}, reg) + assert result == {} + + def test_single_group_no_plugin_overrides_uses_env_defaults(self): + """Single group with no plugins override uses env defaults.""" + gc = GroupConfig(group_id=-100111, warning_topic_id=42) + reg = self._make_registry(gc) + result = compute_effective_plugin_map({"captcha": False, "verify": True}, reg) + assert -100111 in result + assert result[-100111]["captcha"] is False + assert result[-100111]["verify"] is True + assert result[-100111]["profile_monitor"] is True # default + + def test_single_group_with_override_takes_precedence(self): + """Group plugins override wins over env defaults.""" + gc = GroupConfig(group_id=-100222, warning_topic_id=42, plugins={"profile_monitor": False}) + reg = self._make_registry(gc) + result = compute_effective_plugin_map({"profile_monitor": True}, reg) + assert result[-100222]["profile_monitor"] is False # group override wins + + def test_multiple_groups_independent_toggles(self): + """Each group gets its own toggle map; one group's override doesn't affect others.""" + gc1 = GroupConfig(group_id=-100111, warning_topic_id=42) + gc2 = GroupConfig(group_id=-100222, warning_topic_id=42, plugins={"profile_monitor": False}) + gc3 = GroupConfig(group_id=-100333, warning_topic_id=42, plugins={"profile_monitor": True, "captcha": False}) + reg = self._make_registry(gc1, gc2, gc3) + result = compute_effective_plugin_map({"captcha": True}, reg) + # gc1: no override, env defaults all True + assert result[-100111]["profile_monitor"] is True + assert result[-100111]["captcha"] is True + # gc2: profile_monitor disabled via group override + assert result[-100222]["profile_monitor"] is False + assert result[-100222]["captcha"] is True # from env + # gc3: profile_monitor enabled, captcha disabled via group override + assert result[-100333]["profile_monitor"] is True + assert result[-100333]["captcha"] is False + # gc1 unaffected by gc2's disable + assert result[-100111]["profile_monitor"] is True + + def test_result_contains_all_groups(self): + """Every group in registry has an entry in result.""" + gc1 = GroupConfig(group_id=-100111, warning_topic_id=42) + gc2 = GroupConfig(group_id=-100222, warning_topic_id=42) + reg = self._make_registry(gc1, gc2) + result = compute_effective_plugin_map({}, reg) + assert set(result.keys()) == {-100111, -100222} + + def test_each_group_toggle_has_all_known_plugins(self): + """Each group's toggle dict contains all KNOWN_PLUGINS keys.""" + gc = GroupConfig(group_id=-100111, warning_topic_id=42) + reg = self._make_registry(gc) + result = compute_effective_plugin_map({}, reg) + assert set(result[-100111].keys()) == KNOWN_PLUGINS + + def test_profile_monitor_disabled_for_one_group_only(self): + """profile_monitor can be False for group A and True for group B.""" + gc_a = GroupConfig(group_id=-100111, warning_topic_id=42, plugins={"profile_monitor": False}) + gc_b = GroupConfig(group_id=-100222, warning_topic_id=42) + reg = self._make_registry(gc_a, gc_b) + result = compute_effective_plugin_map({}, reg) + assert result[-100111]["profile_monitor"] is False + assert result[-100222]["profile_monitor"] is True + + def test_none_env_defaults_treated_as_empty(self): + """plugins_default=None treated as empty dict.""" + gc = GroupConfig(group_id=-100111, warning_topic_id=42) + reg = self._make_registry(gc) + result = compute_effective_plugin_map({}, reg) + assert result[-100111]["profile_monitor"] is True + + +class TestIsPluginEnabledForGroup: + """Guard utility: is_plugin_enabled_for_group checks effective map.""" + + def test_known_group_enabled_plugin_returns_true(self): + """Enabled plugin for known group returns True.""" + effective_map = {-100111: {"profile_monitor": True, "captcha": False}} + assert is_plugin_enabled_for_group(effective_map, -100111, "profile_monitor") is True + + def test_known_group_disabled_plugin_returns_false(self): + """Disabled plugin for known group returns False.""" + effective_map = {-100111: {"profile_monitor": False, "captcha": True}} + assert is_plugin_enabled_for_group(effective_map, -100111, "profile_monitor") is False + + def test_unknown_group_returns_true_safe_default(self): + """Unknown group_id returns True (safe default).""" + effective_map = {-100111: {"profile_monitor": True}} + assert is_plugin_enabled_for_group(effective_map, -100999, "profile_monitor") is True + + def test_missing_plugin_key_in_toggles_returns_true(self): + """Plugin key missing from group toggles returns True (strict defaults).""" + effective_map = {-100111: {"captcha": False}} + assert is_plugin_enabled_for_group(effective_map, -100111, "profile_monitor") is True + + def test_empty_effective_map_returns_true(self): + """Empty effective map returns True for any group/plugin.""" + assert is_plugin_enabled_for_group({}, -100111, "profile_monitor") is True + + +class TestPluginManagerComputeEffectiveMap: + """PluginManager.compute_effective_map stores result in app.bot_data.""" + + def test_stores_in_bot_data(self): + """compute_effective_map stores result under bot_data['plugin_effective_map'].""" + gc = GroupConfig(group_id=-100111, warning_topic_id=42) + reg = GroupRegistry() + reg.register(gc) + settings = MagicMock() + settings.plugins_default = {} + app = MagicMock() + app.bot_data = {} + + pm = PluginManager() + pm.compute_effective_map(settings, reg, app) + + assert "plugin_effective_map" in app.bot_data + assert -100111 in app.bot_data["plugin_effective_map"] + assert app.bot_data["plugin_effective_map"][-100111]["profile_monitor"] is True + + def test_stores_returns_effective_map(self): + """compute_effective_map returns the computed effective map.""" + gc = GroupConfig(group_id=-100111, warning_topic_id=42) + reg = GroupRegistry() + reg.register(gc) + settings = MagicMock() + settings.plugins_default = {} + app = MagicMock() + app.bot_data = {} + + pm = PluginManager() + result = pm.compute_effective_map(settings, reg, app) + + assert isinstance(result, dict) + assert -100111 in result + assert result[-100111]["profile_monitor"] is True + # bot_data also set + assert app.bot_data["plugin_effective_map"] is result + + def test_multiple_groups_in_map(self): + """Multiple groups each get correct toggle map in bot_data.""" + gc1 = GroupConfig(group_id=-100111, warning_topic_id=42) + gc2 = GroupConfig(group_id=-100222, warning_topic_id=42, plugins={"profile_monitor": False}) + reg = GroupRegistry() + reg.register(gc1) + reg.register(gc2) + settings = MagicMock() + settings.plugins_default = {} + app = MagicMock() + app.bot_data = {} + + pm = PluginManager() + pm.compute_effective_map(settings, reg, app) + + map_ = app.bot_data["plugin_effective_map"] + assert map_[-100111]["profile_monitor"] is True + assert map_[-100222]["profile_monitor"] is False \ No newline at end of file From 9124755d4e3f160dccd877c500a74746747b67f2 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 16:58:26 +0700 Subject: [PATCH 17/20] fix(plugins): apply runtime gating to group-scoped handlers --- src/bot/plugins/__init__.py | 7 +- src/bot/plugins/builtin/captcha.py | 14 +- src/bot/plugins/builtin/profile_monitor.py | 13 +- src/bot/plugins/builtin/spam.py | 46 +++-- src/bot/plugins/builtin/topic_guard.py | 13 +- src/bot/plugins/config.py | 77 +++++++- tests/test_plugin_manager.py | 200 ++++++++++++++++++++- 7 files changed, 326 insertions(+), 44 deletions(-) diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py index 53c7342..db9c22b 100644 --- a/src/bot/plugins/__init__.py +++ b/src/bot/plugins/__init__.py @@ -1,17 +1,18 @@ """Plugin system for PythonID bot. -Provides base contracts, toggle resolution, and plugin definitions -for modular handler registration. +Provides base contracts, toggle resolution, plugin definitions, +and runtime guard wrappers for modular handler registration. """ from bot.plugins.base import PluginProtocol -from bot.plugins.config import is_plugin_enabled, resolve_plugin_toggles +from bot.plugins.config import guard_plugin, is_plugin_enabled, resolve_plugin_toggles from bot.plugins.definitions import PluginManifest, get_plugin_definitions __all__ = [ "PluginProtocol", "PluginManifest", "get_plugin_definitions", + "guard_plugin", "is_plugin_enabled", "resolve_plugin_toggles", ] \ No newline at end of file diff --git a/src/bot/plugins/builtin/captcha.py b/src/bot/plugins/builtin/captcha.py index 7a16de8..c52f93b 100644 --- a/src/bot/plugins/builtin/captcha.py +++ b/src/bot/plugins/builtin/captcha.py @@ -2,6 +2,8 @@ Wraps ``bot.handlers.captcha`` handlers for new member verification. All register at group=0 via ``captcha.get_handlers()``. +All group-scoped callbacks are wrapped with ``guard_plugin("captcha")`` +for runtime per-group gating. Also exposes individual registrar function ``register_captcha`` for fine-grained plugin registration. @@ -13,24 +15,29 @@ from typing import TYPE_CHECKING from bot.handlers import captcha +from bot.plugins.config import guard_plugin if TYPE_CHECKING: from telegram.ext import Application, BaseHandler logger = logging.getLogger(__name__) - # --- Individual registrar function --- def register_captcha(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register captcha handlers onto application.""" + """Register captcha handlers onto application. + + Each handler's callback is wrapped with ``guard_plugin("captcha")`` + for runtime per-group enable/disable gating. + """ handlers = captcha.get_handlers() for h in handlers: + # Wrap the handler callback with runtime guard + h.callback = guard_plugin("captcha")(h.callback) # type: ignore[method-assign] application.add_handler(h) logger.info("Registered handler: captcha_handlers (group=0)") return handlers - # --- Coarse plugin class (keeps existing API) --- class _CaptchaPlugin: @@ -44,5 +51,4 @@ def register(self, application: Application) -> list[BaseHandler]: # type: igno """Register captcha handlers onto application.""" return register_captcha(application) - plugin = _CaptchaPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/profile_monitor.py b/src/bot/plugins/builtin/profile_monitor.py index 3e4736f..0a99ff6 100644 --- a/src/bot/plugins/builtin/profile_monitor.py +++ b/src/bot/plugins/builtin/profile_monitor.py @@ -2,6 +2,7 @@ Wraps ``bot.handlers.message.handle_message`` for profile compliance monitoring. Registers at group=6 with GROUPS & ~COMMAND filter. +Applies runtime gating via ``guard_plugin("profile_monitor")``. Also exposes individual registrar function ``register_profile_monitor`` for fine-grained plugin registration. @@ -15,26 +16,29 @@ from telegram.ext import MessageHandler, filters from bot.handlers.message import handle_message +from bot.plugins.config import guard_plugin if TYPE_CHECKING: from telegram.ext import Application, BaseHandler logger = logging.getLogger(__name__) - # --- Individual registrar function --- def register_profile_monitor(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register profile monitor handler onto application (group=6).""" + """Register profile monitor handler onto application (group=6). + + The callback is wrapped with ``guard_plugin("profile_monitor")`` for + runtime per-group enable/disable gating. + """ handler: BaseHandler = MessageHandler( filters.ChatType.GROUPS & ~filters.COMMAND, - handle_message, + guard_plugin("profile_monitor")(handle_message), ) application.add_handler(handler, group=6) logger.info("Registered handler: message_handler (group=6)") return [handler] - # --- Coarse plugin class (keeps existing API) --- class _ProfileMonitorPlugin: @@ -48,5 +52,4 @@ def register(self, application: Application) -> list[BaseHandler]: # type: igno """Register profile monitor handler onto application.""" return register_profile_monitor(application) - plugin = _ProfileMonitorPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/spam.py b/src/bot/plugins/builtin/spam.py index 26db41d..ad62458 100644 --- a/src/bot/plugins/builtin/spam.py +++ b/src/bot/plugins/builtin/spam.py @@ -2,7 +2,8 @@ Wraps all anti-spam handlers (inline_keyboard_spam, bio_bait_spam, contact_spam, new_user_spam, duplicate_spam) with their respective -filter and group patterns matching main.py. +filter and group patterns matching main.py. All group-scoped callbacks +are wrapped with ``guard_plugin`` for runtime per-group gating. Also exposes individual registrar functions (register_inline_keyboard_spam, register_bio_bait_spam, etc.) for fine-grained plugin registration. @@ -18,70 +19,80 @@ from bot.handlers.anti_spam import handle_contact_spam, handle_inline_keyboard_spam, handle_new_user_spam from bot.handlers.bio_bait import BIO_BAIT_FILTER, handle_bio_bait_spam from bot.handlers.duplicate_spam import handle_duplicate_spam +from bot.plugins.config import guard_plugin if TYPE_CHECKING: from telegram.ext import Application, BaseHandler logger = logging.getLogger(__name__) - # --- Individual registrar functions --- def register_inline_keyboard_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register inline keyboard spam handler (group=1).""" + """Register inline keyboard spam handler (group=1). + + Callback wrapped with ``guard_plugin("inline_keyboard_spam")``. + """ handler: BaseHandler = MessageHandler( filters.ChatType.GROUPS, - handle_inline_keyboard_spam, + guard_plugin("inline_keyboard_spam")(handle_inline_keyboard_spam), ) application.add_handler(handler, group=1) logger.info("Registered handler: inline_keyboard_spam_handler (group=1)") return [handler] - def register_bio_bait_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register bio bait spam handler (group=2).""" + """Register bio bait spam handler (group=2). + + Callback wrapped with ``guard_plugin("bio_bait_spam")``. + """ handler: BaseHandler = MessageHandler( BIO_BAIT_FILTER, - handle_bio_bait_spam, + guard_plugin("bio_bait_spam")(handle_bio_bait_spam), ) application.add_handler(handler, group=2) logger.info("Registered handler: bio_bait_spam_handler (group=2)") return [handler] - def register_contact_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register contact spam handler (group=3).""" + """Register contact spam handler (group=3). + + Callback wrapped with ``guard_plugin("contact_spam")``. + """ handler: BaseHandler = MessageHandler( filters.ChatType.GROUPS & filters.CONTACT, - handle_contact_spam, + guard_plugin("contact_spam")(handle_contact_spam), ) application.add_handler(handler, group=3) logger.info("Registered handler: contact_spam_handler (group=3)") return [handler] - def register_new_user_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register new user spam handler (probation, group=4).""" + """Register new user spam handler (probation, group=4). + + Callback wrapped with ``guard_plugin("new_user_spam")``. + """ handler: BaseHandler = MessageHandler( filters.ChatType.GROUPS, - handle_new_user_spam, + guard_plugin("new_user_spam")(handle_new_user_spam), ) application.add_handler(handler, group=4) logger.info("Registered handler: anti_spam_handler (group=4)") return [handler] - def register_duplicate_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register duplicate message spam handler (group=5).""" + """Register duplicate message spam handler (group=5). + + Callback wrapped with ``guard_plugin("duplicate_spam")``. + """ handler: BaseHandler = MessageHandler( filters.ChatType.GROUPS & ~filters.COMMAND, - handle_duplicate_spam, + guard_plugin("duplicate_spam")(handle_duplicate_spam), ) application.add_handler(handler, group=5) logger.info("Registered handler: duplicate_spam_handler (group=5)") return [handler] - # --- Coarse plugin class (keeps existing API) --- class _SpamPlugin: @@ -101,5 +112,4 @@ def register(self, application: Application) -> list[BaseHandler]: # type: igno handlers.extend(register_duplicate_spam(application)) return handlers - plugin = _SpamPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/topic_guard.py b/src/bot/plugins/builtin/topic_guard.py index 9a50664..bd07b04 100644 --- a/src/bot/plugins/builtin/topic_guard.py +++ b/src/bot/plugins/builtin/topic_guard.py @@ -2,6 +2,7 @@ Wraps ``bot.handlers.topic_guard.guard_warning_topic`` with same filter/group pattern (MessageHandler, MESSAGE|EDITED_MESSAGE, group=-1). +Applies runtime gating via ``guard_plugin("topic_guard")``. Also exposes individual registrar function ``register_topic_guard`` for fine-grained plugin registration. @@ -15,26 +16,29 @@ from telegram.ext import MessageHandler, filters from bot.handlers.topic_guard import guard_warning_topic +from bot.plugins.config import guard_plugin if TYPE_CHECKING: from telegram.ext import Application, BaseHandler logger = logging.getLogger(__name__) - # --- Individual registrar function --- def register_topic_guard(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register topic_guard handler onto application (group=-1).""" + """Register topic_guard handler onto application (group=-1). + + The callback is wrapped with ``guard_plugin("topic_guard")`` for + runtime per-group enable/disable gating. + """ handler: BaseHandler = MessageHandler( filters.UpdateType.MESSAGE | filters.UpdateType.EDITED_MESSAGE, - guard_warning_topic, + guard_plugin("topic_guard")(guard_warning_topic), ) application.add_handler(handler, group=-1) logger.info("Registered handler: topic_guard (group=-1, message + edited_message)") return [handler] - # --- Coarse plugin class (keeps existing API) --- class _TopicGuardPlugin: @@ -48,5 +52,4 @@ def register(self, application: Application) -> list[BaseHandler]: # type: igno """Register topic_guard handler onto application.""" return register_topic_guard(application) - plugin = _TopicGuardPlugin() \ No newline at end of file diff --git a/src/bot/plugins/config.py b/src/bot/plugins/config.py index 62c60f6..7b676ba 100644 --- a/src/bot/plugins/config.py +++ b/src/bot/plugins/config.py @@ -1,13 +1,25 @@ -"""Plugin toggle resolution. +"""Plugin toggle resolution and runtime guard wrapper. Provides deterministic resolution of plugin enabled/disabled state -from environment-level defaults and per-group overrides. +from environment-level defaults and per-group overrides, plus a +reusable ``guard_plugin`` decorator for runtime gating of group-scoped +handler callbacks. """ from __future__ import annotations +import functools +import logging +from typing import TYPE_CHECKING, Any, Callable, Coroutine + from bot.group_config import KNOWN_PLUGINS +if TYPE_CHECKING: + from telegram import Update + from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + def resolve_plugin_toggles( defaults: dict[str, bool], overrides: dict[str, bool] | None, @@ -56,7 +68,6 @@ def is_plugin_enabled(toggles: dict[str, bool], name: str) -> bool: """ return toggles[name] - def is_plugin_enabled_for_group( effective_map: dict[int, dict[str, bool]], group_id: int, @@ -81,4 +92,62 @@ def is_plugin_enabled_for_group( group_toggles = effective_map.get(group_id) if group_toggles is None: return True # Unknown group => safe default - return group_toggles.get(plugin_name, True) # Missing key => safe default \ No newline at end of file + return group_toggles.get(plugin_name, True) # Missing key => safe default + + +def guard_plugin( + plugin_name: str, +) -> Callable[ + [Callable[..., Coroutine[Any, Any, None]]], + Callable[..., Coroutine[Any, Any, None]], +]: + """Return decorator that gates a handler callback on plugin enable state. + + Checks ``context.bot_data["plugin_effective_map"]`` by group id and + ``plugin_name``. If the plugin is disabled for the group, the + decorated callback early-returns (no-op). + + Safe defaults (pass through): + - Unknown group id (not in effective_map) + - Missing plugin key in group toggles + - Empty / missing ``plugin_effective_map`` in bot_data + - Non-group chat (private, channel) + + Usage:: + + @guard_plugin("profile_monitor") + async def my_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + ... + + Args: + plugin_name: Plugin name from ``MANIFEST_ORDER`` / ``KNOWN_PLUGINS``. + + Returns: + Decorator that wraps an async handler callback with runtime gating. + """ + def decorator( + callback: Callable[..., Coroutine[Any, Any, None]], + ) -> Callable[..., Coroutine[Any, Any, None]]: + @functools.wraps(callback) + async def wrapper( + update: "Update", + context: "ContextTypes.DEFAULT_TYPE", + *args: Any, + **kwargs: Any, + ) -> None: + # Only gate group/supergroup updates + if update.effective_chat is None or update.effective_chat.type not in ("group", "supergroup"): + await callback(update, context, *args, **kwargs) + return + + group_id = update.effective_chat.id + effective_map: dict[int, dict[str, bool]] = context.bot_data.get("plugin_effective_map", {}) + + if not is_plugin_enabled_for_group(effective_map, group_id, plugin_name): + logger.debug("Plugin '%s' disabled for group %d, skipping", plugin_name, group_id) + return + + await callback(update, context, *args, **kwargs) + + return wrapper + return decorator \ No newline at end of file diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index cb9b48f..762b7d0 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -1,11 +1,10 @@ """Tests for plugin toggle resolver, plugin contracts, definitions, and built-in wrappers.""" -from unittest.mock import MagicMock - +from unittest.mock import AsyncMock, MagicMock from bot.group_config import KNOWN_PLUGINS, GroupConfig, GroupRegistry from bot.plugins import base -from bot.plugins.config import is_plugin_enabled, is_plugin_enabled_for_group, resolve_plugin_toggles +from bot.plugins.config import guard_plugin, is_plugin_enabled, is_plugin_enabled_for_group, resolve_plugin_toggles from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions from bot.plugins.manager import PluginManager, compute_effective_plugin_map @@ -130,7 +129,6 @@ def test_returned_copy_isolation(self): # Calling again still works assert len(defs3) == len(KNOWN_PLUGINS) - class TestManifestOrder: """MANIFEST_ORDER defines deterministic handler registration order matching main.py.""" @@ -476,4 +474,196 @@ def test_multiple_groups_in_map(self): map_ = app.bot_data["plugin_effective_map"] assert map_[-100111]["profile_monitor"] is True - assert map_[-100222]["profile_monitor"] is False \ No newline at end of file + assert map_[-100222]["profile_monitor"] is False + + +class TestGuardPlugin: + """guard_plugin decorator: gated runtime enable/disable per group.""" + + @staticmethod + def _make_mock_update(chat_id: int, chat_type: str = "supergroup") -> MagicMock: + """Create a mock update with effective_chat.""" + update = MagicMock() + chat = MagicMock() + chat.id = chat_id + chat.type = chat_type + update.effective_chat = chat + return update + + @staticmethod + def _make_mock_context(effective_map: dict | None = None) -> MagicMock: + """Create a mock context with bot_data.""" + context = MagicMock() + context.bot_data = {} + if effective_map is not None: + context.bot_data["plugin_effective_map"] = effective_map + return context + + async def test_enabled_plugin_calls_callback(self): + """Enabled plugin for group -> callback called normally.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({-100111: {"profile_monitor": True}}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_disabled_plugin_skips_callback(self): + """Disabled plugin for group -> callback NOT called.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({-100111: {"profile_monitor": False}}) + + await wrapped(update, context) + + callback.assert_not_awaited() + + async def test_unknown_group_passes_through(self): + """Unknown group_id -> safe default True -> callback called.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = self._make_mock_update(-100999) + context = self._make_mock_context({-100111: {"profile_monitor": False}}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_empty_effective_map_passes_through(self): + """Empty effective_map -> safe defaults -> callback called.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_missing_effective_map_passes_through(self): + """bot_data missing plugin_effective_map -> callback called.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = self._make_mock_update(-100111) + context = MagicMock() + context.bot_data = {} + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_private_chat_passes_through(self): + """Private chat -> bypass gating -> callback called.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = self._make_mock_update(12345, chat_type="private") + context = self._make_mock_context({-100111: {"profile_monitor": False}}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_no_effective_chat_passes_through(self): + """No effective_chat in update -> bypass gating -> callback called.""" + callback = AsyncMock() + wrapped = guard_plugin("profile_monitor")(callback) + + update = MagicMock() + update.effective_chat = None + context = self._make_mock_context({-100111: {"profile_monitor": False}}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_group_a_disabled_group_b_enabled(self): + """Group A disabled -> no-op. Group B enabled -> callback called.""" + callback_a = AsyncMock() + callback_b = AsyncMock() + wrapped_a = guard_plugin("profile_monitor")(callback_a) + wrapped_b = guard_plugin("profile_monitor")(callback_b) + + effective_map = {-100111: {"profile_monitor": False}, -100222: {"profile_monitor": True}} + + # Group A: disabled + update_a = self._make_mock_update(-100111) + context_a = self._make_mock_context(effective_map) + await wrapped_a(update_a, context_a) + callback_a.assert_not_awaited() + + # Group B: enabled + update_b = self._make_mock_update(-100222) + context_b = self._make_mock_context(effective_map) + await wrapped_b(update_b, context_b) + callback_b.assert_awaited_once_with(update_b, context_b) + + async def test_topic_guard_enabled_calls_callback(self): + """topic_guard plugin enabled -> topic_guard callback called.""" + callback = AsyncMock() + wrapped = guard_plugin("topic_guard")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({-100111: {"topic_guard": True}}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_topic_guard_disabled_skips_callback(self): + """topic_guard plugin disabled -> topic_guard callback NOT called.""" + callback = AsyncMock() + wrapped = guard_plugin("topic_guard")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({-100111: {"topic_guard": False}}) + + await wrapped(update, context) + + callback.assert_not_awaited() + + async def test_inline_keyboard_spam_disabled_skips_callback(self): + """inline_keyboard_spam disabled -> callback NOT called.""" + callback = AsyncMock() + wrapped = guard_plugin("inline_keyboard_spam")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({-100111: {"inline_keyboard_spam": False}}) + + await wrapped(update, context) + + callback.assert_not_awaited() + + async def test_guard_plugin_import_exported(self): + """guard_plugin is importable from bot.plugins.config.""" + assert callable(guard_plugin) + + async def test_no_effective_map_key_all_true(self): + """Toggle absent in effective_map -> safe default True.""" + callback = AsyncMock() + wrapped = guard_plugin("some_unknown_plugin")(callback) + + update = self._make_mock_update(-100111) + context = self._make_mock_context({-100111: {"profile_monitor": True}}) + + await wrapped(update, context) + + callback.assert_awaited_once_with(update, context) + + async def test_decorated_function_name_preserved(self): + """guard_plugin preserves __name__ and __wrapped__ of original callback.""" + async def my_handler(update, context): + pass + + wrapped = guard_plugin("profile_monitor")(my_handler) + + assert wrapped.__name__ == "my_handler" + assert wrapped.__wrapped__ is my_handler \ No newline at end of file From 58f704e73b610815f664285921c04a70f66c026e Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Fri, 22 May 2026 17:17:44 +0700 Subject: [PATCH 18/20] docs(config): add plugin toggle examples for env and groups --- .env.example | 7 ++ groups.json.example | 16 +++- tests/test_config.py | 155 ++++++++++++++++++++++++++++++++++++- tests/test_group_config.py | 62 +++++++++++++++ 4 files changed, 235 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 06b548f..20dbcb0 100644 --- a/.env.example +++ b/.env.example @@ -74,6 +74,13 @@ BIO_BAIT_MONITOR_ONLY=false # GROUP_ID/WARNING_TOPIC_ID/etc. fields above. See groups.json.example. # GROUPS_CONFIG_PATH=groups.json +# Default plugin enable/disable map for all groups (optional, single-group mode) +# JSON object mapping built-in plugin names to booleans. +# Plugins not listed inherit their built-in default (enabled). +# Keys must match known plugin names (e.g. "captcha", "dm", "verify"). +# Example: PLUGINS_DEFAULT={"captcha":true,"dm":false} +# PLUGINS_DEFAULT={"captcha":true,"dm":false} + # Logfire Configuration (optional - for production logging) # Get your token from https://logfire.pydantic.dev LOGFIRE_TOKEN=your_logfire_token_here diff --git a/groups.json.example b/groups.json.example index d14caf2..048c68c 100644 --- a/groups.json.example +++ b/groups.json.example @@ -18,7 +18,12 @@ "duplicate_spam_similarity": 0.95, "bio_bait_enabled": true, "bio_bait_monitor_only": false, - "bio_bait_alert_chat_id": null + "bio_bait_alert_chat_id": null, + "plugins": { + "captcha": false, + "dm": true, + "verify": true + } }, { "group_id": -1009876543210, @@ -39,6 +44,11 @@ "duplicate_spam_similarity": 0.90, "bio_bait_enabled": true, "bio_bait_monitor_only": false, - "bio_bait_alert_chat_id": null + "bio_bait_alert_chat_id": null, + "plugins": { + "contact_spam": false, + "duplicate_spam": false, + "profile_monitor": true + } } -] +] \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index 7fe2263..cc71cc1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,9 @@ +"""Tests for the config module.""" + from datetime import timedelta import pytest +from pydantic_settings.exceptions import SettingsError from bot.config import Settings, get_settings, get_env_file @@ -35,7 +38,6 @@ def test_no_env_file_returns_none(self, monkeypatch, tmp_path): monkeypatch.chdir(tmp_path) assert get_env_file() is None - class TestSettings: def test_settings_from_env(self, monkeypatch): monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token_123") @@ -175,6 +177,155 @@ def test_bio_bait_monitor_from_env(self, monkeypatch): assert settings.bio_bait_monitor_only is True assert settings.bio_bait_alert_chat_id == 57747812 +class TestPluginsDefault: + def test_default_empty_dict(self, monkeypatch): + """Test plugins_default defaults to empty dict when not set.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + + settings = Settings(_env_file=None) + + assert settings.plugins_default == {} + + def test_valid_json_string(self, monkeypatch): + """Test valid JSON string with known plugins.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"captcha": true, "dm": false}') + + settings = Settings(_env_file=None) + + assert settings.plugins_default == {"captcha": True, "dm": False} + + def test_empty_string_raises(self, monkeypatch): + """Test empty string env var raises SettingsError (invalid JSON).""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", "") + + with pytest.raises(SettingsError, match="error parsing value"): + Settings(_env_file=None) + + def test_whitespace_only_string_raises(self, monkeypatch): + """Test whitespace-only string env var raises SettingsError (invalid JSON).""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", " ") + + with pytest.raises(SettingsError, match="error parsing value"): + Settings(_env_file=None) + + def test_single_plugin(self, monkeypatch): + """Test single plugin entry.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"captcha": false}') + + settings = Settings(_env_file=None) + + assert settings.plugins_default == {"captcha": False} + + def test_all_known_plugins(self, monkeypatch): + """Test dict with all known plugin names (using a subset).""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv( + "PLUGINS_DEFAULT", + '{"captcha": true, "dm": true, "verify": false, "check": true}', + ) + + settings = Settings(_env_file=None) + + assert settings.plugins_default == { + "captcha": True, + "dm": True, + "verify": False, + "check": True, + } + + def test_invalid_json_string_raises(self, monkeypatch): + """Test invalid JSON string env var raises SettingsError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", "not valid json") + + with pytest.raises(SettingsError, match="error parsing value"): + Settings(_env_file=None) + + def test_invalid_json_string_via_constructor_raises(self, monkeypatch): + """Test invalid JSON string passed via constructor raises our ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + + with pytest.raises(ValueError, match="PLUGINS_DEFAULT must be a valid JSON string"): + Settings(_env_file=None, plugins_default="not valid json") + + def test_empty_string_via_constructor_is_accepted(self, monkeypatch): + """Test empty string passed via constructor is accepted (bypasses env source).""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + + settings = Settings(_env_file=None, plugins_default="") + assert settings.plugins_default == {} + + def test_json_array_raises(self, monkeypatch): + """Test JSON array raises ValueError (must be object).""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '["captcha", "dm"]') + + with pytest.raises(ValueError, match="PLUGINS_DEFAULT must be a JSON object"): + Settings(_env_file=None) + + def test_unknown_plugin_key_raises(self, monkeypatch): + """Test unknown plugin key raises ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"nonexistent_plugin": true}') + + with pytest.raises(ValueError, match="Unknown plugin key.*nonexistent_plugin"): + Settings(_env_file=None) + + def test_non_bool_value_raises(self, monkeypatch): + """Test non-boolean value raises ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"captcha": "yes"}') + + with pytest.raises(ValueError, match="must be a boolean"): + Settings(_env_file=None) + + def test_integer_value_raises(self, monkeypatch): + """Test integer value raises ValueError (must be boolean).""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"captcha": 1}') + + with pytest.raises(ValueError, match="must be a boolean"): + Settings(_env_file=None) + + def test_null_value_raises(self, monkeypatch): + """Test null value raises ValueError.""" + monkeypatch.setenv("TELEGRAM_BOT_TOKEN", "test_token") + monkeypatch.setenv("GROUP_ID", "-100999") + monkeypatch.setenv("WARNING_TOPIC_ID", "1") + monkeypatch.setenv("PLUGINS_DEFAULT", '{"captcha": null}') + + with pytest.raises(ValueError, match="must be a boolean"): + Settings(_env_file=None) class TestSettingsValidation: def test_group_id_must_be_negative(self, monkeypatch): @@ -234,4 +385,4 @@ def test_warning_time_threshold_must_be_positive(self, monkeypatch): monkeypatch.setenv("WARNING_TIME_THRESHOLD_MINUTES", "0") with pytest.raises(ValueError, match="warning_time_threshold_minutes must be greater than 0"): - Settings(_env_file=None) + Settings(_env_file=None) \ No newline at end of file diff --git a/tests/test_group_config.py b/tests/test_group_config.py index ebaf6f9..87de5e8 100644 --- a/tests/test_group_config.py +++ b/tests/test_group_config.py @@ -278,6 +278,68 @@ def test_load_with_plugins(self): assert configs[0].plugins == {"captcha": True} + def test_load_with_plugins_unknown_key_raises(self): + """Test loading JSON with unknown plugin key raises ValidationError.""" + data = [ + {"group_id": -100, "warning_topic_id": 1, "plugins": {"unknown_plugin": True}}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + with pytest.raises(ValidationError, match="Unknown plugin key"): + load_groups_from_json(f.name) + + def test_load_with_plugins_non_bool_raises(self): + """Test loading JSON with non-boolean plugin value raises ValidationError.""" + data = [ + {"group_id": -100, "warning_topic_id": 1, "plugins": {"captcha": "yes"}}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + with pytest.raises(ValidationError, match="value must be a boolean"): + load_groups_from_json(f.name) + + def test_load_with_plugins_multiple_valid(self): + """Test loading JSON with multiple valid plugin entries.""" + data = [ + { + "group_id": -100, + "warning_topic_id": 1, + "plugins": {"captcha": True, "dm": False, "verify": True}, + }, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + configs = load_groups_from_json(f.name) + + assert configs[0].plugins == {"captcha": True, "dm": False, "verify": True} + + def test_load_with_plugins_empty_dict(self): + """Test loading JSON with empty plugins dict.""" + data = [ + {"group_id": -100, "warning_topic_id": 1, "plugins": {}}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + configs = load_groups_from_json(f.name) + + assert configs[0].plugins == {} + + def test_load_with_plugins_absent_is_none(self): + """Test loading JSON without plugins field defaults to None.""" + data = [ + {"group_id": -100, "warning_topic_id": 1}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + configs = load_groups_from_json(f.name) + + assert configs[0].plugins is None + class TestBuildGroupRegistry: def test_builds_from_json_file(self): data = [ From 84b003fc7dd93ea0291b201d3774eba20f51150f Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 23 May 2026 02:31:14 +0700 Subject: [PATCH 19/20] refactor(plugins): unify plugin name registry to definitions.py --- src/bot/group_config.py | 37 +++++++++------------------------- src/bot/plugins/config.py | 11 +++++----- src/bot/plugins/definitions.py | 13 +++++++++--- tests/test_plugin_config.py | 3 ++- tests/test_plugin_manager.py | 11 ++++++++-- 5 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/bot/group_config.py b/src/bot/group_config.py index 265deca..0530f7d 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -15,35 +15,10 @@ from pydantic import BaseModel, field_validator from telegram import Update +from bot.plugins.definitions import PLUGIN_NAMES as KNOWN_PLUGINS + logger = logging.getLogger(__name__) -# Set of all known built-in plugin names. -# Must match the plugin manifest from the plugin system design spec. -KNOWN_PLUGINS: frozenset[str] = frozenset({ - "topic_guard", - "verify", - "unverify", - "check", - "trust", - "untrust", - "trusted_list", - "check_forwarded_message", - "verify_callback", - "unverify_callback", - "warn_callback", - "trust_callback", - "untrust_callback", - "captcha", - "dm", - "inline_keyboard_spam", - "bio_bait_spam", - "contact_spam", - "new_user_spam", - "duplicate_spam", - "profile_monitor", - "auto_restrict_job", - "refresh_admin_ids_job", -}) class GroupConfig(BaseModel): """ @@ -134,6 +109,7 @@ def warning_time_threshold_timedelta(self) -> timedelta: def captcha_timeout_timedelta(self) -> timedelta: return timedelta(seconds=self.captcha_timeout_seconds) + class GroupRegistry: """ Registry of monitored groups. @@ -159,6 +135,7 @@ def all_groups(self) -> list[GroupConfig]: def is_monitored(self, group_id: int) -> bool: return group_id in self._groups + def load_groups_from_json(path: str) -> list[GroupConfig]: """ Parse a groups.json file into a list of GroupConfig objects. @@ -194,6 +171,7 @@ def load_groups_from_json(path: str) -> list[GroupConfig]: return configs + def build_group_registry(settings: object) -> GroupRegistry: """ Build a GroupRegistry from settings. @@ -244,6 +222,7 @@ def build_group_registry(settings: object) -> GroupRegistry: return registry + def get_group_config_for_update(update: Update) -> GroupConfig | None: """ Get the GroupConfig for the group in the given Update. @@ -264,9 +243,11 @@ def get_group_config_for_update(update: Update) -> GroupConfig | None: logger.error("Group registry not initialized; skipping update") return None + # Module-level singleton _registry: GroupRegistry | None = None + def init_group_registry(settings: object) -> GroupRegistry: """ Initialize the global group registry singleton. @@ -283,6 +264,7 @@ def init_group_registry(settings: object) -> GroupRegistry: _registry = build_group_registry(settings) return _registry + def get_group_registry() -> GroupRegistry: """ Get the global group registry singleton. @@ -297,6 +279,7 @@ def get_group_registry() -> GroupRegistry: raise RuntimeError("Group registry not initialized. Call init_group_registry() first.") return _registry + def reset_group_registry() -> None: """Reset the group registry singleton (for testing).""" global _registry diff --git a/src/bot/plugins/config.py b/src/bot/plugins/config.py index 7b676ba..7ae0d6b 100644 --- a/src/bot/plugins/config.py +++ b/src/bot/plugins/config.py @@ -12,7 +12,7 @@ import logging from typing import TYPE_CHECKING, Any, Callable, Coroutine -from bot.group_config import KNOWN_PLUGINS +from bot.plugins.definitions import PLUGIN_NAMES if TYPE_CHECKING: from telegram import Update @@ -36,11 +36,11 @@ def resolve_plugin_toggles( overrides: Per-group overrides from GroupConfig.plugins (or None). Returns: - Dict mapping every ``KNOWN_PLUGINS`` name to its resolved bool. + Dict mapping every ``PLUGIN_NAMES`` name to its resolved bool. """ result: dict[str, bool] = {} - for name in KNOWN_PLUGINS: + for name in PLUGIN_NAMES: # Priority 1: group override (if present) if overrides is not None and name in overrides: result[name] = overrides[name] @@ -84,7 +84,7 @@ def is_plugin_enabled_for_group( ``compute_effective_plugin_map``, stored in ``bot_data["plugin_effective_map"]``. group_id: Telegram group ID to check. - plugin_name: Plugin name from ``MANIFEST_ORDER`` / ``KNOWN_PLUGINS``. + plugin_name: Plugin name from ``MANIFEST_ORDER`` / ``PLUGIN_NAMES``. Returns: True if plugin is enabled for the given group. @@ -94,7 +94,6 @@ def is_plugin_enabled_for_group( return True # Unknown group => safe default return group_toggles.get(plugin_name, True) # Missing key => safe default - def guard_plugin( plugin_name: str, ) -> Callable[ @@ -120,7 +119,7 @@ async def my_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None ... Args: - plugin_name: Plugin name from ``MANIFEST_ORDER`` / ``KNOWN_PLUGINS``. + plugin_name: Plugin name from ``MANIFEST_ORDER`` / ``PLUGIN_NAMES``. Returns: Decorator that wraps an async handler callback with runtime gating. diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py index 81745af..a4922d9 100644 --- a/src/bot/plugins/definitions.py +++ b/src/bot/plugins/definitions.py @@ -1,8 +1,8 @@ """Plugin definitions and manifest for the PythonID bot. Provides the canonical mapping from plugin names to human-readable -metadata. The plugin names must stay in sync with ``KNOWN_PLUGINS`` -in ``bot.group_config``, which is the authoritative source. +metadata. ``PLUGIN_NAMES`` is the single source of truth for all known +built-in plugin identifiers -- other modules import from here. """ from __future__ import annotations @@ -13,7 +13,6 @@ """Type alias for a list of plugin descriptor dicts.""" # Human-readable metadata for each known built-in plugin. -# ``name`` must be present in ``KNOWN_PLUGINS``. # Order matches main.py registration order (topic_guard first). # handler_group values match the PTB group argument used in main.py. _PLUGIN_DEFINITIONS: PluginManifest = [ @@ -42,6 +41,14 @@ {"name": "refresh_admin_ids_job", "handler_group": 6, "description": "Periodic admin cache refresh job (every 10 min)"}, ] +# Single source of truth: canonical set of all known plugin names. +PLUGIN_NAMES: frozenset[str] = frozenset(d["name"] for d in _PLUGIN_DEFINITIONS) # type: ignore[arg-type] +"""Canonical set of all known built-in plugin names. + +Derived automatically from ``_PLUGIN_DEFINITIONS``. This is the +single source of truth -- other modules should import from here. +""" + # Deterministic registration order matching main.py. # topic_guard first (group=-1), refresh_admin_ids_job last. MANIFEST_ORDER: tuple[str, ...] = tuple(d["name"] for d in _PLUGIN_DEFINITIONS) # type: ignore[arg-type] diff --git a/tests/test_plugin_config.py b/tests/test_plugin_config.py index 7badf4c..8c0db2c 100644 --- a/tests/test_plugin_config.py +++ b/tests/test_plugin_config.py @@ -7,7 +7,8 @@ from pydantic_settings.exceptions import SettingsError from bot.config import Settings -from bot.group_config import GroupConfig, KNOWN_PLUGINS +from bot.group_config import GroupConfig +from bot.plugins.definitions import PLUGIN_NAMES as KNOWN_PLUGINS class TestKnownPlugins: diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 762b7d0..35998ec 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -2,10 +2,10 @@ from unittest.mock import AsyncMock, MagicMock -from bot.group_config import KNOWN_PLUGINS, GroupConfig, GroupRegistry +from bot.group_config import GroupConfig, GroupRegistry from bot.plugins import base from bot.plugins.config import guard_plugin, is_plugin_enabled, is_plugin_enabled_for_group, resolve_plugin_toggles -from bot.plugins.definitions import MANIFEST_ORDER, get_plugin_definitions +from bot.plugins.definitions import MANIFEST_ORDER, PLUGIN_NAMES as KNOWN_PLUGINS, get_plugin_definitions from bot.plugins.manager import PluginManager, compute_effective_plugin_map @@ -97,6 +97,12 @@ def test_plugin_protocol_has_fields(self): class TestPluginDefinitions: """Verify plugin definitions match KNOWN_PLUGINS and have correct types.""" + def test_plugin_names_exists(self): + """PLUGIN_NAMES is exported from definitions module.""" + from bot.plugins.definitions import PLUGIN_NAMES + assert isinstance(PLUGIN_NAMES, frozenset) + assert "topic_guard" in PLUGIN_NAMES + def test_names_match_known_plugins(self): """Every definition name is in KNOWN_PLUGINS and every KNOWN_PLUGINS has a definition.""" defs = get_plugin_definitions() @@ -129,6 +135,7 @@ def test_returned_copy_isolation(self): # Calling again still works assert len(defs3) == len(KNOWN_PLUGINS) + class TestManifestOrder: """MANIFEST_ORDER defines deterministic handler registration order matching main.py.""" From b57ba2d8975644bfb909dd02e658a50a48dbdc01 Mon Sep 17 00:00:00 2001 From: Rezha Julio Date: Sat, 23 May 2026 03:15:58 +0700 Subject: [PATCH 20/20] fix: resolve DeepSeek V4 Pro review issues for plugin system C1: Move refresh_admin_ids from main.py to new bot/services/admin_cache.py - Break circular import between main.py and jobs.py - Update jobs.py import from bot.main to bot.services.admin_cache C2: Fix verify_callback description in definitions.py - Change from 'Captcha verify button callback' to 'Admin verify confirm button callback' I1: Restore pre-refactor handler group assignments - Move bio_bait_spam from group 2 to group 6 (no conflict with original groups) - Restore contact_spam to group 2, new_user_spam to group 3 - Restore duplicate_spam to group 4, profile_monitor to group 5 - Add TestHandlerGroupsMatchPreRefactor test class I2: Add documentation comment in commands.py - Explain why guard_plugin is intentionally NOT applied to admin commands I3: Fix job registrar log messages - Use 'job(s)' instead of 'handler(s)' for _job plugins in manager.py I4: Add missing exports to plugins/__init__.py - Export is_plugin_enabled_for_group and compute_effective_plugin_map - Use lazy __getattr__ for compute_effective_plugin_map to avoid circular import M1: Add '# Coarse plugin class for API compatibility. Unused by PluginManager.' - comment to all 7 coarse plugin classes M2: Add test for is_plugin_enabled missing key raising KeyError M3: Add test for compute_effective_plugin_map with non-GroupRegistry input M5: Add comment explaining why \d+ (no negative) is correct for user IDs --- src/bot/main.py | 29 +--- src/bot/plugins/__init__.py | 30 +++- src/bot/plugins/builtin/captcha.py | 1 + src/bot/plugins/builtin/commands.py | 13 +- src/bot/plugins/builtin/dm.py | 1 + src/bot/plugins/builtin/jobs.py | 9 +- src/bot/plugins/builtin/profile_monitor.py | 11 +- src/bot/plugins/builtin/spam.py | 25 +-- src/bot/plugins/builtin/topic_guard.py | 1 + src/bot/plugins/definitions.py | 12 +- src/bot/plugins/manager.py | 3 +- src/bot/services/admin_cache.py | 46 ++++++ tests/test_admin_cache.py | 125 +++++++++++++++ tests/test_plugin_manager.py | 168 +++++++++++++++++++-- 14 files changed, 396 insertions(+), 78 deletions(-) create mode 100644 src/bot/services/admin_cache.py create mode 100644 tests/test_admin_cache.py diff --git a/src/bot/main.py b/src/bot/main.py index 00043d3..5fefece 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -90,33 +90,6 @@ def configure_logging() -> None: logger = logging.getLogger(__name__) -async def refresh_admin_ids(context: ContextTypes.DEFAULT_TYPE) -> None: - """ - Periodically refresh cached admin IDs for all monitored groups. - - Called by JobQueue every 10 minutes to keep admin rosters up to date - when promotions/demotions happen after startup. - """ - registry = get_group_registry() - group_admin_ids: dict[int, list[int]] = {} - all_admin_ids: set[int] = set() - - for gc in registry.all_groups(): - try: - ids = await fetch_group_admin_ids(context.bot, gc.group_id) - group_admin_ids[gc.group_id] = ids - all_admin_ids.update(ids) - except Exception as e: - logger.error(f"Failed to refresh admin IDs for group {gc.group_id}: {e}") - existing = context.bot_data.get("group_admin_ids", {}).get(gc.group_id, []) - group_admin_ids[gc.group_id] = existing - all_admin_ids.update(existing) - - context.bot_data["group_admin_ids"] = group_admin_ids - context.bot_data["admin_ids"] = list(all_admin_ids) - logger.info(f"Refreshed admin IDs: {len(all_admin_ids)} unique admin(s) across {len(group_admin_ids)} group(s)") - - async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """ Handle errors in the bot. @@ -239,4 +212,4 @@ def main() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/src/bot/plugins/__init__.py b/src/bot/plugins/__init__.py index db9c22b..2f8c09e 100644 --- a/src/bot/plugins/__init__.py +++ b/src/bot/plugins/__init__.py @@ -4,15 +4,41 @@ and runtime guard wrappers for modular handler registration. """ +from __future__ import annotations + +from typing import TYPE_CHECKING + from bot.plugins.base import PluginProtocol -from bot.plugins.config import guard_plugin, is_plugin_enabled, resolve_plugin_toggles +from bot.plugins.config import guard_plugin, is_plugin_enabled, is_plugin_enabled_for_group, resolve_plugin_toggles from bot.plugins.definitions import PluginManifest, get_plugin_definitions +if TYPE_CHECKING: + from bot.plugins.manager import compute_effective_plugin_map + __all__ = [ "PluginProtocol", "PluginManifest", + "compute_effective_plugin_map", "get_plugin_definitions", "guard_plugin", "is_plugin_enabled", + "is_plugin_enabled_for_group", "resolve_plugin_toggles", -] \ No newline at end of file +] + + +def __getattr__(name: str) -> object: + """Lazy-load ``compute_effective_plugin_map`` to avoid circular imports. + + The function is defined in ``bot.plugins.manager``, which imports + ``bot.plugins.builtin`` โ†’ ``bot.handlers.captcha`` โ†’ ``bot.group_config`` + โ†’ ``bot.plugins.definitions``. Importing at module level from + ``__init__.py`` would create a circular dependency because + ``group_config`` itself imports from ``bot.plugins.definitions`` + while ``bot.plugins`` is still being initialised. + """ + if name == "compute_effective_plugin_map": + from bot.plugins.manager import compute_effective_plugin_map as _f + return _f + msg = f"module {__name__!r} has no attribute {name!r}" + raise AttributeError(msg) \ No newline at end of file diff --git a/src/bot/plugins/builtin/captcha.py b/src/bot/plugins/builtin/captcha.py index c52f93b..b055ba7 100644 --- a/src/bot/plugins/builtin/captcha.py +++ b/src/bot/plugins/builtin/captcha.py @@ -40,6 +40,7 @@ def register_captcha(application: Application) -> list[BaseHandler]: # type: ig # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _CaptchaPlugin: """Plugin wrapper for captcha handlers.""" diff --git a/src/bot/plugins/builtin/commands.py b/src/bot/plugins/builtin/commands.py index a7846ba..e36757c 100644 --- a/src/bot/plugins/builtin/commands.py +++ b/src/bot/plugins/builtin/commands.py @@ -4,6 +4,11 @@ untrust, trusted_list, check_forwarded_message, and their callbacks). All register at group=0. +Note: guard_plugin is intentionally NOT applied to admin +commands/callbacks. Admin overrides must work in every group regardless +of plugin toggle state. This matches pre-refactor behavior where admin +commands were never gated. + Also exposes individual registrar functions (register_verify, register_unverify, etc.) for fine-grained plugin registration. """ @@ -99,7 +104,12 @@ def register_check_forwarded_message(application: Application) -> list[BaseHandl def register_verify_callback(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register verify callback handler.""" - handler: BaseHandler = CallbackQueryHandler(handle_verify_callback, pattern=r"^verify:\d+$") + handler: BaseHandler = CallbackQueryHandler( + handle_verify_callback, + # User IDs are always positive in Telegram, so \d+ (no negative lookbehind) is correct. + # Group IDs can be negative, but callback data only encodes user IDs. + pattern=r"^verify:\d+$", + ) application.add_handler(handler) logger.info("Registered handler: verify_callback (group=0)") return [handler] @@ -139,6 +149,7 @@ def register_untrust_callback(application: Application) -> list[BaseHandler]: # # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _CommandsPlugin: """Plugin wrapper for command and callback handlers.""" diff --git a/src/bot/plugins/builtin/dm.py b/src/bot/plugins/builtin/dm.py index 07b7bb5..d4835c7 100644 --- a/src/bot/plugins/builtin/dm.py +++ b/src/bot/plugins/builtin/dm.py @@ -37,6 +37,7 @@ def register_dm(application: Application) -> list[BaseHandler]: # type: ignore[ # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _DmPlugin: """Plugin wrapper for DM handler.""" diff --git a/src/bot/plugins/builtin/jobs.py b/src/bot/plugins/builtin/jobs.py index e33f466..20ad5fa 100644 --- a/src/bot/plugins/builtin/jobs.py +++ b/src/bot/plugins/builtin/jobs.py @@ -12,6 +12,7 @@ import logging from typing import TYPE_CHECKING +from bot.services.admin_cache import refresh_admin_ids from bot.services.scheduler import auto_restrict_expired_warnings if TYPE_CHECKING: @@ -19,7 +20,6 @@ logger = logging.getLogger(__name__) - # --- Individual registrar functions --- def register_auto_restrict_job(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] @@ -35,12 +35,8 @@ def register_auto_restrict_job(application: Application) -> list[BaseHandler]: logger.info("JobQueue registered: auto_restrict_job (every 5 minutes, first run in 5 minutes)") return handlers - def register_refresh_admin_ids_job(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register admin cache refresh job (every 10 minutes).""" - # Lazy import to avoid circular dependency (main -> manager -> jobs -> main) - from bot.main import refresh_admin_ids - handlers: list[BaseHandler] = [] if application.job_queue: application.job_queue.run_repeating( @@ -52,9 +48,9 @@ def register_refresh_admin_ids_job(application: Application) -> list[BaseHandler logger.info("JobQueue registered: refresh_admin_ids_job (every 10 minutes)") return handlers - # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _JobsPlugin: """Plugin wrapper for periodic job handlers.""" @@ -69,5 +65,4 @@ def register(self, application: Application) -> list[BaseHandler]: # type: igno handlers.extend(register_refresh_admin_ids_job(application)) return handlers - plugin = _JobsPlugin() \ No newline at end of file diff --git a/src/bot/plugins/builtin/profile_monitor.py b/src/bot/plugins/builtin/profile_monitor.py index 0a99ff6..6a13bfc 100644 --- a/src/bot/plugins/builtin/profile_monitor.py +++ b/src/bot/plugins/builtin/profile_monitor.py @@ -1,7 +1,7 @@ """Built-in plugin: profile_monitor. Wraps ``bot.handlers.message.handle_message`` for profile compliance -monitoring. Registers at group=6 with GROUPS & ~COMMAND filter. +monitoring. Registers at group=5 with GROUPS & ~COMMAND filter. Applies runtime gating via ``guard_plugin("profile_monitor")``. Also exposes individual registrar function ``register_profile_monitor`` @@ -26,7 +26,7 @@ # --- Individual registrar function --- def register_profile_monitor(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register profile monitor handler onto application (group=6). + """Register profile monitor handler onto application (group=5). The callback is wrapped with ``guard_plugin("profile_monitor")`` for runtime per-group enable/disable gating. @@ -35,18 +35,19 @@ def register_profile_monitor(application: Application) -> list[BaseHandler]: # filters.ChatType.GROUPS & ~filters.COMMAND, guard_plugin("profile_monitor")(handle_message), ) - application.add_handler(handler, group=6) - logger.info("Registered handler: message_handler (group=6)") + application.add_handler(handler, group=5) + logger.info("Registered handler: message_handler (group=5)") return [handler] # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _ProfileMonitorPlugin: """Plugin wrapper for profile compliance monitor.""" name: str = "profile_monitor" description: str = "Profile compliance monitoring" - handler_group: int = 6 + handler_group: int = 5 def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] """Register profile monitor handler onto application.""" diff --git a/src/bot/plugins/builtin/spam.py b/src/bot/plugins/builtin/spam.py index ad62458..2f554c9 100644 --- a/src/bot/plugins/builtin/spam.py +++ b/src/bot/plugins/builtin/spam.py @@ -42,7 +42,7 @@ def register_inline_keyboard_spam(application: Application) -> list[BaseHandler] return [handler] def register_bio_bait_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register bio bait spam handler (group=2). + """Register bio bait spam handler (group=6). Callback wrapped with ``guard_plugin("bio_bait_spam")``. """ @@ -50,12 +50,12 @@ def register_bio_bait_spam(application: Application) -> list[BaseHandler]: # ty BIO_BAIT_FILTER, guard_plugin("bio_bait_spam")(handle_bio_bait_spam), ) - application.add_handler(handler, group=2) - logger.info("Registered handler: bio_bait_spam_handler (group=2)") + application.add_handler(handler, group=6) + logger.info("Registered handler: bio_bait_spam_handler (group=6)") return [handler] def register_contact_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register contact spam handler (group=3). + """Register contact spam handler (group=2). Callback wrapped with ``guard_plugin("contact_spam")``. """ @@ -63,12 +63,12 @@ def register_contact_spam(application: Application) -> list[BaseHandler]: # typ filters.ChatType.GROUPS & filters.CONTACT, guard_plugin("contact_spam")(handle_contact_spam), ) - application.add_handler(handler, group=3) - logger.info("Registered handler: contact_spam_handler (group=3)") + application.add_handler(handler, group=2) + logger.info("Registered handler: contact_spam_handler (group=2)") return [handler] def register_new_user_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register new user spam handler (probation, group=4). + """Register new user spam handler (probation, group=3). Callback wrapped with ``guard_plugin("new_user_spam")``. """ @@ -76,12 +76,12 @@ def register_new_user_spam(application: Application) -> list[BaseHandler]: # ty filters.ChatType.GROUPS, guard_plugin("new_user_spam")(handle_new_user_spam), ) - application.add_handler(handler, group=4) - logger.info("Registered handler: anti_spam_handler (group=4)") + application.add_handler(handler, group=3) + logger.info("Registered handler: anti_spam_handler (group=3)") return [handler] def register_duplicate_spam(application: Application) -> list[BaseHandler]: # type: ignore[type-arg] - """Register duplicate message spam handler (group=5). + """Register duplicate message spam handler (group=4). Callback wrapped with ``guard_plugin("duplicate_spam")``. """ @@ -89,12 +89,13 @@ def register_duplicate_spam(application: Application) -> list[BaseHandler]: # t filters.ChatType.GROUPS & ~filters.COMMAND, guard_plugin("duplicate_spam")(handle_duplicate_spam), ) - application.add_handler(handler, group=5) - logger.info("Registered handler: duplicate_spam_handler (group=5)") + application.add_handler(handler, group=4) + logger.info("Registered handler: duplicate_spam_handler (group=4)") return [handler] # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _SpamPlugin: """Plugin wrapper for all anti-spam handlers.""" diff --git a/src/bot/plugins/builtin/topic_guard.py b/src/bot/plugins/builtin/topic_guard.py index bd07b04..ede39dc 100644 --- a/src/bot/plugins/builtin/topic_guard.py +++ b/src/bot/plugins/builtin/topic_guard.py @@ -41,6 +41,7 @@ def register_topic_guard(application: Application) -> list[BaseHandler]: # type # --- Coarse plugin class (keeps existing API) --- +# Coarse plugin class for API compatibility. Unused by PluginManager. class _TopicGuardPlugin: """Plugin wrapper for topic_guard handler.""" diff --git a/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py index a4922d9..60774b0 100644 --- a/src/bot/plugins/definitions.py +++ b/src/bot/plugins/definitions.py @@ -24,7 +24,7 @@ {"name": "untrust", "handler_group": 0, "description": "Admin /untrust command"}, {"name": "trusted_list", "handler_group": 0, "description": "Admin /trusted list command"}, {"name": "check_forwarded_message", "handler_group": 0, "description": "Handle forwarded messages for /check context"}, - {"name": "verify_callback", "handler_group": 0, "description": "Captcha verify button callback"}, + {"name": "verify_callback", "handler_group": 0, "description": "Admin verify confirm button callback"}, {"name": "unverify_callback", "handler_group": 0, "description": "Admin unverify button callback"}, {"name": "warn_callback", "handler_group": 0, "description": "Admin warn button callback"}, {"name": "trust_callback", "handler_group": 0, "description": "Admin trust button callback"}, @@ -32,11 +32,11 @@ {"name": "captcha", "handler_group": 0, "description": "Captcha verification for new members"}, {"name": "dm", "handler_group": 0, "description": "Direct message unrestriction flow"}, {"name": "inline_keyboard_spam", "handler_group": 1, "description": "Block inline keyboard URL spam"}, - {"name": "bio_bait_spam", "handler_group": 2, "description": "Detect and alert on bio bait patterns"}, - {"name": "contact_spam", "handler_group": 3, "description": "Block contact card sharing"}, - {"name": "new_user_spam", "handler_group": 4, "description": "Probation enforcement for new users"}, - {"name": "duplicate_spam", "handler_group": 5, "description": "Repeated message detection"}, - {"name": "profile_monitor", "handler_group": 6, "description": "Profile compliance monitoring"}, + {"name": "bio_bait_spam", "handler_group": 6, "description": "Detect and alert on bio bait patterns"}, + {"name": "contact_spam", "handler_group": 2, "description": "Block contact card sharing"}, + {"name": "new_user_spam", "handler_group": 3, "description": "Probation enforcement for new users"}, + {"name": "duplicate_spam", "handler_group": 4, "description": "Repeated message detection"}, + {"name": "profile_monitor", "handler_group": 5, "description": "Profile compliance monitoring"}, {"name": "auto_restrict_job", "handler_group": 6, "description": "Periodic auto-restriction job (every 5 min)"}, {"name": "refresh_admin_ids_job", "handler_group": 6, "description": "Periodic admin cache refresh job (every 10 min)"}, ] diff --git a/src/bot/plugins/manager.py b/src/bot/plugins/manager.py index 04afe3d..17b222b 100644 --- a/src/bot/plugins/manager.py +++ b/src/bot/plugins/manager.py @@ -148,7 +148,8 @@ def register_all( registrar = self._registry[name] handlers = registrar(application) result[name] = handlers - logger.info("Registered plugin: %s (group=%d, %d handler(s))", name, defs_by_name[name]["handler_group"], len(handlers)) # type: ignore[arg-type] + noun = "job(s)" if name.endswith("_job") else "handler(s)" + logger.info("Registered plugin: %s (group=%d, %d %s)", name, defs_by_name[name]["handler_group"], len(handlers), noun) # type: ignore[arg-type] # Store metadata for later gating metadata: dict[str, dict] = {} diff --git a/src/bot/services/admin_cache.py b/src/bot/services/admin_cache.py new file mode 100644 index 0000000..6a80830 --- /dev/null +++ b/src/bot/services/admin_cache.py @@ -0,0 +1,46 @@ +"""Admin ID cache management for the PythonID bot. + +Provides ``refresh_admin_ids`` for periodic refresh of group admin rosters +in ``bot_data``. Extracted from ``main.py`` to break the circular import +between ``main.py`` and ``jobs.py``. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from bot.group_config import get_group_registry +from bot.services.telegram_utils import fetch_group_admin_ids + +if TYPE_CHECKING: + from telegram.ext import ContextTypes + +logger = logging.getLogger(__name__) + + +async def refresh_admin_ids(context: ContextTypes.DEFAULT_TYPE) -> None: + """ + Periodically refresh cached admin IDs for all monitored groups. + + Called by JobQueue every 10 minutes to keep admin rosters up to date + when promotions/demotions happen after startup. + """ + registry = get_group_registry() + group_admin_ids: dict[int, list[int]] = {} + all_admin_ids: set[int] = set() + + for gc in registry.all_groups(): + try: + ids = await fetch_group_admin_ids(context.bot, gc.group_id) + group_admin_ids[gc.group_id] = ids + all_admin_ids.update(ids) + except Exception as e: + logger.error(f"Failed to refresh admin IDs for group {gc.group_id}: {e}") + existing = context.bot_data.get("group_admin_ids", {}).get(gc.group_id, []) + group_admin_ids[gc.group_id] = existing + all_admin_ids.update(existing) + + context.bot_data["group_admin_ids"] = group_admin_ids + context.bot_data["admin_ids"] = list(all_admin_ids) + logger.info(f"Refreshed admin IDs: {len(all_admin_ids)} unique admin(s) across {len(group_admin_ids)} group(s)") \ No newline at end of file diff --git a/tests/test_admin_cache.py b/tests/test_admin_cache.py new file mode 100644 index 0000000..0f62f51 --- /dev/null +++ b/tests/test_admin_cache.py @@ -0,0 +1,125 @@ +"""Tests for bot.services.admin_cache module. + +Verifies that refresh_admin_ids is importable from the new location +and behaves correctly. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from bot.group_config import GroupConfig, GroupRegistry + + +@pytest.fixture +def mock_registry(): + """Create GroupRegistry with a test group.""" + registry = GroupRegistry() + registry.register(GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + )) + return registry + + +class TestRefreshAdminIds: + """refresh_admin_ids: fetch admin IDs for all groups, cache in bot_data.""" + + async def test_refresh_admin_ids_importable_from_admin_cache(self): + """refresh_admin_ids is importable from bot.services.admin_cache.""" + from bot.services.admin_cache import refresh_admin_ids + assert callable(refresh_admin_ids) + + async def test_refresh_admin_ids_fetches_and_caches(self, mock_registry): + """refresh_admin_ids fetches admins and stores in bot_data.""" + from bot.services.admin_cache import refresh_admin_ids + + bot = AsyncMock() + bot.get_chat_administrators.return_value = [ + MagicMock(user=MagicMock(id=111)), + MagicMock(user=MagicMock(id=222)), + ] + + context = MagicMock() + context.bot = bot + context.bot_data = {} + + with patch("bot.services.admin_cache.get_group_registry", return_value=mock_registry): + with patch("bot.services.admin_cache.fetch_group_admin_ids") as mock_fetch: + mock_fetch.return_value = [111, 222] + await refresh_admin_ids(context) + + assert "group_admin_ids" in context.bot_data + assert "admin_ids" in context.bot_data + assert context.bot_data["group_admin_ids"][-1001234567890] == [111, 222] + assert 111 in context.bot_data["admin_ids"] + assert 222 in context.bot_data["admin_ids"] + + async def test_refresh_admin_ids_multiple_groups(self): + """refresh_admin_ids fetches admins for all groups in registry.""" + from bot.services.admin_cache import refresh_admin_ids + + registry = GroupRegistry() + registry.register(GroupConfig(group_id=-100111, warning_topic_id=1)) + registry.register(GroupConfig(group_id=-100222, warning_topic_id=2)) + + bot = AsyncMock() + context = MagicMock() + context.bot = bot + context.bot_data = {"group_admin_ids": {}, "admin_ids": []} + + with patch("bot.services.admin_cache.get_group_registry", return_value=registry): + with patch("bot.services.admin_cache.fetch_group_admin_ids") as mock_fetch: + def side_effect(bot, gid): + if gid == -100111: + return [111] + return [222] + mock_fetch.side_effect = side_effect + await refresh_admin_ids(context) + + assert -100111 in context.bot_data["group_admin_ids"] + assert -100222 in context.bot_data["group_admin_ids"] + assert context.bot_data["group_admin_ids"][-100111] == [111] + assert context.bot_data["group_admin_ids"][-100222] == [222] + assert set(context.bot_data["admin_ids"]) == {111, 222} + + async def test_refresh_admin_ids_fallback_on_error(self, mock_registry): + """On fetch error, fallback to existing cached data.""" + from bot.services.admin_cache import refresh_admin_ids + + context = MagicMock() + context.bot = AsyncMock() + context.bot_data = { + "group_admin_ids": {-1001234567890: [999]}, + "admin_ids": [999], + } + + with patch("bot.services.admin_cache.get_group_registry", return_value=mock_registry): + with patch("bot.services.admin_cache.fetch_group_admin_ids") as mock_fetch: + mock_fetch.side_effect = Exception("API error") + await refresh_admin_ids(context) + + assert context.bot_data["group_admin_ids"][-1001234567890] == [999] + assert context.bot_data["admin_ids"] == [999] + + async def test_refresh_admin_ids_not_importable_from_main(self): + """refresh_admin_ids is NOT defined in main.py anymore.""" + import bot.main as main_mod + assert not hasattr(main_mod, "refresh_admin_ids") + + async def test_jobs_imports_from_admin_cache(self): + """jobs.py imports refresh_admin_ids from bot.services.admin_cache.""" + import bot.plugins.builtin.jobs as jobs_mod + + with patch("bot.services.admin_cache.refresh_admin_ids"): + import importlib + importlib.reload(jobs_mod) + + app = MagicMock() + app.job_queue = MagicMock() + app.job_queue.run_repeating = MagicMock() + app.bot_data = {} + app.add_handler = MagicMock() + + jobs_mod.register_refresh_admin_ids_job(app) + app.job_queue.run_repeating.assert_called_once() \ No newline at end of file diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 35998ec..de25065 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -3,6 +3,8 @@ from unittest.mock import AsyncMock, MagicMock from bot.group_config import GroupConfig, GroupRegistry +import pytest + from bot.plugins import base from bot.plugins.config import guard_plugin, is_plugin_enabled, is_plugin_enabled_for_group, resolve_plugin_toggles from bot.plugins.definitions import MANIFEST_ORDER, PLUGIN_NAMES as KNOWN_PLUGINS, get_plugin_definitions @@ -134,6 +136,12 @@ def test_returned_copy_isolation(self): assert defs3[0]["name"] != "hacked" # Calling again still works assert len(defs3) == len(KNOWN_PLUGINS) + def test_verify_callback_description(self): + """verify_callback description says 'Admin verify confirm button callback' not 'Captcha verify'.""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["verify_callback"]["description"] == "Admin verify confirm button callback" + class TestManifestOrder: @@ -201,30 +209,30 @@ def test_manifest_order_topic_guard_in_group_negative_one(self): defs = {d["name"]: d for d in get_plugin_definitions()} assert defs["topic_guard"]["handler_group"] == -1 - def test_manifest_order_bio_bait_spam_in_group_two(self): - """bio_bait_spam entry has handler_group=2 (matches main.py).""" + def test_manifest_order_bio_bait_spam_in_group_six(self): + """bio_bait_spam entry has handler_group=6 (new, does not conflict with pre-refactor).""" defs = {d["name"]: d for d in get_plugin_definitions()} - assert defs["bio_bait_spam"]["handler_group"] == 2 + assert defs["bio_bait_spam"]["handler_group"] == 6 - def test_manifest_order_contact_spam_in_group_three(self): - """contact_spam entry has handler_group=3 (matches main.py).""" + def test_manifest_order_contact_spam_in_group_two(self): + """contact_spam entry has handler_group=2 (matches pre-refactor main.py).""" defs = {d["name"]: d for d in get_plugin_definitions()} - assert defs["contact_spam"]["handler_group"] == 3 + assert defs["contact_spam"]["handler_group"] == 2 - def test_manifest_order_new_user_spam_in_group_four(self): - """new_user_spam entry has handler_group=4 (matches main.py).""" + def test_manifest_order_new_user_spam_in_group_three(self): + """new_user_spam entry has handler_group=3 (matches pre-refactor main.py).""" defs = {d["name"]: d for d in get_plugin_definitions()} - assert defs["new_user_spam"]["handler_group"] == 4 + assert defs["new_user_spam"]["handler_group"] == 3 - def test_manifest_order_duplicate_spam_in_group_five(self): - """duplicate_spam entry has handler_group=5 (matches main.py).""" + def test_manifest_order_duplicate_spam_in_group_four(self): + """duplicate_spam entry has handler_group=4 (matches pre-refactor main.py).""" defs = {d["name"]: d for d in get_plugin_definitions()} - assert defs["duplicate_spam"]["handler_group"] == 5 + assert defs["duplicate_spam"]["handler_group"] == 4 - def test_manifest_order_profile_monitor_in_group_six(self): - """profile_monitor entry has handler_group=6 (matches main.py).""" + def test_manifest_order_profile_monitor_in_group_five(self): + """profile_monitor entry has handler_group=5 (matches pre-refactor main.py).""" defs = {d["name"]: d for d in get_plugin_definitions()} - assert defs["profile_monitor"]["handler_group"] == 6 + assert defs["profile_monitor"]["handler_group"] == 5 class TestBuiltinModules: @@ -673,4 +681,132 @@ async def my_handler(update, context): wrapped = guard_plugin("profile_monitor")(my_handler) assert wrapped.__name__ == "my_handler" - assert wrapped.__wrapped__ is my_handler \ No newline at end of file + assert wrapped.__wrapped__ is my_handler +class TestPluginInitExports: + """Verify bot.plugins.__init__ exports the full public API.""" + + def test_is_plugin_enabled_for_group_exported(self): + """is_plugin_enabled_for_group is exported from bot.plugins.""" + from bot.plugins import is_plugin_enabled_for_group + assert callable(is_plugin_enabled_for_group) + + def test_compute_effective_plugin_map_exported(self): + """compute_effective_plugin_map is exported from bot.plugins.""" + from bot.plugins import compute_effective_plugin_map + assert callable(compute_effective_plugin_map) + + def test_all_includes_new_exports(self): + """__all__ includes is_plugin_enabled_for_group and compute_effective_plugin_map.""" + import bot.plugins + assert "is_plugin_enabled_for_group" in bot.plugins.__all__ + assert "compute_effective_plugin_map" in bot.plugins.__all__ + + +class TestIsPluginEnabledEdgeCases: + """Edge cases for is_plugin_enabled.""" + + def test_is_plugin_enabled_missing_key_raises_key_error(self): + """Missing plugin name in toggles raises KeyError.""" + toggles = {"captcha": True, "verify": False} + with pytest.raises(KeyError): + is_plugin_enabled(toggles, "non_existent_plugin") + + +class TestComputeEffectivePluginMapEdgeCases: + """Edge cases for compute_effective_plugin_map.""" + + def test_non_group_registry_returns_empty_map(self): + """Non-GroupRegistry input returns empty dict.""" + result = compute_effective_plugin_map({}, "not_a_registry") + assert result == {} + + def test_none_registry_returns_empty_map(self): + """None input returns empty dict.""" + result = compute_effective_plugin_map({}, None) + assert result == {} + + def test_list_registry_returns_empty_map(self): + """List input returns empty dict.""" + result = compute_effective_plugin_map({}, [1, 2, 3]) + assert result == {} + + +class TestHandlerGroupsMatchPreRefactor: + """Each pre-refactor handler group must match original main.py values. + + Pre-refactor groups (from main branch): + - topic_guard: -1 + - commands, captcha, dm: 0 + - inline_keyboard_spam: 1 + - contact_spam: 2 + - new_user_spam: 3 + - duplicate_spam: 4 + - profile_monitor: 5 + """ + + def test_topic_guard_group_negative_one(self): + """topic_guard must be in group -1.""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["topic_guard"]["handler_group"] == -1 + + def test_commands_group_zero(self): + """All command/callback plugins must be in group 0.""" + command_plugins = [ + "verify", "unverify", "check", "trust", "untrust", + "trusted_list", "check_forwarded_message", + "verify_callback", "unverify_callback", "warn_callback", + "trust_callback", "untrust_callback", + ] + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + for name in command_plugins: + assert defs_by_name[name]["handler_group"] == 0, f"{name} not in group 0" + + def test_captcha_group_zero(self): + """captcha must be in group 0.""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["captcha"]["handler_group"] == 0 + + def test_dm_group_zero(self): + """dm must be in group 0.""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["dm"]["handler_group"] == 0 + + def test_inline_keyboard_spam_group_one(self): + """inline_keyboard_spam must be in group 1.""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["inline_keyboard_spam"]["handler_group"] == 1 + + def test_contact_spam_group_two(self): + """contact_spam must be in group 2 (was shifted to 3).""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["contact_spam"]["handler_group"] == 2 + + def test_new_user_spam_group_three(self): + """new_user_spam must be in group 3 (was shifted to 4).""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["new_user_spam"]["handler_group"] == 3 + + def test_duplicate_spam_group_four(self): + """duplicate_spam must be in group 4 (was shifted to 5).""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["duplicate_spam"]["handler_group"] == 4 + + def test_profile_monitor_group_five(self): + """profile_monitor must be in group 5 (was shifted to 6).""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["profile_monitor"]["handler_group"] == 5 + + def test_bio_bait_spam_not_in_group_two(self): + """bio_bait_spam must NOT use group 2 (that was contact_spam's original group).""" + defs = get_plugin_definitions() + defs_by_name = {d["name"]: d for d in defs} + assert defs_by_name["bio_bait_spam"]["handler_group"] != 2