diff --git a/.env.example b/.env.example index 4cb6079..20dbcb0 100644 --- a/.env.example +++ b/.env.example @@ -58,11 +58,29 @@ 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. # 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/.gitignore b/.gitignore index 6cee43e..10d075c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ __pycache__/ data/ .vscode # AGENTS.md +.worktrees/ 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. diff --git a/groups.json.example b/groups.json.example index b2f99ba..048c68c 100644 --- a/groups.json.example +++ b/groups.json.example @@ -15,7 +15,15 @@ "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, + "plugins": { + "captcha": false, + "dm": true, + "verify": true + } }, { "group_id": -1009876543210, @@ -33,6 +41,14 @@ "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, + "plugins": { + "contact_spam": false, + "duplicate_spam": false, + "profile_monitor": true + } } -] +] \ No newline at end of file diff --git a/src/bot/config.py b/src/bot/config.py index 285d8c5..bbca41b 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -6,16 +6,19 @@ (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 -logger = logging.getLogger(__name__) +from bot.group_config import KNOWN_PLUGINS +logger = logging.getLogger(__name__) def get_env_file() -> str | None: """ @@ -32,7 +35,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(): @@ -42,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. @@ -85,12 +87,16 @@ 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 + 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" 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(), @@ -98,6 +104,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: @@ -115,7 +150,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}") @@ -127,9 +162,13 @@ 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}") + logger.debug(f"plugins_default: {self.plugins_default}") @property def probation_timedelta(self) -> timedelta: @@ -143,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: """ @@ -155,4 +193,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/constants.py b/src/bot/constants.py index bfd6b75..e1a5bfc 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -278,6 +278,51 @@ 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})" +) + +# 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 224d70a..0530f7d 100644 --- a/src/bot/group_config.py +++ b/src/bot/group_config.py @@ -15,6 +15,8 @@ from pydantic import BaseModel, field_validator from telegram import Update +from bot.plugins.definitions import PLUGIN_NAMES as KNOWN_PLUGINS + logger = logging.getLogger(__name__) @@ -41,6 +43,10 @@ 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 + 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 @@ -77,6 +83,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: object) -> dict[str, bool] | None: + if v is None: + return None + 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) @@ -193,6 +213,10 @@ 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=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) @@ -259,4 +283,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/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 new file mode 100644 index 0000000..57be5ee --- /dev/null +++ b/src/bot/handlers/bio_bait.py @@ -0,0 +1,467 @@ +""" +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 + (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. +""" + +import logging +import re +import unicodedata +from time import monotonic + +from telegram import Update +from telegram.ext import ApplicationHandlerStop, ContextTypes, filters as _filters + +from bot.constants import ( + BIO_BAIT_MONITOR_ALERT, + 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.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 +# 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. +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 +USER_BIO_CACHE_MAX_SIZE = 2000 + +# 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]") + +# 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 + 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" + 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}" + 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 (t.me/+). +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 + + mention_count = sum( + 1 for m in TELEGRAM_USERNAME_RE.finditer(normalized) + if m.group(1).lower() not in WHITELISTED_TELEGRAM_PATHS + ) + if mention_count >= 2: + return True + if mention_count == 1 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) + + +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(f"Failed to send bio bait monitor alert: user_id={user_id}, group_id={group_id}") + return False + + +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] + + 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(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) + 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. 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. + 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 + + if is_user_admin_or_trusted(context, group_config.group_id, user.id): + return + + 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: + 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 + + logger.info( + 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 + record_bio_bait_detection_metrics(context, detection_reason, monitor_only) + + 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( + 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: + 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( + f"Bio bait monitor-only mode: no delete/restrict (user_id={user.id}, group_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}") + 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..5fefece 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -1,47 +1,20 @@ """ 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.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 @@ -117,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. @@ -208,6 +154,7 @@ 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) @@ -220,8 +167,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 + 5. Registers all handlers and jobs via PluginManager in MANIFEST_ORDER + 6. Computes per-group effective plugin toggle map for runtime gating 7. Starts the bot polling loop """ # Configure logging first @@ -248,172 +195,15 @@ 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)") - - # 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)") + # Register all handlers and jobs via PluginManager in deterministic order + pm = PluginManager() + plugin_handlers = pm.register_all(application) - # 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)") + logger.info(f"Registered {sum(len(h) for h in plugin_handlers.values())} handler(s) across {len(plugin_handlers)} plugin(s)") - # 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: Contact spam handler - blocks contact card sharing for all members - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS & filters.CONTACT, - handle_contact_spam, - ), - group=2, - ) - logger.info("Registered handler: contact_spam_handler (group=2)") - - # 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=3, - ) - logger.info("Registered handler: anti_spam_handler (group=3)") - - # Handler 10: Duplicate message spam handler - detects repeated identical messages - application.add_handler( - MessageHandler( - filters.ChatType.GROUPS & ~filters.COMMAND, - handle_duplicate_spam, - ), - group=4, - ) - logger.info("Registered handler: duplicate_spam_handler (group=4)") - - # 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=5, - ) - logger.info("Registered handler: message_handler (group=5)") - - # 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)") + # 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") @@ -422,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 new file mode 100644 index 0000000..2f8c09e --- /dev/null +++ b/src/bot/plugins/__init__.py @@ -0,0 +1,44 @@ +"""Plugin system for PythonID bot. + +Provides base contracts, toggle resolution, plugin definitions, +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, 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", +] + + +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/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/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..b055ba7 --- /dev/null +++ b/src/bot/plugins/builtin/captcha.py @@ -0,0 +1,55 @@ +"""Built-in plugin: captcha. + +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. +""" + +from __future__ import annotations + +import logging +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. + + 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) --- + +# Coarse plugin class for API compatibility. Unused by PluginManager. +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.""" + 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 new file mode 100644 index 0000000..e36757c --- /dev/null +++ b/src/bot/plugins/builtin/commands.py @@ -0,0 +1,178 @@ +"""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. + +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. +""" + +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__) + + +# --- 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, + # 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] + + +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) --- + +# Coarse plugin class for API compatibility. Unused by PluginManager. +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] = [] + 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 + + +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..d4835c7 --- /dev/null +++ b/src/bot/plugins/builtin/dm.py @@ -0,0 +1,53 @@ +"""Built-in plugin: dm. + +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 + +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__) + + +# --- 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) --- + +# Coarse plugin class for API compatibility. Unused by PluginManager. +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.""" + 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 new file mode 100644 index 0000000..20ad5fa --- /dev/null +++ b/src/bot/plugins/builtin/jobs.py @@ -0,0 +1,68 @@ +"""Built-in plugin: jobs. + +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 + +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: + from telegram.ext import Application, BaseHandler + +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).""" + 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) --- + +# Coarse plugin class for API compatibility. Unused by PluginManager. +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] = [] + handlers.extend(register_auto_restrict_job(application)) + 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 new file mode 100644 index 0000000..6a13bfc --- /dev/null +++ b/src/bot/plugins/builtin/profile_monitor.py @@ -0,0 +1,56 @@ +"""Built-in plugin: profile_monitor. + +Wraps ``bot.handlers.message.handle_message`` for profile compliance +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`` +for fine-grained plugin registration. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +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=5). + + The callback is wrapped with ``guard_plugin("profile_monitor")`` for + runtime per-group enable/disable gating. + """ + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + guard_plugin("profile_monitor")(handle_message), + ) + 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 = 5 + + def register(self, application: Application) -> list[BaseHandler]: # type: ignore[type-arg] + """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 new file mode 100644 index 0000000..2f554c9 --- /dev/null +++ b/src/bot/plugins/builtin/spam.py @@ -0,0 +1,116 @@ +"""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. 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. +""" + +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 +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). + + Callback wrapped with ``guard_plugin("inline_keyboard_spam")``. + """ + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS, + 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=6). + + Callback wrapped with ``guard_plugin("bio_bait_spam")``. + """ + handler: BaseHandler = MessageHandler( + BIO_BAIT_FILTER, + guard_plugin("bio_bait_spam")(handle_bio_bait_spam), + ) + 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=2). + + Callback wrapped with ``guard_plugin("contact_spam")``. + """ + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & filters.CONTACT, + guard_plugin("contact_spam")(handle_contact_spam), + ) + 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=3). + + Callback wrapped with ``guard_plugin("new_user_spam")``. + """ + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS, + guard_plugin("new_user_spam")(handle_new_user_spam), + ) + 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=4). + + Callback wrapped with ``guard_plugin("duplicate_spam")``. + """ + handler: BaseHandler = MessageHandler( + filters.ChatType.GROUPS & ~filters.COMMAND, + guard_plugin("duplicate_spam")(handle_duplicate_spam), + ) + 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.""" + + 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] = [] + 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 + +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..ede39dc --- /dev/null +++ b/src/bot/plugins/builtin/topic_guard.py @@ -0,0 +1,56 @@ +"""Built-in plugin: topic_guard. + +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. +""" + +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 +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). + + 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_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) --- + +# Coarse plugin class for API compatibility. Unused by PluginManager. +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.""" + 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 new file mode 100644 index 0000000..7ae0d6b --- /dev/null +++ b/src/bot/plugins/config.py @@ -0,0 +1,152 @@ +"""Plugin toggle resolution and runtime guard wrapper. + +Provides deterministic resolution of plugin enabled/disabled state +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.plugins.definitions import PLUGIN_NAMES + +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, +) -> 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 ``PLUGIN_NAMES`` name to its resolved bool. + """ + result: dict[str, bool] = {} + + for name in PLUGIN_NAMES: + # 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] + +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`` / ``PLUGIN_NAMES``. + + 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 + +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`` / ``PLUGIN_NAMES``. + + 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/src/bot/plugins/definitions.py b/src/bot/plugins/definitions.py new file mode 100644 index 0000000..60774b0 --- /dev/null +++ b/src/bot/plugins/definitions.py @@ -0,0 +1,70 @@ +"""Plugin definitions and manifest for the PythonID bot. + +Provides the canonical mapping from plugin names to human-readable +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 + +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. +# 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"}, + {"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": "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"}, + {"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": 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)"}, +] + +# 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] +"""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. + + Returns: + List of plugin descriptor dicts with keys: name, handler_group, description. + """ + return copy.deepcopy(_PLUGIN_DEFINITIONS) \ 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..17b222b --- /dev/null +++ b/src/bot/plugins/manager.py @@ -0,0 +1,191 @@ +"""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"] + + # 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 + +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.config import resolve_plugin_toggles +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"]] + + +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. + + 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 + 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] = {} + 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 + + 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/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/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_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_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 new file mode 100644 index 0000000..838c5a0 --- /dev/null +++ b/tests/test_bio_bait.py @@ -0,0 +1,730 @@ +"""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, + 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, + 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_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" + + 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", + "bัŒั–ะพ 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", + "", + # 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 + + 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 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/+exampleinvitehash") 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_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 + + 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 + + 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.""" + + @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) + + 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 + +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 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): + 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/+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): + 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/+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): + 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/+exampleinvitehash" + 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) + + 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 + +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 + + +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" + ) diff --git a/tests/test_config.py b/tests/test_config.py index 32752d0..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") @@ -153,6 +155,177 @@ 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 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): @@ -212,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 f7ae56d..87de5e8 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) @@ -28,6 +27,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 +42,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"): @@ -109,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): @@ -152,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 = [ @@ -240,6 +267,78 @@ 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} + + 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): @@ -277,6 +376,9 @@ 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 + settings.plugins_default = {} registry = build_group_registry(settings) @@ -289,7 +391,40 @@ 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 + 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): @@ -335,7 +470,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() @@ -364,6 +498,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() @@ -386,9 +521,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 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 diff --git a/tests/test_plugin_config.py b/tests/test_plugin_config.py new file mode 100644 index 0000000..8c0db2c --- /dev/null +++ b/tests/test_plugin_config.py @@ -0,0 +1,234 @@ +"""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 +from bot.plugins.definitions import PLUGIN_NAMES as 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 diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py new file mode 100644 index 0000000..de25065 --- /dev/null +++ b/tests/test_plugin_manager.py @@ -0,0 +1,812 @@ +"""Tests for plugin toggle resolver, plugin contracts, definitions, and built-in wrappers.""" + +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 +from bot.plugins.manager import PluginManager, compute_effective_plugin_map + + +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") + + +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() + 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) + 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: + """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_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"] == 6 + + 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"] == 2 + + 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"] == 3 + + 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"] == 4 + + 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"] == 5 + + +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)) + + +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 + + +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 +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