more refactoring
This commit is contained in:
+183
@@ -0,0 +1,183 @@
|
||||
# Community Bot Refactoring
|
||||
|
||||
This document describes the refactoring performed on the community bot project to improve code organization, maintainability, and testability.
|
||||
|
||||
## Overview
|
||||
|
||||
The original `bot.py` file contained over 3,800 lines of code with mixed concerns, making it difficult to maintain and test. The refactoring separates the code into logical modules and adds comprehensive test coverage.
|
||||
|
||||
## New Structure
|
||||
|
||||
### Helper Modules (`community/helpers/`)
|
||||
|
||||
The helper functions have been extracted into separate modules based on their functionality:
|
||||
|
||||
#### `message_utils.py`
|
||||
- `flag_message()` - Check if a message should be flagged for censorship
|
||||
- `flag_instaban()` - Check if a message should trigger instant ban
|
||||
- `censor_room()` - Check if a message should be censored based on room config
|
||||
- `sanitize_room_name()` - Sanitize room names for aliases
|
||||
- `generate_community_slug()` - Generate community slugs from names
|
||||
|
||||
#### `room_utils.py`
|
||||
- `validate_room_alias()` - Check if a room alias exists
|
||||
- `validate_room_aliases()` - Validate multiple room aliases
|
||||
- `get_room_version_and_creators()` - Get room version and creators
|
||||
- `is_modern_room_version()` - Check if room version is modern (12+)
|
||||
- `user_has_unlimited_power()` - Check if user has unlimited power
|
||||
- `get_moderators_and_above()` - Get users with moderator+ permissions
|
||||
|
||||
#### `user_utils.py`
|
||||
- `check_if_banned()` - Check if user is banned according to banlists
|
||||
- `get_banlist_roomids()` - Get room IDs for banlists
|
||||
- `ban_user_from_rooms()` - Ban user from multiple rooms
|
||||
- `user_permitted()` - Check if user has sufficient power level
|
||||
|
||||
#### `database_utils.py`
|
||||
- `get_messages_to_redact()` - Get messages to redact for a user
|
||||
- `redact_messages()` - Redact queued messages in a room
|
||||
- `upsert_user_timestamp()` - Insert/update user activity timestamp
|
||||
- `get_inactive_users()` - Get lists of inactive users
|
||||
- `cleanup_stale_verification_states()` - Clean up old verification states
|
||||
- `get_verification_state()` - Get verification state for DM room
|
||||
- `create_verification_state()` - Create new verification state
|
||||
- `update_verification_attempts()` - Update verification attempts
|
||||
- `delete_verification_state()` - Delete verification state
|
||||
|
||||
#### `report_utils.py`
|
||||
- `generate_activity_report()` - Generate activity report from DB results
|
||||
- `split_doctor_report()` - Split large reports into chunks
|
||||
- `format_ban_results()` - Format ban operation results
|
||||
- `format_sync_results()` - Format sync operation results
|
||||
|
||||
### Test Structure (`tests/`)
|
||||
|
||||
Comprehensive test coverage has been added for all modules:
|
||||
|
||||
#### `test_message_utils.py`
|
||||
- Tests for message flagging and censoring functions
|
||||
- Tests for room name sanitization and slug generation
|
||||
- Edge cases and error handling
|
||||
|
||||
#### `test_room_utils.py`
|
||||
- Tests for room alias validation
|
||||
- Tests for room version and creator detection
|
||||
- Tests for power level and permission checks
|
||||
|
||||
#### `test_user_utils.py`
|
||||
- Tests for ban checking and user banning
|
||||
- Tests for permission validation
|
||||
- Tests for banlist management
|
||||
|
||||
#### `test_database_utils.py`
|
||||
- Tests for database operations
|
||||
- Tests for message redaction
|
||||
- Tests for user activity tracking
|
||||
- Tests for verification state management
|
||||
|
||||
#### `test_report_utils.py`
|
||||
- Tests for report generation and formatting
|
||||
- Tests for report splitting and chunking
|
||||
- Tests for result formatting
|
||||
|
||||
#### `test_bot_commands.py`
|
||||
- Tests for all command handlers
|
||||
- Tests for permission checking
|
||||
- Tests for error handling
|
||||
|
||||
#### `test_bot_events.py`
|
||||
- Tests for all event handlers
|
||||
- Tests for proactive banning
|
||||
- Tests for power level synchronization
|
||||
- Tests for user activity tracking
|
||||
|
||||
## Benefits of Refactoring
|
||||
|
||||
### 1. **Improved Maintainability**
|
||||
- Code is now organized into logical modules
|
||||
- Each module has a single responsibility
|
||||
- Functions are smaller and more focused
|
||||
- Easier to locate and modify specific functionality
|
||||
|
||||
### 2. **Better Testability**
|
||||
- Each helper function can be tested independently
|
||||
- Mock objects can be easily injected for testing
|
||||
- Test coverage is comprehensive across all modules
|
||||
- Tests are organized by functionality
|
||||
|
||||
### 3. **Enhanced Readability**
|
||||
- Main bot class is now much smaller and focused
|
||||
- Helper functions have clear names and purposes
|
||||
- Code is easier to understand and follow
|
||||
- Documentation is improved with docstrings
|
||||
|
||||
### 4. **Reduced Complexity**
|
||||
- Complex functions have been broken down
|
||||
- Dependencies are clearer and more explicit
|
||||
- Code duplication has been eliminated
|
||||
- Error handling is more consistent
|
||||
|
||||
### 5. **Easier Debugging**
|
||||
- Issues can be isolated to specific modules
|
||||
- Functions are smaller and easier to debug
|
||||
- Test failures provide clear indication of problems
|
||||
- Logging is more targeted and useful
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Prerequisites
|
||||
```bash
|
||||
pip install pytest
|
||||
```
|
||||
|
||||
### Run All Tests
|
||||
```bash
|
||||
python run_tests.py
|
||||
```
|
||||
|
||||
### Run Specific Test Module
|
||||
```bash
|
||||
pytest tests/test_message_utils.py -v
|
||||
```
|
||||
|
||||
### Run Tests with Coverage
|
||||
```bash
|
||||
pytest tests/ --cov=community --cov-report=html
|
||||
```
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Developers
|
||||
|
||||
1. **Import Changes**: Helper functions are now imported from their respective modules:
|
||||
```python
|
||||
from community.helpers import message_utils, room_utils, user_utils
|
||||
```
|
||||
|
||||
2. **Function Calls**: Helper functions now take explicit parameters instead of using `self`:
|
||||
```python
|
||||
# Old
|
||||
result = self.flag_message(msg)
|
||||
|
||||
# New
|
||||
result = message_utils.flag_message(msg, self.config["censor_wordlist"], self.config["censor_files"])
|
||||
```
|
||||
|
||||
3. **Testing**: New tests should be added to the appropriate test module in the `tests/` directory.
|
||||
|
||||
### For Users
|
||||
|
||||
The refactoring is completely transparent to end users. All commands and functionality remain exactly the same.
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Type Hints**: Add comprehensive type hints throughout the codebase
|
||||
2. **Async Context Managers**: Use async context managers for database operations
|
||||
3. **Configuration Validation**: Add configuration validation and schema
|
||||
4. **Logging Improvements**: Implement structured logging
|
||||
5. **Performance Monitoring**: Add performance metrics and monitoring
|
||||
6. **Documentation**: Generate API documentation from docstrings
|
||||
|
||||
## Conclusion
|
||||
|
||||
The refactoring significantly improves the codebase's maintainability, testability, and readability while preserving all existing functionality. The modular structure makes it easier to add new features, fix bugs, and ensure code quality through comprehensive testing.
|
||||
+11
-17
@@ -35,24 +35,18 @@ warn_threshold_days: 30
|
||||
# subject to removal by the purge command.
|
||||
kick_threshold_days: 60
|
||||
|
||||
# track users? if false, will disable all tracking and avoid writing anything to the database.
|
||||
track_users: True
|
||||
# track users? empty list disables all tracking, otherwise specify what to track:
|
||||
# - "messages": track user activity from messages
|
||||
# - "reactions": track user activity from reactions
|
||||
# examples:
|
||||
# track_users: [] # disable all tracking
|
||||
# track_users: ["messages"] # track only messages
|
||||
# track_users: ["reactions"] # track only reactions
|
||||
# track_users: ["messages", "reactions"] # track both
|
||||
track_users: ["messages", "reactions"]
|
||||
|
||||
# track messages? if false, will not track user activity timestamps. enable if you'd like to track
|
||||
# inactive users in your community
|
||||
track_messages: True
|
||||
|
||||
# track reactions? if false, will only track activity based on normal message events, but if true
|
||||
# will update the user's last-active date when they add a reaction to a message
|
||||
track_reactions: True
|
||||
|
||||
# list of users who can use administrative commands. these users will also be made room admins (PL100)
|
||||
# DEPRECATED: set user powerlevels in the parent room instead.
|
||||
admins: []
|
||||
|
||||
# list of users who should be considered community moderators. these users will be made room mods (PL50)
|
||||
# DEPRECATED: set userpowerlevels in the parent room instead.
|
||||
moderators: []
|
||||
# Note: Admin and moderator permissions are now managed through power levels
|
||||
# in the parent room. Set user power levels directly in the parent space.
|
||||
|
||||
# list of users who should be invited to new rooms immediately (other bots, moderators, perhaps)
|
||||
# use full matrix IDs here
|
||||
|
||||
+139
-833
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
# Helper modules for community bot
|
||||
from . import message_utils, room_utils, user_utils, database_utils, report_utils, decorators, common_utils, room_creation_utils, config_manager, response_builder, diagnostic_utils, base_command_handler
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Base command handler for common command patterns."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional
|
||||
from mautrix.types import MessageEvent, UserID
|
||||
from .decorators import require_permission, require_parent_room, handle_errors
|
||||
|
||||
|
||||
class BaseCommandHandler(ABC):
|
||||
"""Base class for command handlers with common patterns."""
|
||||
|
||||
def __init__(self, bot):
|
||||
"""Initialize with bot instance.
|
||||
|
||||
Args:
|
||||
bot: CommunityBot instance
|
||||
"""
|
||||
self.bot = bot
|
||||
self.client = bot.client
|
||||
self.config = bot.config
|
||||
self.config_manager = bot.config_manager
|
||||
self.log = bot.log
|
||||
self.database = bot.database
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
async def check_permissions(self, evt: MessageEvent, min_level: int = 50, room_id: str = None) -> bool:
|
||||
"""Check if user has required permissions.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
min_level: Minimum required power level
|
||||
room_id: Room ID to check permissions in
|
||||
|
||||
Returns:
|
||||
bool: True if user has permissions
|
||||
"""
|
||||
return await self.bot.user_permitted(evt.sender, min_level, room_id)
|
||||
|
||||
async def check_parent_room(self, evt: MessageEvent) -> bool:
|
||||
"""Check if parent room is configured.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
|
||||
Returns:
|
||||
bool: True if parent room is configured
|
||||
"""
|
||||
return await self.bot.check_parent_room(evt)
|
||||
|
||||
async def reply_error(self, evt: MessageEvent, message: str) -> None:
|
||||
"""Reply with an error message.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
message: Error message
|
||||
"""
|
||||
await evt.reply(message)
|
||||
|
||||
async def reply_success(self, evt: MessageEvent, message: str) -> None:
|
||||
"""Reply with a success message.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
message: Success message
|
||||
"""
|
||||
await evt.reply(message)
|
||||
|
||||
async def respond_html(self, evt: MessageEvent, message: str, edits: Optional[MessageEvent] = None) -> None:
|
||||
"""Respond with HTML content.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
message: HTML message
|
||||
edits: Optional message to edit
|
||||
"""
|
||||
await evt.respond(message, allow_html=True, edits=edits)
|
||||
|
||||
def is_tracking_enabled(self) -> bool:
|
||||
"""Check if user tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if tracking is enabled
|
||||
"""
|
||||
return self.config_manager.is_tracking_enabled()
|
||||
|
||||
def is_verification_enabled(self) -> bool:
|
||||
"""Check if verification is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if verification is enabled
|
||||
"""
|
||||
return self.config_manager.is_verification_enabled()
|
||||
|
||||
def get_parent_room(self) -> Optional[str]:
|
||||
"""Get the parent room ID.
|
||||
|
||||
Returns:
|
||||
str: Parent room ID or None
|
||||
"""
|
||||
return self.config_manager.get_parent_room()
|
||||
|
||||
|
||||
class TrackingCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for commands that require user tracking."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with tracking check."""
|
||||
if not self.is_tracking_enabled():
|
||||
await self.reply_error(evt, "user tracking is disabled")
|
||||
return
|
||||
return await self.execute_tracking_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_tracking_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the tracking command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AdminCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for admin-only commands."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with admin permission check."""
|
||||
if not await self.check_permissions(evt, min_level=100):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_admin_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_admin_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the admin command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ModeratorCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for moderator commands."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with moderator permission check."""
|
||||
if not await self.check_permissions(evt, min_level=50):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_moderator_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_moderator_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the moderator command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SpaceCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for commands that require parent space."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with parent space check."""
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
return await self.execute_space_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_space_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the space command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SpaceModeratorCommandHandler(SpaceCommandHandler, ModeratorCommandHandler):
|
||||
"""Base handler for commands that require both parent space and moderator permissions."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with both space and moderator checks."""
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
if not await self.check_permissions(evt, min_level=50):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_space_moderator_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_space_moderator_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the space moderator command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SpaceAdminCommandHandler(SpaceCommandHandler, AdminCommandHandler):
|
||||
"""Base handler for commands that require both parent space and admin permissions."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with both space and admin checks."""
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
if not await self.check_permissions(evt, min_level=100):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_space_admin_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_space_admin_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the space admin command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Common utility functions for bot operations."""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from mautrix.types import EventType, MessageEvent
|
||||
|
||||
|
||||
async def get_room_name(client, room_id: str, logger) -> Optional[str]:
|
||||
"""Get room name from room ID.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: Room ID to get name for
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
str: Room name or None if not found/error
|
||||
"""
|
||||
try:
|
||||
room_name_event = await client.get_state_event(room_id, EventType.ROOM_NAME)
|
||||
return room_name_event.name if room_name_event else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get room name for {room_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_room_power_levels(client, room_id: str, logger) -> Optional[Any]:
|
||||
"""Get power levels for a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: Room ID to get power levels for
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
PowerLevelStateEventContent or None if error
|
||||
"""
|
||||
try:
|
||||
return await client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get power levels for {room_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_room_membership(client, room_id: str, user_id: str, logger) -> bool:
|
||||
"""Check if a user is a member of a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: Room ID to check
|
||||
user_id: User ID to check
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
bool: True if user is a member, False otherwise
|
||||
"""
|
||||
try:
|
||||
await client.get_state_event(room_id, EventType.ROOM_MEMBER, user_id)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def format_room_info(room_id: str, room_name: Optional[str] = None) -> str:
|
||||
"""Format room information for display.
|
||||
|
||||
Args:
|
||||
room_id: Room ID
|
||||
room_name: Optional room name
|
||||
|
||||
Returns:
|
||||
str: Formatted room info
|
||||
"""
|
||||
if room_name:
|
||||
return f"{room_name} ({room_id})"
|
||||
return room_id
|
||||
|
||||
|
||||
def safe_get(dictionary: Dict[str, Any], key: str, default: Any = None) -> Any:
|
||||
"""Safely get a value from a dictionary with a default.
|
||||
|
||||
Args:
|
||||
dictionary: Dictionary to get value from
|
||||
key: Key to look up
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Value from dictionary or default
|
||||
"""
|
||||
return dictionary.get(key, default) if dictionary else default
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Configuration management utilities for the community bot."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Centralized configuration management for the community bot."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize with bot configuration.
|
||||
|
||||
Args:
|
||||
config: Bot configuration dictionary
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def is_tracking_enabled(self) -> bool:
|
||||
"""Check if user tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if tracking is enabled
|
||||
"""
|
||||
track_users = self.config.get("track_users", [])
|
||||
|
||||
# Handle legacy boolean configuration
|
||||
if isinstance(track_users, bool):
|
||||
return track_users
|
||||
|
||||
# Handle new list configuration
|
||||
return isinstance(track_users, list) and len(track_users) > 0
|
||||
|
||||
def is_message_tracking_enabled(self) -> bool:
|
||||
"""Check if message tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if message tracking is enabled
|
||||
"""
|
||||
track_users = self.config.get("track_users", [])
|
||||
|
||||
# Handle legacy boolean configuration - if True, enable both messages and reactions
|
||||
if isinstance(track_users, bool):
|
||||
return track_users
|
||||
|
||||
# Handle new list configuration
|
||||
return isinstance(track_users, list) and "messages" in track_users
|
||||
|
||||
def is_reaction_tracking_enabled(self) -> bool:
|
||||
"""Check if reaction tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if reaction tracking is enabled
|
||||
"""
|
||||
track_users = self.config.get("track_users", [])
|
||||
|
||||
# Handle legacy boolean configuration - if True, enable both messages and reactions
|
||||
if isinstance(track_users, bool):
|
||||
return track_users
|
||||
|
||||
# Handle new list configuration
|
||||
return isinstance(track_users, list) and "reactions" in track_users
|
||||
|
||||
def is_verification_enabled(self) -> bool:
|
||||
"""Check if verification is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if verification is enabled
|
||||
"""
|
||||
return self.config.get("verification_enabled", False)
|
||||
|
||||
def is_proactive_banning_enabled(self) -> bool:
|
||||
"""Check if proactive banning is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if proactive banning is enabled
|
||||
"""
|
||||
return self.config.get("proactive_banning", False)
|
||||
|
||||
def is_encryption_enabled(self) -> bool:
|
||||
"""Check if encryption is enabled by default.
|
||||
|
||||
Returns:
|
||||
bool: True if encryption is enabled
|
||||
"""
|
||||
return self.config.get("encrypt", False)
|
||||
|
||||
def get_room_version(self) -> str:
|
||||
"""Get the configured room version.
|
||||
|
||||
Returns:
|
||||
str: Room version string
|
||||
"""
|
||||
return self.config.get("room_version", "1")
|
||||
|
||||
def get_community_slug(self) -> Optional[str]:
|
||||
"""Get the community slug.
|
||||
|
||||
Returns:
|
||||
str: Community slug or None if not configured
|
||||
"""
|
||||
return self.config.get("community_slug")
|
||||
|
||||
def get_parent_room(self) -> Optional[str]:
|
||||
"""Get the parent room ID.
|
||||
|
||||
Returns:
|
||||
str: Parent room ID or None if not configured
|
||||
"""
|
||||
return self.config.get("parent_room")
|
||||
|
||||
def get_invitees(self) -> List[str]:
|
||||
"""Get the list of users to invite to new rooms.
|
||||
|
||||
Returns:
|
||||
List[str]: List of user IDs to invite
|
||||
"""
|
||||
return self.config.get("invitees", [])
|
||||
|
||||
def get_invite_power_level(self) -> int:
|
||||
"""Get the power level required to invite users.
|
||||
|
||||
Returns:
|
||||
int: Power level for inviting users
|
||||
"""
|
||||
return self.config.get("invite_power_level", 50)
|
||||
|
||||
def get_sleep_duration(self) -> float:
|
||||
"""Get the sleep duration between operations.
|
||||
|
||||
Returns:
|
||||
float: Sleep duration in seconds
|
||||
"""
|
||||
return self.config.get("sleep", 1.0)
|
||||
|
||||
def get_welcome_sleep_duration(self) -> float:
|
||||
"""Get the sleep duration for welcome messages.
|
||||
|
||||
Returns:
|
||||
float: Welcome sleep duration in seconds
|
||||
"""
|
||||
return self.config.get("welcome_sleep", 2.0)
|
||||
|
||||
def get_warn_threshold_days(self) -> int:
|
||||
"""Get the warning threshold for inactive users.
|
||||
|
||||
Returns:
|
||||
int: Number of days before warning
|
||||
"""
|
||||
return self.config.get("warn_threshold_days", 30)
|
||||
|
||||
def get_kick_threshold_days(self) -> int:
|
||||
"""Get the kick threshold for inactive users.
|
||||
|
||||
Returns:
|
||||
int: Number of days before kicking
|
||||
"""
|
||||
return self.config.get("kick_threshold_days", 60)
|
||||
|
||||
def get_verification_phrase(self) -> str:
|
||||
"""Get the verification phrase.
|
||||
|
||||
Returns:
|
||||
str: Verification phrase
|
||||
"""
|
||||
return self.config.get("verification_phrase", "I agree to the rules")
|
||||
|
||||
def get_verification_attempts(self) -> int:
|
||||
"""Get the maximum verification attempts.
|
||||
|
||||
Returns:
|
||||
int: Maximum verification attempts
|
||||
"""
|
||||
return self.config.get("verification_attempts", 3)
|
||||
|
||||
def get_verification_timeout(self) -> int:
|
||||
"""Get the verification timeout in seconds.
|
||||
|
||||
Returns:
|
||||
int: Verification timeout in seconds
|
||||
"""
|
||||
return self.config.get("verification_timeout", 300)
|
||||
|
||||
|
||||
def get_banlist_rooms(self) -> List[str]:
|
||||
"""Get the list of banlist rooms.
|
||||
|
||||
Returns:
|
||||
List[str]: List of banlist room IDs or aliases
|
||||
"""
|
||||
return self.config.get("banlist_rooms", [])
|
||||
|
||||
def get_redaction_rooms(self) -> List[str]:
|
||||
"""Get the list of rooms for redaction.
|
||||
|
||||
Returns:
|
||||
List[str]: List of room IDs for redaction
|
||||
"""
|
||||
return self.config.get("redaction_rooms", [])
|
||||
|
||||
def validate_required_configs(self) -> List[str]:
|
||||
"""Validate that all required configurations are present.
|
||||
|
||||
Returns:
|
||||
List[str]: List of missing required configuration keys
|
||||
"""
|
||||
required_configs = [
|
||||
"parent_room",
|
||||
"room_version",
|
||||
"community_slug"
|
||||
]
|
||||
|
||||
missing = []
|
||||
for config_key in required_configs:
|
||||
if not self.config.get(config_key):
|
||||
missing.append(config_key)
|
||||
|
||||
return missing
|
||||
|
||||
def is_modern_room_version(self) -> bool:
|
||||
"""Check if the configured room version is modern (12+).
|
||||
|
||||
Returns:
|
||||
bool: True if room version is 12 or higher
|
||||
"""
|
||||
try:
|
||||
version = int(self.get_room_version())
|
||||
return version >= 12
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def get_room_creation_settings(self) -> Dict[str, Any]:
|
||||
"""Get settings specific to room creation.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Room creation settings
|
||||
"""
|
||||
return {
|
||||
"room_version": self.get_room_version(),
|
||||
"community_slug": self.get_community_slug(),
|
||||
"invitees": self.get_invitees(),
|
||||
"invite_power_level": self.get_invite_power_level(),
|
||||
"encrypt": self.is_encryption_enabled(),
|
||||
"parent_room": self.get_parent_room()
|
||||
}
|
||||
|
||||
def get_tracking_settings(self) -> Dict[str, Any]:
|
||||
"""Get settings specific to user tracking.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Tracking settings
|
||||
"""
|
||||
return {
|
||||
"track_users": self.config.get("track_users", []),
|
||||
"track_messages": self.is_message_tracking_enabled(),
|
||||
"track_reactions": self.is_reaction_tracking_enabled(),
|
||||
"warn_threshold_days": self.get_warn_threshold_days(),
|
||||
"kick_threshold_days": self.get_kick_threshold_days()
|
||||
}
|
||||
|
||||
def get_verification_settings(self) -> Dict[str, Any]:
|
||||
"""Get settings specific to verification.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Verification settings
|
||||
"""
|
||||
return {
|
||||
"verification_enabled": self.is_verification_enabled(),
|
||||
"verification_phrase": self.get_verification_phrase(),
|
||||
"verification_attempts": self.get_verification_attempts(),
|
||||
"verification_timeout": self.get_verification_timeout()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Database utility functions."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
from mautrix.types import PaginationDirection
|
||||
|
||||
|
||||
async def get_messages_to_redact(client, room_id: str, mxid: str, logger) -> List:
|
||||
"""Get messages from a user in a room that should be redacted.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: The room ID to search in
|
||||
mxid: The user ID whose messages to find
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
list: List of message events to redact
|
||||
"""
|
||||
try:
|
||||
messages = await client.get_messages(
|
||||
room_id,
|
||||
limit=100,
|
||||
filter_json={"senders": [mxid], "not_types": ["m.room.redaction"]},
|
||||
direction=PaginationDirection.BACKWARD,
|
||||
)
|
||||
# Filter out events with empty content
|
||||
filtered_events = [
|
||||
event
|
||||
for event in messages.events
|
||||
if event.content and event.content.serialize()
|
||||
]
|
||||
logger.debug(
|
||||
f"DEBUG found {len(filtered_events)} messages to redact in {room_id} (after filtering empty content)"
|
||||
)
|
||||
return filtered_events
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting messages to redact: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def redact_messages(client, database, room_id: str, sleep_time: float, logger) -> Dict[str, int]:
|
||||
"""Redact messages queued for redaction in a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
database: Database instance
|
||||
room_id: The room ID to redact messages in
|
||||
sleep_time: Sleep time between redactions
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
dict: Counters for successful and failed redactions
|
||||
"""
|
||||
counters = {"success": 0, "failure": 0}
|
||||
events = await database.fetch(
|
||||
"SELECT event_id FROM redaction_tasks WHERE room_id = $1", room_id
|
||||
)
|
||||
for event in events:
|
||||
try:
|
||||
await client.redact(
|
||||
room_id, event["event_id"], reason="content removed"
|
||||
)
|
||||
counters["success"] += 1
|
||||
await database.execute(
|
||||
"DELETE FROM redaction_tasks WHERE event_id = $1", event["event_id"]
|
||||
)
|
||||
await asyncio.sleep(sleep_time)
|
||||
except Exception as e:
|
||||
if "Too Many Requests" in str(e):
|
||||
logger.warning(
|
||||
f"Rate limited while redacting messages in {room_id}, will try again in next loop"
|
||||
)
|
||||
return counters
|
||||
logger.error(f"Failed to redact message: {e}")
|
||||
counters["failure"] += 1
|
||||
await asyncio.sleep(sleep_time)
|
||||
return counters
|
||||
|
||||
|
||||
async def upsert_user_timestamp(database, mxid: str, timestamp: int, logger) -> None:
|
||||
"""Insert or update user activity timestamp.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
mxid: User Matrix ID
|
||||
timestamp: Activity timestamp
|
||||
logger: Logger instance for error reporting
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"""
|
||||
INSERT INTO user_events (mxid, last_message_timestamp, ignore_inactivity)
|
||||
VALUES ($1, $2, 0)
|
||||
ON CONFLICT (mxid) DO UPDATE SET
|
||||
last_message_timestamp = EXCLUDED.last_message_timestamp
|
||||
""",
|
||||
mxid,
|
||||
timestamp,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upsert user timestamp: {e}")
|
||||
|
||||
|
||||
async def get_inactive_users(database, warn_threshold_days: int, kick_threshold_days: int,
|
||||
logger) -> Dict[str, List[str]]:
|
||||
"""Get lists of users who should be warned or kicked for inactivity.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
warn_threshold_days: Days threshold for warning
|
||||
kick_threshold_days: Days threshold for kicking
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
dict: Contains 'warn' and 'kick' lists of user IDs
|
||||
"""
|
||||
try:
|
||||
current_time = int(time.time())
|
||||
warn_threshold = current_time - (warn_threshold_days * 24 * 60 * 60)
|
||||
kick_threshold = current_time - (kick_threshold_days * 24 * 60 * 60)
|
||||
|
||||
# Get users to warn
|
||||
warn_results = await database.fetch(
|
||||
"""
|
||||
SELECT mxid FROM user_events
|
||||
WHERE last_message_timestamp < $1
|
||||
AND last_message_timestamp > $2
|
||||
AND ignore_inactivity = 0
|
||||
""",
|
||||
warn_threshold,
|
||||
kick_threshold,
|
||||
)
|
||||
|
||||
# Get users to kick
|
||||
kick_results = await database.fetch(
|
||||
"""
|
||||
SELECT mxid FROM user_events
|
||||
WHERE last_message_timestamp < $2
|
||||
AND ignore_inactivity = 0
|
||||
""",
|
||||
kick_threshold,
|
||||
)
|
||||
|
||||
return {
|
||||
"warn": [row["mxid"] for row in warn_results],
|
||||
"kick": [row["mxid"] for row in kick_results]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get inactive users: {e}")
|
||||
return {"warn": [], "kick": []}
|
||||
|
||||
|
||||
async def cleanup_stale_verification_states(database, logger) -> None:
|
||||
"""Clean up stale verification states older than 24 hours.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
logger: Logger instance for error reporting
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"""
|
||||
DELETE FROM verification_states
|
||||
WHERE created_at < NOW() - INTERVAL '24 hours'
|
||||
"""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup stale verification states: {e}")
|
||||
|
||||
|
||||
async def get_verification_state(database, dm_room_id: str) -> Dict[str, Any]:
|
||||
"""Get verification state for a DM room.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
|
||||
Returns:
|
||||
dict: Verification state data or None if not found
|
||||
"""
|
||||
try:
|
||||
result = await database.fetchrow(
|
||||
"SELECT * FROM verification_states WHERE dm_room_id = $1",
|
||||
dm_room_id
|
||||
)
|
||||
return dict(result) if result else None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
async def create_verification_state(database, dm_room_id: str, user_id: str,
|
||||
target_room_id: str, verification_phrase: str,
|
||||
attempts_remaining: int, required_power_level: int) -> None:
|
||||
"""Create a new verification state.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
user_id: The user ID being verified
|
||||
target_room_id: The target room ID
|
||||
verification_phrase: The phrase to verify
|
||||
attempts_remaining: Number of attempts remaining
|
||||
required_power_level: Required power level for the target room
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"""
|
||||
INSERT INTO verification_states
|
||||
(dm_room_id, user_id, target_room_id, verification_phrase, attempts_remaining, required_power_level)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
dm_room_id, user_id, target_room_id, verification_phrase,
|
||||
attempts_remaining, required_power_level
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Verification state creation is not critical
|
||||
|
||||
|
||||
async def update_verification_attempts(database, dm_room_id: str, attempts_remaining: int) -> None:
|
||||
"""Update verification attempts remaining.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
attempts_remaining: New number of attempts remaining
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"UPDATE verification_states SET attempts_remaining = $1 WHERE dm_room_id = $2",
|
||||
attempts_remaining, dm_room_id
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Verification state update is not critical
|
||||
|
||||
|
||||
async def delete_verification_state(database, dm_room_id: str) -> None:
|
||||
"""Delete a verification state.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"DELETE FROM verification_states WHERE dm_room_id = $1",
|
||||
dm_room_id
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Verification state deletion is not critical
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Decorators for common bot operations."""
|
||||
|
||||
import functools
|
||||
from typing import Callable, Any, Optional
|
||||
from mautrix.types import UserID, MessageEvent
|
||||
|
||||
|
||||
def require_permission(min_level: int = 50, room_id: Optional[str] = None):
|
||||
"""Decorator to require user permission for command execution.
|
||||
|
||||
Args:
|
||||
min_level: Minimum required power level (default 50 for moderator)
|
||||
room_id: Room ID to check permissions in (None for parent room)
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
if not await self.user_permitted(evt.sender, min_level, room_id):
|
||||
await evt.reply("You don't have permission to use this command")
|
||||
return
|
||||
return await func(self, evt, *args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_parent_room(func: Callable) -> Callable:
|
||||
"""Decorator to require parent room to be configured."""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
return await func(self, evt, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def handle_errors(error_message: str = "An error occurred"):
|
||||
"""Decorator to handle common errors in command execution.
|
||||
|
||||
Args:
|
||||
error_message: Default error message to show to user
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(self, evt, *args, **kwargs)
|
||||
except Exception as e:
|
||||
self.log.error(f"Error in {func.__name__}: {e}")
|
||||
await evt.reply(f"{error_message}: {e}")
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Diagnostic utility functions for the community bot."""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from mautrix.types import EventType
|
||||
from mautrix.client import Client
|
||||
|
||||
|
||||
async def check_space_permissions(
|
||||
client: Client,
|
||||
parent_room: str,
|
||||
logger
|
||||
) -> Dict[str, Any]:
|
||||
"""Check bot permissions in the parent space.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
parent_room: Parent room ID
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
Dict containing space permission information
|
||||
"""
|
||||
try:
|
||||
space_power_levels = await client.get_state_event(
|
||||
parent_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
bot_level = space_power_levels.get_user_level(client.mxid)
|
||||
|
||||
space_info = {
|
||||
"room_id": parent_room,
|
||||
"bot_power_level": bot_level,
|
||||
"has_admin": bot_level >= 100,
|
||||
"users_higher_or_equal": [],
|
||||
"users_equal": [],
|
||||
"users_higher": []
|
||||
}
|
||||
|
||||
# Check for users with equal or higher power level
|
||||
for user, level in space_power_levels.users.items():
|
||||
if user != client.mxid and level >= bot_level:
|
||||
if level == bot_level:
|
||||
space_info["users_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
else:
|
||||
space_info["users_higher"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
space_info["users_higher_or_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
|
||||
return space_info
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check space permissions: {e}")
|
||||
return {
|
||||
"room_id": parent_room,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def check_room_permissions(
|
||||
client: Client,
|
||||
room_id: str,
|
||||
logger
|
||||
) -> Dict[str, Any]:
|
||||
"""Check bot permissions in a specific room.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
room_id: Room ID to check
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
Dict containing room permission information
|
||||
"""
|
||||
try:
|
||||
# Check if bot is in the room
|
||||
try:
|
||||
await client.get_state_event(room_id, EventType.ROOM_MEMBER, client.mxid)
|
||||
except:
|
||||
return {
|
||||
"room_id": room_id,
|
||||
"error": "Bot not in room"
|
||||
}
|
||||
|
||||
# Get power levels
|
||||
room_power_levels = await client.get_state_event(
|
||||
room_id, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
bot_level = room_power_levels.get_user_level(client.mxid)
|
||||
|
||||
# Get room name if available
|
||||
room_name = room_id
|
||||
try:
|
||||
from .common_utils import get_room_name
|
||||
room_name = await get_room_name(client, room_id, logger) or room_id
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get room version and creators
|
||||
from .room_utils import get_room_version_and_creators
|
||||
room_version, creators = await get_room_version_and_creators(client, room_id, logger)
|
||||
|
||||
# Check if bot has unlimited power (creator in modern room versions)
|
||||
from .room_utils import user_has_unlimited_power
|
||||
bot_has_unlimited_power = await user_has_unlimited_power(client, client.mxid, room_id)
|
||||
|
||||
room_report = {
|
||||
"room_id": room_id,
|
||||
"room_name": room_name,
|
||||
"room_version": room_version,
|
||||
"creators": creators,
|
||||
"bot_power_level": bot_level,
|
||||
"has_admin": bot_level >= 100 or bot_has_unlimited_power,
|
||||
"bot_has_unlimited_power": bot_has_unlimited_power,
|
||||
"users_higher_or_equal": [],
|
||||
"users_equal": [],
|
||||
"users_higher": []
|
||||
}
|
||||
|
||||
# Check for users with equal or higher power level
|
||||
for user, level in room_power_levels.users.items():
|
||||
if user != client.mxid and level >= bot_level:
|
||||
if level == bot_level:
|
||||
room_report["users_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
else:
|
||||
room_report["users_higher"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
room_report["users_higher_or_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
|
||||
return room_report
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check room permissions for {room_id}: {e}")
|
||||
return {
|
||||
"room_id": room_id,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def analyze_room_data(
|
||||
room_data: Dict[str, Any],
|
||||
is_modern_room_version_func
|
||||
) -> Tuple[str, str, bool, bool, bool]:
|
||||
"""Analyze room data to determine status and categorization.
|
||||
|
||||
Args:
|
||||
room_data: Room data dictionary
|
||||
is_modern_room_version_func: Function to check if room version is modern
|
||||
|
||||
Returns:
|
||||
Tuple of (status, category, is_admin, is_modern, has_error)
|
||||
"""
|
||||
if "error" in room_data:
|
||||
if room_data["error"] == "Bot not in room":
|
||||
return "not_in_room", "error", False, False, True
|
||||
else:
|
||||
return "error", "error", False, False, True
|
||||
|
||||
# Check if modern room version
|
||||
is_modern = is_modern_room_version_func(room_data.get("room_version", "1"))
|
||||
|
||||
# Check admin status
|
||||
is_admin = room_data.get("has_admin", False)
|
||||
|
||||
if is_admin:
|
||||
return "admin", "admin", True, is_modern, False
|
||||
else:
|
||||
return "no_admin", "problematic", False, is_modern, False
|
||||
|
||||
|
||||
def generate_space_summary(
|
||||
space_data: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Generate HTML summary for space permissions.
|
||||
|
||||
Args:
|
||||
space_data: Space permission data
|
||||
|
||||
Returns:
|
||||
str: HTML formatted space summary
|
||||
"""
|
||||
if "error" in space_data:
|
||||
return f"<h4>📋 Parent Space</h4><br />❌ <b>Error:</b> {space_data['error']}<br /><br />"
|
||||
|
||||
space_status = "✅" if space_data.get("has_admin", False) else "❌"
|
||||
response = f"<h4>📋 Parent Space</h4><br />"
|
||||
response += f"{space_status} <b>Administrative privileges:</b> {'Yes' if space_data['has_admin'] else 'No'} (level: {space_data['bot_power_level']})<br />"
|
||||
|
||||
if space_data.get("users_higher"):
|
||||
response += f"⚠️ <b>Users with higher power:</b> {', '.join([f'{u['user']} ({u['level']})' for u in space_data['users_higher']])}<br />"
|
||||
if space_data.get("users_equal"):
|
||||
response += f"⚠️ <b>Users with equal power:</b> {', '.join([f'{u['user']} ({u['level']})' for u in space_data['users_equal']])}<br />"
|
||||
|
||||
response += "<br />"
|
||||
return response
|
||||
|
||||
|
||||
def generate_room_summary(
|
||||
rooms_data: Dict[str, Any],
|
||||
is_modern_room_version_func
|
||||
) -> Tuple[str, Dict[str, int]]:
|
||||
"""Generate HTML summary for room permissions.
|
||||
|
||||
Args:
|
||||
rooms_data: Dictionary of room data
|
||||
is_modern_room_version_func: Function to check if room version is modern
|
||||
|
||||
Returns:
|
||||
Tuple of (HTML response, statistics dict)
|
||||
"""
|
||||
problematic_rooms = []
|
||||
stats = {
|
||||
"admin_rooms": 0,
|
||||
"non_admin_rooms": 0,
|
||||
"error_rooms": 0,
|
||||
"not_in_room_count": 0,
|
||||
"modern_rooms": 0,
|
||||
"legacy_rooms": 0
|
||||
}
|
||||
|
||||
for room_id, room_data in rooms_data.items():
|
||||
status, category, is_admin, is_modern, has_error = analyze_room_data(
|
||||
room_data, is_modern_room_version_func
|
||||
)
|
||||
|
||||
# Update statistics
|
||||
if has_error:
|
||||
stats["error_rooms"] += 1
|
||||
if room_data.get("error") == "Bot not in room":
|
||||
stats["not_in_room_count"] += 1
|
||||
else:
|
||||
if is_admin:
|
||||
stats["admin_rooms"] += 1
|
||||
else:
|
||||
stats["non_admin_rooms"] += 1
|
||||
|
||||
if is_modern:
|
||||
stats["modern_rooms"] += 1
|
||||
else:
|
||||
stats["legacy_rooms"] += 1
|
||||
|
||||
# Generate room info for problematic rooms
|
||||
if category in ["error", "problematic"] or (is_admin and (room_data.get("users_higher") or room_data.get("users_equal"))):
|
||||
if has_error:
|
||||
if room_data["error"] == "Bot not in room":
|
||||
problematic_rooms.append(f"❌ <b>{room_data.get('room_name', room_id)}</b> ({room_id}): Bot not in room")
|
||||
else:
|
||||
problematic_rooms.append(f"❌ <b>{room_data.get('room_name', room_id)}</b> ({room_id}): Error - {room_data['error']}")
|
||||
elif is_admin:
|
||||
# Show unlimited power status for modern rooms
|
||||
if room_data.get("bot_has_unlimited_power", False):
|
||||
room_info = f"✅ <b>{room_data['room_name']}</b> ({room_id}): Unlimited Power (Creator) [v{room_data.get('room_version', '1')}]"
|
||||
else:
|
||||
room_info = f"✅ <b>{room_data['room_name']}</b> ({room_id}): Admin: Yes (level: {room_data['bot_power_level']}) [v{room_data.get('room_version', '1')}]"
|
||||
|
||||
# Add power level conflict info
|
||||
if room_data.get("users_higher") or room_data.get("users_equal"):
|
||||
if room_data.get("bot_has_unlimited_power", False):
|
||||
room_info += f" - Note: Power level conflicts are irrelevant for creators with unlimited power"
|
||||
else:
|
||||
if room_data.get("users_higher"):
|
||||
room_info += f" - Higher power users: {len(room_data['users_higher'])}"
|
||||
if room_data.get("users_equal"):
|
||||
room_info += f" - Equal power users: {len(room_data['users_equal'])}"
|
||||
problematic_rooms.append(room_info)
|
||||
else:
|
||||
problematic_rooms.append(f"❌ <b>{room_data['room_name']}</b> ({room_id}): Admin: No (level: {room_data['bot_power_level']}) [v{room_data.get('room_version', '1')}]")
|
||||
|
||||
# Generate HTML response
|
||||
response = ""
|
||||
if problematic_rooms:
|
||||
response += f"<h4>🏠 Problematic Rooms ({len(problematic_rooms)} of {len(rooms_data)} total)</h4><br />"
|
||||
response += "<i>Use <code>!community doctor <room_id></code> for detailed analysis of specific rooms</i><br /><br />"
|
||||
for room_info in problematic_rooms:
|
||||
response += f"{room_info}<br />"
|
||||
response += "<br />"
|
||||
|
||||
return response, stats
|
||||
|
||||
|
||||
def generate_summary_stats(
|
||||
space_data: Dict[str, Any],
|
||||
room_stats: Dict[str, int]
|
||||
) -> str:
|
||||
"""Generate summary statistics HTML.
|
||||
|
||||
Args:
|
||||
space_data: Space permission data
|
||||
room_stats: Room statistics
|
||||
|
||||
Returns:
|
||||
str: HTML formatted summary statistics
|
||||
"""
|
||||
response = f"<h4>📊 Summary</h4><br />"
|
||||
response += f"• Parent space: {'✅ Admin' if space_data.get('has_admin', False) else '❌ No admin'}<br />"
|
||||
response += f"• Rooms with admin: {room_stats['admin_rooms']}<br />"
|
||||
response += f"• Rooms without admin: {room_stats['non_admin_rooms']}<br />"
|
||||
response += f"• Modern room versions (12+): {room_stats['modern_rooms']}<br />"
|
||||
response += f"• Legacy room versions (1-11): {room_stats['legacy_rooms']}<br />"
|
||||
|
||||
# Add note about unlimited power for modern rooms
|
||||
if room_stats['modern_rooms'] > 0:
|
||||
response += f"<br />ℹ️ <b>Note:</b> In modern room versions (12+), creators have unlimited power and cannot be restricted by power levels.<br />"
|
||||
|
||||
if room_stats['not_in_room_count'] > 0:
|
||||
response += f"• Rooms bot not in: {room_stats['not_in_room_count']}<br />"
|
||||
if room_stats['error_rooms'] > 0:
|
||||
response += f"• Rooms with errors: {room_stats['error_rooms']}<br />"
|
||||
|
||||
response += "<br />"
|
||||
return response
|
||||
|
||||
|
||||
def generate_issues_and_warnings(
|
||||
issues: List[str],
|
||||
warnings: List[str]
|
||||
) -> str:
|
||||
"""Generate issues and warnings HTML.
|
||||
|
||||
Args:
|
||||
issues: List of critical issues
|
||||
warnings: List of warnings
|
||||
|
||||
Returns:
|
||||
str: HTML formatted issues and warnings
|
||||
"""
|
||||
response = ""
|
||||
|
||||
if issues:
|
||||
response += f"<h4>🚨 Critical Issues</h4><br />"
|
||||
for issue in issues:
|
||||
response += f"• {issue}<br />"
|
||||
response += "<br />"
|
||||
|
||||
if warnings:
|
||||
response += f"<h4>⚠️ Warnings</h4><br />"
|
||||
for warning in warnings:
|
||||
response += f"• {warning}<br />"
|
||||
response += "<br />"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def generate_all_clear_message() -> str:
|
||||
"""Generate all clear message HTML.
|
||||
|
||||
Returns:
|
||||
str: HTML formatted all clear message
|
||||
"""
|
||||
return "<h4>✅ All Clear</h4><br />No permission issues detected. The bot should be able to manage all rooms and users effectively.<br />"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Message and content utility functions."""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from mautrix.types import MessageType, MediaMessageEventContent
|
||||
|
||||
|
||||
def flag_message(msg, censor_wordlist: list, censor_files: bool) -> bool:
|
||||
"""Check if a message should be flagged for censorship.
|
||||
|
||||
Args:
|
||||
msg: The message event to check
|
||||
censor_wordlist: List of regex patterns to check against
|
||||
censor_files: Whether to flag file messages
|
||||
|
||||
Returns:
|
||||
bool: True if message should be flagged
|
||||
"""
|
||||
if msg.content.msgtype in [
|
||||
MessageType.FILE,
|
||||
MessageType.IMAGE,
|
||||
MessageType.VIDEO,
|
||||
]:
|
||||
return censor_files
|
||||
|
||||
for w in censor_wordlist:
|
||||
try:
|
||||
if bool(re.search(w, msg.content.body, re.IGNORECASE)):
|
||||
return True
|
||||
except Exception:
|
||||
# Skip invalid regex patterns
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def flag_instaban(msg, instaban_wordlist: list) -> bool:
|
||||
"""Check if a message should trigger an instant ban.
|
||||
|
||||
Args:
|
||||
msg: The message event to check
|
||||
instaban_wordlist: List of regex patterns that trigger instant ban
|
||||
|
||||
Returns:
|
||||
bool: True if message should trigger instant ban
|
||||
"""
|
||||
for w in instaban_wordlist:
|
||||
try:
|
||||
if bool(re.search(w, msg.content.body, re.IGNORECASE)):
|
||||
return True
|
||||
except Exception:
|
||||
# Skip invalid regex patterns
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def censor_room(msg, censor_config) -> bool:
|
||||
"""Check if a message should be censored based on room configuration.
|
||||
|
||||
Args:
|
||||
msg: The message event to check
|
||||
censor_config: Censor configuration (bool or list of room IDs)
|
||||
|
||||
Returns:
|
||||
bool: True if message should be censored
|
||||
"""
|
||||
if isinstance(censor_config, bool):
|
||||
return censor_config
|
||||
elif isinstance(censor_config, list):
|
||||
return msg.room_id in censor_config
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_room_name(room_name: str) -> str:
|
||||
"""Sanitize a room name for use in aliases.
|
||||
|
||||
Args:
|
||||
room_name: The room name to sanitize
|
||||
|
||||
Returns:
|
||||
str: Sanitized room name (alphanumeric only, lowercase)
|
||||
"""
|
||||
return re.sub(r"[^a-zA-Z0-9]", "", room_name).lower()
|
||||
|
||||
|
||||
def generate_community_slug(community_name: str) -> str:
|
||||
"""Generate a community slug from the community name.
|
||||
|
||||
Args:
|
||||
community_name: The full community name
|
||||
|
||||
Returns:
|
||||
str: A slug made from the first letter of each word, lowercase
|
||||
"""
|
||||
words = community_name.strip().split()
|
||||
return ''.join(word[0].lower() for word in words if word)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Report generation and formatting utility functions."""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import time
|
||||
|
||||
|
||||
def generate_activity_report(database_results: Dict[str, List[Dict]]) -> Dict[str, Any]:
|
||||
"""Generate an activity report from database results.
|
||||
|
||||
Args:
|
||||
database_results: Dictionary containing 'active', 'inactive', 'ignored' results
|
||||
|
||||
Returns:
|
||||
dict: Formatted activity report
|
||||
"""
|
||||
report = {}
|
||||
|
||||
# Process active users
|
||||
active_results = database_results.get("active", [])
|
||||
report["active"] = [row["mxid"] for row in active_results] or ["none"]
|
||||
|
||||
# Process inactive users
|
||||
inactive_results = database_results.get("inactive", [])
|
||||
report["inactive"] = [row["mxid"] for row in inactive_results] or ["none"]
|
||||
|
||||
# Process ignored users
|
||||
ignored_results = database_results.get("ignored", [])
|
||||
report["ignored"] = [row["mxid"] for row in ignored_results] or ["none"]
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def split_doctor_report(report_text: str, max_chunk_size: int = 4000) -> List[str]:
|
||||
"""Split a doctor report into chunks that fit within size limits.
|
||||
|
||||
Args:
|
||||
report_text: The full report text
|
||||
max_chunk_size: Maximum size per chunk
|
||||
|
||||
Returns:
|
||||
list: List of report chunks
|
||||
"""
|
||||
if len(report_text) <= max_chunk_size:
|
||||
return [report_text]
|
||||
|
||||
# Try to split by sections first
|
||||
sections = _split_by_sections(report_text, max_chunk_size)
|
||||
if sections:
|
||||
return sections
|
||||
|
||||
# Fall back to character-based splitting
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for line in report_text.split('\n'):
|
||||
if len(current_chunk) + len(line) + 1 > max_chunk_size:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = line
|
||||
else:
|
||||
# Single line is too long, split it
|
||||
chunks.append(line[:max_chunk_size])
|
||||
current_chunk = line[max_chunk_size:]
|
||||
else:
|
||||
if current_chunk:
|
||||
current_chunk += '\n' + line
|
||||
else:
|
||||
current_chunk = line
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _split_by_sections(text: str, max_size: int) -> List[str]:
|
||||
"""Split text by sections (lines starting with specific patterns).
|
||||
|
||||
Args:
|
||||
text: The text to split
|
||||
max_size: Maximum size per section
|
||||
|
||||
Returns:
|
||||
list: List of text sections
|
||||
"""
|
||||
section_headers = ["Active users:", "Inactive users:", "Ignored users:"]
|
||||
sections = []
|
||||
current_section = ""
|
||||
|
||||
lines = text.split('\n')
|
||||
for line in lines:
|
||||
if any(line.startswith(header) for header in section_headers):
|
||||
if current_section and len(current_section) > max_size:
|
||||
# Current section is too big, need to split it further
|
||||
return []
|
||||
if current_section:
|
||||
sections.append(current_section.strip())
|
||||
current_section = line
|
||||
else:
|
||||
if len(current_section) + len(line) + 1 > max_size:
|
||||
# This section would be too big
|
||||
return []
|
||||
if current_section:
|
||||
current_section += '\n' + line
|
||||
else:
|
||||
current_section = line
|
||||
|
||||
if current_section:
|
||||
sections.append(current_section.strip())
|
||||
|
||||
return sections if all(len(s) <= max_size for s in sections) else []
|
||||
|
||||
|
||||
def format_ban_results(ban_event_map: Dict[str, List[str]]) -> str:
|
||||
"""Format ban results for display.
|
||||
|
||||
Args:
|
||||
ban_event_map: Dictionary containing ban results
|
||||
|
||||
Returns:
|
||||
str: Formatted ban results
|
||||
"""
|
||||
ban_list = ban_event_map.get("ban_list", {})
|
||||
error_list = ban_event_map.get("error_list", {})
|
||||
|
||||
result_parts = []
|
||||
|
||||
for user, rooms in ban_list.items():
|
||||
if rooms:
|
||||
result_parts.append(f"Banned {user} from: {', '.join(rooms)}")
|
||||
|
||||
for user, rooms in error_list.items():
|
||||
if rooms:
|
||||
result_parts.append(f"Failed to ban {user} from: {', '.join(rooms)}")
|
||||
|
||||
return '\n'.join(result_parts) if result_parts else "No ban operations performed"
|
||||
|
||||
|
||||
def format_sync_results(sync_results: Dict[str, List[str]]) -> str:
|
||||
"""Format sync results for display.
|
||||
|
||||
Args:
|
||||
sync_results: Dictionary containing sync results
|
||||
|
||||
Returns:
|
||||
str: Formatted sync results
|
||||
"""
|
||||
added = sync_results.get("added", [])
|
||||
dropped = sync_results.get("dropped", [])
|
||||
|
||||
added_str = "<br />".join(added) if added else "none"
|
||||
dropped_str = "<br />".join(dropped) if dropped else "none"
|
||||
|
||||
return f"Added: {added_str}<br /><br />Dropped: {dropped_str}"
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Response building utilities for the community bot."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from mautrix.types import MessageEvent
|
||||
|
||||
|
||||
class ResponseBuilder:
|
||||
"""Builder for consistent response formatting."""
|
||||
|
||||
@staticmethod
|
||||
def build_html_response(title: str, content: str, allow_html: bool = True) -> str:
|
||||
"""Build an HTML formatted response.
|
||||
|
||||
Args:
|
||||
title: Response title
|
||||
content: Response content
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted response
|
||||
"""
|
||||
if allow_html:
|
||||
return f"<p><b>{title}</b><br />{content}</p>"
|
||||
else:
|
||||
return f"{title}\n{content}"
|
||||
|
||||
@staticmethod
|
||||
def build_error_response(error: str, allow_html: bool = True) -> str:
|
||||
"""Build an error response.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted error response
|
||||
"""
|
||||
if allow_html:
|
||||
return f"<p><b>Error:</b> {error}</p>"
|
||||
else:
|
||||
return f"Error: {error}"
|
||||
|
||||
@staticmethod
|
||||
def build_success_response(message: str, allow_html: bool = True) -> str:
|
||||
"""Build a success response.
|
||||
|
||||
Args:
|
||||
message: Success message
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted success response
|
||||
"""
|
||||
if allow_html:
|
||||
return f"<p><b>Success:</b> {message}</p>"
|
||||
else:
|
||||
return f"Success: {message}"
|
||||
|
||||
@staticmethod
|
||||
def build_list_response(title: str, items: List[str], allow_html: bool = True) -> str:
|
||||
"""Build a list response.
|
||||
|
||||
Args:
|
||||
title: List title
|
||||
items: List items
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted list response
|
||||
"""
|
||||
if not items:
|
||||
return ResponseBuilder.build_html_response(title, "No items found.", allow_html)
|
||||
|
||||
if allow_html:
|
||||
items_html = "<br />".join(items)
|
||||
return f"<p><b>{title}</b><br />{items_html}</p>"
|
||||
else:
|
||||
items_text = "\n".join(f"- {item}" for item in items)
|
||||
return f"{title}\n{items_text}"
|
||||
|
||||
@staticmethod
|
||||
def build_room_link(alias: str, server: str) -> str:
|
||||
"""Build a Matrix room link.
|
||||
|
||||
Args:
|
||||
alias: Room alias
|
||||
server: Server name
|
||||
|
||||
Returns:
|
||||
str: HTML room link
|
||||
"""
|
||||
return f"<a href='https://matrix.to/#/#{alias}:{server}'>#{alias}:{server}</a>"
|
||||
|
||||
@staticmethod
|
||||
def build_user_link(user_id: str) -> str:
|
||||
"""Build a Matrix user link.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
str: HTML user link
|
||||
"""
|
||||
return f"<a href='https://matrix.to/#/{user_id}'>{user_id}</a>"
|
||||
|
||||
@staticmethod
|
||||
def build_activity_report_response(report: Dict[str, List[str]], config: Dict[str, Any]) -> str:
|
||||
"""Build an activity report response.
|
||||
|
||||
Args:
|
||||
report: Activity report data
|
||||
config: Bot configuration
|
||||
|
||||
Returns:
|
||||
str: Formatted activity report
|
||||
"""
|
||||
warn_threshold = config.get("warn_threshold_days", 30)
|
||||
kick_threshold = config.get("kick_threshold_days", 60)
|
||||
|
||||
response_parts = []
|
||||
|
||||
if report.get("warn_inactive"):
|
||||
warn_list = "<br />".join(report["warn_inactive"])
|
||||
response_parts.append(
|
||||
f"<p><b>Users inactive for between {warn_threshold} and {kick_threshold} days:</b><br />"
|
||||
f"{warn_list}<br /></p>"
|
||||
)
|
||||
|
||||
if report.get("kick_inactive"):
|
||||
kick_list = "<br />".join(report["kick_inactive"])
|
||||
response_parts.append(
|
||||
f"<p><b>Users inactive for at least {kick_threshold} days:</b><br />"
|
||||
f"{kick_list}<br /></p>"
|
||||
)
|
||||
|
||||
if report.get("ignored"):
|
||||
ignored_list = "<br />".join(report["ignored"])
|
||||
response_parts.append(
|
||||
f"<p><b>Ignored users:</b><br />{ignored_list}</p>"
|
||||
)
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
@staticmethod
|
||||
def build_ban_results_response(results: Dict[str, Any]) -> str:
|
||||
"""Build a ban results response.
|
||||
|
||||
Args:
|
||||
results: Ban results data
|
||||
|
||||
Returns:
|
||||
str: Formatted ban results
|
||||
"""
|
||||
ban_list = results.get("ban_list", [])
|
||||
error_list = results.get("error_list", [])
|
||||
|
||||
response_parts = []
|
||||
|
||||
if ban_list:
|
||||
ban_list_html = "<br />".join(ban_list)
|
||||
response_parts.append(f"<p><b>Users banned:</b><br /><code>{ban_list_html}</code></p>")
|
||||
|
||||
if error_list:
|
||||
error_list_html = "<br />".join(error_list)
|
||||
response_parts.append(f"<p><b>Errors:</b><br /><code>{error_list_html}</code></p>")
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append("<p>No users were banned.</p>")
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
@staticmethod
|
||||
def build_sync_results_response(results: Dict[str, List[str]]) -> str:
|
||||
"""Build a sync results response.
|
||||
|
||||
Args:
|
||||
results: Sync results data
|
||||
|
||||
Returns:
|
||||
str: Formatted sync results
|
||||
"""
|
||||
added = results.get("added", [])
|
||||
dropped = results.get("dropped", [])
|
||||
|
||||
response_parts = []
|
||||
|
||||
if added:
|
||||
added_html = "<br />".join(added)
|
||||
response_parts.append(f"<p><b>Added:</b><br />{added_html}</p>")
|
||||
|
||||
if dropped:
|
||||
dropped_html = "<br />".join(dropped)
|
||||
response_parts.append(f"<p><b>Dropped:</b><br />{dropped_html}</p>")
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append("<p>No changes made.</p>")
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
@staticmethod
|
||||
def build_doctor_report_response(report: Dict[str, Any]) -> str:
|
||||
"""Build a doctor report response.
|
||||
|
||||
Args:
|
||||
report: Doctor report data
|
||||
|
||||
Returns:
|
||||
str: Formatted doctor report
|
||||
"""
|
||||
response_parts = []
|
||||
|
||||
# Space information
|
||||
if report.get("space"):
|
||||
space = report["space"]
|
||||
space_info = f"<b>Space:</b> {space.get('room_id', 'Unknown')}<br />"
|
||||
space_info += f"Bot Power Level: {space.get('bot_power_level', 'Unknown')}<br />"
|
||||
space_info += f"Has Admin: {space.get('has_admin', False)}<br />"
|
||||
response_parts.append(f"<p>{space_info}</p>")
|
||||
|
||||
# Room information
|
||||
if report.get("rooms"):
|
||||
rooms_info = "<b>Rooms:</b><br />"
|
||||
for room_id, room_data in report["rooms"].items():
|
||||
rooms_info += f"- {room_id}: {room_data.get('status', 'Unknown')}<br />"
|
||||
response_parts.append(f"<p>{rooms_info}</p>")
|
||||
|
||||
# Issues
|
||||
if report.get("issues"):
|
||||
issues_html = "<br />".join(report["issues"])
|
||||
response_parts.append(f"<p><b>Issues:</b><br />{issues_html}</p>")
|
||||
|
||||
# Warnings
|
||||
if report.get("warnings"):
|
||||
warnings_html = "<br />".join(report["warnings"])
|
||||
response_parts.append(f"<p><b>Warnings:</b><br />{warnings_html}</p>")
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append("<p>No issues found.</p>")
|
||||
|
||||
return "".join(response_parts)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Room creation utility functions for the community bot."""
|
||||
|
||||
import re
|
||||
import asyncio
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
from mautrix.types import MessageEvent, PowerLevelStateEventContent, EventType
|
||||
from mautrix.client import Client
|
||||
|
||||
|
||||
async def validate_room_creation_params(
|
||||
roomname: str,
|
||||
config: dict,
|
||||
evt: Optional[MessageEvent] = None
|
||||
) -> Tuple[str, bool, bool, str]:
|
||||
"""Validate and process room creation parameters.
|
||||
|
||||
Args:
|
||||
roomname: Original room name
|
||||
config: Bot configuration
|
||||
evt: Optional MessageEvent for error responses
|
||||
|
||||
Returns:
|
||||
Tuple of (sanitized_name, force_encryption, force_unencryption, error_msg)
|
||||
"""
|
||||
# Check for encryption flags
|
||||
encrypted_flag_regex = re.compile(r"(\s+|^)-+encrypt(ed)?\s?")
|
||||
unencrypted_flag_regex = re.compile(r"(\s+|^)-+unencrypt(ed)?\s?")
|
||||
force_encryption = bool(encrypted_flag_regex.search(roomname))
|
||||
force_unencryption = bool(unencrypted_flag_regex.search(roomname))
|
||||
|
||||
# Clean up room name
|
||||
if force_encryption:
|
||||
roomname = encrypted_flag_regex.sub("", roomname)
|
||||
if force_unencryption:
|
||||
roomname = unencrypted_flag_regex.sub("", roomname)
|
||||
|
||||
sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", roomname).lower()
|
||||
|
||||
# Check if community slug is configured
|
||||
if not config.get("community_slug"):
|
||||
error_msg = "No community slug configured. Please run initialize command first."
|
||||
return sanitized_name, force_encryption, force_unencryption, error_msg
|
||||
|
||||
return sanitized_name, force_encryption, force_unencryption, ""
|
||||
|
||||
|
||||
async def prepare_room_creation_data(
|
||||
sanitized_name: str,
|
||||
config: dict,
|
||||
client: Client,
|
||||
invitees: Optional[List[str]] = None
|
||||
) -> Tuple[str, str, List[str], str]:
|
||||
"""Prepare data needed for room creation.
|
||||
|
||||
Args:
|
||||
sanitized_name: Sanitized room name
|
||||
config: Bot configuration
|
||||
client: Matrix client
|
||||
invitees: Optional list of users to invite
|
||||
|
||||
Returns:
|
||||
Tuple of (alias_localpart, server, room_invitees, parent_room)
|
||||
"""
|
||||
# Create alias with community slug
|
||||
alias_localpart = f"{sanitized_name}-{config['community_slug']}"
|
||||
|
||||
# Get server and invitees
|
||||
server = client.parse_user_id(client.mxid)[1]
|
||||
room_invitees = invitees if invitees is not None else config["invitees"]
|
||||
parent_room = config["parent_room"]
|
||||
|
||||
return alias_localpart, server, room_invitees, parent_room
|
||||
|
||||
|
||||
async def prepare_power_levels(
|
||||
client: Client,
|
||||
config: dict,
|
||||
parent_room: str,
|
||||
power_level_override: Optional[PowerLevelStateEventContent] = None
|
||||
) -> PowerLevelStateEventContent:
|
||||
"""Prepare power levels for room creation.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
config: Bot configuration
|
||||
parent_room: Parent room ID
|
||||
power_level_override: Optional existing power level override
|
||||
|
||||
Returns:
|
||||
PowerLevelStateEventContent for room creation
|
||||
"""
|
||||
if power_level_override:
|
||||
return power_level_override
|
||||
|
||||
if parent_room:
|
||||
# Get parent room power levels to extract user power levels
|
||||
parent_power_levels = await client.get_state_event(
|
||||
parent_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
|
||||
# Create new power levels with server defaults, not copying all permissions from space
|
||||
power_levels = PowerLevelStateEventContent()
|
||||
|
||||
# Copy only user power levels from parent space, not the entire permission set
|
||||
if parent_power_levels.users:
|
||||
user_power_levels = parent_power_levels.users.copy()
|
||||
# Ensure bot has highest power
|
||||
user_power_levels[client.mxid] = 1000
|
||||
power_levels.users = user_power_levels
|
||||
else:
|
||||
power_levels.users = {
|
||||
client.mxid: 1000, # Bot gets highest power
|
||||
}
|
||||
|
||||
# Set explicit config values
|
||||
power_levels.invite = config["invite_power_level"]
|
||||
|
||||
return power_levels
|
||||
else:
|
||||
# If no parent room, create default power levels
|
||||
power_levels = PowerLevelStateEventContent()
|
||||
power_levels.users = {
|
||||
client.mxid: 1000, # Bot gets highest power
|
||||
}
|
||||
power_levels.invite = config["invite_power_level"]
|
||||
return power_levels
|
||||
|
||||
|
||||
def prepare_initial_state(
|
||||
config: dict,
|
||||
parent_room: str,
|
||||
server: str,
|
||||
force_encryption: bool,
|
||||
force_unencryption: bool,
|
||||
creation_content: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Prepare initial state events for room creation.
|
||||
|
||||
Args:
|
||||
config: Bot configuration
|
||||
parent_room: Parent room ID
|
||||
server: Server name
|
||||
force_encryption: Whether to force encryption
|
||||
force_unencryption: Whether to force no encryption
|
||||
creation_content: Optional creation content
|
||||
|
||||
Returns:
|
||||
List of initial state events
|
||||
"""
|
||||
initial_state = []
|
||||
|
||||
# Only add space parent state if we have a parent room
|
||||
if parent_room:
|
||||
initial_state.extend([
|
||||
{
|
||||
"type": str(EventType.SPACE_PARENT),
|
||||
"state_key": parent_room,
|
||||
"content": {
|
||||
"via": [server],
|
||||
"canonical": True
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": str(EventType.ROOM_JOIN_RULES),
|
||||
"content": {
|
||||
"join_rule": "restricted",
|
||||
"allow": [{
|
||||
"type": "m.room_membership",
|
||||
"room_id": parent_room
|
||||
}]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
# Add encryption if needed
|
||||
if (config.get("encrypt", False) and not force_unencryption) or force_encryption:
|
||||
initial_state.append({
|
||||
"type": str(EventType.ROOM_ENCRYPTION),
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2"
|
||||
}
|
||||
})
|
||||
|
||||
# Add history visibility if specified in creation_content
|
||||
if creation_content and "m.room.history_visibility" in creation_content:
|
||||
initial_state.append({
|
||||
"type": str(EventType.ROOM_HISTORY_VISIBILITY),
|
||||
"content": {
|
||||
"history_visibility": creation_content["m.room.history_visibility"]
|
||||
}
|
||||
})
|
||||
|
||||
return initial_state
|
||||
|
||||
|
||||
def adjust_power_levels_for_modern_rooms(
|
||||
power_levels: PowerLevelStateEventContent,
|
||||
room_version: str
|
||||
) -> PowerLevelStateEventContent:
|
||||
"""Adjust power levels for modern room versions.
|
||||
|
||||
Args:
|
||||
power_levels: Power level state content
|
||||
room_version: Room version string
|
||||
|
||||
Returns:
|
||||
Adjusted power level state content
|
||||
"""
|
||||
# For modern room versions (12+), remove the bot from power levels
|
||||
# as creators have unlimited power by default and cannot appear in power levels
|
||||
if room_version and int(room_version) >= 12 and power_levels:
|
||||
if power_levels.users:
|
||||
# Remove bot from users list but keep other important settings
|
||||
power_levels.users.pop("bot_mxid", None) # Will be replaced with actual bot mxid
|
||||
|
||||
return power_levels
|
||||
|
||||
|
||||
async def add_room_to_space(
|
||||
client: Client,
|
||||
parent_room: str,
|
||||
room_id: str,
|
||||
server: str,
|
||||
sleep_duration: float
|
||||
) -> None:
|
||||
"""Add created room to parent space.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
parent_room: Parent room ID
|
||||
room_id: Created room ID
|
||||
server: Server name
|
||||
sleep_duration: Sleep duration between operations
|
||||
"""
|
||||
if parent_room:
|
||||
await client.send_state_event(
|
||||
parent_room,
|
||||
EventType.SPACE_CHILD,
|
||||
{
|
||||
"via": [server],
|
||||
"suggested": False
|
||||
},
|
||||
state_key=room_id
|
||||
)
|
||||
await asyncio.sleep(sleep_duration)
|
||||
|
||||
|
||||
async def verify_room_creation(
|
||||
client: Client,
|
||||
room_id: str,
|
||||
expected_version: str,
|
||||
logger
|
||||
) -> None:
|
||||
"""Verify that room was created with correct settings.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
room_id: Created room ID
|
||||
expected_version: Expected room version
|
||||
logger: Logger instance
|
||||
"""
|
||||
try:
|
||||
from .room_utils import get_room_version_and_creators
|
||||
actual_version, actual_creators = await get_room_version_and_creators(client, room_id, logger)
|
||||
logger.info(f"Room {room_id} created with version {actual_version} (requested: {expected_version})")
|
||||
if actual_version != expected_version:
|
||||
logger.warning(f"Room version mismatch: requested {expected_version}, got {actual_version}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not verify room version for {room_id}: {e}")
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Room and space utility functions."""
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple, List
|
||||
from mautrix.types import EventType, PowerLevelStateEventContent
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
|
||||
async def validate_room_alias(client, alias_localpart: str, server: str) -> bool:
|
||||
"""Check if a room alias already exists.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
alias_localpart: The localpart of the alias (without # and :server)
|
||||
server: The server domain
|
||||
|
||||
Returns:
|
||||
bool: True if alias is available, False if it already exists
|
||||
"""
|
||||
try:
|
||||
full_alias = f"#{alias_localpart}:{server}"
|
||||
await client.resolve_room_alias(full_alias)
|
||||
# If we get here, the alias exists
|
||||
return False
|
||||
except MNotFound:
|
||||
# Alias doesn't exist, so it's available
|
||||
return True
|
||||
except Exception as e:
|
||||
# For other errors, assume alias is available to be safe
|
||||
return True
|
||||
|
||||
|
||||
async def validate_room_aliases(client, room_names: list[str], community_slug: str, server: str) -> Tuple[bool, List[str]]:
|
||||
"""Validate that all room aliases are available.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_names: List of room names to validate
|
||||
community_slug: The community slug to append
|
||||
server: The server domain
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, list_of_conflicting_aliases)
|
||||
"""
|
||||
if not community_slug:
|
||||
return False, []
|
||||
|
||||
conflicting_aliases = []
|
||||
|
||||
for room_name in room_names:
|
||||
# Clean the room name and create alias
|
||||
from .message_utils import sanitize_room_name
|
||||
sanitized_name = sanitize_room_name(room_name)
|
||||
alias_localpart = f"{sanitized_name}-{community_slug}"
|
||||
|
||||
# Check if alias is available
|
||||
is_available = await validate_room_alias(client, alias_localpart, server)
|
||||
if not is_available:
|
||||
conflicting_aliases.append(f"#{alias_localpart}:{server}")
|
||||
|
||||
return len(conflicting_aliases) == 0, conflicting_aliases
|
||||
|
||||
|
||||
async def get_room_version_and_creators(client, room_id: str) -> Tuple[str, List[str]]:
|
||||
"""Get the room version and creators for a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: The room ID to check
|
||||
|
||||
Returns:
|
||||
tuple: (room_version, list_of_creators)
|
||||
"""
|
||||
try:
|
||||
# Get all state events to find the creation event
|
||||
state_events = await client.get_state(room_id)
|
||||
|
||||
# Find the m.room.create event
|
||||
creation_event = None
|
||||
for event in state_events:
|
||||
if event.type == EventType.ROOM_CREATE:
|
||||
creation_event = event
|
||||
break
|
||||
|
||||
if not creation_event:
|
||||
# Default to version 1 if no creation event found
|
||||
return "1", []
|
||||
|
||||
room_version = creation_event.content.get("room_version", "1")
|
||||
creators = []
|
||||
|
||||
# Add the sender of the creation event as a creator
|
||||
if creation_event.sender:
|
||||
creators.append(creation_event.sender)
|
||||
|
||||
# Add any additional creators from the content
|
||||
additional_creators = creation_event.content.get("additional_creators", [])
|
||||
if isinstance(additional_creators, list):
|
||||
creators.extend(additional_creators)
|
||||
|
||||
return room_version, creators
|
||||
|
||||
except Exception:
|
||||
# Default to version 1 if there's an error
|
||||
return "1", []
|
||||
|
||||
|
||||
def is_modern_room_version(room_version: str) -> bool:
|
||||
"""Check if a room version is 12 or newer (modern room versions).
|
||||
|
||||
Args:
|
||||
room_version: The room version string to check
|
||||
|
||||
Returns:
|
||||
bool: True if room version is 12 or newer
|
||||
"""
|
||||
try:
|
||||
version_num = int(room_version)
|
||||
return version_num >= 12
|
||||
except (ValueError, TypeError):
|
||||
# If we can't parse the version, assume it's not modern
|
||||
return False
|
||||
|
||||
|
||||
async def user_has_unlimited_power(client, user_id: str, room_id: str) -> bool:
|
||||
"""Check if a user has unlimited power in a room (creator in modern room versions).
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user_id: The user ID to check
|
||||
room_id: The room ID to check in
|
||||
|
||||
Returns:
|
||||
bool: True if user has unlimited power
|
||||
"""
|
||||
try:
|
||||
room_version, creators = await get_room_version_and_creators(client, room_id)
|
||||
|
||||
# In modern room versions (12+), creators have unlimited power
|
||||
if is_modern_room_version(room_version):
|
||||
return user_id in creators
|
||||
|
||||
# In older room versions, creators don't have special unlimited power
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_moderators_and_above(client, parent_room: str) -> List[str]:
|
||||
"""Get list of users with moderator or higher permissions from the parent space.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
parent_room: The parent room ID
|
||||
|
||||
Returns:
|
||||
list: List of user IDs with power level >= 50 (moderator or above)
|
||||
"""
|
||||
try:
|
||||
power_levels = await client.get_state_event(
|
||||
parent_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
moderators = []
|
||||
for user, level in power_levels.users.items():
|
||||
if level >= 50: # Moderator level or above
|
||||
moderators.append(user)
|
||||
return moderators
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,199 @@
|
||||
"""User management utility functions."""
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import time
|
||||
from typing import List, Dict, Tuple
|
||||
from mautrix.types import EventType, UserID
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
|
||||
async def check_if_banned(client, userid: str, banlists: List[str], logger) -> bool:
|
||||
"""Check if a user is banned according to banlists.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
userid: The user ID to check
|
||||
banlists: List of banlist room IDs or aliases
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
bool: True if user is banned
|
||||
"""
|
||||
is_banned = False
|
||||
myrooms = await client.get_joined_rooms()
|
||||
banlist_roomids = await get_banlist_roomids(client, banlists, logger)
|
||||
|
||||
for list_id in banlist_roomids:
|
||||
if list_id not in myrooms:
|
||||
logger.error(
|
||||
f"Bot must be in {list_id} before attempting to use it as a banlist."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
list_state = await client.get_state(list_id)
|
||||
user_policies = list(
|
||||
filter(lambda p: p.type.t == "m.policy.rule.user", list_state)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
continue
|
||||
|
||||
for rule in user_policies:
|
||||
try:
|
||||
if bool(
|
||||
fnmatch.fnmatch(userid, rule["content"]["entity"])
|
||||
) and bool(re.search("ban$", rule["content"]["recommendation"])):
|
||||
return True
|
||||
except Exception:
|
||||
# Skip invalid rules
|
||||
pass
|
||||
|
||||
return is_banned
|
||||
|
||||
|
||||
async def get_banlist_roomids(client, banlists: List[str], logger) -> List[str]:
|
||||
"""Get room IDs for all configured banlists.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
banlists: List of banlist room IDs or aliases
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
list: List of room IDs for banlists
|
||||
"""
|
||||
banlist_roomids = []
|
||||
for banlist in banlists:
|
||||
if banlist.startswith("#"):
|
||||
try:
|
||||
room_info = await client.resolve_room_alias(banlist)
|
||||
list_id = room_info["room_id"]
|
||||
banlist_roomids.append(list_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Banlist fetching failed for {banlist}: {e}")
|
||||
continue
|
||||
else:
|
||||
list_id = banlist
|
||||
banlist_roomids.append(list_id)
|
||||
|
||||
return banlist_roomids
|
||||
|
||||
|
||||
async def ban_user_from_rooms(client, user: str, roomlist: List[str], reason: str = "banned",
|
||||
all_rooms: bool = False, redact_on_ban: bool = False,
|
||||
get_messages_to_redact_func=None, database=None,
|
||||
sleep_time: float = 0.1, logger=None) -> Dict:
|
||||
"""Ban a user from a list of rooms.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user: User ID to ban
|
||||
roomlist: List of room IDs to ban from
|
||||
reason: Reason for the ban
|
||||
all_rooms: Whether to ban even if user is not in room
|
||||
redact_on_ban: Whether to queue messages for redaction
|
||||
get_messages_to_redact_func: Function to get messages to redact
|
||||
database: Database instance for redaction tasks
|
||||
sleep_time: Sleep time between operations
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
dict: Ban results with success/error lists
|
||||
"""
|
||||
ban_event_map = {"ban_list": {}, "error_list": {}}
|
||||
ban_event_map["ban_list"][user] = []
|
||||
|
||||
for room in roomlist:
|
||||
try:
|
||||
roomname = None
|
||||
try:
|
||||
roomnamestate = await client.get_state_event(room, "m.room.name")
|
||||
roomname = roomnamestate["name"]
|
||||
except:
|
||||
pass
|
||||
|
||||
# ban user even if they're not in the room!
|
||||
if not all_rooms:
|
||||
await client.get_state_event(room, EventType.ROOM_MEMBER, user)
|
||||
|
||||
await client.ban_user(room, user, reason=reason)
|
||||
if roomname:
|
||||
ban_event_map["ban_list"][user].append(roomname)
|
||||
else:
|
||||
ban_event_map["ban_list"][user].append(room)
|
||||
time.sleep(sleep_time)
|
||||
except MNotFound:
|
||||
pass
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.warning(e)
|
||||
ban_event_map["error_list"][user] = []
|
||||
ban_event_map["error_list"][user].append(roomname or room)
|
||||
|
||||
if redact_on_ban and get_messages_to_redact_func and database:
|
||||
messages = await get_messages_to_redact_func(room, user)
|
||||
# Queue messages for redaction
|
||||
for msg in messages:
|
||||
await database.execute(
|
||||
"INSERT INTO redaction_tasks (event_id, room_id) VALUES ($1, $2)",
|
||||
msg.event_id,
|
||||
room,
|
||||
)
|
||||
if logger:
|
||||
logger.info(
|
||||
f"Queued {len(messages)} messages for redaction in {roomname or room}"
|
||||
)
|
||||
|
||||
return ban_event_map
|
||||
|
||||
|
||||
async def user_permitted(client, user_id: UserID, parent_room: str, min_level: int = 50,
|
||||
room_id: str = None, logger=None) -> bool:
|
||||
"""Check if a user has sufficient power level in a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user_id: The Matrix ID of the user to check
|
||||
parent_room: The parent room ID
|
||||
min_level: Minimum required power level (default 50 for moderator)
|
||||
room_id: The room ID to check permissions in. If None, uses parent room.
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
bool: True if user has sufficient power level
|
||||
"""
|
||||
try:
|
||||
target_room = room_id or parent_room
|
||||
|
||||
# First check if user has unlimited power (creator in modern room versions)
|
||||
from .room_utils import user_has_unlimited_power
|
||||
if await user_has_unlimited_power(client, user_id, target_room):
|
||||
return True
|
||||
|
||||
# Then check power level
|
||||
power_levels = await client.get_state_event(
|
||||
target_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
user_level = power_levels.get_user_level(user_id)
|
||||
return user_level >= min_level
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"Failed to check user power level: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def user_has_unlimited_power(client, user_id: str, room_id: str) -> bool:
|
||||
"""Check if a user has unlimited power in a room (creator in modern room versions).
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user_id: The user ID to check
|
||||
room_id: The room ID to check in
|
||||
|
||||
Returns:
|
||||
bool: True if user has unlimited power
|
||||
"""
|
||||
from .room_utils import user_has_unlimited_power as room_user_has_unlimited_power
|
||||
return await room_user_has_unlimited_power(client, user_id, room_id)
|
||||
@@ -93,24 +93,18 @@ plugin_config:
|
||||
# subject to removal by the purge command.
|
||||
kick_threshold_days: 60
|
||||
|
||||
# track users? if false, will disable all tracking and avoid writing anything to the database.
|
||||
track_users: True
|
||||
# track users? empty list disables all tracking, otherwise specify what to track:
|
||||
# - "messages": track user activity from messages
|
||||
# - "reactions": track user activity from reactions
|
||||
# examples:
|
||||
# track_users: [] # disable all tracking
|
||||
# track_users: ["messages"] # track only messages
|
||||
# track_users: ["reactions"] # track only reactions
|
||||
# track_users: ["messages", "reactions"] # track both
|
||||
track_users: ["messages", "reactions"]
|
||||
|
||||
# track messages? if false, will not track user activity timestamps. enable if you'd like to track
|
||||
# inactive users in your community
|
||||
track_messages: True
|
||||
|
||||
# track reactions? if false, will only track activity based on normal message events, but if true
|
||||
# will update the user's last-active date when they add a reaction to a message
|
||||
track_reactions: True
|
||||
|
||||
# list of users who can use administrative commands. these users will also be made room admins (PL100)
|
||||
# DEPRECATED: set user powerlevels in the parent room instead.
|
||||
admins: []
|
||||
|
||||
# list of users who should be considered community moderators. these users will be made room mods (PL50)
|
||||
# DEPRECATED: set userpowerlevels in the parent room instead.
|
||||
moderators: []
|
||||
# Note: Admin and moderator permissions are now managed through power levels
|
||||
# in the parent room. Set user power levels directly in the parent space.
|
||||
|
||||
# list of users who should be invited to new rooms immediately (other bots, moderators, perhaps)
|
||||
# use full matrix IDs here
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
maubot: 0.1.0
|
||||
id: org.jobmachine.communitybot
|
||||
version: 0.2.12
|
||||
version: 0.3.0
|
||||
license: MIT
|
||||
modules:
|
||||
- community
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[tool:pytest]
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = -v --tb=short
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test runner for the community bot project."""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
def run_tests():
|
||||
"""Run all tests for the community bot project."""
|
||||
print("Running community bot tests...")
|
||||
|
||||
# Change to the project directory
|
||||
project_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
os.chdir(project_dir)
|
||||
|
||||
# Run pytest
|
||||
try:
|
||||
result = subprocess.run([
|
||||
sys.executable, "-m", "pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--tb=short",
|
||||
"--color=yes"
|
||||
], check=True)
|
||||
print("\n✅ All tests passed!")
|
||||
return 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"\n❌ Tests failed with exit code {e.returncode}")
|
||||
return e.returncode
|
||||
except FileNotFoundError:
|
||||
print("❌ pytest not found. Please install it with: pip install pytest")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_tests())
|
||||
@@ -0,0 +1 @@
|
||||
# Test package for community bot
|
||||
@@ -0,0 +1,430 @@
|
||||
"""Tests for bot command handlers."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
||||
from mautrix.types import EventType, UserID, MessageEvent, StateEvent
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
from community.bot import CommunityBot
|
||||
|
||||
|
||||
class TestBotCommands:
|
||||
"""Test cases for bot command handlers."""
|
||||
|
||||
@pytest.fixture
|
||||
def bot(self):
|
||||
"""Create a mock bot instance for testing."""
|
||||
bot = Mock(spec=CommunityBot)
|
||||
bot.client = Mock()
|
||||
bot.database = Mock()
|
||||
bot.log = Mock()
|
||||
bot.config = {
|
||||
"parent_room": "!parent:example.com",
|
||||
"community_slug": "test",
|
||||
"track_users": True,
|
||||
"warn_threshold_days": 7,
|
||||
"kick_threshold_days": 14,
|
||||
"sleep": 0.1,
|
||||
"censor_wordlist": [r"badword"],
|
||||
"censor_files": False,
|
||||
"censor": True,
|
||||
"banlists": ["!banlist:example.com"],
|
||||
"redact_on_ban": False,
|
||||
"admins": [],
|
||||
"moderators": []
|
||||
}
|
||||
return bot
|
||||
|
||||
@pytest.fixture
|
||||
def mock_evt(self):
|
||||
"""Create a mock MessageEvent for testing."""
|
||||
evt = Mock(spec=MessageEvent)
|
||||
evt.sender = "@user:example.com"
|
||||
evt.room_id = "!room:example.com"
|
||||
evt.reply = AsyncMock()
|
||||
evt.respond = AsyncMock()
|
||||
return evt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_parent_room_configured(self, bot, mock_evt):
|
||||
"""Test check_parent_room when parent room is configured."""
|
||||
# Use the mock bot instance
|
||||
bot.config = {"parent_room": "!parent:example.com"}
|
||||
|
||||
result = await bot.check_parent_room(mock_evt)
|
||||
|
||||
assert result == True
|
||||
mock_evt.reply.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_parent_room_not_configured(self, bot, mock_evt):
|
||||
"""Test check_parent_room when parent room is not configured."""
|
||||
bot.config = {"parent_room": None}
|
||||
|
||||
result = await bot.check_parent_room(mock_evt)
|
||||
|
||||
assert result == False
|
||||
mock_evt.reply.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_banlists_command(self, bot, mock_evt):
|
||||
"""Test the check_banlists command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock the check_if_banned method
|
||||
with patch.object(real_bot, 'check_if_banned', return_value=True):
|
||||
await real_bot.check_banlists(mock_evt, "@test:example.com")
|
||||
|
||||
mock_evt.reply.assert_called_once_with("user on banlist: True")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_space_members_command(self, bot, mock_evt):
|
||||
"""Test the sync_space_members command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'do_sync', return_value={"added": [], "dropped": []}):
|
||||
|
||||
await real_bot.sync_space_members(mock_evt)
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_space_members_no_permission(self, bot, mock_evt):
|
||||
"""Test sync_space_members command without permission."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'user_permitted', return_value=False):
|
||||
await real_bot.sync_space_members(mock_evt)
|
||||
|
||||
mock_evt.reply.assert_called_once_with("You don't have permission to use this command")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_space_members_tracking_disabled(self, bot, mock_evt):
|
||||
"""Test sync_space_members command when tracking is disabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = {**bot.config, "track_users": False}
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True):
|
||||
await real_bot.sync_space_members(mock_evt)
|
||||
|
||||
mock_evt.respond.assert_called_once_with("user tracking is disabled")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignore_command(self, bot, mock_evt):
|
||||
"""Test the ignore command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock database operations
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True):
|
||||
await real_bot.ignore_user(mock_evt, "@test:example.com")
|
||||
|
||||
real_bot.database.execute.assert_called()
|
||||
mock_evt.reply.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unignore_command(self, bot, mock_evt):
|
||||
"""Test the unignore command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock database operations
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True):
|
||||
await real_bot.unignore_user(mock_evt, "@test:example.com")
|
||||
|
||||
real_bot.database.execute.assert_called()
|
||||
mock_evt.reply.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kick_command(self, bot, mock_evt):
|
||||
"""Test the kick command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]), \
|
||||
patch.object(real_bot, 'ban_this_user', return_value={"ban_list": {}, "error_list": {}}):
|
||||
|
||||
await real_bot.kick_user(mock_evt, "@test:example.com")
|
||||
|
||||
mock_evt.reply.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ban_command(self, bot, mock_evt):
|
||||
"""Test the ban command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]), \
|
||||
patch.object(real_bot, 'ban_this_user', return_value={"ban_list": {}, "error_list": {}}):
|
||||
|
||||
await real_bot.ban_user(mock_evt, "@test:example.com")
|
||||
|
||||
mock_evt.reply.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doctor_command(self, bot, mock_evt):
|
||||
"""Test the doctor command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]):
|
||||
|
||||
await real_bot.doctor(mock_evt)
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_doctor_room_detail_command(self, bot, mock_evt):
|
||||
"""Test the doctor room detail command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, '_doctor_room_detail', return_value=None):
|
||||
|
||||
await real_bot.doctor_room_detail(mock_evt, "!room:example.com")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_command(self, bot, mock_evt):
|
||||
"""Test the initialize command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'create_space', return_value=("!space:example.com", "#space:example.com")):
|
||||
|
||||
await real_bot.initialize(mock_evt, "Test Community")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_room_command(self, bot, mock_evt):
|
||||
"""Test the create_room command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'validate_room_aliases', return_value=(True, [])), \
|
||||
patch.object(real_bot, 'create_room', return_value=("!room:example.com", "#room:example.com")):
|
||||
|
||||
await real_bot.create_room(mock_evt, "Test Room")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_room_command_alias_conflict(self, bot, mock_evt):
|
||||
"""Test create_room command with alias conflict."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'validate_room_aliases', return_value=(False, ["#conflict:example.com"])):
|
||||
|
||||
await real_bot.create_room(mock_evt, "Test Room")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
# Should mention the conflict
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_room_command(self, bot, mock_evt):
|
||||
"""Test the archive_room command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'do_archive_room', return_value=None):
|
||||
|
||||
await real_bot.archive_room(mock_evt, "!room:example.com")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_room_command(self, bot, mock_evt):
|
||||
"""Test the remove_room command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'remove_room_aliases', return_value=[]):
|
||||
|
||||
await real_bot.remove_room(mock_evt, "!room:example.com")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_join_room_command(self, bot, mock_evt):
|
||||
"""Test the join_room command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'join_room', return_value="!room:example.com"):
|
||||
|
||||
await real_bot.join_room(mock_evt, "!room:example.com")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_leave_room_command(self, bot, mock_evt):
|
||||
"""Test the leave_room command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'leave_room', return_value=None):
|
||||
|
||||
await real_bot.leave_room(mock_evt, "!room:example.com")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_command(self, bot, mock_evt):
|
||||
"""Test the verify command."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'create_verification_dm', return_value="!dm:example.com"):
|
||||
|
||||
await real_bot.verify_user(mock_evt, "@test:example.com", "!room:example.com")
|
||||
|
||||
mock_evt.respond.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_require_permission(self, bot, mock_evt):
|
||||
"""Test that commands require proper permissions."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Test various commands that require permission
|
||||
commands_to_test = [
|
||||
('sync_space_members', []),
|
||||
('ignore_user', ['@test:example.com']),
|
||||
('unignore_user', ['@test:example.com']),
|
||||
('kick_user', ['@test:example.com']),
|
||||
('ban_user', ['@test:example.com']),
|
||||
('doctor', []),
|
||||
('doctor_room_detail', ['!room:example.com']),
|
||||
('initialize', ['Test Community']),
|
||||
('create_room', ['Test Room']),
|
||||
('archive_room', ['!room:example.com']),
|
||||
('remove_room', ['!room:example.com']),
|
||||
('join_room', ['!room:example.com']),
|
||||
('leave_room', ['!room:example.com']),
|
||||
('verify_user', ['@test:example.com', '!room:example.com'])
|
||||
]
|
||||
|
||||
for command_name, args in commands_to_test:
|
||||
with patch.object(real_bot, 'user_permitted', return_value=False):
|
||||
command_func = getattr(real_bot, command_name)
|
||||
await command_func(mock_evt, *args)
|
||||
|
||||
# Should respond with permission denied message
|
||||
mock_evt.reply.assert_called()
|
||||
mock_evt.reply.reset_mock()
|
||||
@@ -0,0 +1,452 @@
|
||||
"""Tests for bot event handlers."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
||||
from mautrix.types import EventType, UserID, MessageEvent, StateEvent, ReactionEvent
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
from community.bot import CommunityBot
|
||||
|
||||
|
||||
class TestBotEvents:
|
||||
"""Test cases for bot event handlers."""
|
||||
|
||||
@pytest.fixture
|
||||
def bot(self):
|
||||
"""Create a mock bot instance for testing."""
|
||||
bot = Mock(spec=CommunityBot)
|
||||
bot.client = Mock()
|
||||
bot.database = Mock()
|
||||
bot.log = Mock()
|
||||
bot.config = {
|
||||
"parent_room": "!parent:example.com",
|
||||
"community_slug": "test",
|
||||
"track_users": True,
|
||||
"track_messages": True,
|
||||
"track_reactions": True,
|
||||
"warn_threshold_days": 7,
|
||||
"kick_threshold_days": 14,
|
||||
"sleep": 0.1,
|
||||
"censor_wordlist": [r"badword"],
|
||||
"censor_files": False,
|
||||
"censor": True,
|
||||
"banlists": ["!banlist:example.com"],
|
||||
"redact_on_ban": False,
|
||||
"proactive_banning": True,
|
||||
"check_if_human": True,
|
||||
"verification_phrases": ["test phrase"],
|
||||
"verification_attempts": 3,
|
||||
"verification_message": "Please verify",
|
||||
"invite_power_level": 50,
|
||||
"uncensor_pl": 50
|
||||
}
|
||||
return bot
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_evt(self):
|
||||
"""Create a mock MessageEvent for testing."""
|
||||
evt = Mock(spec=MessageEvent)
|
||||
evt.sender = "@user:example.com"
|
||||
evt.room_id = "!room:example.com"
|
||||
evt.timestamp = 1234567890
|
||||
evt.content = Mock()
|
||||
evt.content.body = "test message"
|
||||
evt.content.msgtype = "m.text"
|
||||
evt.reply = AsyncMock()
|
||||
evt.respond = AsyncMock()
|
||||
evt.react = AsyncMock()
|
||||
return evt
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state_evt(self):
|
||||
"""Create a mock StateEvent for testing."""
|
||||
evt = Mock(spec=StateEvent)
|
||||
evt.sender = "@user:example.com"
|
||||
evt.room_id = "!room:example.com"
|
||||
evt.state_key = "@user:example.com"
|
||||
evt.content = {
|
||||
"entity": "@banned:example.com",
|
||||
"recommendation": "ban"
|
||||
}
|
||||
evt.prev_content = {}
|
||||
return evt
|
||||
|
||||
@pytest.fixture
|
||||
def mock_reaction_evt(self):
|
||||
"""Create a mock ReactionEvent for testing."""
|
||||
evt = Mock(spec=ReactionEvent)
|
||||
evt.sender = "@user:example.com"
|
||||
evt.room_id = "!room:example.com"
|
||||
evt.content = Mock()
|
||||
evt.content.relates_to = Mock()
|
||||
evt.content.relates_to.event_id = "!msg:example.com"
|
||||
evt.content.relates_to.key = "👍"
|
||||
return evt
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_ban_event_proactive_banning_enabled(self, bot, mock_state_evt):
|
||||
"""Test ban event handler with proactive banning enabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock required methods
|
||||
with patch.object(real_bot, 'get_banlist_roomids', return_value=["!banlist:example.com"]), \
|
||||
patch.object(real_bot, 'ban_this_user', return_value={"ban_list": {}, "error_list": {}}):
|
||||
|
||||
await real_bot.check_ban_event(mock_state_evt)
|
||||
|
||||
# Should call ban_this_user
|
||||
real_bot.ban_this_user.assert_called_once_with("@banned:example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_ban_event_proactive_banning_disabled(self, bot, mock_state_evt):
|
||||
"""Test ban event handler with proactive banning disabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = {**bot.config, "proactive_banning": False}
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'get_banlist_roomids', return_value=["!banlist:example.com"]):
|
||||
await real_bot.check_ban_event(mock_state_evt)
|
||||
|
||||
# Should not call ban_this_user
|
||||
real_bot.ban_this_user.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_ban_event_wrong_room(self, bot, mock_state_evt):
|
||||
"""Test ban event handler with wrong room."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'get_banlist_roomids', return_value=["!other:example.com"]):
|
||||
await real_bot.check_ban_event(mock_state_evt)
|
||||
|
||||
# Should not call ban_this_user
|
||||
real_bot.ban_this_user.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_power_levels(self, bot, mock_state_evt):
|
||||
"""Test power levels sync event handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock power level changes
|
||||
mock_state_evt.prev_content = {"users": {"@user:example.com": 25}}
|
||||
mock_state_evt.content = {"users": {"@user:example.com": 50}}
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com", "!room2:example.com"]), \
|
||||
patch.object(real_bot, 'sync_power_levels_to_room', return_value=None):
|
||||
|
||||
await real_bot.sync_power_levels(mock_state_evt)
|
||||
|
||||
# Should sync to all rooms
|
||||
assert real_bot.sync_power_levels_to_room.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_power_levels_wrong_room(self, bot, mock_state_evt):
|
||||
"""Test power levels sync with wrong room."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.log = bot.log
|
||||
|
||||
mock_state_evt.room_id = "!other:example.com"
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]):
|
||||
await real_bot.sync_power_levels(mock_state_evt)
|
||||
|
||||
# Should not sync to any rooms
|
||||
real_bot.sync_power_levels_to_room.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_leave_events(self, bot, mock_state_evt):
|
||||
"""Test leave events handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock database operations
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com", "!room2:example.com"]):
|
||||
await real_bot.handle_leave_events(mock_state_evt)
|
||||
|
||||
# Should delete user from database
|
||||
real_bot.database.execute.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_leave(self, bot, mock_state_evt):
|
||||
"""Test leave event handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'handle_leave_events', return_value=None):
|
||||
await real_bot.handle_leave(mock_state_evt)
|
||||
|
||||
real_bot.handle_leave_events.assert_called_once_with(mock_state_evt)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_kick(self, bot, mock_state_evt):
|
||||
"""Test kick event handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'handle_leave_events', return_value=None):
|
||||
await real_bot.handle_kick(mock_state_evt)
|
||||
|
||||
real_bot.handle_leave_events.assert_called_once_with(mock_state_evt)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_ban(self, bot, mock_state_evt):
|
||||
"""Test ban event handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'handle_leave_events', return_value=None):
|
||||
await real_bot.handle_ban(mock_state_evt)
|
||||
|
||||
real_bot.handle_leave_events.assert_called_once_with(mock_state_evt)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_newjoin_event(self, bot, mock_state_evt):
|
||||
"""Test new join event handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock database operations
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com", "!room2:example.com"]), \
|
||||
patch.object(real_bot, 'upsert_user_timestamp', return_value=None):
|
||||
|
||||
await real_bot.newjoin(mock_state_evt)
|
||||
|
||||
# Should update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_message_timestamp_tracking_enabled(self, bot, mock_message_evt):
|
||||
"""Test message timestamp update with tracking enabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock power levels
|
||||
power_levels = Mock()
|
||||
power_levels.get_user_level.return_value = 25
|
||||
|
||||
real_bot.client.get_state_event = AsyncMock(return_value=power_levels)
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]), \
|
||||
patch.object(real_bot, 'upsert_user_timestamp', return_value=None):
|
||||
|
||||
await real_bot.update_message_timestamp(mock_message_evt)
|
||||
|
||||
# Should update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_message_timestamp_tracking_disabled(self, bot, mock_message_evt):
|
||||
"""Test message timestamp update with tracking disabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = {**bot.config, "track_messages": False}
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]):
|
||||
await real_bot.update_message_timestamp(mock_message_evt)
|
||||
|
||||
# Should not update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_verification(self, bot, mock_message_evt):
|
||||
"""Test verification message handler."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock verification state
|
||||
verification_state = {
|
||||
"user_id": "@user:example.com",
|
||||
"target_room_id": "!room:example.com",
|
||||
"verification_phrase": "test phrase",
|
||||
"attempts_remaining": 3,
|
||||
"required_power_level": 50
|
||||
}
|
||||
|
||||
real_bot.database.fetchrow = AsyncMock(return_value=verification_state)
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
# Mock message content
|
||||
mock_message_evt.content.body = "test phrase"
|
||||
|
||||
with patch.object(real_bot, 'user_permitted', return_value=True), \
|
||||
patch.object(real_bot, 'join_room', return_value="!room:example.com"):
|
||||
|
||||
await real_bot.handle_verification(mock_message_evt)
|
||||
|
||||
# Should process verification
|
||||
real_bot.database.execute.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_verification_wrong_phrase(self, bot, mock_message_evt):
|
||||
"""Test verification with wrong phrase."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock verification state
|
||||
verification_state = {
|
||||
"user_id": "@user:example.com",
|
||||
"target_room_id": "!room:example.com",
|
||||
"verification_phrase": "correct phrase",
|
||||
"attempts_remaining": 3,
|
||||
"required_power_level": 50
|
||||
}
|
||||
|
||||
real_bot.database.fetchrow = AsyncMock(return_value=verification_state)
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
# Mock message content with wrong phrase
|
||||
mock_message_evt.content.body = "wrong phrase"
|
||||
|
||||
await real_bot.handle_verification(mock_message_evt)
|
||||
|
||||
# Should decrement attempts
|
||||
real_bot.database.execute.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_verification_no_state(self, bot, mock_message_evt):
|
||||
"""Test verification with no verification state."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock no verification state
|
||||
real_bot.database.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
await real_bot.handle_verification(mock_message_evt)
|
||||
|
||||
# Should not process verification
|
||||
real_bot.database.execute.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_reaction_tracking_enabled(self, bot, mock_reaction_evt):
|
||||
"""Test reaction event handler with tracking enabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
# Mock power levels
|
||||
power_levels = Mock()
|
||||
power_levels.get_user_level.return_value = 25
|
||||
|
||||
real_bot.client.get_state_event = AsyncMock(return_value=power_levels)
|
||||
real_bot.database.execute = AsyncMock()
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]), \
|
||||
patch.object(real_bot, 'upsert_user_timestamp', return_value=None):
|
||||
|
||||
await real_bot.handle_reaction(mock_reaction_evt)
|
||||
|
||||
# Should update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_reaction_tracking_disabled(self, bot, mock_reaction_evt):
|
||||
"""Test reaction event handler with tracking disabled."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = {**bot.config, "track_reactions": False}
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room1:example.com"]):
|
||||
await real_bot.handle_reaction(mock_reaction_evt)
|
||||
|
||||
# Should not update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_reaction_wrong_room(self, bot, mock_reaction_evt):
|
||||
"""Test reaction event handler with wrong room."""
|
||||
from community.bot import CommunityBot
|
||||
|
||||
real_bot = CommunityBot()
|
||||
real_bot.config = bot.config
|
||||
real_bot.client = bot.client
|
||||
real_bot.database = bot.database
|
||||
real_bot.log = bot.log
|
||||
|
||||
with patch.object(real_bot, 'get_space_roomlist', return_value=["!other:example.com"]):
|
||||
await real_bot.handle_reaction(mock_reaction_evt)
|
||||
|
||||
# Should not update user timestamp
|
||||
real_bot.upsert_user_timestamp.assert_not_called()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Tests for database utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
import asyncio
|
||||
|
||||
from community.helpers.database_utils import (
|
||||
get_messages_to_redact, redact_messages, upsert_user_timestamp,
|
||||
get_inactive_users, cleanup_stale_verification_states,
|
||||
get_verification_state, create_verification_state,
|
||||
update_verification_attempts, delete_verification_state
|
||||
)
|
||||
|
||||
|
||||
class TestDatabaseUtils:
|
||||
"""Test cases for database utility functions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_to_redact_success(self):
|
||||
"""Test getting messages to redact successfully."""
|
||||
client = Mock()
|
||||
|
||||
# Mock message events
|
||||
msg1 = Mock()
|
||||
msg1.content = Mock()
|
||||
msg1.content.serialize.return_value = {"body": "test"}
|
||||
|
||||
msg2 = Mock()
|
||||
msg2.content = None
|
||||
|
||||
msg3 = Mock()
|
||||
msg3.content = Mock()
|
||||
msg3.content.serialize.return_value = {"body": "test2"}
|
||||
|
||||
messages = Mock()
|
||||
messages.events = [msg1, msg2, msg3]
|
||||
|
||||
client.get_messages = AsyncMock(return_value=messages)
|
||||
|
||||
logger = Mock()
|
||||
result = await get_messages_to_redact(client, "!room:example.com", "@user:example.com", logger)
|
||||
|
||||
assert len(result) == 2 # Only msg1 and msg3 have content
|
||||
assert msg1 in result
|
||||
assert msg3 in result
|
||||
assert msg2 not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_to_redact_error(self):
|
||||
"""Test getting messages to redact with error."""
|
||||
client = Mock()
|
||||
client.get_messages = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
logger = Mock()
|
||||
result = await get_messages_to_redact(client, "!room:example.com", "@user:example.com", logger)
|
||||
|
||||
assert result == []
|
||||
logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redact_messages_success(self):
|
||||
"""Test redacting messages successfully."""
|
||||
client = Mock()
|
||||
client.redact = AsyncMock()
|
||||
|
||||
database = Mock()
|
||||
database.fetch = AsyncMock(return_value=[
|
||||
{"event_id": "!msg1:example.com"},
|
||||
{"event_id": "!msg2:example.com"}
|
||||
])
|
||||
database.execute = AsyncMock()
|
||||
|
||||
logger = Mock()
|
||||
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
result = await redact_messages(client, database, "!room:example.com", 0.1, logger)
|
||||
|
||||
assert result["success"] == 2
|
||||
assert result["failure"] == 0
|
||||
assert client.redact.call_count == 2
|
||||
assert database.execute.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redact_messages_rate_limited(self):
|
||||
"""Test redacting messages with rate limiting."""
|
||||
client = Mock()
|
||||
client.redact = AsyncMock(side_effect=Exception("Too Many Requests"))
|
||||
|
||||
database = Mock()
|
||||
database.fetch = AsyncMock(return_value=[
|
||||
{"event_id": "!msg1:example.com"}
|
||||
])
|
||||
|
||||
logger = Mock()
|
||||
|
||||
with patch('asyncio.sleep', new_callable=AsyncMock):
|
||||
result = await redact_messages(client, database, "!room:example.com", 0.1, logger)
|
||||
|
||||
assert result["success"] == 0
|
||||
assert result["failure"] == 0 # Rate limited, so no failure count
|
||||
logger.warning.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_user_timestamp_success(self):
|
||||
"""Test upserting user timestamp successfully."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock()
|
||||
|
||||
logger = Mock()
|
||||
|
||||
await upsert_user_timestamp(database, "@user:example.com", 1234567890, logger)
|
||||
|
||||
database.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_inactive_users_success(self):
|
||||
"""Test getting inactive users successfully."""
|
||||
database = Mock()
|
||||
database.fetch = AsyncMock(side_effect=[
|
||||
[{"mxid": "@user1:example.com"}, {"mxid": "@user2:example.com"}], # warn results
|
||||
[{"mxid": "@user3:example.com"}] # kick results
|
||||
])
|
||||
|
||||
logger = Mock()
|
||||
|
||||
with patch('time.time', return_value=1234567890):
|
||||
result = await get_inactive_users(database, 7, 14, logger)
|
||||
|
||||
assert len(result["warn"]) == 2
|
||||
assert len(result["kick"]) == 1
|
||||
assert "@user1:example.com" in result["warn"]
|
||||
assert "@user3:example.com" in result["kick"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_inactive_users_error(self):
|
||||
"""Test getting inactive users with error."""
|
||||
database = Mock()
|
||||
database.fetch = AsyncMock(side_effect=Exception("Database error"))
|
||||
|
||||
logger = Mock()
|
||||
|
||||
result = await get_inactive_users(database, 7, 14, logger)
|
||||
|
||||
assert result == {"warn": [], "kick": []}
|
||||
logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_stale_verification_states_success(self):
|
||||
"""Test cleaning up stale verification states successfully."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock()
|
||||
|
||||
logger = Mock()
|
||||
|
||||
await cleanup_stale_verification_states(database, logger)
|
||||
|
||||
database.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_stale_verification_states_error(self):
|
||||
"""Test cleaning up stale verification states with error."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock(side_effect=Exception("Database error"))
|
||||
|
||||
logger = Mock()
|
||||
|
||||
await cleanup_stale_verification_states(database, logger)
|
||||
|
||||
logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_verification_state_success(self):
|
||||
"""Test getting verification state successfully."""
|
||||
database = Mock()
|
||||
database.fetchrow = AsyncMock(return_value={
|
||||
"dm_room_id": "!dm:example.com",
|
||||
"user_id": "@user:example.com",
|
||||
"target_room_id": "!room:example.com",
|
||||
"verification_phrase": "test phrase",
|
||||
"attempts_remaining": 3,
|
||||
"required_power_level": 50
|
||||
})
|
||||
|
||||
result = await get_verification_state(database, "!dm:example.com")
|
||||
|
||||
assert result is not None
|
||||
assert result["dm_room_id"] == "!dm:example.com"
|
||||
assert result["user_id"] == "@user:example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_verification_state_not_found(self):
|
||||
"""Test getting verification state when not found."""
|
||||
database = Mock()
|
||||
database.fetchrow = AsyncMock(return_value=None)
|
||||
|
||||
result = await get_verification_state(database, "!dm:example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_verification_state_error(self):
|
||||
"""Test getting verification state with error."""
|
||||
database = Mock()
|
||||
database.fetchrow = AsyncMock(side_effect=Exception("Database error"))
|
||||
|
||||
result = await get_verification_state(database, "!dm:example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_verification_state_success(self):
|
||||
"""Test creating verification state successfully."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock()
|
||||
|
||||
await create_verification_state(
|
||||
database, "!dm:example.com", "@user:example.com",
|
||||
"!room:example.com", "test phrase", 3, 50
|
||||
)
|
||||
|
||||
database.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_verification_state_error(self):
|
||||
"""Test creating verification state with error (should not raise)."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock(side_effect=Exception("Database error"))
|
||||
|
||||
# Should not raise exception
|
||||
await create_verification_state(
|
||||
database, "!dm:example.com", "@user:example.com",
|
||||
"!room:example.com", "test phrase", 3, 50
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_verification_attempts_success(self):
|
||||
"""Test updating verification attempts successfully."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock()
|
||||
|
||||
await update_verification_attempts(database, "!dm:example.com", 2)
|
||||
|
||||
database.execute.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_verification_state_success(self):
|
||||
"""Test deleting verification state successfully."""
|
||||
database = Mock()
|
||||
database.execute = AsyncMock()
|
||||
|
||||
await delete_verification_state(database, "!dm:example.com")
|
||||
|
||||
database.execute.assert_called_once()
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for message utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock
|
||||
from mautrix.types import MessageType, MediaMessageEventContent
|
||||
|
||||
from community.helpers.message_utils import (
|
||||
flag_message, flag_instaban, censor_room,
|
||||
sanitize_room_name, generate_community_slug
|
||||
)
|
||||
|
||||
|
||||
class TestMessageUtils:
|
||||
"""Test cases for message utility functions."""
|
||||
|
||||
def test_flag_message_file_types(self):
|
||||
"""Test that file messages are flagged when censor_files is True."""
|
||||
msg = Mock()
|
||||
msg.content.msgtype = MessageType.FILE
|
||||
msg.content.body = "test file"
|
||||
|
||||
assert flag_message(msg, [], True) == True
|
||||
assert flag_message(msg, [], False) == False
|
||||
|
||||
def test_flag_message_wordlist(self):
|
||||
"""Test that messages are flagged based on wordlist patterns."""
|
||||
msg = Mock()
|
||||
msg.content.msgtype = MessageType.TEXT
|
||||
msg.content.body = "This is a test message with badword"
|
||||
|
||||
wordlist = [r"badword", r"another.*pattern"]
|
||||
|
||||
assert flag_message(msg, wordlist, False) == True
|
||||
|
||||
msg.content.body = "This is a clean message"
|
||||
assert flag_message(msg, wordlist, False) == False
|
||||
|
||||
def test_flag_message_invalid_regex(self):
|
||||
"""Test that invalid regex patterns are handled gracefully."""
|
||||
msg = Mock()
|
||||
msg.content.msgtype = MessageType.TEXT
|
||||
msg.content.body = "test message"
|
||||
|
||||
wordlist = [r"valid.*pattern", r"[invalid", r"another.*pattern"]
|
||||
|
||||
# Should not raise exception and should work with valid patterns
|
||||
result = flag_message(msg, wordlist, False)
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_flag_instaban(self):
|
||||
"""Test instant ban flagging."""
|
||||
msg = Mock()
|
||||
msg.content.msgtype = MessageType.TEXT
|
||||
msg.content.body = "This contains instaban_word"
|
||||
|
||||
instaban_list = [r"instaban_word", r"another.*instaban"]
|
||||
|
||||
assert flag_instaban(msg, instaban_list) == True
|
||||
|
||||
msg.content.body = "This is clean"
|
||||
assert flag_instaban(msg, instaban_list) == False
|
||||
|
||||
def test_censor_room_boolean_config(self):
|
||||
"""Test room censoring with boolean configuration."""
|
||||
msg = Mock()
|
||||
msg.room_id = "!room123:example.com"
|
||||
|
||||
assert censor_room(msg, True) == True
|
||||
assert censor_room(msg, False) == False
|
||||
|
||||
def test_censor_room_list_config(self):
|
||||
"""Test room censoring with list configuration."""
|
||||
msg = Mock()
|
||||
msg.room_id = "!room123:example.com"
|
||||
|
||||
censor_list = ["!room123:example.com", "!room456:example.com"]
|
||||
|
||||
assert censor_room(msg, censor_list) == True
|
||||
|
||||
msg.room_id = "!room789:example.com"
|
||||
assert censor_room(msg, censor_list) == False
|
||||
|
||||
def test_censor_room_invalid_config(self):
|
||||
"""Test room censoring with invalid configuration."""
|
||||
msg = Mock()
|
||||
msg.room_id = "!room123:example.com"
|
||||
|
||||
assert censor_room(msg, "invalid") == False
|
||||
assert censor_room(msg, None) == False
|
||||
|
||||
def test_sanitize_room_name(self):
|
||||
"""Test room name sanitization."""
|
||||
assert sanitize_room_name("Test Room 123") == "testroom123"
|
||||
assert sanitize_room_name("Special@#$%Characters") == "specialcharacters"
|
||||
assert sanitize_room_name("UPPERCASE") == "uppercase"
|
||||
assert sanitize_room_name("123 Numbers") == "123numbers"
|
||||
assert sanitize_room_name("") == ""
|
||||
|
||||
def test_generate_community_slug(self):
|
||||
"""Test community slug generation."""
|
||||
assert generate_community_slug("Test Community") == "tc"
|
||||
assert generate_community_slug("My Awesome Community") == "mac"
|
||||
assert generate_community_slug("Single") == "s"
|
||||
assert generate_community_slug("Multiple Spaces") == "ms"
|
||||
assert generate_community_slug("") == ""
|
||||
assert generate_community_slug(" ") == ""
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Tests for report utility functions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from community.helpers.report_utils import (
|
||||
generate_activity_report, split_doctor_report, format_ban_results,
|
||||
format_sync_results
|
||||
)
|
||||
|
||||
|
||||
class TestReportUtils:
|
||||
"""Test cases for report utility functions."""
|
||||
|
||||
def test_generate_activity_report_success(self):
|
||||
"""Test generating activity report successfully."""
|
||||
database_results = {
|
||||
"active": [{"mxid": "@user1:example.com"}, {"mxid": "@user2:example.com"}],
|
||||
"inactive": [{"mxid": "@user3:example.com"}],
|
||||
"ignored": [{"mxid": "@user4:example.com"}]
|
||||
}
|
||||
|
||||
result = generate_activity_report(database_results)
|
||||
|
||||
assert result["active"] == ["@user1:example.com", "@user2:example.com"]
|
||||
assert result["inactive"] == ["@user3:example.com"]
|
||||
assert result["ignored"] == ["@user4:example.com"]
|
||||
|
||||
def test_generate_activity_report_empty(self):
|
||||
"""Test generating activity report with empty results."""
|
||||
database_results = {
|
||||
"active": [],
|
||||
"inactive": [],
|
||||
"ignored": []
|
||||
}
|
||||
|
||||
result = generate_activity_report(database_results)
|
||||
|
||||
assert result["active"] == ["none"]
|
||||
assert result["inactive"] == ["none"]
|
||||
assert result["ignored"] == ["none"]
|
||||
|
||||
def test_generate_activity_report_missing_keys(self):
|
||||
"""Test generating activity report with missing keys."""
|
||||
database_results = {}
|
||||
|
||||
result = generate_activity_report(database_results)
|
||||
|
||||
assert result["active"] == ["none"]
|
||||
assert result["inactive"] == ["none"]
|
||||
assert result["ignored"] == ["none"]
|
||||
|
||||
def test_split_doctor_report_small(self):
|
||||
"""Test splitting small report that doesn't need splitting."""
|
||||
report_text = "This is a small report that fits in one chunk."
|
||||
|
||||
result = split_doctor_report(report_text, 1000)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] == report_text
|
||||
|
||||
def test_split_doctor_report_large(self):
|
||||
"""Test splitting large report into chunks."""
|
||||
# Create a large report
|
||||
lines = [f"Line {i}: This is a test line for splitting" for i in range(100)]
|
||||
report_text = "\n".join(lines)
|
||||
|
||||
result = split_doctor_report(report_text, 100) # Small chunk size
|
||||
|
||||
assert len(result) > 1
|
||||
assert all(len(chunk) <= 100 for chunk in result)
|
||||
|
||||
# Verify all content is preserved (account for newlines)
|
||||
combined = "\n".join(result)
|
||||
assert combined == report_text
|
||||
|
||||
def test_split_doctor_report_with_sections(self):
|
||||
"""Test splitting report with section headers."""
|
||||
report_text = """<h3>Section 1</h3>
|
||||
This is section 1 content.
|
||||
<h3>Section 2</h3>
|
||||
This is section 2 content.
|
||||
<h3>Section 3</h3>
|
||||
This is section 3 content."""
|
||||
|
||||
result = split_doctor_report(report_text, 50) # Small chunk size
|
||||
|
||||
assert len(result) > 1
|
||||
assert all(len(chunk) <= 50 for chunk in result)
|
||||
|
||||
def test_format_ban_results_success(self):
|
||||
"""Test formatting ban results with successful bans."""
|
||||
ban_event_map = {
|
||||
"ban_list": {
|
||||
"@user1:example.com": ["Room 1", "Room 2"],
|
||||
"@user2:example.com": ["Room 3"]
|
||||
},
|
||||
"error_list": {}
|
||||
}
|
||||
|
||||
result = format_ban_results(ban_event_map)
|
||||
|
||||
assert "Banned @user1:example.com from: Room 1, Room 2" in result
|
||||
assert "Banned @user2:example.com from: Room 3" in result
|
||||
|
||||
def test_format_ban_results_with_errors(self):
|
||||
"""Test formatting ban results with errors."""
|
||||
ban_event_map = {
|
||||
"ban_list": {
|
||||
"@user1:example.com": ["Room 1"]
|
||||
},
|
||||
"error_list": {
|
||||
"@user2:example.com": ["Room 2", "Room 3"]
|
||||
}
|
||||
}
|
||||
|
||||
result = format_ban_results(ban_event_map)
|
||||
|
||||
assert "Banned @user1:example.com from: Room 1" in result
|
||||
assert "Failed to ban @user2:example.com from: Room 2, Room 3" in result
|
||||
|
||||
def test_format_ban_results_empty(self):
|
||||
"""Test formatting empty ban results."""
|
||||
ban_event_map = {
|
||||
"ban_list": {},
|
||||
"error_list": {}
|
||||
}
|
||||
|
||||
result = format_ban_results(ban_event_map)
|
||||
|
||||
assert result == "No ban operations performed"
|
||||
|
||||
def test_format_sync_results_success(self):
|
||||
"""Test formatting sync results with data."""
|
||||
sync_results = {
|
||||
"added": ["@user1:example.com", "@user2:example.com"],
|
||||
"dropped": ["@user3:example.com"]
|
||||
}
|
||||
|
||||
result = format_sync_results(sync_results)
|
||||
|
||||
assert "Added: @user1:example.com<br />@user2:example.com" in result
|
||||
assert "Dropped: @user3:example.com" in result
|
||||
|
||||
def test_format_sync_results_empty(self):
|
||||
"""Test formatting empty sync results."""
|
||||
sync_results = {
|
||||
"added": [],
|
||||
"dropped": []
|
||||
}
|
||||
|
||||
result = format_sync_results(sync_results)
|
||||
|
||||
assert "Added: none" in result
|
||||
assert "Dropped: none" in result
|
||||
|
||||
def test_format_sync_results_missing_keys(self):
|
||||
"""Test formatting sync results with missing keys."""
|
||||
sync_results = {}
|
||||
|
||||
result = format_sync_results(sync_results)
|
||||
|
||||
assert "Added: none" in result
|
||||
assert "Dropped: none" in result
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Tests for room utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from mautrix.types import EventType, PowerLevelStateEventContent
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
from community.helpers.room_utils import (
|
||||
validate_room_alias, validate_room_aliases, get_room_version_and_creators,
|
||||
is_modern_room_version, user_has_unlimited_power, get_moderators_and_above
|
||||
)
|
||||
|
||||
|
||||
class TestRoomUtils:
|
||||
"""Test cases for room utility functions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_alias_exists(self):
|
||||
"""Test alias validation when alias exists."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock()
|
||||
|
||||
# Alias exists - should return False
|
||||
result = await validate_room_alias(client, "test", "example.com")
|
||||
assert result == False
|
||||
client.resolve_room_alias.assert_called_once_with("#test:example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_alias_not_exists(self):
|
||||
"""Test alias validation when alias doesn't exist."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
|
||||
|
||||
# Alias doesn't exist - should return True
|
||||
result = await validate_room_alias(client, "test", "example.com")
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_alias_error(self):
|
||||
"""Test alias validation with error."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
# Error should return True (assume available)
|
||||
result = await validate_room_alias(client, "test", "example.com")
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_no_slug(self):
|
||||
"""Test alias validation without community slug."""
|
||||
client = Mock()
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "", "example.com")
|
||||
assert result == (False, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_success(self):
|
||||
"""Test successful alias validation."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", "example.com")
|
||||
assert result == (True, [])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_room_aliases_conflicts(self):
|
||||
"""Test alias validation with conflicts."""
|
||||
client = Mock()
|
||||
|
||||
def resolve_side_effect(alias):
|
||||
if "room1" in alias:
|
||||
return {"room_id": "!room1:example.com"} # Exists
|
||||
else:
|
||||
raise MNotFound() # Doesn't exist
|
||||
|
||||
client.resolve_room_alias = AsyncMock(side_effect=resolve_side_effect)
|
||||
|
||||
result = await validate_room_aliases(client, ["room1", "room2"], "test", "example.com")
|
||||
assert result == (False, ["#room1-test:example.com"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_room_version_and_creators_success(self):
|
||||
"""Test getting room version and creators successfully."""
|
||||
client = Mock()
|
||||
|
||||
# Mock state events
|
||||
create_event = Mock()
|
||||
create_event.type = EventType.ROOM_CREATE
|
||||
create_event.sender = "@creator:example.com"
|
||||
create_event.content = {
|
||||
"room_version": "12",
|
||||
"additional_creators": ["@creator2:example.com"]
|
||||
}
|
||||
|
||||
other_event = Mock()
|
||||
other_event.type = EventType.ROOM_POWER_LEVELS
|
||||
|
||||
client.get_state = AsyncMock(return_value=[create_event, other_event])
|
||||
|
||||
version, creators = await get_room_version_and_creators(client, "!room:example.com")
|
||||
|
||||
assert version == "12"
|
||||
assert "@creator:example.com" in creators
|
||||
assert "@creator2:example.com" in creators
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_room_version_and_creators_no_create_event(self):
|
||||
"""Test getting room version when no create event exists."""
|
||||
client = Mock()
|
||||
client.get_state = AsyncMock(return_value=[])
|
||||
|
||||
version, creators = await get_room_version_and_creators(client, "!room:example.com")
|
||||
|
||||
assert version == "1"
|
||||
assert creators == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_room_version_and_creators_error(self):
|
||||
"""Test getting room version with error."""
|
||||
client = Mock()
|
||||
client.get_state = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
version, creators = await get_room_version_and_creators(client, "!room:example.com")
|
||||
|
||||
assert version == "1"
|
||||
assert creators == []
|
||||
|
||||
def test_is_modern_room_version(self):
|
||||
"""Test modern room version detection."""
|
||||
assert is_modern_room_version("12") == True
|
||||
assert is_modern_room_version("13") == True
|
||||
assert is_modern_room_version("11") == False
|
||||
assert is_modern_room_version("1") == False
|
||||
assert is_modern_room_version("invalid") == False
|
||||
assert is_modern_room_version("") == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_unlimited_power_modern_room(self):
|
||||
"""Test unlimited power check in modern room."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.get_room_version_and_creators') as mock_get_version:
|
||||
mock_get_version.return_value = ("12", ["@user:example.com"])
|
||||
|
||||
result = await user_has_unlimited_power(client, "@user:example.com", "!room:example.com")
|
||||
assert result == True
|
||||
|
||||
result = await user_has_unlimited_power(client, "@other:example.com", "!room:example.com")
|
||||
assert result == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_unlimited_power_old_room(self):
|
||||
"""Test unlimited power check in old room."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.get_room_version_and_creators') as mock_get_version:
|
||||
mock_get_version.return_value = ("11", ["@user:example.com"])
|
||||
|
||||
result = await user_has_unlimited_power(client, "@user:example.com", "!room:example.com")
|
||||
assert result == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_has_unlimited_power_error(self):
|
||||
"""Test unlimited power check with error."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.get_room_version_and_creators') as mock_get_version:
|
||||
mock_get_version.side_effect = Exception("Network error")
|
||||
|
||||
result = await user_has_unlimited_power(client, "@user:example.com", "!room:example.com")
|
||||
assert result == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_moderators_and_above_success(self):
|
||||
"""Test getting moderators successfully."""
|
||||
client = Mock()
|
||||
|
||||
power_levels = Mock()
|
||||
power_levels.users = {
|
||||
"@user1:example.com": 50, # Moderator
|
||||
"@user2:example.com": 100, # Admin
|
||||
"@user3:example.com": 25, # Regular user
|
||||
"@user4:example.com": 75, # Above moderator
|
||||
}
|
||||
|
||||
client.get_state_event = AsyncMock(return_value=power_levels)
|
||||
|
||||
moderators = await get_moderators_and_above(client, "!room:example.com")
|
||||
|
||||
assert "@user1:example.com" in moderators
|
||||
assert "@user2:example.com" in moderators
|
||||
assert "@user4:example.com" in moderators
|
||||
assert "@user3:example.com" not in moderators
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_moderators_and_above_error(self):
|
||||
"""Test getting moderators with error."""
|
||||
client = Mock()
|
||||
client.get_state_event = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
moderators = await get_moderators_and_above(client, "!room:example.com")
|
||||
|
||||
assert moderators == []
|
||||
@@ -0,0 +1,223 @@
|
||||
"""Tests for user utility functions."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from mautrix.types import EventType, UserID
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
from community.helpers.user_utils import (
|
||||
check_if_banned, get_banlist_roomids, ban_user_from_rooms, user_permitted
|
||||
)
|
||||
|
||||
|
||||
class TestUserUtils:
|
||||
"""Test cases for user utility functions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_if_banned_success(self):
|
||||
"""Test successful ban check."""
|
||||
client = Mock()
|
||||
client.get_joined_rooms = AsyncMock(return_value=["!room1:example.com", "!room2:example.com"])
|
||||
|
||||
# Mock state events
|
||||
ban_rule = Mock()
|
||||
ban_rule.type.t = "m.policy.rule.user"
|
||||
ban_rule.content = {
|
||||
"entity": "@banned:example.com",
|
||||
"recommendation": "ban"
|
||||
}
|
||||
|
||||
client.get_state = AsyncMock(return_value=[ban_rule])
|
||||
|
||||
# Mock get_banlist_roomids to return the room ID directly
|
||||
with patch('community.helpers.user_utils.get_banlist_roomids') as mock_get_banlists:
|
||||
mock_get_banlists.return_value = ["!room1:example.com"]
|
||||
|
||||
logger = Mock()
|
||||
result = await check_if_banned(client, "@banned:example.com", ["!room1:example.com"], logger)
|
||||
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_if_banned_not_banned(self):
|
||||
"""Test ban check when user is not banned."""
|
||||
client = Mock()
|
||||
client.get_joined_rooms = AsyncMock(return_value=["!room1:example.com"])
|
||||
|
||||
with patch('community.helpers.user_utils.get_banlist_roomids') as mock_get_banlists:
|
||||
mock_get_banlists.return_value = ["!room1:example.com"]
|
||||
|
||||
# Mock state events with no ban rules
|
||||
client.get_state = AsyncMock(return_value=[])
|
||||
|
||||
logger = Mock()
|
||||
result = await check_if_banned(client, "@user:example.com", ["!room1:example.com"], logger)
|
||||
|
||||
assert result == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_if_banned_room_not_joined(self):
|
||||
"""Test ban check when bot is not in banlist room."""
|
||||
client = Mock()
|
||||
client.get_joined_rooms = AsyncMock(return_value=["!room2:example.com"])
|
||||
|
||||
with patch('community.helpers.user_utils.get_banlist_roomids') as mock_get_banlists:
|
||||
mock_get_banlists.return_value = ["!room1:example.com"]
|
||||
|
||||
logger = Mock()
|
||||
result = await check_if_banned(client, "@user:example.com", ["!room1:example.com"], logger)
|
||||
|
||||
assert result == False
|
||||
logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_banlist_roomids_aliases(self):
|
||||
"""Test getting banlist room IDs with aliases."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(return_value={"room_id": "!room1:example.com"})
|
||||
|
||||
banlists = ["#banlist1:example.com", "!room2:example.com"]
|
||||
logger = Mock()
|
||||
|
||||
result = await get_banlist_roomids(client, banlists, logger)
|
||||
|
||||
assert "!room1:example.com" in result
|
||||
assert "!room2:example.com" in result
|
||||
client.resolve_room_alias.assert_called_once_with("#banlist1:example.com")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_banlist_roomids_alias_error(self):
|
||||
"""Test getting banlist room IDs with alias resolution error."""
|
||||
client = Mock()
|
||||
client.resolve_room_alias = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
banlists = ["#banlist1:example.com", "!room2:example.com"]
|
||||
logger = Mock()
|
||||
|
||||
result = await get_banlist_roomids(client, banlists, logger)
|
||||
|
||||
assert "!room2:example.com" in result
|
||||
assert "!room1:example.com" not in result
|
||||
logger.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ban_user_from_rooms_success(self):
|
||||
"""Test successful user banning from rooms."""
|
||||
client = Mock()
|
||||
client.ban_user = AsyncMock()
|
||||
client.get_state_event = AsyncMock(return_value={"name": "Test Room"})
|
||||
|
||||
roomlist = ["!room1:example.com", "!room2:example.com"]
|
||||
logger = Mock()
|
||||
|
||||
result = await ban_user_from_rooms(
|
||||
client, "@user:example.com", roomlist, "banned", False, False, None, None, 0.1, logger
|
||||
)
|
||||
|
||||
assert "ban_list" in result
|
||||
assert "error_list" in result
|
||||
assert "@user:example.com" in result["ban_list"]
|
||||
assert len(result["ban_list"]["@user:example.com"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ban_user_from_rooms_with_redaction(self):
|
||||
"""Test user banning with message redaction."""
|
||||
client = Mock()
|
||||
client.ban_user = AsyncMock()
|
||||
client.get_state_event = AsyncMock(return_value={"name": "Test Room"})
|
||||
|
||||
# Mock message redaction
|
||||
mock_msg = Mock()
|
||||
mock_msg.event_id = "!msg123:example.com"
|
||||
get_messages_func = AsyncMock(return_value=[mock_msg])
|
||||
|
||||
database = Mock()
|
||||
database.execute = AsyncMock()
|
||||
|
||||
roomlist = ["!room1:example.com"]
|
||||
logger = Mock()
|
||||
|
||||
result = await ban_user_from_rooms(
|
||||
client, "@user:example.com", roomlist, "banned", False, True,
|
||||
get_messages_func, database, 0.1, logger
|
||||
)
|
||||
|
||||
assert "ban_list" in result
|
||||
database.execute.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ban_user_from_rooms_error(self):
|
||||
"""Test user banning with errors."""
|
||||
client = Mock()
|
||||
client.ban_user = AsyncMock(side_effect=Exception("Ban failed"))
|
||||
client.get_state_event = AsyncMock(return_value={"name": "Test Room"})
|
||||
|
||||
roomlist = ["!room1:example.com"]
|
||||
logger = Mock()
|
||||
|
||||
result = await ban_user_from_rooms(
|
||||
client, "@user:example.com", roomlist, "banned", False, False, None, None, 0.1, logger
|
||||
)
|
||||
|
||||
assert "error_list" in result
|
||||
assert "@user:example.com" in result["error_list"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_permitted_unlimited_power(self):
|
||||
"""Test user permission check with unlimited power."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.user_has_unlimited_power') as mock_unlimited:
|
||||
mock_unlimited.return_value = True
|
||||
|
||||
result = await user_permitted(client, "@user:example.com", "!parent:example.com", 50, None, None)
|
||||
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_permitted_sufficient_level(self):
|
||||
"""Test user permission check with sufficient power level."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.user_has_unlimited_power') as mock_unlimited:
|
||||
mock_unlimited.return_value = False
|
||||
|
||||
power_levels = Mock()
|
||||
power_levels.get_user_level.return_value = 75
|
||||
|
||||
client.get_state_event = AsyncMock(return_value=power_levels)
|
||||
|
||||
result = await user_permitted(client, "@user:example.com", "!parent:example.com", 50, None, None)
|
||||
|
||||
assert result == True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_permitted_insufficient_level(self):
|
||||
"""Test user permission check with insufficient power level."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.user_has_unlimited_power') as mock_unlimited:
|
||||
mock_unlimited.return_value = False
|
||||
|
||||
power_levels = Mock()
|
||||
power_levels.get_user_level.return_value = 25
|
||||
|
||||
client.get_state_event = AsyncMock(return_value=power_levels)
|
||||
|
||||
result = await user_permitted(client, "@user:example.com", "!parent:example.com", 50, None, None)
|
||||
|
||||
assert result == False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_permitted_error(self):
|
||||
"""Test user permission check with error."""
|
||||
client = Mock()
|
||||
|
||||
with patch('community.helpers.room_utils.user_has_unlimited_power') as mock_unlimited:
|
||||
mock_unlimited.side_effect = Exception("Network error")
|
||||
|
||||
logger = Mock()
|
||||
result = await user_permitted(client, "@user:example.com", "!parent:example.com", 50, None, logger)
|
||||
|
||||
assert result == False
|
||||
logger.error.assert_called()
|
||||
Reference in New Issue
Block a user