diff --git a/.env.example b/.env.example index 5945b09..a6f2cbc 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ CAPTCHA_ENABLED=false # Default: 120 seconds (2 minutes) CAPTCHA_TIMEOUT_SECONDS=120 +# 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 + # 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 6e7ec68..28ed532 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env .env.staging +groups.json .venv/ __pycache__/ *.pyc diff --git a/groups.json.example b/groups.json.example new file mode 100644 index 0000000..fca1559 --- /dev/null +++ b/groups.json.example @@ -0,0 +1,26 @@ +[ + { + "group_id": -1001234567890, + "warning_topic_id": 123, + "restrict_failed_users": false, + "warning_threshold": 3, + "warning_time_threshold_minutes": 180, + "captcha_enabled": false, + "captcha_timeout_seconds": 120, + "new_user_probation_hours": 72, + "new_user_violation_threshold": 3, + "rules_link": "https://t.me/pythonID/290029/321799" + }, + { + "group_id": -1009876543210, + "warning_topic_id": 456, + "restrict_failed_users": true, + "warning_threshold": 5, + "warning_time_threshold_minutes": 60, + "captcha_enabled": true, + "captcha_timeout_seconds": 180, + "new_user_probation_hours": 168, + "new_user_violation_threshold": 2, + "rules_link": "https://t.me/mygroup/rules" + } +] diff --git a/src/bot/config.py b/src/bot/config.py index 9b17911..92f3334 100644 --- a/src/bot/config.py +++ b/src/bot/config.py @@ -79,6 +79,7 @@ class Settings(BaseSettings): captcha_timeout_seconds: int = 120 new_user_probation_hours: int = 72 # 3 days default new_user_violation_threshold: int = 3 # restrict after this many violations + groups_config_path: str = "groups.json" logfire_token: str | None = None logfire_service_name: str = "pythonid-bot" logfire_environment: str = "production" diff --git a/src/bot/database/service.py b/src/bot/database/service.py index 8dfbaaf..75f9e74 100644 --- a/src/bot/database/service.py +++ b/src/bot/database/service.py @@ -374,6 +374,32 @@ def get_warnings_past_time_threshold( ) return [record for record in records] + def get_warnings_past_time_threshold_for_group( + self, group_id: int, threshold: timedelta + ) -> list[UserWarning]: + """ + Find active warnings for a specific group that exceeded the time threshold. + + Args: + group_id: Telegram group ID to filter by. + threshold: Time duration since first warning to trigger restriction. + + Returns: + list[UserWarning]: Warning records that should be auto-restricted. + """ + with Session(self._engine) as session: + cutoff_time = datetime.now(UTC) - threshold + statement = select(UserWarning).where( + UserWarning.group_id == group_id, + ~UserWarning.is_restricted, + UserWarning.first_warned_at <= cutoff_time, + ) + records = session.exec(statement).all() + logger.info( + f"Found {len(records)} warnings past {threshold} threshold for group {group_id}" + ) + return [record for record in records] + def add_pending_captcha( self, user_id: int, diff --git a/src/bot/group_config.py b/src/bot/group_config.py new file mode 100644 index 0000000..97ce9af --- /dev/null +++ b/src/bot/group_config.py @@ -0,0 +1,246 @@ +""" +Multi-group configuration for the PythonID bot. + +This module provides per-group settings via GroupConfig and a GroupRegistry +that allows a single bot instance to manage multiple Telegram groups. +Groups can be configured via a groups.json file or fall back to the +single-group .env configuration for backward compatibility. +""" + +import json +import logging +from datetime import timedelta +from pathlib import Path + +from pydantic import BaseModel, field_validator +from telegram import Update + +logger = logging.getLogger(__name__) + + +class GroupConfig(BaseModel): + """ + Per-group configuration settings. + + Each monitored group has its own set of feature flags and thresholds. + """ + + group_id: int + warning_topic_id: int + restrict_failed_users: bool = False + warning_threshold: int = 3 + warning_time_threshold_minutes: int = 180 + captcha_enabled: bool = False + captcha_timeout_seconds: int = 120 + new_user_probation_hours: int = 72 + new_user_violation_threshold: int = 3 + rules_link: str = "https://t.me/pythonID/290029/321799" + + @field_validator("group_id") + @classmethod + def group_id_must_be_negative(cls, v: int) -> int: + if v >= 0: + raise ValueError("group_id must be negative (Telegram supergroup IDs are negative)") + return v + + @field_validator("warning_threshold") + @classmethod + def warning_threshold_must_be_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("warning_threshold must be greater than 0") + return v + + @field_validator("warning_time_threshold_minutes") + @classmethod + def warning_time_threshold_must_be_positive(cls, v: int) -> int: + if v <= 0: + raise ValueError("warning_time_threshold_minutes must be greater than 0") + return v + + @field_validator("captcha_timeout_seconds") + @classmethod + def captcha_timeout_must_be_in_range(cls, v: int) -> int: + if not (10 <= v <= 600): + raise ValueError("captcha_timeout_seconds must be between 10 and 600 seconds") + return v + + @field_validator("new_user_probation_hours") + @classmethod + def probation_hours_must_be_non_negative(cls, v: int) -> int: + if v < 0: + raise ValueError("new_user_probation_hours must be >= 0") + return v + + @property + def probation_timedelta(self) -> timedelta: + return timedelta(hours=self.new_user_probation_hours) + + @property + def warning_time_threshold_timedelta(self) -> timedelta: + return timedelta(minutes=self.warning_time_threshold_minutes) + + @property + def captcha_timeout_timedelta(self) -> timedelta: + return timedelta(seconds=self.captcha_timeout_seconds) + + +class GroupRegistry: + """ + Registry of monitored groups. + + Provides O(1) lookup by group_id and iteration over all groups. + """ + + def __init__(self) -> None: + self._groups: dict[int, GroupConfig] = {} + + def register(self, config: GroupConfig) -> None: + if config.group_id in self._groups: + raise ValueError(f"Duplicate group_id: {config.group_id}") + self._groups[config.group_id] = config + logger.info(f"Registered group {config.group_id} (warning_topic={config.warning_topic_id})") + + def get(self, group_id: int) -> GroupConfig | None: + return self._groups.get(group_id) + + def all_groups(self) -> list[GroupConfig]: + return list(self._groups.values()) + + def is_monitored(self, group_id: int) -> bool: + return group_id in self._groups + + +def load_groups_from_json(path: str) -> list[GroupConfig]: + """ + Parse a groups.json file into a list of GroupConfig objects. + + Args: + path: Path to the JSON file. + + Returns: + List of GroupConfig instances. + + Raises: + FileNotFoundError: If the file doesn't exist. + json.JSONDecodeError: If the file is not valid JSON. + ValueError: If the JSON structure is invalid. + """ + with open(path) as f: + data = json.load(f) + + if not isinstance(data, list): + raise ValueError("groups.json must contain a JSON array of group objects") + + if not data: + raise ValueError("groups.json must contain at least one group") + + configs = [GroupConfig(**item) for item in data] + + # Check for duplicate group_ids + seen_ids: set[int] = set() + for config in configs: + if config.group_id in seen_ids: + raise ValueError(f"Duplicate group_id in groups.json: {config.group_id}") + seen_ids.add(config.group_id) + + return configs + + +def build_group_registry(settings: object) -> GroupRegistry: + """ + Build a GroupRegistry from settings. + + If groups.json exists at the configured path, loads from it. + Otherwise creates a single GroupConfig from .env fields (backward compatible). + + Args: + settings: Application Settings instance. + + Returns: + Populated GroupRegistry. + """ + registry = GroupRegistry() + groups_path = getattr(settings, "groups_config_path", "groups.json") + + if Path(groups_path).exists(): + logger.info(f"Loading group configuration from {groups_path}") + configs = load_groups_from_json(groups_path) + for config in configs: + registry.register(config) + logger.info(f"Loaded {len(configs)} group(s) from {groups_path}") + else: + logger.info("No groups.json found, using single-group config from .env") + config = GroupConfig( + group_id=settings.group_id, + warning_topic_id=settings.warning_topic_id, + restrict_failed_users=settings.restrict_failed_users, + warning_threshold=settings.warning_threshold, + warning_time_threshold_minutes=settings.warning_time_threshold_minutes, + captcha_enabled=settings.captcha_enabled, + captcha_timeout_seconds=settings.captcha_timeout_seconds, + new_user_probation_hours=settings.new_user_probation_hours, + new_user_violation_threshold=settings.new_user_violation_threshold, + rules_link=settings.rules_link, + ) + registry.register(config) + + return registry + + +def get_group_config_for_update(update: Update) -> GroupConfig | None: + """ + Get the GroupConfig for the group in the given Update. + + Returns None if the update's chat is not a monitored group. + + Args: + update: Telegram Update object. + + Returns: + GroupConfig if the chat is monitored, None otherwise. + """ + if not update.effective_chat: + return None + return get_group_registry().get(update.effective_chat.id) + + +# Module-level singleton +_registry: GroupRegistry | None = None + + +def init_group_registry(settings: object) -> GroupRegistry: + """ + Initialize the global group registry singleton. + + Must be called once at application startup. + + Args: + settings: Application Settings instance. + + Returns: + Initialized GroupRegistry. + """ + global _registry + _registry = build_group_registry(settings) + return _registry + + +def get_group_registry() -> GroupRegistry: + """ + Get the global group registry singleton. + + Returns: + GroupRegistry instance. + + Raises: + RuntimeError: If init_group_registry() hasn't been called. + """ + if _registry is None: + raise RuntimeError("Group registry not initialized. Call init_group_registry() first.") + return _registry + + +def reset_group_registry() -> None: + """Reset the group registry singleton (for testing).""" + global _registry + _registry = None diff --git a/src/bot/handlers/anti_spam.py b/src/bot/handlers/anti_spam.py index c5e1e37..71823ad 100644 --- a/src/bot/handlers/anti_spam.py +++ b/src/bot/handlers/anti_spam.py @@ -14,7 +14,6 @@ from telegram import Message, MessageEntity, Update from telegram.ext import ContextTypes -from bot.config import get_settings from bot.constants import ( NEW_USER_SPAM_RESTRICTION, NEW_USER_SPAM_WARNING, @@ -24,6 +23,7 @@ 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 logger = logging.getLogger(__name__) @@ -140,20 +140,20 @@ def is_url_whitelisted(url: str) -> bool: # 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 @@ -214,12 +214,12 @@ async def handle_new_user_spam( if not update.message or not update.message.from_user: return - settings = get_settings() + group_config = get_group_config_for_update(update) chat = update.effective_chat user = update.message.from_user - # Only process messages from the configured group - if not chat or chat.id != settings.group_id: + # Only process messages from monitored groups + if group_config is None: return # Ignore bots @@ -227,7 +227,7 @@ async def handle_new_user_spam( return db = get_database() - record = db.get_new_user_probation(user.id, settings.group_id) + record = db.get_new_user_probation(user.id, group_config.group_id) # User not on probation if not record: @@ -240,9 +240,9 @@ async def handle_new_user_spam( joined_at = joined_at.replace(tzinfo=UTC) now = datetime.now(UTC) - probation_end = joined_at + settings.probation_timedelta + probation_end = joined_at + group_config.probation_timedelta if now >= probation_end: - db.clear_new_user_probation(user.id, settings.group_id) + db.clear_new_user_probation(user.id, group_config.group_id) logger.info(f"Probation expired for user_id={user.id}, cleared record") return @@ -270,20 +270,20 @@ async def handle_new_user_spam( ) # 2. Increment violation count - record = db.increment_new_user_violation(user.id, settings.group_id) + record = db.increment_new_user_violation(user.id, group_config.group_id) # 3. First violation: send warning to warning topic if record.violation_count == 1: - probation_display = format_hours_display(settings.new_user_probation_hours) + probation_display = format_hours_display(group_config.new_user_probation_hours) warning_text = NEW_USER_SPAM_WARNING.format( user_mention=user_mention, probation_display=probation_display, - rules_link=settings.rules_link, + rules_link=group_config.rules_link, ) try: await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, text=warning_text, parse_mode="Markdown", ) @@ -295,10 +295,10 @@ async def handle_new_user_spam( ) # 4. Threshold reached: restrict user and notify - if record.violation_count == settings.new_user_violation_threshold: + if record.violation_count == group_config.new_user_violation_threshold: try: await context.bot.restrict_chat_member( - chat_id=settings.group_id, + chat_id=group_config.group_id, user_id=user.id, permissions=RESTRICTED_PERMISSIONS, ) @@ -311,11 +311,11 @@ async def handle_new_user_spam( restriction_text = NEW_USER_SPAM_RESTRICTION.format( user_mention=user_mention, violation_count=record.violation_count, - rules_link=settings.rules_link, + rules_link=group_config.rules_link, ) await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, text=restriction_text, parse_mode="Markdown", ) diff --git a/src/bot/handlers/captcha.py b/src/bot/handlers/captcha.py index 14b7391..5429bbd 100644 --- a/src/bot/handlers/captcha.py +++ b/src/bot/handlers/captcha.py @@ -19,7 +19,6 @@ filters, ) -from bot.config import Settings, get_settings from bot.constants import ( CAPTCHA_FAILED_VERIFICATION_MESSAGE, CAPTCHA_VERIFIED_MESSAGE, @@ -28,6 +27,7 @@ RESTRICTED_PERMISSIONS, ) from bot.database.service import get_database +from bot.group_config import GroupConfig, get_group_config_for_update, get_group_registry from bot.services.telegram_utils import get_user_mention, unrestrict_user logger = logging.getLogger(__name__) @@ -51,7 +51,7 @@ async def _initiate_captcha_challenge( context: ContextTypes.DEFAULT_TYPE, user: User, chat_id: int, - settings: Settings, + group_config: GroupConfig, ) -> None: """ Initiate captcha challenge for a new member. @@ -62,7 +62,7 @@ async def _initiate_captcha_challenge( context: Bot context with helper methods and job queue. user: The user to challenge. chat_id: The group chat ID. - settings: Bot settings. + group_config: Per-group configuration. """ user_id = user.id user_mention = get_user_mention(user) @@ -87,12 +87,12 @@ async def _initiate_captcha_challenge( welcome_message = CAPTCHA_WELCOME_MESSAGE.format( user_mention=user_mention, - timeout=settings.captcha_timeout_seconds, + timeout=group_config.captcha_timeout_seconds, ) sent_message = await context.bot.send_message( chat_id=chat_id, - message_thread_id=settings.warning_topic_id, + message_thread_id=group_config.warning_topic_id, text=welcome_message, parse_mode="Markdown", reply_markup=keyboard, @@ -102,7 +102,7 @@ async def _initiate_captcha_challenge( try: db.add_pending_captcha( user_id=user_id, - group_id=settings.group_id, + group_id=group_config.group_id, chat_id=sent_message.chat_id, message_id=sent_message.message_id, user_full_name=user.full_name, @@ -111,14 +111,14 @@ async def _initiate_captcha_challenge( logger.info(f"Captcha already exists for user {user_id} (race condition handled)") return - job_name = get_captcha_job_name(settings.group_id, user_id) + job_name = get_captcha_job_name(group_config.group_id, user_id) context.job_queue.run_once( captcha_timeout_callback, - when=settings.captcha_timeout_seconds, + when=group_config.captcha_timeout_seconds, name=job_name, data={ "user_id": user_id, - "group_id": settings.group_id, + "group_id": group_config.group_id, "chat_id": sent_message.chat_id, "message_id": sent_message.message_id, "user_full_name": user.full_name, @@ -127,7 +127,7 @@ async def _initiate_captcha_challenge( logger.info( f"Sent captcha challenge to user {user_id} ({user.full_name}), " - f"timeout in {settings.captcha_timeout_seconds}s" + f"timeout in {group_config.captcha_timeout_seconds}s" ) @@ -149,12 +149,12 @@ async def new_member_handler( logger.info("No message or no new chat members, skipping") return - settings = get_settings() + group_config = get_group_config_for_update(update) - if update.effective_chat and update.effective_chat.id != settings.group_id: - logger.info(f"Message from wrong chat {update.effective_chat.id}, expected {settings.group_id}, skipping") + if group_config is None: + logger.info(f"Message from unmonitored chat {update.effective_chat.id if update.effective_chat else None}, skipping") return - + logger.info(f"Processing new members: {len(update.message.new_chat_members)} member(s)") db = get_database() @@ -165,18 +165,18 @@ async def new_member_handler( user_id = new_member.id # Start probation for all new users (regardless of captcha setting) - db.start_new_user_probation(user_id, settings.group_id) + db.start_new_user_probation(user_id, group_config.group_id) # If captcha is disabled, we're done - user just gets probation - if not settings.captcha_enabled: + if not group_config.captcha_enabled: logger.info(f"Captcha disabled, probation started for user {user_id}") continue - if db.get_pending_captcha(user_id, settings.group_id): + if db.get_pending_captcha(user_id, group_config.group_id): logger.info(f"Captcha already pending for user {user_id}, skipping duplicate (new_member_handler)") continue - await _initiate_captcha_challenge(context, new_member, settings.group_id, settings) + await _initiate_captcha_challenge(context, new_member, group_config.group_id, group_config) async def chat_member_handler( @@ -197,10 +197,10 @@ async def chat_member_handler( logger.info("No chat_member in update, skipping") return - settings = get_settings() + group_config = get_group_config_for_update(update) - if update.effective_chat and update.effective_chat.id != settings.group_id: - logger.info(f"Update from wrong chat {update.effective_chat.id}, expected {settings.group_id}, skipping") + if group_config is None: + logger.info(f"Update from unmonitored chat {update.effective_chat.id if update.effective_chat else None}, skipping") return old_status = update.chat_member.old_chat_member.status @@ -229,20 +229,20 @@ async def chat_member_handler( # Start probation for all new users (regardless of captcha setting) db = get_database() - db.start_new_user_probation(new_member.id, settings.group_id) + db.start_new_user_probation(new_member.id, group_config.group_id) # If captcha is disabled, we're done - user just gets probation - if not settings.captcha_enabled: + if not group_config.captcha_enabled: logger.info(f"Captcha disabled, probation started for user {new_member.id}") return user_id = new_member.id - if db.get_pending_captcha(user_id, settings.group_id): + if db.get_pending_captcha(user_id, group_config.group_id): logger.info(f"Captcha already pending for user {user_id}, skipping duplicate (chat_member_handler)") return - await _initiate_captcha_challenge(context, new_member, settings.group_id, settings) + await _initiate_captcha_challenge(context, new_member, group_config.group_id, group_config) async def captcha_callback_handler( @@ -271,27 +271,41 @@ async def captcha_callback_handler( await query.answer(CAPTCHA_WRONG_USER_MESSAGE, show_alert=True) return - settings = get_settings() + # Look up which group this captcha belongs to + db = get_database() + registry = get_group_registry() + + # Find the group for this pending captcha + group_config = None + for gc in registry.all_groups(): + pending = db.get_pending_captcha(target_user_id, gc.group_id) + if pending: + group_config = gc + break + + if group_config is None: + logger.warning(f"No pending captcha found for user {target_user_id} in any monitored group") + await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) + return - job_name = get_captcha_job_name(settings.group_id, target_user_id) + job_name = get_captcha_job_name(group_config.group_id, target_user_id) current_jobs = context.job_queue.get_jobs_by_name(job_name) for job in current_jobs: job.schedule_removal() logger.info(f"Cancelled timeout job for user {target_user_id}") try: - await unrestrict_user(context.bot, settings.group_id, target_user_id) + await unrestrict_user(context.bot, group_config.group_id, target_user_id) logger.info(f"Unrestricted verified user {target_user_id}") except Exception as e: logger.error(f"Failed to unrestrict user {target_user_id}: {e}") await query.answer(CAPTCHA_FAILED_VERIFICATION_MESSAGE, show_alert=True) return # Stop execution here so user can retry - db = get_database() - db.remove_pending_captcha(target_user_id, settings.group_id) + db.remove_pending_captcha(target_user_id, group_config.group_id) # Start anti-spam probation for verified user - db.start_new_user_probation(target_user_id, settings.group_id) + db.start_new_user_probation(target_user_id, group_config.group_id) user_mention = get_user_mention(query.from_user) @@ -344,7 +358,7 @@ def get_handlers() -> list: Return list of handlers to register for captcha verification. Returns: - list: List containing chat member handler, message handler (fallback), + list: List containing chat member handler, message handler (fallback), and callback query handler. """ return [ diff --git a/src/bot/handlers/check.py b/src/bot/handlers/check.py index 9a3c509..177493f 100644 --- a/src/bot/handlers/check.py +++ b/src/bot/handlers/check.py @@ -23,6 +23,7 @@ MISSING_ITEMS_SEPARATOR, ) from bot.database.service import get_database +from bot.group_config import get_group_registry from bot.services.telegram_utils import ( extract_forwarded_user, get_user_mention, @@ -38,12 +39,12 @@ async def _build_check_response( ) -> tuple[str, InlineKeyboardMarkup | None]: """ Build the check response message and keyboard. - + Args: bot: Telegram bot instance. user_id: ID of the user to check. user_name: Display name of the user. - + Returns: Tuple of (message text, optional keyboard markup). """ @@ -57,10 +58,10 @@ async def _build_check_response( user_mention = get_user_mention_by_id(user_id, user_name) photo_status = "✅" if result.has_profile_photo else "❌" username_status = "✅" if result.has_username else "❌" - + db = get_database() is_whitelisted = db.is_user_photo_whitelisted(user_id) - + if result.is_complete: action_prompt = ADMIN_CHECK_ACTION_COMPLETE if is_whitelisted: @@ -83,7 +84,7 @@ async def _build_check_response( InlineKeyboardButton("✅ Verify User", callback_data=f"verify:{user_id}"), ] ]) - + message = ADMIN_CHECK_PROMPT.format( user_mention=user_mention, user_id=user_id, @@ -91,7 +92,7 @@ async def _build_check_response( username_status=username_status, action_prompt=action_prompt, ) - + return message, keyboard @@ -100,9 +101,9 @@ async def handle_check_command( ) -> None: """ Handle /check command to manually check a user's profile. - + Usage: /check USER_ID (e.g., /check 123456789) - + Only works in bot DMs for admins. """ if not update.message or not update.message.from_user: @@ -139,10 +140,10 @@ async def handle_check_command( # Get user info for display name chat = await context.bot.get_chat(target_user_id) user_name = chat.full_name or f"User {target_user_id}" - + message, keyboard = await _build_check_response(context.bot, target_user_id, user_name) await update.message.reply_text(message, reply_markup=keyboard, parse_mode="Markdown") - + logger.info( f"Admin {admin_user_id} ({update.message.from_user.full_name}) " f"checked profile for user {target_user_id}" @@ -160,7 +161,7 @@ async def handle_check_forwarded_message( ) -> None: """ Handle forwarded messages from admins to check user profile. - + When an admin forwards a user's message to the bot in DM, this handler checks the user's profile and shows action buttons. """ @@ -191,7 +192,7 @@ async def handle_check_forwarded_message( try: message, keyboard = await _build_check_response(context.bot, user_id, user_name) await update.message.reply_text(message, reply_markup=keyboard, parse_mode="Markdown") - + logger.info( f"Admin {admin_user_id} ({update.message.from_user.full_name}) " f"forwarded message from user {user_id} for profile check" @@ -209,8 +210,8 @@ async def handle_warn_callback( ) -> None: """ Handle callback query for warn button. - - Sends a warning message to the user in the group. + + Sends a warning message to the user in all monitored groups (or the first group). """ query = update.callback_query if not query or not query.from_user or not query.data: @@ -248,33 +249,42 @@ async def handle_warn_callback( missing_text = MISSING_ITEMS_SEPARATOR.join(missing_items) if missing_items else "profil" settings = get_settings() - + registry = get_group_registry() + try: # Get user info for mention chat = await context.bot.get_chat(target_user_id) user_mention = get_user_mention(chat) - - # Send warning to group - warn_message = ADMIN_WARN_USER_MESSAGE.format( - user_mention=user_mention, - missing_text=missing_text, - rules_link=settings.rules_link, - ) - await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, - text=warn_message, - parse_mode="Markdown", - ) - - # Update the original message - success_message = ADMIN_WARN_SENT_MESSAGE.format(user_mention=user_mention) - await query.edit_message_text(success_message, parse_mode="Markdown") - - logger.info( - f"Admin {admin_user_id} ({query.from_user.full_name}) " - f"sent warning to user {target_user_id} in group" - ) + + # Send warning to all monitored groups + sent_to_any = False + for group_config in registry.all_groups(): + warn_message = ADMIN_WARN_USER_MESSAGE.format( + user_mention=user_mention, + missing_text=missing_text, + rules_link=group_config.rules_link, + ) + try: + await context.bot.send_message( + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, + text=warn_message, + parse_mode="Markdown", + ) + sent_to_any = True + logger.info( + f"Admin {admin_user_id} sent warning to user {target_user_id} in group {group_config.group_id}" + ) + except Exception as e: + logger.error(f"Failed to send warning to group {group_config.group_id}: {e}") + + if sent_to_any: + # Update the original message + success_message = ADMIN_WARN_SENT_MESSAGE.format(user_mention=user_mention) + await query.edit_message_text(success_message, parse_mode="Markdown") + else: + await query.edit_message_text("❌ Gagal mengirim peringatan ke semua grup.") + except TimedOut: await query.edit_message_text("⏳ Request timeout. Silakan coba lagi.") logger.warning(f"Timeout sending warning to user {target_user_id}") diff --git a/src/bot/handlers/dm.py b/src/bot/handlers/dm.py index b307a41..fb4c5f1 100644 --- a/src/bot/handlers/dm.py +++ b/src/bot/handlers/dm.py @@ -3,10 +3,11 @@ This module handles private messages to the bot, primarily for the unrestriction flow. When a restricted user DMs the bot: -1. Check if user is in the group +1. Check if user is in any monitored group 2. Check if user has an active pending captcha (redirect to group) 3. Check if user's profile is complete 4. If profile-restricted by bot and profile complete, unrestrict them + across all monitored groups where they are restricted """ import logging @@ -27,6 +28,7 @@ MISSING_ITEMS_SEPARATOR, ) from bot.database.service import get_database +from bot.group_config import get_group_registry from bot.services.telegram_utils import get_user_mention, get_user_status, unrestrict_user from bot.services.user_checker import check_user_profile @@ -38,11 +40,11 @@ async def handle_dm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: Handle direct messages to the bot for unrestriction flow. This handler processes DMs (including /start) and: - 1. Checks if user is a member of the monitored group + 1. Checks if user is a member of any monitored group 2. Checks if user has an active pending captcha (redirect to group) 3. Checks if user's profile is complete (photo + username) 4. If user was restricted by the bot and now has complete profile, - removes the restriction using the group's default permissions + removes the restriction in all groups where restricted Args: update: Telegram update containing the message. @@ -60,32 +62,35 @@ async def handle_dm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.message.from_user settings = get_settings() - + registry = get_group_registry() + db = get_database() + logger.info(f"DM handler called for user_id={user.id} ({user.full_name})") - # Check user's status in the group - logger.info(f"Checking user status in group_id={settings.group_id} for user_id={user.id}") - user_status = await get_user_status(context.bot, settings.group_id, user.id) + # Check user's membership across all monitored groups + member_groups = [] + for gc in registry.all_groups(): + logger.info(f"Checking user status in group_id={gc.group_id} for user_id={user.id}") + user_status = await get_user_status(context.bot, gc.group_id, user.id) + if user_status is not None and user_status not in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED): + member_groups.append((gc, user_status)) - # User not in group (or we can't check) - if user_status is None or user_status in (ChatMemberStatus.LEFT, ChatMemberStatus.BANNED): + # User not in any monitored group + if not member_groups: await update.message.reply_text(DM_NOT_IN_GROUP_MESSAGE) - logger.info( - f"DM from user {user.id} ({user.full_name}) - not in group {settings.group_id}" - ) + logger.info(f"DM from user {user.id} ({user.full_name}) - not in any monitored group") return - db = get_database() - - # Check if user has an active pending captcha - logger.info(f"Checking for pending captcha for user_id={user.id} in group_id={settings.group_id}") - pending_captcha = db.get_pending_captcha(user.id, settings.group_id) - if pending_captcha: - await update.message.reply_text(CAPTCHA_PENDING_DM_MESSAGE) - logger.info( - f"DM from user {user.id} ({user.full_name}) - has pending captcha (group_id={settings.group_id})" - ) - return + # Check if user has an active pending captcha in any group + for gc, _ in member_groups: + logger.info(f"Checking for pending captcha for user_id={user.id} in group_id={gc.group_id}") + pending_captcha = db.get_pending_captcha(user.id, gc.group_id) + if pending_captcha: + await update.message.reply_text(CAPTCHA_PENDING_DM_MESSAGE) + logger.info( + f"DM from user {user.id} ({user.full_name}) - has pending captcha (group_id={gc.group_id})" + ) + return # Check if user's profile is complete logger.info(f"Checking user profile completeness for user_id={user.id} ({user.full_name})") @@ -105,53 +110,70 @@ async def handle_dm(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: ) return - # Check if user was restricted by this bot (not by admin) - logger.info(f"Checking bot restriction status for user_id={user.id} in group_id={settings.group_id}") - if not db.is_user_restricted_by_bot(user.id, settings.group_id): - await update.message.reply_text(DM_NO_RESTRICTION_MESSAGE) - logger.info( - f"DM from user {user.id} ({user.full_name}) - no bot restriction (group_id={settings.group_id})" - ) - return + # Find all groups where user is restricted by bot + restricted_groups = [] + for gc, user_status in member_groups: + logger.info(f"Checking bot restriction status for user_id={user.id} in group_id={gc.group_id}") + if db.is_user_restricted_by_bot(user.id, gc.group_id): + restricted_groups.append((gc, user_status)) - # User was restricted by bot but is no longer restricted on Telegram - # (e.g., admin already unrestricted them) - just clear our record - if user_status != ChatMemberStatus.RESTRICTED: - db.mark_user_unrestricted(user.id, settings.group_id) - await update.message.reply_text(DM_ALREADY_UNRESTRICTED_MESSAGE) + # User not restricted by bot in any group + if not restricted_groups: + await update.message.reply_text(DM_NO_RESTRICTION_MESSAGE) logger.info( - f"User {user.id} ({user.full_name}) already unrestricted - clearing record (group_id={settings.group_id})" + f"DM from user {user.id} ({user.full_name}) - no bot restriction in any group" ) return - # Remove restriction - logger.info(f"Unrestricting user_id={user.id} ({user.full_name}) in group_id={settings.group_id}") - try: - await unrestrict_user(context.bot, settings.group_id, user.id) - - # Clear our database record so we don't try to unrestrict again - db.mark_user_unrestricted(user.id, settings.group_id) - + # Unrestrict user from all groups where restricted by bot + unrestricted_any = False + all_already_unrestricted = True + + for gc, user_status in restricted_groups: + # User was restricted by bot but is no longer restricted on Telegram + # (e.g., admin already unrestricted them) - just clear our record + if user_status != ChatMemberStatus.RESTRICTED: + db.mark_user_unrestricted(user.id, gc.group_id) + logger.info( + f"User {user.id} ({user.full_name}) already unrestricted in group {gc.group_id} - clearing record" + ) + continue + + all_already_unrestricted = False + + # Remove restriction + logger.info(f"Unrestricting user_id={user.id} ({user.full_name}) in group_id={gc.group_id}") + try: + await unrestrict_user(context.bot, gc.group_id, user.id) + db.mark_user_unrestricted(user.id, gc.group_id) + unrestricted_any = True + + # Send notification to warning topic + user_mention = get_user_mention(user) + notification_message = DM_UNRESTRICTION_NOTIFICATION.format( + user_mention=user_mention + ) + await context.bot.send_message( + chat_id=gc.group_id, + message_thread_id=gc.warning_topic_id, + text=notification_message, + parse_mode="Markdown", + ) + logger.info( + f"Unrestricted user {user.id} ({user.full_name}) via DM (group_id={gc.group_id})" + ) + except Exception: + logger.error( + f"Failed to unrestrict user {user.id} ({user.full_name}) via DM (group_id={gc.group_id})", + exc_info=True, + ) + + if unrestricted_any: await update.message.reply_text(DM_UNRESTRICTION_SUCCESS_MESSAGE) - - # Send notification to warning topic - user_mention = get_user_mention(user) - notification_message = DM_UNRESTRICTION_NOTIFICATION.format( - user_mention=user_mention - ) - await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, - text=notification_message, - parse_mode="Markdown", - ) - - logger.info( - f"Unrestricted user {user.id} ({user.full_name}) via DM (group_id={settings.group_id})" - ) - except Exception: - logger.error( - f"Failed to unrestrict user {user.id} ({user.full_name}) via DM (group_id={settings.group_id})", - exc_info=True, + elif all_already_unrestricted: + await update.message.reply_text(DM_ALREADY_UNRESTRICTED_MESSAGE) + else: + # All unrestriction attempts failed + raise RuntimeError( + f"Failed to unrestrict user {user.id} in any group" ) - raise diff --git a/src/bot/handlers/message.py b/src/bot/handlers/message.py index 38b2ad3..5200cd4 100644 --- a/src/bot/handlers/message.py +++ b/src/bot/handlers/message.py @@ -13,7 +13,6 @@ from telegram.ext import ContextTypes -from bot.config import get_settings from bot.constants import ( MISSING_ITEMS_SEPARATOR, RESTRICTED_PERMISSIONS, @@ -23,6 +22,7 @@ format_threshold_display, ) from bot.database.service import get_database +from bot.group_config import get_group_config_for_update from bot.services.bot_info import BotInfoCache from bot.services.telegram_utils import get_user_mention from bot.services.user_checker import check_user_profile @@ -35,7 +35,7 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Handle incoming group messages and check user profiles. This handler: - 1. Filters to only process messages in the configured group + 1. Filters to only process messages in monitored groups 2. Ignores bot messages 3. Checks if user has profile photo and username 4. If incomplete, either warns or applies progressive restriction @@ -49,12 +49,12 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> logger.info("Skipping message: no message or sender") return - settings = get_settings() + group_config = get_group_config_for_update(update) - # Only process messages from the configured group - if update.effective_chat and update.effective_chat.id != settings.group_id: + # Only process messages from monitored groups + if group_config is None: logger.info( - f"Skipping message: wrong group (chat_id={update.effective_chat.id}, expected={settings.group_id})" + f"Skipping message: chat not monitored (chat_id={update.effective_chat.id if update.effective_chat else None})" ) return @@ -87,25 +87,25 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) # Warning mode: just send warning, don't restrict - if not settings.restrict_failed_users: + if not group_config.restrict_failed_users: try: threshold_display = format_threshold_display( - settings.warning_time_threshold_minutes + group_config.warning_time_threshold_minutes ) warning_message = WARNING_MESSAGE_NO_RESTRICTION.format( user_mention=user_mention, missing_text=missing_text, threshold_display=threshold_display, - rules_link=settings.rules_link, + rules_link=group_config.rules_link, ) await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, text=warning_message, parse_mode="Markdown", ) logger.info( - f"Warned user {user.id} ({user.full_name}) for missing: {missing_text} (group_id={settings.group_id})" + f"Warned user {user.id} ({user.full_name}) for missing: {missing_text} (group_id={group_config.group_id})" ) except Exception: logger.error( @@ -116,32 +116,32 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> # Progressive restriction mode: track messages and restrict at threshold db = get_database() - record = db.get_or_create_user_warning(user.id, settings.group_id) + record = db.get_or_create_user_warning(user.id, group_config.group_id) # First message: send warning with threshold info if record.message_count == 1: try: threshold_display = format_threshold_display( - settings.warning_time_threshold_minutes + group_config.warning_time_threshold_minutes ) warning_message = WARNING_MESSAGE_WITH_THRESHOLD.format( user_mention=user_mention, missing_text=missing_text, - warning_threshold=settings.warning_threshold, + warning_threshold=group_config.warning_threshold, threshold_display=threshold_display, - rules_link=settings.rules_link, + rules_link=group_config.rules_link, ) logger.info( - f"Sending first warning: user_id={user.id}, user={user.full_name}, threshold={settings.warning_threshold}" + f"Sending first warning: user_id={user.id}, user={user.full_name}, threshold={group_config.warning_threshold}" ) await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, text=warning_message, parse_mode="Markdown", ) logger.info( - f"First warning for user {user.id} ({user.full_name}) for missing: {missing_text} (group_id={settings.group_id})" + f"First warning for user {user.id} ({user.full_name}) for missing: {missing_text} (group_id={group_config.group_id})" ) except Exception: logger.error( @@ -150,21 +150,21 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) # Threshold reached: restrict user - if record.message_count >= settings.warning_threshold: + if record.message_count >= group_config.warning_threshold: try: # Apply restriction (mute user) logger.info( f"Restricting user: user_id={user.id}, user={user.full_name}, message_count={record.message_count}" ) await context.bot.restrict_chat_member( - chat_id=settings.group_id, + chat_id=group_config.group_id, user_id=user.id, permissions=RESTRICTED_PERMISSIONS, ) logger.info( - f"Restriction applied: user_id={user.id}, user={user.full_name}, group_id={settings.group_id}" + f"Restriction applied: user_id={user.id}, user={user.full_name}, group_id={group_config.group_id}" ) - db.mark_user_restricted(user.id, settings.group_id) + db.mark_user_restricted(user.id, group_config.group_id) # Get bot username for DM link (cached to avoid repeated API calls) bot_username = await BotInfoCache.get_username(context.bot) @@ -175,20 +175,20 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> user_mention=user_mention, message_count=record.message_count, missing_text=missing_text, - rules_link=settings.rules_link, + rules_link=group_config.rules_link, dm_link=dm_link, ) logger.info( f"Sending restriction notice: user_id={user.id}, user={user.full_name}, message_count={record.message_count}" ) await context.bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, text=restriction_message, parse_mode="Markdown", ) logger.info( - f"Restricted user {user.id} ({user.full_name}) after {record.message_count} messages (group_id={settings.group_id})" + f"Restricted user {user.id} ({user.full_name}) after {record.message_count} messages (group_id={group_config.group_id})" ) except Exception: logger.error( @@ -197,8 +197,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> ) else: # Not at threshold yet: silently increment count (no spam) - db.increment_message_count(user.id, settings.group_id) + db.increment_message_count(user.id, group_config.group_id) logger.info( f"Silent increment for user {user.id} ({user.full_name}), " - f"count: {record.message_count + 1}/{settings.warning_threshold}" + f"count: {record.message_count + 1}/{group_config.warning_threshold}" ) diff --git a/src/bot/handlers/topic_guard.py b/src/bot/handlers/topic_guard.py index abeda43..64bf015 100644 --- a/src/bot/handlers/topic_guard.py +++ b/src/bot/handlers/topic_guard.py @@ -11,7 +11,7 @@ from telegram import Update from telegram.ext import ContextTypes -from bot.config import get_settings +from bot.group_config import get_group_config_for_update logger = logging.getLogger(__name__) @@ -37,7 +37,7 @@ async def guard_warning_topic(update: Update, context: ContextTypes.DEFAULT_TYPE logger.info("No message or no sender, skipping") return - settings = get_settings() + group_config = get_group_config_for_update(update) user = update.message.from_user chat_id = update.effective_chat.id if update.effective_chat else None thread_id = update.message.message_thread_id @@ -46,17 +46,17 @@ async def guard_warning_topic(update: Update, context: ContextTypes.DEFAULT_TYPE f"Topic guard called: user_id={user.id}, chat_id={chat_id}, thread_id={thread_id}" ) - # Only process messages from the configured group - if chat_id != settings.group_id: + # Only process messages from monitored groups + if group_config is None: logger.info( - f"Wrong group (chat_id={chat_id}, expected {settings.group_id}), skipping" + f"Chat not monitored (chat_id={chat_id}), skipping" ) return # Only guard the warning topic, not other topics - if thread_id != settings.warning_topic_id: + if thread_id != group_config.warning_topic_id: logger.info( - f"Wrong topic (thread_id={thread_id}, expected {settings.warning_topic_id}), skipping" + f"Wrong topic (thread_id={thread_id}, expected {group_config.warning_topic_id}), skipping" ) return @@ -70,7 +70,7 @@ async def guard_warning_topic(update: Update, context: ContextTypes.DEFAULT_TYPE # Check if user is an admin or creator logger.info(f"Checking admin status for user {user.id} ({user.full_name})") chat_member = await context.bot.get_chat_member( - chat_id=settings.group_id, + chat_id=group_config.group_id, user_id=user.id, ) @@ -84,7 +84,7 @@ async def guard_warning_topic(update: Update, context: ContextTypes.DEFAULT_TYPE # Delete message from non-admin user logger.info( f"Deleting message from non-admin user {user.id} ({user.full_name}) " - f"in warning topic (group_id={settings.group_id}, thread_id={thread_id})" + f"in warning topic (group_id={group_config.group_id}, thread_id={thread_id})" ) await update.message.delete() diff --git a/src/bot/handlers/verify.py b/src/bot/handlers/verify.py index 7a37ff0..7c74d15 100644 --- a/src/bot/handlers/verify.py +++ b/src/bot/handlers/verify.py @@ -12,27 +12,28 @@ from telegram.error import BadRequest from telegram.ext import ContextTypes -from bot.config import Settings, get_settings from bot.constants import VERIFICATION_CLEARANCE_MESSAGE from bot.database.service import DatabaseService, get_database +from bot.group_config import GroupRegistry, get_group_registry from bot.services.telegram_utils import get_user_mention, unrestrict_user logger = logging.getLogger(__name__) async def verify_user( - bot: Bot, db: DatabaseService, settings: Settings, target_user_id: int, admin_user_id: int + bot: Bot, db: DatabaseService, registry: GroupRegistry, target_user_id: int, admin_user_id: int ) -> str: """ Verify a user by adding them to the photo verification whitelist. This function handles the core verification logic: adds user to whitelist, - unrestricts them, deletes warnings, and sends clearance notification if needed. + unrestricts them in all monitored groups, deletes warnings, and sends + clearance notification if needed. Args: bot: Telegram bot instance. db: Database service instance. - settings: Bot settings instance. + registry: Group registry for iterating all groups. target_user_id: ID of the user to verify. admin_user_id: ID of the admin performing the verification. @@ -47,35 +48,41 @@ async def verify_user( verified_by_admin_id=admin_user_id, ) - # Unrestrict user if they are restricted - try: - await unrestrict_user(bot, settings.group_id, target_user_id) - logger.info(f"Unrestricted user {target_user_id} during verification") - except BadRequest as e: - # User might not be restricted or not in group - that's okay - logger.info(f"Could not unrestrict user {target_user_id}: {e}") - - # Delete all warning records for this user - deleted_count = db.delete_user_warnings(target_user_id, settings.group_id) - - # Send notification to warning topic if user had previous warnings - if deleted_count > 0: - # Get user info for proper mention - user_info = await bot.get_chat(target_user_id) - user_mention = get_user_mention(user_info) - - # Send clearance message to warning topic - clearance_message = VERIFICATION_CLEARANCE_MESSAGE.format( - user_mention=user_mention - ) - await bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, - text=clearance_message, - parse_mode="Markdown" - ) - logger.info(f"Sent clearance notification to warning topic for user {target_user_id}") - logger.info(f"Deleted {deleted_count} warning record(s) for user {target_user_id}") + # Unrestrict user and delete warnings in all monitored groups + total_deleted = 0 + for group_config in registry.all_groups(): + # Unrestrict user if they are restricted + try: + await unrestrict_user(bot, group_config.group_id, target_user_id) + logger.info(f"Unrestricted user {target_user_id} in group {group_config.group_id} during verification") + except BadRequest as e: + # User might not be restricted or not in group - that's okay + logger.info(f"Could not unrestrict user {target_user_id} in group {group_config.group_id}: {e}") + + # Delete all warning records for this user in this group + deleted_count = db.delete_user_warnings(target_user_id, group_config.group_id) + total_deleted += deleted_count + + # Send notification to warning topic if user had previous warnings + if deleted_count > 0: + # Get user info for proper mention + user_info = await bot.get_chat(target_user_id) + user_mention = get_user_mention(user_info) + + # Send clearance message to warning topic + clearance_message = VERIFICATION_CLEARANCE_MESSAGE.format( + user_mention=user_mention + ) + await bot.send_message( + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, + text=clearance_message, + parse_mode="Markdown" + ) + logger.info(f"Sent clearance notification to warning topic for user {target_user_id} in group {group_config.group_id}") + + if total_deleted > 0: + logger.info(f"Deleted {total_deleted} total warning record(s) for user {target_user_id}") return ( f"✅ User dengan ID {target_user_id} telah diverifikasi:\n" @@ -158,8 +165,8 @@ async def handle_verify_command( db = get_database() try: - settings = get_settings() - message = await verify_user(context.bot, db, settings, target_user_id, admin_user_id) + registry = get_group_registry() + message = await verify_user(context.bot, db, registry, target_user_id, admin_user_id) await update.message.reply_text(message) logger.info( f"Admin {admin_user_id} ({update.message.from_user.full_name}) " @@ -270,8 +277,8 @@ async def handle_verify_callback( db = get_database() try: - settings = get_settings() - message = await verify_user(context.bot, db, settings, target_user_id, admin_user_id) + registry = get_group_registry() + message = await verify_user(context.bot, db, registry, target_user_id, admin_user_id) await query.edit_message_text(message) logger.info( f"Admin {admin_user_id} ({query.from_user.full_name}) " diff --git a/src/bot/main.py b/src/bot/main.py index 2fdb9c9..8d5858c 100644 --- a/src/bot/main.py +++ b/src/bot/main.py @@ -16,6 +16,7 @@ from bot.config import get_settings from bot.database.service import 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_new_user_spam from bot.handlers.dm import handle_dm @@ -39,7 +40,7 @@ def configure_logging() -> None: """ Configure logging with Logfire integration. - + Uses minimal instrumentation to conserve Logfire quota: - Configurable log level via LOG_LEVEL environment variable - Disables database query tracing @@ -54,21 +55,21 @@ def configure_logging() -> None: level=logging.INFO, force=True, # Override any existing config ) - + # Now load settings (this will trigger model_post_init logging) settings = get_settings() - + # Get log level from settings and convert to logging constant log_level_str = settings.log_level.upper() log_level = getattr(logging, log_level_str, logging.INFO) - + # Determine if we should send to Logfire # Only send if enabled AND token is provided send_to_logfire = settings.logfire_enabled and settings.logfire_token is not None - + # Map log level to Logfire console min_log_level logfire_min_level = log_level_str.lower() - + # Configure Logfire with minimal instrumentation logfire.configure( token=settings.logfire_token, @@ -83,7 +84,7 @@ def configure_logging() -> None: # Disable auto-instrumentation to save quota inspect_arguments=False, ) - + # Reconfigure logging with Logfire handler and configured level logging.basicConfig( format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", @@ -91,12 +92,12 @@ def configure_logging() -> None: handlers=[logfire.LogfireLoggingHandler()], force=True, # Override previous config ) - + # Suppress verbose HTTP logs from httpx/httpcore used by python-telegram-bot # These libraries log every HTTP request at INFO level, flooding logs with Telegram API polling requests logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) - + logger = logging.getLogger(__name__) logger.info(f"Logging level set to {log_level_str}") if send_to_logfire: @@ -133,26 +134,37 @@ async def post_init(application: Application) -> None: # type: ignore[type-arg] Post-initialization callback to fetch and cache group admin IDs. This runs once after the bot starts and before polling begins. - Fetches admin list from the monitored group and stores it in bot_data. - Also recovers any pending captcha verifications from database. + Fetches admin list from all monitored groups and stores per-group + and union admin IDs in bot_data. Also recovers pending captchas. Args: application: The Application instance. """ logger.info("Starting post_init: fetching admin IDs and recovering captcha state") - settings = get_settings() - - logger.info(f"Fetching admin IDs for group {settings.group_id}") - try: - admin_ids = await fetch_group_admin_ids(application.bot, settings.group_id) # type: ignore[arg-type] - application.bot_data["admin_ids"] = admin_ids # type: ignore[index] - logger.info(f"Fetched {len(admin_ids)} admin(s) from group {settings.group_id}") - except Exception as e: - logger.error(f"Failed to fetch admin IDs: {e}") - application.bot_data["admin_ids"] = [] # type: ignore[index] - - # Recover pending captcha verifications - if settings.captcha_enabled: + registry = get_group_registry() + + # Fetch admin IDs for all monitored groups + group_admin_ids: dict[int, list[int]] = {} + all_admin_ids: set[int] = set() + + for gc in registry.all_groups(): + logger.info(f"Fetching admin IDs for group {gc.group_id}") + try: + ids = await fetch_group_admin_ids(application.bot, gc.group_id) # type: ignore[arg-type] + group_admin_ids[gc.group_id] = ids + all_admin_ids.update(ids) + logger.info(f"Fetched {len(ids)} admin(s) from group {gc.group_id}") + except Exception as e: + logger.error(f"Failed to fetch admin IDs for group {gc.group_id}: {e}") + group_admin_ids[gc.group_id] = [] + + application.bot_data["group_admin_ids"] = group_admin_ids # type: ignore[index] + application.bot_data["admin_ids"] = list(all_admin_ids) # type: ignore[index] + logger.info(f"Total unique admins across all groups: {len(all_admin_ids)}") + + # Recover pending captcha verifications for groups with captcha enabled + has_captcha = any(gc.captcha_enabled for gc in registry.all_groups()) + 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) @@ -165,16 +177,26 @@ def main() -> None: This function: 1. Configures logging with Logfire integration 2. Loads configuration from environment - 3. Initializes the SQLite database - 4. Registers message handlers in priority order - 5. Starts JobQueue for periodic tasks - 6. Starts the bot polling loop + 3. Initializes the group registry (from groups.json or .env fallback) + 4. Initializes the SQLite database + 5. Registers message handlers in priority order + 6. Starts JobQueue for periodic tasks + 7. Starts the bot polling loop """ # Configure logging first configure_logging() - + settings = get_settings() - logger.info(f"Starting PythonID bot (environment: {settings.logfire_environment}, group_id: {settings.group_id})") + + # Initialize group registry + registry = init_group_registry(settings) + group_count = len(registry.all_groups()) + logger.info(f"Starting PythonID bot (environment: {settings.logfire_environment}, groups: {group_count})") + for gc in registry.all_groups(): + logger.info( + f" Group {gc.group_id}: warning_topic={gc.warning_topic_id}, " + f"restrict={gc.restrict_failed_users}, captcha={gc.captcha_enabled}" + ) # Initialize database (creates tables if they don't exist) init_database(settings.database_path) @@ -262,8 +284,8 @@ def main() -> None: ) logger.info("Registered handler: anti_spam_handler (group=0)") - # Handler 9: Group message handler - monitors messages in the configured - # group and warns/restricts users with incomplete profiles + # Handler 9: Group message handler - monitors messages in monitored + # groups and warns/restricts users with incomplete profiles application.add_handler( MessageHandler( filters.ChatType.GROUPS & ~filters.COMMAND, @@ -283,9 +305,9 @@ def main() -> None: ) logger.info("JobQueue registered: auto_restrict_job (every 5 minutes, first run in 5 minutes)") - logger.info(f"Starting bot polling for group {settings.group_id}") + logger.info(f"Starting bot polling for {group_count} group(s)") logger.info("All handlers registered successfully") - + application.run_polling(allowed_updates=["message", "callback_query", "chat_member"]) diff --git a/src/bot/services/captcha_recovery.py b/src/bot/services/captcha_recovery.py index bc43625..d6fde8e 100644 --- a/src/bot/services/captcha_recovery.py +++ b/src/bot/services/captcha_recovery.py @@ -12,9 +12,9 @@ from telegram import Bot from telegram.ext import Application -from bot.config import get_settings from bot.constants import CAPTCHA_TIMEOUT_MESSAGE from bot.database.service import get_database +from bot.group_config import get_group_registry from bot.handlers.captcha import captcha_timeout_callback, get_captcha_job_name from bot.services.bot_info import BotInfoCache from bot.services.telegram_utils import get_user_mention_by_id @@ -86,12 +86,15 @@ async def recover_pending_captchas(application: Application) -> None: 1. If timeout has already passed: immediately expire them 2. If timeout hasn't passed yet: reschedule the timeout job + Each pending captcha uses the timeout from its group's config. + Captchas for groups no longer in the registry are skipped. + This prevents users from being stuck in restricted state after bot restart. Args: application: The Application instance with bot and job_queue. """ - settings = get_settings() + registry = get_group_registry() db = get_database() pending_records = db.get_all_pending_captchas() @@ -106,10 +109,19 @@ async def recover_pending_captchas(application: Application) -> None: for record in pending_records: try: + # Look up the group config for this captcha + group_config = registry.get(record.group_id) + if group_config is None: + logger.warning( + f"Skipping captcha for user {record.user_id} in group {record.group_id} " + f"- group no longer in registry" + ) + continue + # Make created_at timezone-aware (SQLite stores without timezone) created_at_utc = record.created_at.replace(tzinfo=UTC) elapsed_seconds = (now - created_at_utc).total_seconds() - remaining_seconds = settings.captcha_timeout_timedelta.total_seconds() - elapsed_seconds + remaining_seconds = group_config.captcha_timeout_timedelta.total_seconds() - elapsed_seconds if remaining_seconds <= 0: # Timeout has already passed, expire immediately diff --git a/src/bot/services/scheduler.py b/src/bot/services/scheduler.py index 9cf3981..8cd600f 100644 --- a/src/bot/services/scheduler.py +++ b/src/bot/services/scheduler.py @@ -2,7 +2,8 @@ Scheduler service for automated bot tasks. This module manages periodic tasks like auto-restricting users who exceed -time thresholds for profile completion. +time thresholds for profile completion. Iterates per-group since each +group may have different threshold settings. """ import logging @@ -11,13 +12,13 @@ from telegram.ext import ContextTypes -from bot.config import get_settings from bot.constants import ( RESTRICTED_PERMISSIONS, RESTRICTION_MESSAGE_AFTER_TIME, format_threshold_display, ) from bot.database.service import get_database +from bot.group_config import get_group_registry from bot.services.bot_info import BotInfoCache from bot.services.telegram_utils import get_user_mention, get_user_status @@ -28,88 +29,90 @@ async def auto_restrict_expired_warnings(context: ContextTypes.DEFAULT_TYPE) -> """ Periodically check and restrict users who exceeded time threshold. - Finds all active warnings past the configured hours threshold and - applies restrictions (mutes) to those users. + Iterates per-group since each group may have different + warning_time_threshold_minutes. Finds all active warnings past the + configured threshold and applies restrictions (mutes) to those users. Args: context: Telegram job context for sending messages. """ logger.info("Starting auto-restriction job") - settings = get_settings() + registry = get_group_registry() db = get_database() - # Get warnings that exceeded time threshold - expired_warnings = db.get_warnings_past_time_threshold( - settings.warning_time_threshold_timedelta - ) - - if not expired_warnings: - logger.info("No expired warnings to process") - return - - logger.info(f"Processing {len(expired_warnings)} expired warnings") - # Get bot username once for all DM links bot = context.bot bot_username = await BotInfoCache.get_username(bot) dm_link = f"https://t.me/{bot_username}" - for warning in expired_warnings: - try: - logger.info(f"Checking status for user_id={warning.user_id}") - # Check if user is kicked - user_status = await get_user_status(bot, settings.group_id, warning.user_id) - - # Skip if user is kicked (can't rejoin without admin re-invite) - if user_status == ChatMemberStatus.BANNED: - db.delete_user_warnings(warning.user_id, warning.group_id) - logger.info( - f"Skipped auto-restriction for user {warning.user_id} - user kicked (group_id={settings.group_id})" - ) - continue - - logger.info(f"Applying restriction to user_id={warning.user_id}") - # Apply restriction (even if user left, they'll be restricted when they rejoin) - await bot.restrict_chat_member( - chat_id=settings.group_id, - user_id=warning.user_id, - permissions=RESTRICTED_PERMISSIONS, - ) - db.mark_user_restricted(warning.user_id, settings.group_id) - - # Get user info for proper mention + for group_config in registry.all_groups(): + # Get warnings that exceeded time threshold for this group + expired_warnings = db.get_warnings_past_time_threshold_for_group( + group_config.group_id, group_config.warning_time_threshold_timedelta + ) + + if not expired_warnings: + logger.info(f"No expired warnings for group {group_config.group_id}") + continue + + logger.info(f"Processing {len(expired_warnings)} expired warnings for group {group_config.group_id}") + + for warning in expired_warnings: try: - user_member = await bot.get_chat_member( - chat_id=settings.group_id, + logger.info(f"Checking status for user_id={warning.user_id}") + # Check if user is kicked + user_status = await get_user_status(bot, group_config.group_id, warning.user_id) + + # Skip if user is kicked (can't rejoin without admin re-invite) + if user_status == ChatMemberStatus.BANNED: + db.delete_user_warnings(warning.user_id, warning.group_id) + logger.info( + f"Skipped auto-restriction for user {warning.user_id} - user kicked (group_id={group_config.group_id})" + ) + continue + + logger.info(f"Applying restriction to user_id={warning.user_id}") + # Apply restriction (even if user left, they'll be restricted when they rejoin) + await bot.restrict_chat_member( + chat_id=group_config.group_id, user_id=warning.user_id, + permissions=RESTRICTED_PERMISSIONS, + ) + db.mark_user_restricted(warning.user_id, group_config.group_id) + + # Get user info for proper mention + try: + user_member = await bot.get_chat_member( + chat_id=group_config.group_id, + user_id=warning.user_id, + ) + user = user_member.user + user_mention = get_user_mention(user) + except Exception: + # Fallback to user ID if we can't get user info + user_mention = f"User {warning.user_id}" + + # Send notification to warning topic + threshold_display = format_threshold_display( + group_config.warning_time_threshold_minutes + ) + restriction_message = RESTRICTION_MESSAGE_AFTER_TIME.format( + user_mention=user_mention, + threshold_display=threshold_display, + rules_link=group_config.rules_link, + dm_link=dm_link, + ) + await bot.send_message( + chat_id=group_config.group_id, + message_thread_id=group_config.warning_topic_id, + text=restriction_message, + parse_mode="Markdown", + ) + + logger.info( + f"Auto-restricted user {warning.user_id} after {group_config.warning_time_threshold_minutes} minutes (group_id={group_config.group_id})" + ) + except Exception as e: + logger.error( + f"Error auto-restricting user {warning.user_id} in group {group_config.group_id}: {e}", exc_info=True ) - user = user_member.user - user_mention = get_user_mention(user) - except Exception: - # Fallback to user ID if we can't get user info - user_mention = f"User {warning.user_id}" - - # Send notification to warning topic - threshold_display = format_threshold_display( - settings.warning_time_threshold_minutes - ) - restriction_message = RESTRICTION_MESSAGE_AFTER_TIME.format( - user_mention=user_mention, - threshold_display=threshold_display, - rules_link=settings.rules_link, - dm_link=dm_link, - ) - await bot.send_message( - chat_id=settings.group_id, - message_thread_id=settings.warning_topic_id, - text=restriction_message, - parse_mode="Markdown", - ) - - logger.info( - f"Auto-restricted user {warning.user_id} after {settings.warning_time_threshold_minutes} minutes (group_id={settings.group_id})" - ) - except Exception as e: - logger.error( - f"Error auto-restricting user {warning.user_id} in group {settings.group_id}: {e}", exc_info=True - ) diff --git a/tests/test_anti_spam.py b/tests/test_anti_spam.py index 970bb25..6601c9f 100644 --- a/tests/test_anti_spam.py +++ b/tests/test_anti_spam.py @@ -6,6 +6,7 @@ import pytest from telegram import Chat, Message, MessageEntity, User +from bot.group_config import GroupConfig from bot.handlers.anti_spam import ( extract_urls, handle_new_user_spam, @@ -296,7 +297,7 @@ def mock_update(self): update.message.from_user.full_name = "Test User" update.message.from_user.username = "testuser" update.effective_chat = MagicMock(spec=Chat) - update.effective_chat.id = -100123456 # group_id from settings + update.effective_chat.id = -100123456 # group_id from group_config # Default: not forwarded, no links, no external reply, no story update.message.forward_origin = None @@ -320,51 +321,50 @@ def mock_context(self): return context @pytest.fixture - def mock_settings(self): - """Create mock settings.""" - settings = MagicMock() - settings.group_id = -100123456 - settings.warning_topic_id = 123 - settings.rules_link = "https://example.com/rules" - settings.new_user_probation_hours = 168 - settings.new_user_violation_threshold = 3 - settings.probation_timedelta = timedelta(hours=168) - return settings + def group_config(self): + """Create group config for anti-spam tests.""" + return GroupConfig( + group_id=-100123456, + warning_topic_id=123, + rules_link="https://example.com/rules", + new_user_probation_hours=168, + new_user_violation_threshold=3, + ) @pytest.mark.asyncio async def test_ignores_message_from_wrong_group( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context ): """Test that messages from other groups are ignored.""" mock_update.effective_chat.id = -999999 # Different group - with patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings): + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=None): await handle_new_user_spam(mock_update, mock_context) mock_update.message.delete.assert_not_called() @pytest.mark.asyncio async def test_ignores_bot_messages( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that bot messages are ignored.""" mock_update.message.from_user.is_bot = True - with patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings): + with patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config): await handle_new_user_spam(mock_update, mock_context) mock_update.message.delete.assert_not_called() @pytest.mark.asyncio async def test_ignores_user_not_on_probation( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that users without probation record are ignored.""" mock_db = MagicMock() mock_db.get_new_user_probation.return_value = None with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -373,7 +373,7 @@ async def test_ignores_user_not_on_probation( @pytest.mark.asyncio async def test_handles_naive_datetime_from_database( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that naive datetimes from database are handled correctly.""" mock_update.message.forward_origin = MagicMock() # Trigger violation @@ -391,7 +391,7 @@ async def test_handles_naive_datetime_from_database( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): # Should not raise TypeError @@ -401,7 +401,7 @@ async def test_handles_naive_datetime_from_database( @pytest.mark.asyncio async def test_clears_expired_probation( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that expired probation is cleared and message is not deleted.""" mock_record = MagicMock() @@ -411,7 +411,7 @@ async def test_clears_expired_probation( mock_db.get_new_user_probation.return_value = mock_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -421,7 +421,7 @@ async def test_clears_expired_probation( @pytest.mark.asyncio async def test_ignores_regular_message( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that regular messages (no forward, no link) are ignored.""" mock_record = MagicMock() @@ -431,7 +431,7 @@ async def test_ignores_regular_message( mock_db.get_new_user_probation.return_value = mock_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -440,7 +440,7 @@ async def test_ignores_regular_message( @pytest.mark.asyncio async def test_deletes_forwarded_message( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that forwarded messages are deleted.""" mock_update.message.forward_origin = MagicMock() # Any non-None value @@ -457,7 +457,7 @@ async def test_deletes_forwarded_message( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -466,7 +466,7 @@ async def test_deletes_forwarded_message( @pytest.mark.asyncio async def test_deletes_message_with_non_whitelisted_link( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that messages with non-whitelisted links are deleted.""" entity = MagicMock(spec=MessageEntity) @@ -487,7 +487,7 @@ async def test_deletes_message_with_non_whitelisted_link( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -496,7 +496,7 @@ async def test_deletes_message_with_non_whitelisted_link( @pytest.mark.asyncio async def test_allows_whitelisted_link( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that messages with whitelisted links are allowed.""" entity = MagicMock(spec=MessageEntity) @@ -513,7 +513,7 @@ async def test_allows_whitelisted_link( mock_db.get_new_user_probation.return_value = mock_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -522,7 +522,7 @@ async def test_allows_whitelisted_link( @pytest.mark.asyncio async def test_sends_warning_on_first_violation( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that warning is sent on first violation.""" mock_update.message.forward_origin = MagicMock() # Any non-None value @@ -538,7 +538,7 @@ async def test_sends_warning_on_first_violation( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -547,7 +547,7 @@ async def test_sends_warning_on_first_violation( @pytest.mark.asyncio async def test_no_warning_on_second_violation( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that no warning is sent on subsequent violations.""" mock_update.message.forward_origin = MagicMock() # Any non-None value @@ -563,7 +563,7 @@ async def test_no_warning_on_second_violation( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -572,7 +572,7 @@ async def test_no_warning_on_second_violation( @pytest.mark.asyncio async def test_restricts_user_at_threshold( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that user is restricted when reaching threshold.""" mock_update.message.forward_origin = MagicMock() # Any non-None value @@ -588,7 +588,7 @@ async def test_restricts_user_at_threshold( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -597,7 +597,7 @@ async def test_restricts_user_at_threshold( @pytest.mark.asyncio async def test_sends_restriction_notification_at_threshold( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that restriction notification is sent when user is restricted.""" mock_update.message.forward_origin = MagicMock() @@ -613,7 +613,7 @@ async def test_sends_restriction_notification_at_threshold( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -624,7 +624,7 @@ async def test_sends_restriction_notification_at_threshold( @pytest.mark.asyncio async def test_deletes_message_with_external_reply( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that messages with external replies are deleted.""" mock_update.message.external_reply = MagicMock() # Any non-None value @@ -640,7 +640,7 @@ async def test_deletes_message_with_external_reply( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -649,7 +649,7 @@ async def test_deletes_message_with_external_reply( @pytest.mark.asyncio async def test_deletes_message_with_story( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that messages with shared stories are deleted.""" mock_update.message.story = MagicMock() # Any non-None value @@ -665,7 +665,7 @@ async def test_deletes_message_with_story( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -673,27 +673,25 @@ async def test_deletes_message_with_story( mock_update.message.delete.assert_called_once() @pytest.mark.asyncio - async def test_ignores_update_without_message(self, mock_context, mock_settings): + async def test_ignores_update_without_message(self, mock_context): """Test that update without message is ignored.""" mock_update = MagicMock() mock_update.message = None - with patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings): - await handle_new_user_spam(mock_update, mock_context) + await handle_new_user_spam(mock_update, mock_context) @pytest.mark.asyncio - async def test_ignores_message_without_from_user(self, mock_context, mock_settings): + async def test_ignores_message_without_from_user(self, mock_context): """Test that message without from_user is ignored.""" mock_update = MagicMock() mock_update.message = MagicMock(spec=Message) mock_update.message.from_user = None - with patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings): - await handle_new_user_spam(mock_update, mock_context) + await handle_new_user_spam(mock_update, mock_context) @pytest.mark.asyncio async def test_continues_when_delete_fails( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that handler continues when message delete fails.""" mock_update.message.forward_origin = MagicMock() @@ -710,7 +708,7 @@ async def test_continues_when_delete_fails( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -719,7 +717,7 @@ async def test_continues_when_delete_fails( @pytest.mark.asyncio async def test_continues_when_send_warning_fails( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that handler continues when sending warning fails.""" mock_update.message.forward_origin = MagicMock() @@ -738,7 +736,7 @@ async def test_continues_when_send_warning_fails( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) @@ -747,7 +745,7 @@ async def test_continues_when_send_warning_fails( @pytest.mark.asyncio async def test_continues_when_restrict_fails( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test that handler completes when restrict fails.""" mock_update.message.forward_origin = MagicMock() @@ -766,7 +764,7 @@ async def test_continues_when_restrict_fails( mock_db.increment_new_user_violation.return_value = updated_record with ( - patch("bot.handlers.anti_spam.get_settings", return_value=mock_settings), + patch("bot.handlers.anti_spam.get_group_config_for_update", return_value=group_config), patch("bot.handlers.anti_spam.get_database", return_value=mock_db), ): await handle_new_user_spam(mock_update, mock_context) diff --git a/tests/test_captcha.py b/tests/test_captcha.py index a60a608..30f813b 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -5,6 +5,7 @@ import pytest from bot.database.service import init_database, reset_database +from bot.group_config import GroupConfig, GroupRegistry from bot.handlers.captcha import ( captcha_callback_handler, captcha_timeout_callback, @@ -14,12 +15,20 @@ @pytest.fixture -def mock_settings(): - settings = MagicMock() - settings.group_id = -1001234567890 - settings.captcha_enabled = True - settings.captcha_timeout_seconds = 300 - return settings +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=0, + captcha_enabled=True, + captcha_timeout_seconds=300, + ) + + +@pytest.fixture +def mock_registry(group_config): + registry = GroupRegistry() + registry.register(group_config) + return registry @pytest.fixture @@ -67,14 +76,14 @@ def temp_db(): class TestNewMemberHandler: async def test_new_member_restricts_user( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.restrict_chat_member.assert_called_once() @@ -83,14 +92,14 @@ async def test_new_member_restricts_user( assert call_args.kwargs["user_id"] == 12345 async def test_new_member_sends_captcha_message( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.send_message.assert_called_once() @@ -101,7 +110,7 @@ async def test_new_member_sends_captcha_message( assert call_args.kwargs["reply_markup"] is not None async def test_new_member_saves_to_database( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): from bot.database.service import get_database @@ -110,7 +119,7 @@ async def test_new_member_saves_to_database( sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) db = get_database() @@ -121,14 +130,14 @@ async def test_new_member_saves_to_database( assert pending.message_id == 999 async def test_new_member_schedules_timeout( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.job_queue.run_once.assert_called_once() @@ -138,71 +147,74 @@ async def test_new_member_schedules_timeout( assert call_args.kwargs["data"]["user_id"] == 12345 async def test_captcha_disabled_skips_check( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, temp_db ): - mock_settings.captcha_enabled = False + disabled_config = GroupConfig( + group_id=-1001234567890, + warning_topic_id=0, + captcha_enabled=False, + captcha_timeout_seconds=300, + ) - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=disabled_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() async def test_bot_members_skipped( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): mock_update_new_member.message.new_chat_members[0].is_bot = True - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() - async def test_no_message_does_nothing(self, mock_context, mock_settings): + async def test_no_message_does_nothing(self, mock_context): update = MagicMock() update.message = None - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): - await new_member_handler(update, mock_context) + await new_member_handler(update, mock_context) mock_context.bot.restrict_chat_member.assert_not_called() - async def test_no_new_members_does_nothing(self, mock_context, mock_settings): + async def test_no_new_members_does_nothing(self, mock_context): update = MagicMock() update.message = MagicMock() update.message.new_chat_members = None - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): - await new_member_handler(update, mock_context) + await new_member_handler(update, mock_context) mock_context.bot.restrict_chat_member.assert_not_called() async def test_wrong_group_skipped( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, temp_db ): mock_update_new_member.effective_chat.id = -9999999999 - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=None): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() async def test_restrict_failure_continues_gracefully( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): mock_context.bot.restrict_chat_member.side_effect = Exception( "Restriction failed" ) - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.send_message.assert_not_called() async def test_duplicate_prevention_new_member_handler( - self, mock_update_new_member, mock_context, mock_settings, temp_db + self, mock_update_new_member, mock_context, group_config, temp_db ): """Test that duplicate captcha is prevented in new_member_handler.""" from bot.database.service import get_database @@ -210,7 +222,7 @@ async def test_duplicate_prevention_new_member_handler( db = get_database() db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await new_member_handler(mock_update_new_member, mock_context) mock_context.bot.restrict_chat_member.assert_not_called() @@ -219,7 +231,7 @@ async def test_duplicate_prevention_new_member_handler( class TestCaptchaCallbackHandler: async def test_captcha_callback_verifies_correct_user( - self, mock_context, mock_settings, temp_db + self, mock_context, mock_registry, temp_db ): from bot.database.service import get_database @@ -238,14 +250,14 @@ async def test_captcha_callback_verifies_correct_user( update = MagicMock() update.callback_query = query - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + with patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry): await captcha_callback_handler(update, mock_context) query.answer.assert_called_once() assert db.get_pending_captcha(12345, -1001234567890) is None async def test_captcha_callback_unrestricts_user( - self, mock_context, mock_settings, temp_db + self, mock_context, mock_registry, temp_db ): from bot.database.service import get_database @@ -265,7 +277,7 @@ async def test_captcha_callback_unrestricts_user( update.callback_query = query with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, ): mock_unrestrict.return_value = AsyncMock() @@ -276,7 +288,7 @@ async def test_captcha_callback_unrestricts_user( assert mock_unrestrict.call_args.args[2] == 12345 async def test_captcha_callback_deletes_message( - self, mock_context, mock_settings, temp_db + self, mock_context, mock_registry, temp_db ): from bot.database.service import get_database @@ -296,7 +308,7 @@ async def test_captcha_callback_deletes_message( update.callback_query = query with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), ): await captcha_callback_handler(update, mock_context) @@ -305,7 +317,7 @@ async def test_captcha_callback_deletes_message( call_args = query.edit_message_text.call_args assert "Terima kasih" in call_args.kwargs["text"] - async def test_wrong_user_rejected(self, mock_context, mock_settings, temp_db): + async def test_wrong_user_rejected(self, mock_context, mock_registry, temp_db): from bot.database.service import get_database db = get_database() @@ -321,7 +333,7 @@ async def test_wrong_user_rejected(self, mock_context, mock_settings, temp_db): update.callback_query = query with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, ): await captcha_callback_handler(update, mock_context) @@ -353,7 +365,7 @@ async def test_no_query_data_does_nothing(self, mock_context): mock_context.job_queue.get_jobs_by_name.assert_not_called() - async def test_cancels_timeout_job(self, mock_context, mock_settings, temp_db): + async def test_cancels_timeout_job(self, mock_context, mock_registry, temp_db): from bot.database.service import get_database db = get_database() @@ -376,7 +388,7 @@ async def test_cancels_timeout_job(self, mock_context, mock_settings, temp_db): update.callback_query = query with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), ): await captcha_callback_handler(update, mock_context) @@ -387,7 +399,7 @@ async def test_cancels_timeout_job(self, mock_context, mock_settings, temp_db): mock_job.schedule_removal.assert_called_once() async def test_unrestrict_failure_stops_execution( - self, mock_context, mock_settings, temp_db + self, mock_context, mock_registry, temp_db ): """Test that unrestrict failure prevents false verification.""" from bot.database.service import get_database @@ -408,7 +420,7 @@ async def test_unrestrict_failure_stops_execution( update.callback_query = query with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user") as mock_unrestrict, ): mock_unrestrict.side_effect = Exception("Unrestrict failed") @@ -422,7 +434,7 @@ async def test_unrestrict_failure_stops_execution( query.answer.assert_called_with("Gagal memverifikasi. Silakan coba lagi.", show_alert=True) async def test_edit_message_failure_in_callback_continues_gracefully( - self, mock_context, mock_settings, temp_db + self, mock_context, mock_registry, temp_db ): from bot.database.service import get_database @@ -443,7 +455,7 @@ async def test_edit_message_failure_in_callback_continues_gracefully( update.callback_query = query with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_registry", return_value=mock_registry), patch("bot.handlers.captcha.unrestrict_user", return_value=AsyncMock()), ): await captcha_callback_handler(update, mock_context) @@ -604,11 +616,11 @@ def create_chat_member_update(self, old_status, new_status, user_id=12345, group """Helper to create ChatMemberUpdated update objects.""" update = MagicMock() update.chat_member = MagicMock() - + old_member = MagicMock() old_member.status = old_status update.chat_member.old_chat_member = old_member - + new_member = MagicMock() new_member.status = new_status new_member.user = MagicMock() @@ -616,177 +628,181 @@ def create_chat_member_update(self, old_status, new_status, user_id=12345, group new_member.user.is_bot = False new_member.user.full_name = "Test User" update.chat_member.new_chat_member = new_member - + update.effective_chat = MagicMock() update.effective_chat.id = group_id - + return update async def test_left_to_member_triggers_captcha( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): - """Test LEFT → MEMBER transition triggers captcha.""" + """Test LEFT -> MEMBER transition triggers captcha.""" from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update(ChatMemberStatus.LEFT, ChatMemberStatus.MEMBER) - + sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_called_once() mock_context.bot.send_message.assert_called_once() async def test_banned_to_member_triggers_captcha( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): - """Test BANNED → MEMBER transition triggers captcha.""" + """Test BANNED -> MEMBER transition triggers captcha.""" from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update(ChatMemberStatus.BANNED, ChatMemberStatus.MEMBER) - + sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_called_once() mock_context.bot.send_message.assert_called_once() async def test_member_to_administrator_no_captcha( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): - """Test MEMBER → ADMINISTRATOR transition should NOT trigger captcha.""" + """Test MEMBER -> ADMINISTRATOR transition should NOT trigger captcha.""" from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update(ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR) - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() async def test_left_to_restricted_triggers_captcha( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): - """Test LEFT → RESTRICTED transition triggers captcha (user joined but auto-restricted).""" + """Test LEFT -> RESTRICTED transition triggers captcha (user joined but auto-restricted).""" from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update(ChatMemberStatus.LEFT, ChatMemberStatus.RESTRICTED) - + sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_called_once() mock_context.bot.send_message.assert_called_once() async def test_duplicate_prevention_chat_member( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): """Test that duplicate captcha is prevented in chat_member_handler.""" from bot.database.service import get_database from telegram.constants import ChatMemberStatus - + db = get_database() db.add_pending_captcha(12345, -1001234567890, -1001234567890, 999, "Test User") - + update = self.create_chat_member_update(ChatMemberStatus.LEFT, ChatMemberStatus.MEMBER) - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() async def test_race_condition_handling( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): """Test race condition handling when both handlers trigger simultaneously.""" from sqlalchemy.exc import IntegrityError from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update(ChatMemberStatus.LEFT, ChatMemberStatus.MEMBER) - + sent_message = MagicMock() sent_message.chat_id = -1001234567890 sent_message.message_id = 999 mock_context.bot.send_message.return_value = sent_message - + with ( - patch("bot.handlers.captcha.get_settings", return_value=mock_settings), + patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config), patch("bot.database.service.DatabaseService.add_pending_captcha") as mock_add, ): mock_add.side_effect = IntegrityError(None, None, None) await chat_member_handler(update, mock_context) - + # Should handle gracefully and not schedule timeout job mock_context.job_queue.run_once.assert_not_called() async def test_bot_member_skipped_in_chat_member( - self, mock_context, mock_settings, temp_db + self, mock_context, group_config, temp_db ): """Test that bot members are skipped in chat_member_handler.""" from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update(ChatMemberStatus.LEFT, ChatMemberStatus.MEMBER) update.chat_member.new_chat_member.user.is_bot = True - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=group_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() async def test_captcha_disabled_skips_in_chat_member( - self, mock_context, mock_settings, temp_db + self, mock_context, temp_db ): """Test captcha disabled skips processing in chat_member_handler.""" from telegram.constants import ChatMemberStatus - - mock_settings.captcha_enabled = False + + disabled_config = GroupConfig( + group_id=-1001234567890, + warning_topic_id=0, + captcha_enabled=False, + captcha_timeout_seconds=300, + ) update = self.create_chat_member_update(ChatMemberStatus.LEFT, ChatMemberStatus.MEMBER) - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=disabled_config): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() async def test_wrong_group_skipped_in_chat_member( - self, mock_context, mock_settings, temp_db + self, mock_context, temp_db ): """Test wrong group is skipped in chat_member_handler.""" from telegram.constants import ChatMemberStatus - + update = self.create_chat_member_update( ChatMemberStatus.LEFT, ChatMemberStatus.MEMBER, group_id=-9999999999 ) - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): + + with patch("bot.handlers.captcha.get_group_config_for_update", return_value=None): await chat_member_handler(update, mock_context) - + mock_context.bot.restrict_chat_member.assert_not_called() mock_context.bot.send_message.assert_not_called() - async def test_no_chat_member_does_nothing(self, mock_context, mock_settings): + async def test_no_chat_member_does_nothing(self, mock_context): """Test that missing chat_member in update does nothing.""" update = MagicMock() update.chat_member = None - - with patch("bot.handlers.captcha.get_settings", return_value=mock_settings): - await chat_member_handler(update, mock_context) - + + await chat_member_handler(update, mock_context) + mock_context.bot.restrict_chat_member.assert_not_called() diff --git a/tests/test_captcha_recovery.py b/tests/test_captcha_recovery.py index e740f4a..f3f1348 100644 --- a/tests/test_captcha_recovery.py +++ b/tests/test_captcha_recovery.py @@ -8,6 +8,7 @@ from sqlmodel import Session, text from bot.database.service import get_database, init_database, reset_database +from bot.group_config import GroupConfig, GroupRegistry from bot.services.captcha_recovery import ( handle_captcha_expiration, recover_pending_captchas, @@ -15,12 +16,19 @@ @pytest.fixture -def mock_settings(): - settings = MagicMock() - settings.group_id = -1001234567890 - settings.captcha_timeout_seconds = 300 - settings.captcha_timeout_timedelta = timedelta(seconds=300) - return settings +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=0, + captcha_timeout_seconds=300, + ) + + +@pytest.fixture +def mock_registry(group_config): + registry = GroupRegistry() + registry.register(group_config) + return registry @pytest.fixture @@ -58,7 +66,7 @@ async def test_handle_captcha_expiration_success( with patch("bot.services.captcha_recovery.BotInfoCache.get_username") as mock_username: mock_username.return_value = "testbot" - + await handle_captcha_expiration( bot=mock_bot, user_id=12345, @@ -106,7 +114,7 @@ async def test_handle_captcha_expiration_message_edit_fails( with patch("bot.services.captcha_recovery.BotInfoCache.get_username") as mock_username: mock_username.return_value = "testbot" - + await handle_captcha_expiration( bot=mock_bot, user_id=12345, @@ -122,27 +130,27 @@ async def test_handle_captcha_expiration_message_edit_fails( class TestRecoverPendingCaptchas: async def test_recover_pending_captchas_no_records( - self, mock_application, mock_settings, temp_db, caplog + self, mock_application, mock_registry, temp_db, caplog ): caplog.set_level(logging.INFO) - with patch("bot.services.captcha_recovery.get_settings", return_value=mock_settings): + with patch("bot.services.captcha_recovery.get_group_registry", return_value=mock_registry): await recover_pending_captchas(mock_application) assert "No pending captcha verifications to recover" in caplog.text mock_application.job_queue.run_once.assert_not_called() async def test_recover_pending_captchas_expired_timeout( - self, mock_application, mock_settings, temp_db, caplog + self, mock_application, mock_registry, temp_db, caplog ): caplog.set_level(logging.INFO) db = get_database() - + # Create a record that expired 100 seconds ago old_time = datetime.now(UTC) - timedelta(seconds=400) record = db.add_pending_captcha( 12345, -1001234567890, -1001234567890, 999, "Test User" ) - + # Manually update created_at to simulate old record with Session(db._engine) as session: stmt = text("UPDATE pending_validations SET created_at = :created_at WHERE id = :id") @@ -150,7 +158,7 @@ async def test_recover_pending_captchas_expired_timeout( session.commit() with ( - patch("bot.services.captcha_recovery.get_settings", return_value=mock_settings), + patch("bot.services.captcha_recovery.get_group_registry", return_value=mock_registry), patch("bot.services.captcha_recovery.handle_captcha_expiration") as mock_expire, ): mock_expire.return_value = AsyncMock() @@ -171,17 +179,17 @@ async def test_recover_pending_captchas_expired_timeout( assert "Captcha recovery complete" in caplog.text async def test_recover_pending_captchas_reschedule_timeout( - self, mock_application, mock_settings, temp_db, caplog + self, mock_application, mock_registry, temp_db, caplog ): caplog.set_level(logging.INFO) db = get_database() - + # Create a record with 150 seconds remaining (150 seconds ago) recent_time = datetime.now(UTC) - timedelta(seconds=150) record = db.add_pending_captcha( 12345, -1001234567890, -1001234567890, 999, "Test User" ) - + # Manually update created_at with Session(db._engine) as session: stmt = text("UPDATE pending_validations SET created_at = :created_at WHERE id = :id") @@ -189,14 +197,14 @@ async def test_recover_pending_captchas_reschedule_timeout( session.commit() with ( - patch("bot.services.captcha_recovery.get_settings", return_value=mock_settings), + patch("bot.services.captcha_recovery.get_group_registry", return_value=mock_registry), patch("bot.services.captcha_recovery.captcha_timeout_callback") as mock_callback, ): await recover_pending_captchas(mock_application) mock_application.job_queue.run_once.assert_called_once() call_args = mock_application.job_queue.run_once.call_args - + assert call_args.args[0] == mock_callback assert 149 <= call_args.kwargs["when"] <= 151 # Allow 1 second tolerance assert call_args.kwargs["name"] == "captcha_timeout_-1001234567890_12345" @@ -211,52 +219,52 @@ async def test_recover_pending_captchas_reschedule_timeout( assert "remaining:" in caplog.text async def test_recover_pending_captchas_handles_errors( - self, mock_application, mock_settings, temp_db, caplog + self, mock_application, mock_registry, temp_db, caplog ): caplog.set_level(logging.INFO) db = get_database() - + # Create a record record = db.add_pending_captcha( 12345, -1001234567890, -1001234567890, 999, "Test User" ) with ( - patch("bot.services.captcha_recovery.get_settings", return_value=mock_settings), + patch("bot.services.captcha_recovery.get_group_registry", return_value=mock_registry), patch("bot.services.captcha_recovery.handle_captcha_expiration") as mock_expire, ): mock_expire.side_effect = Exception("Something went wrong") - + # Manually update to make it expired old_time = datetime.now(UTC) - timedelta(seconds=400) with Session(db._engine) as session: stmt = text("UPDATE pending_validations SET created_at = :created_at WHERE id = :id") session.execute(stmt, {"created_at": old_time, "id": record.id}) session.commit() - + await recover_pending_captchas(mock_application) assert "Failed to recover captcha for user 12345: Something went wrong" in caplog.text assert "Captcha recovery complete" in caplog.text async def test_recover_pending_captchas_multiple_records( - self, mock_application, mock_settings, temp_db, caplog + self, mock_application, mock_registry, temp_db, caplog ): caplog.set_level(logging.INFO) db = get_database() - + # Create expired record old_time = datetime.now(UTC) - timedelta(seconds=400) record1 = db.add_pending_captcha( 12345, -1001234567890, -1001234567890, 999, "User One" ) - + # Create pending record recent_time = datetime.now(UTC) - timedelta(seconds=150) record2 = db.add_pending_captcha( 67890, -1001234567890, -1001234567890, 888, "User Two" ) - + # Manually update created_at for both with Session(db._engine) as session: stmt = text("UPDATE pending_validations SET created_at = :created_at WHERE id = :id") @@ -265,7 +273,7 @@ async def test_recover_pending_captchas_multiple_records( session.commit() with ( - patch("bot.services.captcha_recovery.get_settings", return_value=mock_settings), + patch("bot.services.captcha_recovery.get_group_registry", return_value=mock_registry), patch("bot.services.captcha_recovery.handle_captcha_expiration") as mock_expire, patch("bot.services.captcha_recovery.captcha_timeout_callback"), ): @@ -275,7 +283,7 @@ async def test_recover_pending_captchas_multiple_records( # Should expire the first one mock_expire.assert_called_once() assert mock_expire.call_args.kwargs["user_id"] == 12345 - + # Should reschedule the second one mock_application.job_queue.run_once.assert_called_once() call_args = mock_application.job_queue.run_once.call_args @@ -285,3 +293,29 @@ async def test_recover_pending_captchas_multiple_records( assert "Expiring captcha for user 12345" in caplog.text assert "Rescheduling captcha timeout for user 67890" in caplog.text assert "Captcha recovery complete" in caplog.text + + async def test_recover_pending_captchas_skips_unknown_group( + self, mock_application, mock_registry, temp_db, caplog + ): + caplog.set_level(logging.WARNING) + db = get_database() + + # Create a captcha record for a group NOT in the registry + unknown_group_id = -1009999999999 + old_time = datetime.now(UTC) - timedelta(seconds=400) + record = db.add_pending_captcha( + 12345, unknown_group_id, unknown_group_id, 999, "Test User" + ) + + with Session(db._engine) as session: + stmt = text("UPDATE pending_validations SET created_at = :created_at WHERE id = :id") + session.execute(stmt, {"created_at": old_time, "id": record.id}) + session.commit() + + with patch("bot.services.captcha_recovery.get_group_registry", return_value=mock_registry): + await recover_pending_captchas(mock_application) + + # Should skip - no expiration, no reschedule + mock_application.job_queue.run_once.assert_not_called() + + assert "group no longer in registry" in caplog.text diff --git a/tests/test_check.py b/tests/test_check.py index c9306f9..9401843 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -5,6 +5,7 @@ import pytest from telegram.error import TimedOut +from bot.group_config import GroupConfig, GroupRegistry from bot.handlers.check import ( handle_check_command, handle_check_forwarded_message, @@ -22,6 +23,22 @@ def mock_settings(): return settings +@pytest.fixture +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=12345, + rules_link="https://t.me/test/rules", + ) + + +@pytest.fixture +def mock_registry(group_config): + registry = GroupRegistry() + registry.register(group_config) + return registry + + @pytest.fixture def mock_update(): update = MagicMock() @@ -88,9 +105,7 @@ async def test_check_command_invalid_user_id(self, mock_update, mock_context): call_args = mock_update.message.reply_text.call_args assert "angka" in call_args.args[0] - async def test_check_command_complete_profile( - self, mock_update, mock_context, mock_settings - ): + async def test_check_command_complete_profile(self, mock_update, mock_context): """Shows complete profile (no warn button).""" mock_context.args = ["555666"] @@ -102,7 +117,6 @@ async def test_check_command_complete_profile( mock_db.is_user_photo_whitelisted.return_value = False with ( - patch("bot.handlers.check.get_settings", return_value=mock_settings), patch( "bot.handlers.check.check_user_profile", return_value=complete_result, @@ -114,11 +128,11 @@ async def test_check_command_complete_profile( mock_update.message.reply_text.assert_called_once() call_args = mock_update.message.reply_text.call_args assert "555666" in call_args.args[0] - assert "✅" in call_args.args[0] + assert "\u2705" in call_args.args[0] assert call_args.kwargs.get("reply_markup") is None async def test_check_command_complete_profile_whitelisted( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context ): """Shows complete profile with unverify button when user is whitelisted.""" mock_context.args = ["555666"] @@ -131,7 +145,6 @@ async def test_check_command_complete_profile_whitelisted( mock_db.is_user_photo_whitelisted.return_value = True with ( - patch("bot.handlers.check.get_settings", return_value=mock_settings), patch( "bot.handlers.check.check_user_profile", return_value=complete_result, @@ -143,16 +156,14 @@ async def test_check_command_complete_profile_whitelisted( mock_update.message.reply_text.assert_called_once() call_args = mock_update.message.reply_text.call_args assert "555666" in call_args.args[0] - assert "✅" in call_args.args[0] + assert "\u2705" in call_args.args[0] keyboard = call_args.kwargs.get("reply_markup") assert keyboard is not None buttons = keyboard.inline_keyboard[0] assert any("unverify:555666" in btn.callback_data for btn in buttons) assert any("Unverify User" in btn.text for btn in buttons) - async def test_check_command_incomplete_profile( - self, mock_update, mock_context, mock_settings - ): + async def test_check_command_incomplete_profile(self, mock_update, mock_context): """Shows incomplete profile with warn button.""" mock_context.args = ["555666"] @@ -164,7 +175,6 @@ async def test_check_command_incomplete_profile( mock_db.is_user_photo_whitelisted.return_value = False with ( - patch("bot.handlers.check.get_settings", return_value=mock_settings), patch( "bot.handlers.check.check_user_profile", return_value=incomplete_result, @@ -176,7 +186,7 @@ async def test_check_command_incomplete_profile( mock_update.message.reply_text.assert_called_once() call_args = mock_update.message.reply_text.call_args assert "555666" in call_args.args[0] - assert "❌" in call_args.args[0] + assert "\u274c" in call_args.args[0] keyboard = call_args.kwargs.get("reply_markup") assert keyboard is not None buttons = keyboard.inline_keyboard[0] @@ -261,9 +271,7 @@ async def test_check_forwarded_hidden_user(self, mock_update, mock_context): call_args = mock_update.message.reply_text.call_args assert "Tidak dapat mengekstrak" in call_args.args[0] - async def test_check_forwarded_success( - self, mock_update, mock_context, mock_settings - ): + async def test_check_forwarded_success(self, mock_update, mock_context): """Successfully checks forwarded user.""" forwarded_user = MagicMock() forwarded_user.id = 555666 @@ -278,7 +286,6 @@ async def test_check_forwarded_success( mock_db.is_user_photo_whitelisted.return_value = False with ( - patch("bot.handlers.check.get_settings", return_value=mock_settings), patch( "bot.handlers.check.check_user_profile", return_value=complete_result, @@ -292,7 +299,7 @@ async def test_check_forwarded_success( assert "555666" in call_args.args[0] async def test_check_forwarded_with_forward_origin( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context ): """Successfully checks forwarded user via forward_origin.""" forwarded_user = MagicMock() @@ -312,7 +319,6 @@ async def test_check_forwarded_with_forward_origin( mock_db.is_user_photo_whitelisted.return_value = False with ( - patch("bot.handlers.check.get_settings", return_value=mock_settings), patch( "bot.handlers.check.check_user_profile", return_value=complete_result, @@ -338,7 +344,7 @@ async def test_check_forwarded_no_from_user(self, mock_context): await handle_check_forwarded_message(update, mock_context) - async def test_check_forwarded_error(self, mock_update, mock_context, mock_settings): + async def test_check_forwarded_error(self, mock_update, mock_context): """Handles error gracefully when checking forwarded user.""" forwarded_user = MagicMock() forwarded_user.id = 555666 @@ -391,8 +397,10 @@ async def test_warn_callback_non_admin(self, mock_context): call_args = query.edit_message_text.call_args assert "izin" in call_args.args[0] - async def test_warn_callback_success(self, mock_context, mock_settings): - """Successfully sends warning to group.""" + async def test_warn_callback_success( + self, mock_context, mock_settings, group_config, mock_registry + ): + """Successfully sends warning to all monitored groups.""" update = MagicMock() query = MagicMock() query.from_user = MagicMock() @@ -407,14 +415,23 @@ async def test_warn_callback_success(self, mock_context, mock_settings): mock_chat.full_name = "Test User" mock_context.bot.get_chat.return_value = mock_chat - with patch("bot.handlers.check.get_settings", return_value=mock_settings): + with ( + patch("bot.handlers.check.get_settings", return_value=mock_settings), + patch( + "bot.handlers.check.get_group_registry", + return_value=mock_registry, + ), + ): await handle_warn_callback(update, mock_context) query.answer.assert_called_once() mock_context.bot.send_message.assert_called_once() send_call_args = mock_context.bot.send_message.call_args - assert send_call_args.kwargs["chat_id"] == mock_settings.group_id - assert send_call_args.kwargs["message_thread_id"] == mock_settings.warning_topic_id + assert send_call_args.kwargs["chat_id"] == group_config.group_id + assert ( + send_call_args.kwargs["message_thread_id"] + == group_config.warning_topic_id + ) assert "foto profil publik" in send_call_args.kwargs["text"] assert "username" in send_call_args.kwargs["text"] @@ -423,7 +440,7 @@ async def test_warn_callback_success(self, mock_context, mock_settings): assert "dikirim" in edit_call_args.args[0] async def test_warn_callback_success_missing_photo_only( - self, mock_context, mock_settings + self, mock_context, mock_settings, group_config, mock_registry ): """Successfully sends warning for missing photo only.""" update = MagicMock() @@ -441,7 +458,13 @@ async def test_warn_callback_success_missing_photo_only( mock_chat.username = "testuser" mock_context.bot.get_chat.return_value = mock_chat - with patch("bot.handlers.check.get_settings", return_value=mock_settings): + with ( + patch("bot.handlers.check.get_settings", return_value=mock_settings), + patch( + "bot.handlers.check.get_group_registry", + return_value=mock_registry, + ), + ): await handle_warn_callback(update, mock_context) send_call_args = mock_context.bot.send_message.call_args @@ -491,7 +514,9 @@ async def test_warn_callback_no_data(self, mock_context): await handle_warn_callback(update, mock_context) - async def test_warn_callback_send_message_error(self, mock_context, mock_settings): + async def test_warn_callback_send_message_error( + self, mock_context, mock_settings, mock_registry + ): """Handles send_message error gracefully.""" update = MagicMock() query = MagicMock() @@ -508,15 +533,23 @@ async def test_warn_callback_send_message_error(self, mock_context, mock_setting mock_context.bot.get_chat.return_value = mock_chat mock_context.bot.send_message.side_effect = Exception("Failed to send") - with patch("bot.handlers.check.get_settings", return_value=mock_settings): + with ( + patch("bot.handlers.check.get_settings", return_value=mock_settings), + patch( + "bot.handlers.check.get_group_registry", + return_value=mock_registry, + ), + ): await handle_warn_callback(update, mock_context) query.edit_message_text.assert_called_once() call_args = query.edit_message_text.call_args assert "Gagal mengirim" in call_args.args[0] - async def test_warn_callback_timeout(self, mock_context, mock_settings): - """Handles TimedOut error gracefully.""" + async def test_warn_callback_timeout( + self, mock_context, mock_settings, mock_registry + ): + """Handles TimedOut error gracefully (per-group failure).""" update = MagicMock() query = MagicMock() query.from_user = MagicMock() @@ -532,7 +565,44 @@ async def test_warn_callback_timeout(self, mock_context, mock_settings): mock_context.bot.get_chat.return_value = mock_chat mock_context.bot.send_message.side_effect = TimedOut() - with patch("bot.handlers.check.get_settings", return_value=mock_settings): + with ( + patch("bot.handlers.check.get_settings", return_value=mock_settings), + patch( + "bot.handlers.check.get_group_registry", + return_value=mock_registry, + ), + ): + await handle_warn_callback(update, mock_context) + + # TimedOut is caught per-group inside the loop, so all groups fail + # and the "failed to send to all groups" message is shown + query.edit_message_text.assert_called_once() + call_args = query.edit_message_text.call_args + assert "Gagal mengirim" in call_args.args[0] + + async def test_warn_callback_get_chat_timeout( + self, mock_context, mock_settings, mock_registry + ): + """Handles TimedOut on get_chat (before the per-group loop).""" + update = MagicMock() + query = MagicMock() + query.from_user = MagicMock() + query.from_user.id = 12345 + query.from_user.full_name = "Admin User" + query.data = "warn:555666:pu" + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + update.callback_query = query + + mock_context.bot.get_chat.side_effect = TimedOut() + + with ( + patch("bot.handlers.check.get_settings", return_value=mock_settings), + patch( + "bot.handlers.check.get_group_registry", + return_value=mock_registry, + ), + ): await handle_warn_callback(update, mock_context) query.edit_message_text.assert_called_once() diff --git a/tests/test_dm_handler.py b/tests/test_dm_handler.py index 7bd5ab8..99c4b3d 100644 --- a/tests/test_dm_handler.py +++ b/tests/test_dm_handler.py @@ -3,17 +3,32 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from telegram.error import BadRequest from bot.database.service import init_database, reset_database +from bot.group_config import GroupConfig, GroupRegistry from bot.handlers.dm import handle_dm from bot.services.user_checker import ProfileCheckResult +@pytest.fixture +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + rules_link="https://t.me/test/rules", + ) + + +@pytest.fixture +def mock_registry(group_config): + registry = GroupRegistry() + registry.register(group_config) + return registry + + @pytest.fixture def mock_settings(): settings = MagicMock() - settings.group_id = -1001234567890 settings.rules_link = "https://t.me/test/rules" return settings @@ -37,9 +52,6 @@ def mock_context(): context = MagicMock() context.bot = AsyncMock() context.bot.id = 99999 - user_member = MagicMock() - user_member.status = "member" - context.bot.get_chat_member.return_value = user_member return context @@ -70,18 +82,25 @@ async def test_no_user(self, mock_context): mock_context.bot.restrict_chat_member.assert_not_called() - async def test_non_private_chat_ignored(self, mock_update, mock_context, mock_settings): + async def test_non_private_chat_ignored(self, mock_update, mock_context): mock_update.effective_chat.type = "group" - with patch("bot.handlers.dm.get_settings", return_value=mock_settings): - await handle_dm(mock_update, mock_context) + await handle_dm(mock_update, mock_context) mock_update.message.reply_text.assert_not_called() - async def test_user_not_in_group(self, mock_update, mock_context, mock_settings): - mock_context.bot.get_chat_member.side_effect = BadRequest("User not found") - - with patch("bot.handlers.dm.get_settings", return_value=mock_settings): + async def test_user_not_in_group( + self, mock_update, mock_context, mock_settings, mock_registry, temp_db + ): + with ( + patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value=None, + ), + ): await handle_dm(mock_update, mock_context) mock_update.message.reply_text.assert_called_once() @@ -89,24 +108,36 @@ async def test_user_not_in_group(self, mock_update, mock_context, mock_settings) assert "belum bergabung di grup" in call_args.args[0] mock_context.bot.restrict_chat_member.assert_not_called() - async def test_user_left_group(self, mock_update, mock_context, mock_settings): - user_member = MagicMock() - user_member.status = "left" - mock_context.bot.get_chat_member.return_value = user_member - - with patch("bot.handlers.dm.get_settings", return_value=mock_settings): + async def test_user_left_group( + self, mock_update, mock_context, mock_settings, mock_registry, temp_db + ): + with ( + patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="left", + ), + ): await handle_dm(mock_update, mock_context) mock_update.message.reply_text.assert_called_once() call_args = mock_update.message.reply_text.call_args assert "belum bergabung di grup" in call_args.args[0] - async def test_user_kicked_from_group(self, mock_update, mock_context, mock_settings): - user_member = MagicMock() - user_member.status = "kicked" - mock_context.bot.get_chat_member.return_value = user_member - - with patch("bot.handlers.dm.get_settings", return_value=mock_settings): + async def test_user_kicked_from_group( + self, mock_update, mock_context, mock_settings, mock_registry, temp_db + ): + with ( + patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="kicked", + ), + ): await handle_dm(mock_update, mock_context) mock_update.message.reply_text.assert_called_once() @@ -114,7 +145,7 @@ async def test_user_kicked_from_group(self, mock_update, mock_context, mock_sett assert "belum bergabung di grup" in call_args.args[0] async def test_missing_profile_sends_requirements( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -122,6 +153,12 @@ async def test_missing_profile_sends_requirements( with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="member", + ), patch( "bot.handlers.dm.check_user_profile", return_value=incomplete_result, @@ -136,7 +173,7 @@ async def test_missing_profile_sends_requirements( mock_context.bot.restrict_chat_member.assert_not_called() async def test_missing_username_sends_requirements( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): incomplete_result = ProfileCheckResult( has_profile_photo=True, has_username=False @@ -144,6 +181,12 @@ async def test_missing_username_sends_requirements( with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="member", + ), patch( "bot.handlers.dm.check_user_profile", return_value=incomplete_result, @@ -155,7 +198,7 @@ async def test_missing_username_sends_requirements( assert "username" in call_args.args[0] async def test_complete_profile_not_restricted_by_bot( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): complete_result = ProfileCheckResult( has_profile_photo=True, has_username=True @@ -163,6 +206,12 @@ async def test_complete_profile_not_restricted_by_bot( with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="member", + ), patch( "bot.handlers.dm.check_user_profile", return_value=complete_result, @@ -176,7 +225,7 @@ async def test_complete_profile_not_restricted_by_bot( mock_context.bot.restrict_chat_member.assert_not_called() async def test_complete_profile_unrestricts_user( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): from bot.database.service import get_database @@ -186,33 +235,29 @@ async def test_complete_profile_unrestricts_user( db.increment_message_count(12345, -1001234567890) db.mark_user_restricted(12345, -1001234567890) - user_member = MagicMock() - user_member.status = "restricted" - mock_context.bot.get_chat_member.return_value = user_member - - chat = MagicMock() - chat.permissions = MagicMock() - chat.permissions.can_send_messages = True - mock_context.bot.get_chat.return_value = chat - complete_result = ProfileCheckResult( has_profile_photo=True, has_username=True ) with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="restricted", + ), patch( "bot.handlers.dm.check_user_profile", return_value=complete_result, ), + patch( + "bot.handlers.dm.unrestrict_user", + new_callable=AsyncMock, + ), ): await handle_dm(mock_update, mock_context) - mock_context.bot.restrict_chat_member.assert_called_once() - call_args = mock_context.bot.restrict_chat_member.call_args - assert call_args.kwargs["chat_id"] == -1001234567890 - assert call_args.kwargs["user_id"] == 12345 - reply_args = mock_update.message.reply_text.call_args assert "✅" in reply_args.args[0] assert "dicabut" in reply_args.args[0] @@ -220,7 +265,7 @@ async def test_complete_profile_unrestricts_user( assert db.is_user_restricted_by_bot(12345, -1001234567890) is False async def test_user_already_unrestricted_on_telegram( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): from bot.database.service import get_database @@ -230,16 +275,18 @@ async def test_user_already_unrestricted_on_telegram( db.increment_message_count(12345, -1001234567890) db.mark_user_restricted(12345, -1001234567890) - user_member = MagicMock() - user_member.status = "member" - mock_context.bot.get_chat_member.return_value = user_member - complete_result = ProfileCheckResult( has_profile_photo=True, has_username=True ) with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="member", + ), patch( "bot.handlers.dm.check_user_profile", return_value=complete_result, @@ -253,7 +300,7 @@ async def test_user_already_unrestricted_on_telegram( assert db.is_user_restricted_by_bot(12345, -1001234567890) is False async def test_does_not_unrestrict_admin_restricted_user( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): from bot.database.service import get_database @@ -266,6 +313,12 @@ async def test_does_not_unrestrict_admin_restricted_user( with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="member", + ), patch( "bot.handlers.dm.check_user_profile", return_value=complete_result, @@ -278,7 +331,7 @@ async def test_does_not_unrestrict_admin_restricted_user( assert "tidak memiliki pembatasan dari bot" in call_args.args[0] async def test_redirects_user_with_pending_captcha_to_group( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): from bot.database.service import get_database @@ -291,16 +344,20 @@ async def test_redirects_user_with_pending_captcha_to_group( user_full_name="Test User", ) - user_member = MagicMock() - user_member.status = "restricted" - mock_context.bot.get_chat_member.return_value = user_member - - with patch("bot.handlers.dm.get_settings", return_value=mock_settings): + with ( + patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="restricted", + ), + ): await handle_dm(mock_update, mock_context) # Should not unrestrict user mock_context.bot.restrict_chat_member.assert_not_called() - + # Should tell user to check group and verify reply_args = mock_update.message.reply_text.call_args assert "⏳" in reply_args.args[0] @@ -311,7 +368,7 @@ async def test_redirects_user_with_pending_captcha_to_group( assert db.get_pending_captcha(12345, -1001234567890) is not None async def test_pending_captcha_check_takes_priority_over_profile_check( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): from bot.database.service import get_database @@ -324,12 +381,14 @@ async def test_pending_captcha_check_takes_priority_over_profile_check( user_full_name="Test User", ) - user_member = MagicMock() - user_member.status = "restricted" - mock_context.bot.get_chat_member.return_value = user_member - with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="restricted", + ), patch("bot.handlers.dm.check_user_profile") as mock_check_profile, ): await handle_dm(mock_update, mock_context) @@ -370,7 +429,7 @@ def test_returns_true_when_restricted_by_bot(self, temp_db): class TestUnrestrictUserError: async def test_unrestrict_user_exception_logged_and_raised( - self, mock_update, mock_context, mock_settings, temp_db + self, mock_update, mock_context, mock_settings, mock_registry, temp_db ): from bot.database.service import get_database @@ -380,16 +439,18 @@ async def test_unrestrict_user_exception_logged_and_raised( db.increment_message_count(12345, -1001234567890) db.mark_user_restricted(12345, -1001234567890) - user_member = MagicMock() - user_member.status = "restricted" - mock_context.bot.get_chat_member.return_value = user_member - complete_result = ProfileCheckResult( has_profile_photo=True, has_username=True ) with ( patch("bot.handlers.dm.get_settings", return_value=mock_settings), + patch("bot.handlers.dm.get_group_registry", return_value=mock_registry), + patch( + "bot.handlers.dm.get_user_status", + new_callable=AsyncMock, + return_value="restricted", + ), patch( "bot.handlers.dm.check_user_profile", return_value=complete_result, @@ -399,5 +460,5 @@ async def test_unrestrict_user_exception_logged_and_raised( side_effect=Exception("test error"), ), ): - with pytest.raises(Exception, match="test error"): + with pytest.raises(RuntimeError, match="Failed to unrestrict user 12345 in any group"): await handle_dm(mock_update, mock_context) diff --git a/tests/test_group_config.py b/tests/test_group_config.py new file mode 100644 index 0000000..1672739 --- /dev/null +++ b/tests/test_group_config.py @@ -0,0 +1,340 @@ +"""Tests for the group_config module.""" + +import json +import tempfile +from datetime import timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from bot.group_config import ( + GroupConfig, + GroupRegistry, + build_group_registry, + get_group_config_for_update, + get_group_registry, + init_group_registry, + load_groups_from_json, + reset_group_registry, +) + + +class TestGroupConfig: + def test_minimal_config(self): + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=42) + assert gc.group_id == -1001234567890 + assert gc.warning_topic_id == 42 + assert gc.restrict_failed_users is False + assert gc.warning_threshold == 3 + assert gc.captcha_enabled is False + + def test_full_config(self): + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + restrict_failed_users=True, + warning_threshold=5, + warning_time_threshold_minutes=60, + captcha_enabled=True, + captcha_timeout_seconds=180, + new_user_probation_hours=168, + new_user_violation_threshold=2, + rules_link="https://t.me/mygroup/rules", + ) + assert gc.restrict_failed_users is True + assert gc.warning_threshold == 5 + assert gc.captcha_timeout_seconds == 180 + + def test_group_id_must_be_negative(self): + with pytest.raises(ValidationError, match="group_id must be negative"): + GroupConfig(group_id=123, warning_topic_id=42) + + def test_group_id_zero_rejected(self): + with pytest.raises(ValidationError, match="group_id must be negative"): + GroupConfig(group_id=0, warning_topic_id=42) + + def test_warning_threshold_must_be_positive(self): + with pytest.raises(ValidationError, match="warning_threshold must be greater than 0"): + GroupConfig(group_id=-1, warning_topic_id=42, warning_threshold=0) + + def test_warning_time_threshold_must_be_positive(self): + with pytest.raises(ValidationError, match="warning_time_threshold_minutes must be greater than 0"): + GroupConfig(group_id=-1, warning_topic_id=42, warning_time_threshold_minutes=0) + + def test_captcha_timeout_must_be_in_range(self): + with pytest.raises(ValidationError, match="captcha_timeout_seconds must be between 10 and 600"): + GroupConfig(group_id=-1, warning_topic_id=42, captcha_timeout_seconds=5) + with pytest.raises(ValidationError, match="captcha_timeout_seconds must be between 10 and 600"): + GroupConfig(group_id=-1, warning_topic_id=42, captcha_timeout_seconds=601) + + def test_probation_hours_must_be_non_negative(self): + with pytest.raises(ValidationError, match="new_user_probation_hours must be >= 0"): + GroupConfig(group_id=-1, warning_topic_id=42, new_user_probation_hours=-1) + + def test_probation_hours_zero_is_valid(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42, new_user_probation_hours=0) + assert gc.new_user_probation_hours == 0 + + def test_probation_timedelta(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42, new_user_probation_hours=72) + assert gc.probation_timedelta == timedelta(hours=72) + + def test_warning_time_threshold_timedelta(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42, warning_time_threshold_minutes=180) + assert gc.warning_time_threshold_timedelta == timedelta(minutes=180) + + def test_captcha_timeout_timedelta(self): + gc = GroupConfig(group_id=-1, warning_topic_id=42, captcha_timeout_seconds=120) + assert gc.captcha_timeout_timedelta == timedelta(seconds=120) + + +class TestGroupRegistry: + def test_register_and_get(self): + registry = GroupRegistry() + gc = GroupConfig(group_id=-100, warning_topic_id=1) + registry.register(gc) + assert registry.get(-100) == gc + + def test_get_returns_none_for_unknown(self): + registry = GroupRegistry() + assert registry.get(-999) is None + + def test_is_monitored(self): + registry = GroupRegistry() + gc = GroupConfig(group_id=-100, warning_topic_id=1) + registry.register(gc) + assert registry.is_monitored(-100) is True + assert registry.is_monitored(-999) is False + + def test_all_groups(self): + registry = GroupRegistry() + gc1 = GroupConfig(group_id=-100, warning_topic_id=1) + gc2 = GroupConfig(group_id=-200, warning_topic_id=2) + registry.register(gc1) + registry.register(gc2) + groups = registry.all_groups() + assert len(groups) == 2 + assert gc1 in groups + assert gc2 in groups + + def test_duplicate_group_id_raises(self): + registry = GroupRegistry() + gc = GroupConfig(group_id=-100, warning_topic_id=1) + registry.register(gc) + with pytest.raises(ValueError, match="Duplicate group_id"): + registry.register(gc) + + def test_empty_registry(self): + registry = GroupRegistry() + assert registry.all_groups() == [] + assert registry.get(-100) is None + assert registry.is_monitored(-100) is False + + +class TestLoadGroupsFromJson: + def test_load_valid_json(self): + data = [ + {"group_id": -100, "warning_topic_id": 1}, + {"group_id": -200, "warning_topic_id": 2}, + ] + 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 len(configs) == 2 + assert configs[0].group_id == -100 + assert configs[1].group_id == -200 + + def test_load_with_all_fields(self): + data = [ + { + "group_id": -100, + "warning_topic_id": 1, + "restrict_failed_users": True, + "warning_threshold": 5, + "captcha_enabled": True, + "captcha_timeout_seconds": 180, + "rules_link": "https://example.com/rules", + } + ] + 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].restrict_failed_users is True + assert configs[0].warning_threshold == 5 + + def test_file_not_found(self): + with pytest.raises(FileNotFoundError): + load_groups_from_json("/nonexistent/path.json") + + def test_invalid_json(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("not json") + f.flush() + with pytest.raises(json.JSONDecodeError): + load_groups_from_json(f.name) + + def test_not_array(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"group_id": -100}, f) + f.flush() + with pytest.raises(ValueError, match="must contain a JSON array"): + load_groups_from_json(f.name) + + def test_empty_array(self): + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump([], f) + f.flush() + with pytest.raises(ValueError, match="must contain at least one group"): + load_groups_from_json(f.name) + + def test_duplicate_group_ids(self): + data = [ + {"group_id": -100, "warning_topic_id": 1}, + {"group_id": -100, "warning_topic_id": 2}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + with pytest.raises(ValueError, match="Duplicate group_id"): + load_groups_from_json(f.name) + + def test_invalid_group_config(self): + data = [{"group_id": 123, "warning_topic_id": 1}] # Positive group_id + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + with pytest.raises(ValidationError, match="group_id must be negative"): + load_groups_from_json(f.name) + + +class TestBuildGroupRegistry: + def test_builds_from_json_file(self): + data = [ + {"group_id": -100, "warning_topic_id": 1}, + {"group_id": -200, "warning_topic_id": 2}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + f.flush() + + settings = MagicMock() + settings.groups_config_path = f.name + + registry = build_group_registry(settings) + + assert len(registry.all_groups()) == 2 + assert registry.is_monitored(-100) + assert registry.is_monitored(-200) + + def test_falls_back_to_env(self): + settings = MagicMock() + settings.groups_config_path = "/nonexistent/groups.json" + settings.group_id = -1001234567890 + settings.warning_topic_id = 42 + 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" + + registry = build_group_registry(settings) + + assert len(registry.all_groups()) == 1 + gc = registry.get(-1001234567890) + assert gc is not None + assert gc.warning_topic_id == 42 + assert gc.rules_link == "https://t.me/test/rules" + + +class TestGetGroupConfigForUpdate: + def test_returns_config_for_monitored_group(self): + gc = GroupConfig(group_id=-100, warning_topic_id=1) + registry = GroupRegistry() + registry.register(gc) + + update = MagicMock() + update.effective_chat = MagicMock() + update.effective_chat.id = -100 + + with patch("bot.group_config.get_group_registry", return_value=registry): + result = get_group_config_for_update(update) + + assert result == gc + + def test_returns_none_for_unmonitored_group(self): + registry = GroupRegistry() + + update = MagicMock() + update.effective_chat = MagicMock() + update.effective_chat.id = -999 + + with patch("bot.group_config.get_group_registry", return_value=registry): + result = get_group_config_for_update(update) + + assert result is None + + def test_returns_none_when_no_effective_chat(self): + update = MagicMock() + update.effective_chat = None + + result = get_group_config_for_update(update) + assert result is None + + +class TestSingleton: + def setup_method(self): + reset_group_registry() + + def teardown_method(self): + reset_group_registry() + + def test_get_before_init_raises(self): + with pytest.raises(RuntimeError, match="not initialized"): + get_group_registry() + + def test_init_and_get(self): + 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" + + registry = init_group_registry(settings) + assert registry is get_group_registry() + assert registry.is_monitored(-100) + + def test_reset_clears_registry(self): + 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" + + init_group_registry(settings) + reset_group_registry() + + with pytest.raises(RuntimeError, match="not initialized"): + get_group_registry() diff --git a/tests/test_message_handler.py b/tests/test_message_handler.py index c610cf0..e6477b7 100644 --- a/tests/test_message_handler.py +++ b/tests/test_message_handler.py @@ -5,20 +5,21 @@ import pytest from bot.database.service import init_database, reset_database +from bot.group_config import GroupConfig from bot.handlers.message import handle_message from bot.services.user_checker import ProfileCheckResult @pytest.fixture -def mock_settings(): - settings = MagicMock() - settings.group_id = -1001234567890 - settings.warning_topic_id = 42 - settings.restrict_failed_users = False - settings.warning_time_threshold_minutes = 180 - settings.warning_threshold = 3 - settings.rules_link = "https://example.com/rules" - return settings +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + restrict_failed_users=False, + warning_time_threshold_minutes=180, + warning_threshold=3, + rules_link="https://example.com/rules", + ) @pytest.fixture @@ -69,29 +70,29 @@ async def test_no_user(self, mock_context): mock_context.bot.send_message.assert_not_called() - async def test_wrong_group(self, mock_update, mock_context, mock_settings): + async def test_wrong_group(self, mock_update, mock_context): mock_update.effective_chat.id = -100999999 # Different group - with patch("bot.handlers.message.get_settings", return_value=mock_settings): + with patch("bot.handlers.message.get_group_config_for_update", return_value=None): await handle_message(mock_update, mock_context) mock_context.bot.send_message.assert_not_called() - async def test_bot_user_ignored(self, mock_update, mock_context, mock_settings): + async def test_bot_user_ignored(self, mock_update, mock_context, group_config): mock_update.message.from_user.is_bot = True - with patch("bot.handlers.message.get_settings", return_value=mock_settings): + with patch("bot.handlers.message.get_group_config_for_update", return_value=group_config): await handle_message(mock_update, mock_context) mock_context.bot.send_message.assert_not_called() async def test_complete_profile_no_warning( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): complete_result = ProfileCheckResult(has_profile_photo=True, has_username=True) with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=complete_result, @@ -102,14 +103,14 @@ async def test_complete_profile_no_warning( mock_context.bot.send_message.assert_not_called() async def test_missing_photo_sends_warning( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True ) with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -124,7 +125,7 @@ async def test_missing_photo_sends_warning( assert "foto profil publik" in call_args.kwargs["text"] async def test_missing_username_sends_warning( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): mock_update.message.from_user.username = None incomplete_result = ProfileCheckResult( @@ -132,7 +133,7 @@ async def test_missing_username_sends_warning( ) with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -146,7 +147,7 @@ async def test_missing_username_sends_warning( assert "Test User" in call_args.kwargs["text"] async def test_missing_both_sends_warning( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): mock_update.message.from_user.username = None incomplete_result = ProfileCheckResult( @@ -154,7 +155,7 @@ async def test_missing_both_sends_warning( ) with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -168,7 +169,7 @@ async def test_missing_both_sends_warning( assert "username" in call_args.kwargs["text"] async def test_warning_mentions_username_when_available( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): mock_update.message.from_user.username = "cooluser" incomplete_result = ProfileCheckResult( @@ -176,7 +177,7 @@ async def test_warning_mentions_username_when_available( ) with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -190,18 +191,18 @@ async def test_warning_mentions_username_when_available( class TestHandleMessageWithProgressiveRestriction: @pytest.fixture - def mock_settings_with_restriction(self): - settings = MagicMock() - settings.group_id = -1001234567890 - settings.warning_topic_id = 42 - settings.restrict_failed_users = True - settings.warning_threshold = 3 - settings.warning_time_threshold_minutes = 180 - settings.rules_link = "https://example.com/rules" - return settings + def group_config_with_restriction(self): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + restrict_failed_users=True, + warning_threshold=3, + warning_time_threshold_minutes=180, + rules_link="https://example.com/rules", + ) async def test_first_message_sends_warning( - self, mock_update, mock_context, mock_settings_with_restriction, temp_db + self, mock_update, mock_context, group_config_with_restriction, temp_db ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -209,8 +210,8 @@ async def test_first_message_sends_warning( with ( patch( - "bot.handlers.message.get_settings", - return_value=mock_settings_with_restriction, + "bot.handlers.message.get_group_config_for_update", + return_value=group_config_with_restriction, ), patch( "bot.handlers.message.check_user_profile", @@ -226,7 +227,7 @@ async def test_first_message_sends_warning( mock_context.bot.restrict_chat_member.assert_not_called() async def test_second_message_silent( - self, mock_update, mock_context, mock_settings_with_restriction, temp_db + self, mock_update, mock_context, group_config_with_restriction, temp_db ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -234,8 +235,8 @@ async def test_second_message_silent( with ( patch( - "bot.handlers.message.get_settings", - return_value=mock_settings_with_restriction, + "bot.handlers.message.get_group_config_for_update", + return_value=group_config_with_restriction, ), patch( "bot.handlers.message.check_user_profile", @@ -253,7 +254,7 @@ async def test_second_message_silent( mock_context.bot.restrict_chat_member.assert_not_called() async def test_threshold_message_restricts_user( - self, mock_update, mock_context, mock_settings_with_restriction, temp_db + self, mock_update, mock_context, group_config_with_restriction, temp_db ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -261,8 +262,8 @@ async def test_threshold_message_restricts_user( with ( patch( - "bot.handlers.message.get_settings", - return_value=mock_settings_with_restriction, + "bot.handlers.message.get_group_config_for_update", + return_value=group_config_with_restriction, ), patch( "bot.handlers.message.check_user_profile", @@ -284,14 +285,14 @@ async def test_threshold_message_restricts_user( assert "dibatasi" in call_args.kwargs["text"] async def test_no_restriction_when_disabled( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True ) with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -304,7 +305,7 @@ async def test_no_restriction_when_disabled( assert "⚠️" in call_args.kwargs["text"] async def test_different_users_tracked_separately( - self, mock_update, mock_context, mock_settings_with_restriction, temp_db + self, mock_update, mock_context, group_config_with_restriction, temp_db ): incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -312,8 +313,8 @@ async def test_different_users_tracked_separately( with ( patch( - "bot.handlers.message.get_settings", - return_value=mock_settings_with_restriction, + "bot.handlers.message.get_group_config_for_update", + return_value=group_config_with_restriction, ), patch( "bot.handlers.message.check_user_profile", @@ -336,7 +337,7 @@ async def test_different_users_tracked_separately( class TestHandleMessageErrorHandling: - async def test_send_warning_message_fails(self, mock_update, mock_context, mock_settings): + async def test_send_warning_message_fails(self, mock_update, mock_context, group_config): """Test when sending warning message fails (lines 110-111).""" incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -344,7 +345,7 @@ async def test_send_warning_message_fails(self, mock_update, mock_context, mock_ mock_context.bot.send_message.side_effect = Exception("test error") with ( - patch("bot.handlers.message.get_settings", return_value=mock_settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=group_config), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -359,13 +360,14 @@ async def test_send_first_warning_fails( self, mock_update, mock_context, temp_db ): """Test when sending first warning fails (lines 146-147).""" - settings = MagicMock() - settings.group_id = -1001234567890 - settings.warning_topic_id = 42 - settings.restrict_failed_users = True - settings.warning_threshold = 3 - settings.warning_time_threshold_minutes = 180 - settings.rules_link = "https://example.com/rules" + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + restrict_failed_users=True, + warning_threshold=3, + warning_time_threshold_minutes=180, + rules_link="https://example.com/rules", + ) incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -373,7 +375,7 @@ async def test_send_first_warning_fails( mock_context.bot.send_message.side_effect = Exception("test error") with ( - patch("bot.handlers.message.get_settings", return_value=settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=gc), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, @@ -388,13 +390,14 @@ async def test_restrict_user_fails( self, mock_update, mock_context, temp_db ): """Test when restricting user fails (lines 193-194).""" - settings = MagicMock() - settings.group_id = -1001234567890 - settings.warning_topic_id = 42 - settings.restrict_failed_users = True - settings.warning_threshold = 3 - settings.warning_time_threshold_minutes = 180 - settings.rules_link = "https://example.com/rules" + gc = GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + restrict_failed_users=True, + warning_threshold=3, + warning_time_threshold_minutes=180, + rules_link="https://example.com/rules", + ) incomplete_result = ProfileCheckResult( has_profile_photo=False, has_username=True @@ -402,7 +405,7 @@ async def test_restrict_user_fails( mock_context.bot.restrict_chat_member.side_effect = Exception("test error") with ( - patch("bot.handlers.message.get_settings", return_value=settings), + patch("bot.handlers.message.get_group_config_for_update", return_value=gc), patch( "bot.handlers.message.check_user_profile", return_value=incomplete_result, diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9b15234..3149d07 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -11,12 +11,30 @@ from telegram.constants import ChatMemberStatus from bot.database.models import UserWarning +from bot.group_config import GroupConfig, GroupRegistry from bot.services.scheduler import auto_restrict_expired_warnings +@pytest.fixture +def group_config(): + return GroupConfig( + group_id=-100999, + warning_topic_id=123, + warning_time_threshold_minutes=180, + rules_link="https://example.com/rules", + ) + + +@pytest.fixture +def mock_registry(group_config): + registry = GroupRegistry() + registry.register(group_config) + return registry + + class TestAutoRestrictExpiredWarnings: @pytest.mark.asyncio - async def test_restricts_expired_warnings(self): + async def test_restricts_expired_warnings(self, mock_registry): """Test that expired warnings are restricted.""" # Mock database with expired warning mock_warning = UserWarning( @@ -31,7 +49,7 @@ async def test_restricts_expired_warnings(self): ) mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = [mock_warning] + mock_db.get_warnings_past_time_threshold_for_group.return_value = [mock_warning] mock_db.mark_user_restricted = MagicMock() # Mock bot @@ -43,15 +61,8 @@ async def test_restricts_expired_warnings(self): mock_context = MagicMock() mock_context.bot = mock_bot - # Mock settings - mock_settings = MagicMock() - mock_settings.warning_time_threshold_minutes = 180 - mock_settings.group_id = -100999 - mock_settings.warning_topic_id = 123 - mock_settings.rules_link = "https://example.com/rules" - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): with patch( "bot.services.scheduler.BotInfoCache.get_username", new_callable=AsyncMock, @@ -76,28 +87,35 @@ async def test_restricts_expired_warnings(self): assert "dibatasi" in call_args.kwargs["text"] @pytest.mark.asyncio - async def test_handles_no_expired_warnings(self): + async def test_handles_no_expired_warnings(self, mock_registry): """Test that function handles empty list gracefully.""" mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = [] + mock_db.get_warnings_past_time_threshold_for_group.return_value = [] mock_bot = AsyncMock() mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_hours = 3 - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): - await auto_restrict_expired_warnings(mock_context) + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): + with patch( + "bot.services.scheduler.BotInfoCache.get_username", + new_callable=AsyncMock, + return_value="test_bot", + ): + await auto_restrict_expired_warnings(mock_context) # Should not call restrict or send message mock_bot.restrict_chat_member.assert_not_called() mock_bot.send_message.assert_not_called() + # Should have queried once for the single group in registry + mock_db.get_warnings_past_time_threshold_for_group.assert_called_once_with( + -100999, timedelta(minutes=180) + ) + @pytest.mark.asyncio - async def test_restricts_multiple_expired_warnings(self): + async def test_restricts_multiple_expired_warnings(self, mock_registry): """Test that multiple expired warnings are processed.""" mock_warnings = [ UserWarning( @@ -123,7 +141,7 @@ async def test_restricts_multiple_expired_warnings(self): ] mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = mock_warnings + mock_db.get_warnings_past_time_threshold_for_group.return_value = mock_warnings mock_db.mark_user_restricted = MagicMock() mock_bot = AsyncMock() @@ -133,14 +151,8 @@ async def test_restricts_multiple_expired_warnings(self): mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_minutes = 180 - mock_settings.group_id = -100999 - mock_settings.warning_topic_id = 123 - mock_settings.rules_link = "https://example.com/rules" - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): with patch( "bot.services.scheduler.BotInfoCache.get_username", new_callable=AsyncMock, @@ -154,7 +166,7 @@ async def test_restricts_multiple_expired_warnings(self): assert mock_bot.send_message.call_count == 2 @pytest.mark.asyncio - async def test_handles_restriction_errors(self): + async def test_handles_restriction_errors(self, mock_registry): """Test that function handles errors gracefully.""" mock_warning = UserWarning( id=1, @@ -168,7 +180,7 @@ async def test_handles_restriction_errors(self): ) mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = [mock_warning] + mock_db.get_warnings_past_time_threshold_for_group.return_value = [mock_warning] mock_bot = AsyncMock() mock_bot.restrict_chat_member = AsyncMock(side_effect=Exception("API error")) @@ -176,42 +188,55 @@ async def test_handles_restriction_errors(self): mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_minutes = 180 - mock_settings.group_id = -100999 - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): - # Should not raise, but log the error - await auto_restrict_expired_warnings(mock_context) + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): + with patch( + "bot.services.scheduler.BotInfoCache.get_username", + new_callable=AsyncMock, + return_value="test_bot", + ): + # Should not raise, but log the error + await auto_restrict_expired_warnings(mock_context) # Verify restriction was attempted mock_bot.restrict_chat_member.assert_called_once() @pytest.mark.asyncio async def test_uses_correct_time_threshold(self): - """Test that the correct time threshold from settings is used.""" + """Test that the correct time threshold from group config is used.""" + # Create a group config with a different threshold + custom_group_config = GroupConfig( + group_id=-100999, + warning_topic_id=123, + warning_time_threshold_minutes=300, + rules_link="https://example.com/rules", + ) + custom_registry = GroupRegistry() + custom_registry.register(custom_group_config) + mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = [] + mock_db.get_warnings_past_time_threshold_for_group.return_value = [] mock_bot = AsyncMock() mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_timedelta = timedelta(minutes=300) - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): - await auto_restrict_expired_warnings(mock_context) + with patch("bot.services.scheduler.get_group_registry", return_value=custom_registry): + with patch( + "bot.services.scheduler.BotInfoCache.get_username", + new_callable=AsyncMock, + return_value="test_bot", + ): + await auto_restrict_expired_warnings(mock_context) - # Verify correct threshold was passed to database query - mock_db.get_warnings_past_time_threshold.assert_called_once_with( - timedelta(minutes=300) + # Verify correct group_id and threshold were passed to database query + mock_db.get_warnings_past_time_threshold_for_group.assert_called_once_with( + -100999, timedelta(minutes=300) ) @pytest.mark.asyncio - async def test_skips_kicked_user_and_deletes_warning(self): + async def test_skips_kicked_user_and_deletes_warning(self, mock_registry): """Test that kicked users have their warning deleted so they don't reappear.""" mock_warning = UserWarning( id=1, @@ -225,7 +250,7 @@ async def test_skips_kicked_user_and_deletes_warning(self): ) mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = [mock_warning] + mock_db.get_warnings_past_time_threshold_for_group.return_value = [mock_warning] mock_db.delete_user_warnings = MagicMock() mock_bot = AsyncMock() @@ -235,18 +260,19 @@ async def test_skips_kicked_user_and_deletes_warning(self): mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_minutes = 180 - mock_settings.group_id = -100999 - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): with patch( - "bot.services.scheduler.get_user_status", + "bot.services.scheduler.BotInfoCache.get_username", new_callable=AsyncMock, - return_value=ChatMemberStatus.BANNED, + return_value="test_bot", ): - await auto_restrict_expired_warnings(mock_context) + with patch( + "bot.services.scheduler.get_user_status", + new_callable=AsyncMock, + return_value=ChatMemberStatus.BANNED, + ): + await auto_restrict_expired_warnings(mock_context) # Verify warning was deleted (not just marked unrestricted) mock_db.delete_user_warnings.assert_called_once_with(123, -100999) @@ -256,7 +282,7 @@ async def test_skips_kicked_user_and_deletes_warning(self): mock_bot.send_message.assert_not_called() @pytest.mark.asyncio - async def test_kicked_user_not_in_subsequent_queries(self): + async def test_kicked_user_not_in_subsequent_queries(self, mock_registry): """Test that deleted warnings don't appear in subsequent threshold queries.""" mock_warning = UserWarning( id=1, @@ -272,7 +298,7 @@ async def test_kicked_user_not_in_subsequent_queries(self): # Track calls to simulate deletion effect call_count = 0 - def get_warnings_side_effect(threshold): + def get_warnings_side_effect(group_id, threshold): nonlocal call_count call_count += 1 # First call returns the warning, subsequent calls return empty @@ -282,7 +308,7 @@ def get_warnings_side_effect(threshold): return [] mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.side_effect = get_warnings_side_effect + mock_db.get_warnings_past_time_threshold_for_group.side_effect = get_warnings_side_effect mock_db.delete_user_warnings = MagicMock() mock_bot = AsyncMock() @@ -292,29 +318,30 @@ def get_warnings_side_effect(threshold): mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_minutes = 180 - mock_settings.group_id = -100999 - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): with patch( - "bot.services.scheduler.get_user_status", + "bot.services.scheduler.BotInfoCache.get_username", new_callable=AsyncMock, - return_value=ChatMemberStatus.BANNED, + return_value="test_bot", ): - # First run - should process and delete warning - await auto_restrict_expired_warnings(mock_context) - # Second run - should find no warnings - await auto_restrict_expired_warnings(mock_context) + with patch( + "bot.services.scheduler.get_user_status", + new_callable=AsyncMock, + return_value=ChatMemberStatus.BANNED, + ): + # First run - should process and delete warning + await auto_restrict_expired_warnings(mock_context) + # Second run - should find no warnings + await auto_restrict_expired_warnings(mock_context) # Verify delete was called exactly once (first run only) mock_db.delete_user_warnings.assert_called_once_with(123, -100999) - # Verify the query was called twice - assert mock_db.get_warnings_past_time_threshold.call_count == 2 + # Verify the query was called twice (once per run, one group each) + assert mock_db.get_warnings_past_time_threshold_for_group.call_count == 2 @pytest.mark.asyncio - async def test_handles_get_chat_member_failure(self): + async def test_handles_get_chat_member_failure(self, mock_registry): """Test fallback user mention when get_chat_member fails.""" mock_warning = UserWarning( id=1, @@ -328,7 +355,7 @@ async def test_handles_get_chat_member_failure(self): ) mock_db = MagicMock() - mock_db.get_warnings_past_time_threshold.return_value = [mock_warning] + mock_db.get_warnings_past_time_threshold_for_group.return_value = [mock_warning] mock_db.mark_user_restricted = MagicMock() mock_bot = AsyncMock() @@ -340,14 +367,8 @@ async def test_handles_get_chat_member_failure(self): mock_context = MagicMock() mock_context.bot = mock_bot - mock_settings = MagicMock() - mock_settings.warning_time_threshold_minutes = 180 - mock_settings.group_id = -100999 - mock_settings.warning_topic_id = 123 - mock_settings.rules_link = "https://example.com/rules" - with patch("bot.services.scheduler.get_database", return_value=mock_db): - with patch("bot.services.scheduler.get_settings", return_value=mock_settings): + with patch("bot.services.scheduler.get_group_registry", return_value=mock_registry): with patch( "bot.services.scheduler.get_user_status", new_callable=AsyncMock, @@ -367,5 +388,3 @@ async def test_handles_get_chat_member_failure(self): mock_bot.send_message.assert_called_once() call_args = mock_bot.send_message.call_args assert "User 123" in call_args.kwargs["text"] - - diff --git a/tests/test_topic_guard.py b/tests/test_topic_guard.py index e2db947..2ec8b29 100644 --- a/tests/test_topic_guard.py +++ b/tests/test_topic_guard.py @@ -2,15 +2,16 @@ import pytest +from bot.group_config import GroupConfig from bot.handlers.topic_guard import guard_warning_topic @pytest.fixture -def mock_settings(): - settings = MagicMock() - settings.group_id = -1001234567890 - settings.warning_topic_id = 42 - return settings +def group_config(): + return GroupConfig( + group_id=-1001234567890, + warning_topic_id=42, + ) @pytest.fixture @@ -53,43 +54,43 @@ async def test_no_user(self, mock_context): mock_context.bot.get_chat_member.assert_not_called() - async def test_wrong_group_ignored(self, mock_update, mock_context, mock_settings): + async def test_wrong_group_ignored(self, mock_update, mock_context): mock_update.effective_chat.id = -100999999 - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=None): await guard_warning_topic(mock_update, mock_context) mock_context.bot.get_chat_member.assert_not_called() mock_update.message.delete.assert_not_called() async def test_different_topic_ignored( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): mock_update.message.message_thread_id = 999 - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): await guard_warning_topic(mock_update, mock_context) mock_context.bot.get_chat_member.assert_not_called() mock_update.message.delete.assert_not_called() - async def test_bot_message_allowed(self, mock_update, mock_context, mock_settings): + async def test_bot_message_allowed(self, mock_update, mock_context, group_config): mock_update.message.from_user.id = 99999 # Same as bot id - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): await guard_warning_topic(mock_update, mock_context) mock_context.bot.get_chat_member.assert_not_called() mock_update.message.delete.assert_not_called() async def test_admin_message_allowed( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): chat_member = MagicMock() chat_member.status = "administrator" mock_context.bot.get_chat_member.return_value = chat_member - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): await guard_warning_topic(mock_update, mock_context) mock_context.bot.get_chat_member.assert_called_once_with( @@ -99,37 +100,37 @@ async def test_admin_message_allowed( mock_update.message.delete.assert_not_called() async def test_creator_message_allowed( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): chat_member = MagicMock() chat_member.status = "creator" mock_context.bot.get_chat_member.return_value = chat_member - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): await guard_warning_topic(mock_update, mock_context) mock_update.message.delete.assert_not_called() async def test_regular_user_message_deleted( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): chat_member = MagicMock() chat_member.status = "member" mock_context.bot.get_chat_member.return_value = chat_member - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): await guard_warning_topic(mock_update, mock_context) mock_update.message.delete.assert_called_once() async def test_restricted_user_message_deleted( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): chat_member = MagicMock() chat_member.status = "restricted" mock_context.bot.get_chat_member.return_value = chat_member - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): await guard_warning_topic(mock_update, mock_context) mock_update.message.delete.assert_called_once() @@ -137,7 +138,7 @@ async def test_restricted_user_message_deleted( class TestGuardWarningTopicErrorHandling: async def test_delete_message_exception_logged( - self, mock_update, mock_context, mock_settings + self, mock_update, mock_context, group_config ): """Test when update.message.delete() raises an exception (lines 91-92).""" chat_member = MagicMock() @@ -145,7 +146,7 @@ async def test_delete_message_exception_logged( mock_context.bot.get_chat_member.return_value = chat_member mock_update.message.delete.side_effect = Exception("test error") - with patch("bot.handlers.topic_guard.get_settings", return_value=mock_settings): + with patch("bot.handlers.topic_guard.get_group_config_for_update", return_value=group_config): # Should not raise, error is caught and logged await guard_warning_topic(mock_update, mock_context) diff --git a/tests/test_verify_handler.py b/tests/test_verify_handler.py index b9d075e..3dd493c 100644 --- a/tests/test_verify_handler.py +++ b/tests/test_verify_handler.py @@ -5,6 +5,7 @@ import pytest from bot.database.service import get_database, init_database, reset_database +from bot.group_config import GroupConfig, GroupRegistry from bot.handlers.verify import ( handle_unverify_callback, handle_unverify_command, @@ -43,7 +44,7 @@ def mock_context(): context.bot.get_chat = AsyncMock() context.bot.restrict_chat_member = AsyncMock() context.bot.send_message = AsyncMock() - + # Mock get_chat to return both chat permissions and user info mock_chat = MagicMock() mock_permissions = MagicMock() @@ -58,7 +59,7 @@ def mock_context(): mock_chat.full_name = "Test User" mock_chat.username = "testuser" context.bot.get_chat.return_value = mock_chat - + context.bot_data = {"admin_ids": [12345]} context.args = [] return context @@ -122,14 +123,11 @@ async def test_invalid_user_id_format(self, mock_update, mock_context): assert "angka" in call_args.args[0] async def test_successful_verify_new_user(self, mock_update, mock_context, temp_db, monkeypatch): - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 11111111 # Use unique ID mock_context.args = [str(target_user_id)] @@ -147,7 +145,12 @@ class MockSettings: db = get_database() assert db.is_user_photo_whitelisted(target_user_id) - async def test_verify_already_whitelisted_user(self, mock_update, mock_context, temp_db): + async def test_verify_already_whitelisted_user(self, mock_update, mock_context, temp_db, monkeypatch): + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 555666 db = get_database() db.add_photo_verification_whitelist( @@ -163,14 +166,11 @@ async def test_verify_already_whitelisted_user(self, mock_update, mock_context, assert "sudah ada di whitelist" in call_args.args[0] async def test_verify_multiple_users(self, mock_update, mock_context, temp_db, monkeypatch): - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + db = get_database() # Verify first user @@ -199,14 +199,11 @@ async def test_verify_respects_admin_ids(self, mock_update, mock_context): assert "izin" in call_args.args[0] async def test_verify_with_extra_args_uses_first(self, mock_update, mock_context, temp_db, monkeypatch): - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 22222222 # Use unique ID mock_context.args = [str(target_user_id), "extra", "args"] @@ -220,14 +217,11 @@ class MockSettings: assert db.is_user_photo_whitelisted(target_user_id) async def test_verify_large_user_id(self, mock_update, mock_context, temp_db, monkeypatch): - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + large_id = 9999999999 mock_context.args = [str(large_id)] @@ -238,14 +232,11 @@ class MockSettings: async def test_verify_unrestricts_user(self, mock_update, mock_context, temp_db, monkeypatch): """Test that verify command unrestricts the user.""" - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 33333333 # Use unique ID mock_context.args = [str(target_user_id)] @@ -259,23 +250,20 @@ class MockSettings: async def test_verify_deletes_warnings(self, mock_update, mock_context, temp_db, monkeypatch): """Test that verify command deletes all warning records.""" - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 66666666 # Use unique ID db = get_database() # Create some warning records for the user - db.get_or_create_user_warning(target_user_id, MockSettings.group_id) - db.increment_message_count(target_user_id, MockSettings.group_id) - + db.get_or_create_user_warning(target_user_id, gc.group_id) + db.increment_message_count(target_user_id, gc.group_id) + # Verify there's at least one warning - warning = db.get_or_create_user_warning(target_user_id, MockSettings.group_id) + warning = db.get_or_create_user_warning(target_user_id, gc.group_id) assert warning.message_count >= 1 # Now verify the user @@ -283,26 +271,24 @@ class MockSettings: await handle_verify_command(mock_update, mock_context) # Warnings should be deleted - trying to get warnings should create a new one - new_warning = db.get_or_create_user_warning(target_user_id, MockSettings.group_id) + new_warning = db.get_or_create_user_warning(target_user_id, gc.group_id) assert new_warning.message_count == 1 # Fresh start async def test_verify_sends_clearance_message_when_warnings_deleted( self, mock_update, mock_context, temp_db, monkeypatch ): """Test that clearance message is sent to warning topic when user with warnings is verified.""" - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) target_user_id = 77777777 db = get_database() # Seed a warning for the target user - db.get_or_create_user_warning(target_user_id, MockSettings.group_id) - db.increment_message_count(target_user_id, MockSettings.group_id) + db.get_or_create_user_warning(target_user_id, gc.group_id) + db.increment_message_count(target_user_id, gc.group_id) # Mock get_chat to return a user with username mock_user_chat = MagicMock() @@ -320,8 +306,8 @@ class MockSettings: # Verify send_message was called with correct clearance message mock_context.bot.send_message.assert_called_once() call_kwargs = mock_context.bot.send_message.call_args.kwargs - assert call_kwargs["chat_id"] == MockSettings.group_id - assert call_kwargs["message_thread_id"] == MockSettings.warning_topic_id + assert call_kwargs["chat_id"] == gc.group_id + assert call_kwargs["message_thread_id"] == gc.warning_topic_id assert "@verified_user" in call_kwargs["text"] assert call_kwargs["parse_mode"] == "Markdown" @@ -330,18 +316,15 @@ async def test_verify_handles_non_restricted_user_gracefully( ): """Test that verify doesn't fail if user is not restricted.""" from telegram.error import BadRequest - - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 44444444 # Use unique ID mock_context.args = [str(target_user_id)] - + # Simulate BadRequest when trying to unrestrict a non-restricted user mock_context.bot.restrict_chat_member.side_effect = BadRequest("User not restricted") @@ -351,7 +334,7 @@ class MockSettings: # User should still be whitelisted db = get_database() assert db.is_user_photo_whitelisted(target_user_id) - + # Should still send success message mock_update.message.reply_text.assert_called_once() call_args = mock_update.message.reply_text.call_args @@ -361,21 +344,18 @@ async def test_verify_with_warnings_sends_notification_to_topic( self, mock_update, mock_context, temp_db, monkeypatch ): """Test that verify sends notification to warning topic when user has warnings.""" - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 77777777 # Use unique ID db = get_database() # Create warning records for the user - db.get_or_create_user_warning(target_user_id, MockSettings.group_id) - db.increment_message_count(target_user_id, MockSettings.group_id) - db.increment_message_count(target_user_id, MockSettings.group_id) + db.get_or_create_user_warning(target_user_id, gc.group_id) + db.increment_message_count(target_user_id, gc.group_id) + db.increment_message_count(target_user_id, gc.group_id) # Now verify the user mock_context.args = [str(target_user_id)] @@ -384,8 +364,8 @@ class MockSettings: # Should send notification to warning topic mock_context.bot.send_message.assert_called_once() call_args = mock_context.bot.send_message.call_args - assert call_args.kwargs["chat_id"] == MockSettings.group_id - assert call_args.kwargs["message_thread_id"] == MockSettings.warning_topic_id + assert call_args.kwargs["chat_id"] == gc.group_id + assert call_args.kwargs["message_thread_id"] == gc.warning_topic_id assert call_args.kwargs["parse_mode"] == "Markdown" # Check the message contains user mention assert "@testuser" in call_args.kwargs["text"] @@ -394,14 +374,11 @@ async def test_verify_without_warnings_no_notification( self, mock_update, mock_context, temp_db, monkeypatch ): """Test that verify doesn't send notification when user has no warnings.""" - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + target_user_id = 88888888 # Use unique ID mock_context.args = [str(target_user_id)] @@ -576,7 +553,7 @@ async def test_non_admin_rejected(self, mock_context): query.answer = AsyncMock() query.edit_message_text = AsyncMock() update.callback_query = query - + mock_context.bot_data = {"admin_ids": [12345]} await handle_verify_callback(update, mock_context) @@ -605,14 +582,11 @@ async def test_invalid_callback_data_format(self, mock_context): assert "tidak valid" in call_args.args[0] async def test_successful_verify_callback(self, temp_db, mock_context, monkeypatch): - # Mock the settings - class MockSettings: - group_id = -1001234567890 - warning_topic_id = 12345 - telegram_bot_token = "fake_token" - - monkeypatch.setattr("bot.handlers.verify.get_settings", lambda: MockSettings()) - + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + update = MagicMock() query = MagicMock() query.from_user = MagicMock() @@ -629,15 +603,20 @@ class MockSettings: query.edit_message_text.assert_called_once() call_args = query.edit_message_text.call_args assert "diverifikasi" in call_args.args[0] - + # Verify user was added to whitelist db = get_database() assert db.is_user_photo_whitelisted(999888) - async def test_verify_callback_already_whitelisted(self, temp_db, mock_context): + async def test_verify_callback_already_whitelisted(self, temp_db, mock_context, monkeypatch): + gc = GroupConfig(group_id=-1001234567890, warning_topic_id=12345) + registry = GroupRegistry() + registry.register(gc) + monkeypatch.setattr("bot.handlers.verify.get_group_registry", lambda: registry) + db = get_database() db.add_photo_verification_whitelist(user_id=555666, verified_by_admin_id=12345) - + update = MagicMock() query = MagicMock() query.from_user = MagicMock() @@ -665,9 +644,9 @@ async def test_verify_callback_generic_exception(self, temp_db, mock_context): query.answer = AsyncMock() query.edit_message_text = AsyncMock() update.callback_query = query - - # Mock get_settings to raise a generic exception - with patch("bot.handlers.verify.get_settings", side_effect=RuntimeError("Settings error")): + + # Mock get_group_registry to raise a generic exception + with patch("bot.handlers.verify.get_group_registry", side_effect=RuntimeError("Registry error")): await handle_verify_callback(update, mock_context) query.answer.assert_called_once() @@ -700,7 +679,7 @@ async def test_non_admin_rejected(self, mock_context): query.answer = AsyncMock() query.edit_message_text = AsyncMock() update.callback_query = query - + mock_context.bot_data = {"admin_ids": [12345]} await handle_unverify_callback(update, mock_context) @@ -731,7 +710,7 @@ async def test_invalid_callback_data_format(self, mock_context): async def test_successful_unverify_callback(self, temp_db, mock_context): db = get_database() db.add_photo_verification_whitelist(user_id=555666, verified_by_admin_id=12345) - + update = MagicMock() query = MagicMock() query.from_user = MagicMock() @@ -748,7 +727,7 @@ async def test_successful_unverify_callback(self, temp_db, mock_context): query.edit_message_text.assert_called_once() call_args = query.edit_message_text.call_args assert "dihapus dari whitelist" in call_args.args[0] - + # Verify user was removed from whitelist assert not db.is_user_photo_whitelisted(555666) @@ -780,7 +759,7 @@ async def test_unverify_callback_generic_exception(self, temp_db, mock_context): query.answer = AsyncMock() query.edit_message_text = AsyncMock() update.callback_query = query - + # Mock unverify_user to raise a generic exception with patch("bot.handlers.verify.unverify_user", side_effect=RuntimeError("Unverify error")): await handle_unverify_callback(update, mock_context)