more refactoring

This commit is contained in:
William Kray
2025-09-09 14:49:45 -07:00
parent 6582112dfb
commit 87e02b7ea6
28 changed files with 4664 additions and 894 deletions
+183
View File
@@ -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
View File
@@ -35,24 +35,18 @@ warn_threshold_days: 30
# subject to removal by the purge command. # subject to removal by the purge command.
kick_threshold_days: 60 kick_threshold_days: 60
# track users? if false, will disable all tracking and avoid writing anything to the database. # track users? empty list disables all tracking, otherwise specify what to track:
track_users: True # - "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 # Note: Admin and moderator permissions are now managed through power levels
# inactive users in your community # in the parent room. Set user power levels directly in the parent space.
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: []
# list of users who should be invited to new rooms immediately (other bots, moderators, perhaps) # list of users who should be invited to new rooms immediately (other bots, moderators, perhaps)
# use full matrix IDs here # use full matrix IDs here
+165 -859
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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
+266
View File
@@ -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
+89
View File
@@ -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
+270
View File
@@ -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()
}
+251
View File
@@ -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
+51
View File
@@ -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
+362
View File
@@ -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 &lt;room_id&gt;</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 />"
+98
View File
@@ -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)
+154
View File
@@ -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}"
+240
View File
@@ -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)
+269
View File
@@ -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}")
+170
View File
@@ -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 []
+199
View File
@@ -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)
+11 -17
View File
@@ -93,24 +93,18 @@ plugin_config:
# subject to removal by the purge command. # subject to removal by the purge command.
kick_threshold_days: 60 kick_threshold_days: 60
# track users? if false, will disable all tracking and avoid writing anything to the database. # track users? empty list disables all tracking, otherwise specify what to track:
track_users: True # - "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 # Note: Admin and moderator permissions are now managed through power levels
# inactive users in your community # in the parent room. Set user power levels directly in the parent space.
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: []
# list of users who should be invited to new rooms immediately (other bots, moderators, perhaps) # list of users who should be invited to new rooms immediately (other bots, moderators, perhaps)
# use full matrix IDs here # use full matrix IDs here
+1 -1
View File
@@ -1,6 +1,6 @@
maubot: 0.1.0 maubot: 0.1.0
id: org.jobmachine.communitybot id: org.jobmachine.communitybot
version: 0.2.12 version: 0.3.0
license: MIT license: MIT
modules: modules:
- community - community
+6
View File
@@ -0,0 +1,6 @@
[tool:pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
Executable
+35
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
# Test package for community bot
+430
View File
@@ -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()
+452
View File
@@ -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()
+253
View File
@@ -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()
+106
View File
@@ -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(" ") == ""
+163
View File
@@ -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
+203
View File
@@ -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 == []
+223
View File
@@ -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()