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
+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)