more refactoring
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# Helper modules for community bot
|
||||
from . import message_utils, room_utils, user_utils, database_utils, report_utils, decorators, common_utils, room_creation_utils, config_manager, response_builder, diagnostic_utils, base_command_handler
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Base command handler for common command patterns."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional
|
||||
from mautrix.types import MessageEvent, UserID
|
||||
from .decorators import require_permission, require_parent_room, handle_errors
|
||||
|
||||
|
||||
class BaseCommandHandler(ABC):
|
||||
"""Base class for command handlers with common patterns."""
|
||||
|
||||
def __init__(self, bot):
|
||||
"""Initialize with bot instance.
|
||||
|
||||
Args:
|
||||
bot: CommunityBot instance
|
||||
"""
|
||||
self.bot = bot
|
||||
self.client = bot.client
|
||||
self.config = bot.config
|
||||
self.config_manager = bot.config_manager
|
||||
self.log = bot.log
|
||||
self.database = bot.database
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
async def check_permissions(self, evt: MessageEvent, min_level: int = 50, room_id: str = None) -> bool:
|
||||
"""Check if user has required permissions.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
min_level: Minimum required power level
|
||||
room_id: Room ID to check permissions in
|
||||
|
||||
Returns:
|
||||
bool: True if user has permissions
|
||||
"""
|
||||
return await self.bot.user_permitted(evt.sender, min_level, room_id)
|
||||
|
||||
async def check_parent_room(self, evt: MessageEvent) -> bool:
|
||||
"""Check if parent room is configured.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
|
||||
Returns:
|
||||
bool: True if parent room is configured
|
||||
"""
|
||||
return await self.bot.check_parent_room(evt)
|
||||
|
||||
async def reply_error(self, evt: MessageEvent, message: str) -> None:
|
||||
"""Reply with an error message.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
message: Error message
|
||||
"""
|
||||
await evt.reply(message)
|
||||
|
||||
async def reply_success(self, evt: MessageEvent, message: str) -> None:
|
||||
"""Reply with a success message.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
message: Success message
|
||||
"""
|
||||
await evt.reply(message)
|
||||
|
||||
async def respond_html(self, evt: MessageEvent, message: str, edits: Optional[MessageEvent] = None) -> None:
|
||||
"""Respond with HTML content.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
message: HTML message
|
||||
edits: Optional message to edit
|
||||
"""
|
||||
await evt.respond(message, allow_html=True, edits=edits)
|
||||
|
||||
def is_tracking_enabled(self) -> bool:
|
||||
"""Check if user tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if tracking is enabled
|
||||
"""
|
||||
return self.config_manager.is_tracking_enabled()
|
||||
|
||||
def is_verification_enabled(self) -> bool:
|
||||
"""Check if verification is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if verification is enabled
|
||||
"""
|
||||
return self.config_manager.is_verification_enabled()
|
||||
|
||||
def get_parent_room(self) -> Optional[str]:
|
||||
"""Get the parent room ID.
|
||||
|
||||
Returns:
|
||||
str: Parent room ID or None
|
||||
"""
|
||||
return self.config_manager.get_parent_room()
|
||||
|
||||
|
||||
class TrackingCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for commands that require user tracking."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with tracking check."""
|
||||
if not self.is_tracking_enabled():
|
||||
await self.reply_error(evt, "user tracking is disabled")
|
||||
return
|
||||
return await self.execute_tracking_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_tracking_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the tracking command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class AdminCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for admin-only commands."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with admin permission check."""
|
||||
if not await self.check_permissions(evt, min_level=100):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_admin_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_admin_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the admin command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ModeratorCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for moderator commands."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with moderator permission check."""
|
||||
if not await self.check_permissions(evt, min_level=50):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_moderator_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_moderator_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the moderator command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SpaceCommandHandler(BaseCommandHandler):
|
||||
"""Base handler for commands that require parent space."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with parent space check."""
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
return await self.execute_space_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_space_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the space command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SpaceModeratorCommandHandler(SpaceCommandHandler, ModeratorCommandHandler):
|
||||
"""Base handler for commands that require both parent space and moderator permissions."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with both space and moderator checks."""
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
if not await self.check_permissions(evt, min_level=50):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_space_moderator_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_space_moderator_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the space moderator command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class SpaceAdminCommandHandler(SpaceCommandHandler, AdminCommandHandler):
|
||||
"""Base handler for commands that require both parent space and admin permissions."""
|
||||
|
||||
async def execute(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute command with both space and admin checks."""
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
if not await self.check_permissions(evt, min_level=100):
|
||||
await self.reply_error(evt, "You don't have permission to use this command")
|
||||
return
|
||||
return await self.execute_space_admin_command(evt, *args, **kwargs)
|
||||
|
||||
@abstractmethod
|
||||
async def execute_space_admin_command(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
"""Execute the space admin command logic.
|
||||
|
||||
Args:
|
||||
evt: Message event
|
||||
*args: Command arguments
|
||||
**kwargs: Additional keyword arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Common utility functions for bot operations."""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from mautrix.types import EventType, MessageEvent
|
||||
|
||||
|
||||
async def get_room_name(client, room_id: str, logger) -> Optional[str]:
|
||||
"""Get room name from room ID.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: Room ID to get name for
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
str: Room name or None if not found/error
|
||||
"""
|
||||
try:
|
||||
room_name_event = await client.get_state_event(room_id, EventType.ROOM_NAME)
|
||||
return room_name_event.name if room_name_event else None
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get room name for {room_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_room_power_levels(client, room_id: str, logger) -> Optional[Any]:
|
||||
"""Get power levels for a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: Room ID to get power levels for
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
PowerLevelStateEventContent or None if error
|
||||
"""
|
||||
try:
|
||||
return await client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not get power levels for {room_id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def check_room_membership(client, room_id: str, user_id: str, logger) -> bool:
|
||||
"""Check if a user is a member of a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: Room ID to check
|
||||
user_id: User ID to check
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
bool: True if user is a member, False otherwise
|
||||
"""
|
||||
try:
|
||||
await client.get_state_event(room_id, EventType.ROOM_MEMBER, user_id)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def format_room_info(room_id: str, room_name: Optional[str] = None) -> str:
|
||||
"""Format room information for display.
|
||||
|
||||
Args:
|
||||
room_id: Room ID
|
||||
room_name: Optional room name
|
||||
|
||||
Returns:
|
||||
str: Formatted room info
|
||||
"""
|
||||
if room_name:
|
||||
return f"{room_name} ({room_id})"
|
||||
return room_id
|
||||
|
||||
|
||||
def safe_get(dictionary: Dict[str, Any], key: str, default: Any = None) -> Any:
|
||||
"""Safely get a value from a dictionary with a default.
|
||||
|
||||
Args:
|
||||
dictionary: Dictionary to get value from
|
||||
key: Key to look up
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Value from dictionary or default
|
||||
"""
|
||||
return dictionary.get(key, default) if dictionary else default
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Configuration management utilities for the community bot."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
"""Centralized configuration management for the community bot."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initialize with bot configuration.
|
||||
|
||||
Args:
|
||||
config: Bot configuration dictionary
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def is_tracking_enabled(self) -> bool:
|
||||
"""Check if user tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if tracking is enabled
|
||||
"""
|
||||
track_users = self.config.get("track_users", [])
|
||||
|
||||
# Handle legacy boolean configuration
|
||||
if isinstance(track_users, bool):
|
||||
return track_users
|
||||
|
||||
# Handle new list configuration
|
||||
return isinstance(track_users, list) and len(track_users) > 0
|
||||
|
||||
def is_message_tracking_enabled(self) -> bool:
|
||||
"""Check if message tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if message tracking is enabled
|
||||
"""
|
||||
track_users = self.config.get("track_users", [])
|
||||
|
||||
# Handle legacy boolean configuration - if True, enable both messages and reactions
|
||||
if isinstance(track_users, bool):
|
||||
return track_users
|
||||
|
||||
# Handle new list configuration
|
||||
return isinstance(track_users, list) and "messages" in track_users
|
||||
|
||||
def is_reaction_tracking_enabled(self) -> bool:
|
||||
"""Check if reaction tracking is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if reaction tracking is enabled
|
||||
"""
|
||||
track_users = self.config.get("track_users", [])
|
||||
|
||||
# Handle legacy boolean configuration - if True, enable both messages and reactions
|
||||
if isinstance(track_users, bool):
|
||||
return track_users
|
||||
|
||||
# Handle new list configuration
|
||||
return isinstance(track_users, list) and "reactions" in track_users
|
||||
|
||||
def is_verification_enabled(self) -> bool:
|
||||
"""Check if verification is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if verification is enabled
|
||||
"""
|
||||
return self.config.get("verification_enabled", False)
|
||||
|
||||
def is_proactive_banning_enabled(self) -> bool:
|
||||
"""Check if proactive banning is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if proactive banning is enabled
|
||||
"""
|
||||
return self.config.get("proactive_banning", False)
|
||||
|
||||
def is_encryption_enabled(self) -> bool:
|
||||
"""Check if encryption is enabled by default.
|
||||
|
||||
Returns:
|
||||
bool: True if encryption is enabled
|
||||
"""
|
||||
return self.config.get("encrypt", False)
|
||||
|
||||
def get_room_version(self) -> str:
|
||||
"""Get the configured room version.
|
||||
|
||||
Returns:
|
||||
str: Room version string
|
||||
"""
|
||||
return self.config.get("room_version", "1")
|
||||
|
||||
def get_community_slug(self) -> Optional[str]:
|
||||
"""Get the community slug.
|
||||
|
||||
Returns:
|
||||
str: Community slug or None if not configured
|
||||
"""
|
||||
return self.config.get("community_slug")
|
||||
|
||||
def get_parent_room(self) -> Optional[str]:
|
||||
"""Get the parent room ID.
|
||||
|
||||
Returns:
|
||||
str: Parent room ID or None if not configured
|
||||
"""
|
||||
return self.config.get("parent_room")
|
||||
|
||||
def get_invitees(self) -> List[str]:
|
||||
"""Get the list of users to invite to new rooms.
|
||||
|
||||
Returns:
|
||||
List[str]: List of user IDs to invite
|
||||
"""
|
||||
return self.config.get("invitees", [])
|
||||
|
||||
def get_invite_power_level(self) -> int:
|
||||
"""Get the power level required to invite users.
|
||||
|
||||
Returns:
|
||||
int: Power level for inviting users
|
||||
"""
|
||||
return self.config.get("invite_power_level", 50)
|
||||
|
||||
def get_sleep_duration(self) -> float:
|
||||
"""Get the sleep duration between operations.
|
||||
|
||||
Returns:
|
||||
float: Sleep duration in seconds
|
||||
"""
|
||||
return self.config.get("sleep", 1.0)
|
||||
|
||||
def get_welcome_sleep_duration(self) -> float:
|
||||
"""Get the sleep duration for welcome messages.
|
||||
|
||||
Returns:
|
||||
float: Welcome sleep duration in seconds
|
||||
"""
|
||||
return self.config.get("welcome_sleep", 2.0)
|
||||
|
||||
def get_warn_threshold_days(self) -> int:
|
||||
"""Get the warning threshold for inactive users.
|
||||
|
||||
Returns:
|
||||
int: Number of days before warning
|
||||
"""
|
||||
return self.config.get("warn_threshold_days", 30)
|
||||
|
||||
def get_kick_threshold_days(self) -> int:
|
||||
"""Get the kick threshold for inactive users.
|
||||
|
||||
Returns:
|
||||
int: Number of days before kicking
|
||||
"""
|
||||
return self.config.get("kick_threshold_days", 60)
|
||||
|
||||
def get_verification_phrase(self) -> str:
|
||||
"""Get the verification phrase.
|
||||
|
||||
Returns:
|
||||
str: Verification phrase
|
||||
"""
|
||||
return self.config.get("verification_phrase", "I agree to the rules")
|
||||
|
||||
def get_verification_attempts(self) -> int:
|
||||
"""Get the maximum verification attempts.
|
||||
|
||||
Returns:
|
||||
int: Maximum verification attempts
|
||||
"""
|
||||
return self.config.get("verification_attempts", 3)
|
||||
|
||||
def get_verification_timeout(self) -> int:
|
||||
"""Get the verification timeout in seconds.
|
||||
|
||||
Returns:
|
||||
int: Verification timeout in seconds
|
||||
"""
|
||||
return self.config.get("verification_timeout", 300)
|
||||
|
||||
|
||||
def get_banlist_rooms(self) -> List[str]:
|
||||
"""Get the list of banlist rooms.
|
||||
|
||||
Returns:
|
||||
List[str]: List of banlist room IDs or aliases
|
||||
"""
|
||||
return self.config.get("banlist_rooms", [])
|
||||
|
||||
def get_redaction_rooms(self) -> List[str]:
|
||||
"""Get the list of rooms for redaction.
|
||||
|
||||
Returns:
|
||||
List[str]: List of room IDs for redaction
|
||||
"""
|
||||
return self.config.get("redaction_rooms", [])
|
||||
|
||||
def validate_required_configs(self) -> List[str]:
|
||||
"""Validate that all required configurations are present.
|
||||
|
||||
Returns:
|
||||
List[str]: List of missing required configuration keys
|
||||
"""
|
||||
required_configs = [
|
||||
"parent_room",
|
||||
"room_version",
|
||||
"community_slug"
|
||||
]
|
||||
|
||||
missing = []
|
||||
for config_key in required_configs:
|
||||
if not self.config.get(config_key):
|
||||
missing.append(config_key)
|
||||
|
||||
return missing
|
||||
|
||||
def is_modern_room_version(self) -> bool:
|
||||
"""Check if the configured room version is modern (12+).
|
||||
|
||||
Returns:
|
||||
bool: True if room version is 12 or higher
|
||||
"""
|
||||
try:
|
||||
version = int(self.get_room_version())
|
||||
return version >= 12
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
def get_room_creation_settings(self) -> Dict[str, Any]:
|
||||
"""Get settings specific to room creation.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Room creation settings
|
||||
"""
|
||||
return {
|
||||
"room_version": self.get_room_version(),
|
||||
"community_slug": self.get_community_slug(),
|
||||
"invitees": self.get_invitees(),
|
||||
"invite_power_level": self.get_invite_power_level(),
|
||||
"encrypt": self.is_encryption_enabled(),
|
||||
"parent_room": self.get_parent_room()
|
||||
}
|
||||
|
||||
def get_tracking_settings(self) -> Dict[str, Any]:
|
||||
"""Get settings specific to user tracking.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Tracking settings
|
||||
"""
|
||||
return {
|
||||
"track_users": self.config.get("track_users", []),
|
||||
"track_messages": self.is_message_tracking_enabled(),
|
||||
"track_reactions": self.is_reaction_tracking_enabled(),
|
||||
"warn_threshold_days": self.get_warn_threshold_days(),
|
||||
"kick_threshold_days": self.get_kick_threshold_days()
|
||||
}
|
||||
|
||||
def get_verification_settings(self) -> Dict[str, Any]:
|
||||
"""Get settings specific to verification.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Verification settings
|
||||
"""
|
||||
return {
|
||||
"verification_enabled": self.is_verification_enabled(),
|
||||
"verification_phrase": self.get_verification_phrase(),
|
||||
"verification_attempts": self.get_verification_attempts(),
|
||||
"verification_timeout": self.get_verification_timeout()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Database utility functions."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
from mautrix.types import PaginationDirection
|
||||
|
||||
|
||||
async def get_messages_to_redact(client, room_id: str, mxid: str, logger) -> List:
|
||||
"""Get messages from a user in a room that should be redacted.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: The room ID to search in
|
||||
mxid: The user ID whose messages to find
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
list: List of message events to redact
|
||||
"""
|
||||
try:
|
||||
messages = await client.get_messages(
|
||||
room_id,
|
||||
limit=100,
|
||||
filter_json={"senders": [mxid], "not_types": ["m.room.redaction"]},
|
||||
direction=PaginationDirection.BACKWARD,
|
||||
)
|
||||
# Filter out events with empty content
|
||||
filtered_events = [
|
||||
event
|
||||
for event in messages.events
|
||||
if event.content and event.content.serialize()
|
||||
]
|
||||
logger.debug(
|
||||
f"DEBUG found {len(filtered_events)} messages to redact in {room_id} (after filtering empty content)"
|
||||
)
|
||||
return filtered_events
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting messages to redact: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def redact_messages(client, database, room_id: str, sleep_time: float, logger) -> Dict[str, int]:
|
||||
"""Redact messages queued for redaction in a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
database: Database instance
|
||||
room_id: The room ID to redact messages in
|
||||
sleep_time: Sleep time between redactions
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
dict: Counters for successful and failed redactions
|
||||
"""
|
||||
counters = {"success": 0, "failure": 0}
|
||||
events = await database.fetch(
|
||||
"SELECT event_id FROM redaction_tasks WHERE room_id = $1", room_id
|
||||
)
|
||||
for event in events:
|
||||
try:
|
||||
await client.redact(
|
||||
room_id, event["event_id"], reason="content removed"
|
||||
)
|
||||
counters["success"] += 1
|
||||
await database.execute(
|
||||
"DELETE FROM redaction_tasks WHERE event_id = $1", event["event_id"]
|
||||
)
|
||||
await asyncio.sleep(sleep_time)
|
||||
except Exception as e:
|
||||
if "Too Many Requests" in str(e):
|
||||
logger.warning(
|
||||
f"Rate limited while redacting messages in {room_id}, will try again in next loop"
|
||||
)
|
||||
return counters
|
||||
logger.error(f"Failed to redact message: {e}")
|
||||
counters["failure"] += 1
|
||||
await asyncio.sleep(sleep_time)
|
||||
return counters
|
||||
|
||||
|
||||
async def upsert_user_timestamp(database, mxid: str, timestamp: int, logger) -> None:
|
||||
"""Insert or update user activity timestamp.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
mxid: User Matrix ID
|
||||
timestamp: Activity timestamp
|
||||
logger: Logger instance for error reporting
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"""
|
||||
INSERT INTO user_events (mxid, last_message_timestamp, ignore_inactivity)
|
||||
VALUES ($1, $2, 0)
|
||||
ON CONFLICT (mxid) DO UPDATE SET
|
||||
last_message_timestamp = EXCLUDED.last_message_timestamp
|
||||
""",
|
||||
mxid,
|
||||
timestamp,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to upsert user timestamp: {e}")
|
||||
|
||||
|
||||
async def get_inactive_users(database, warn_threshold_days: int, kick_threshold_days: int,
|
||||
logger) -> Dict[str, List[str]]:
|
||||
"""Get lists of users who should be warned or kicked for inactivity.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
warn_threshold_days: Days threshold for warning
|
||||
kick_threshold_days: Days threshold for kicking
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
dict: Contains 'warn' and 'kick' lists of user IDs
|
||||
"""
|
||||
try:
|
||||
current_time = int(time.time())
|
||||
warn_threshold = current_time - (warn_threshold_days * 24 * 60 * 60)
|
||||
kick_threshold = current_time - (kick_threshold_days * 24 * 60 * 60)
|
||||
|
||||
# Get users to warn
|
||||
warn_results = await database.fetch(
|
||||
"""
|
||||
SELECT mxid FROM user_events
|
||||
WHERE last_message_timestamp < $1
|
||||
AND last_message_timestamp > $2
|
||||
AND ignore_inactivity = 0
|
||||
""",
|
||||
warn_threshold,
|
||||
kick_threshold,
|
||||
)
|
||||
|
||||
# Get users to kick
|
||||
kick_results = await database.fetch(
|
||||
"""
|
||||
SELECT mxid FROM user_events
|
||||
WHERE last_message_timestamp < $2
|
||||
AND ignore_inactivity = 0
|
||||
""",
|
||||
kick_threshold,
|
||||
)
|
||||
|
||||
return {
|
||||
"warn": [row["mxid"] for row in warn_results],
|
||||
"kick": [row["mxid"] for row in kick_results]
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get inactive users: {e}")
|
||||
return {"warn": [], "kick": []}
|
||||
|
||||
|
||||
async def cleanup_stale_verification_states(database, logger) -> None:
|
||||
"""Clean up stale verification states older than 24 hours.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
logger: Logger instance for error reporting
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"""
|
||||
DELETE FROM verification_states
|
||||
WHERE created_at < NOW() - INTERVAL '24 hours'
|
||||
"""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to cleanup stale verification states: {e}")
|
||||
|
||||
|
||||
async def get_verification_state(database, dm_room_id: str) -> Dict[str, Any]:
|
||||
"""Get verification state for a DM room.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
|
||||
Returns:
|
||||
dict: Verification state data or None if not found
|
||||
"""
|
||||
try:
|
||||
result = await database.fetchrow(
|
||||
"SELECT * FROM verification_states WHERE dm_room_id = $1",
|
||||
dm_room_id
|
||||
)
|
||||
return dict(result) if result else None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
async def create_verification_state(database, dm_room_id: str, user_id: str,
|
||||
target_room_id: str, verification_phrase: str,
|
||||
attempts_remaining: int, required_power_level: int) -> None:
|
||||
"""Create a new verification state.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
user_id: The user ID being verified
|
||||
target_room_id: The target room ID
|
||||
verification_phrase: The phrase to verify
|
||||
attempts_remaining: Number of attempts remaining
|
||||
required_power_level: Required power level for the target room
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"""
|
||||
INSERT INTO verification_states
|
||||
(dm_room_id, user_id, target_room_id, verification_phrase, attempts_remaining, required_power_level)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
dm_room_id, user_id, target_room_id, verification_phrase,
|
||||
attempts_remaining, required_power_level
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Verification state creation is not critical
|
||||
|
||||
|
||||
async def update_verification_attempts(database, dm_room_id: str, attempts_remaining: int) -> None:
|
||||
"""Update verification attempts remaining.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
attempts_remaining: New number of attempts remaining
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"UPDATE verification_states SET attempts_remaining = $1 WHERE dm_room_id = $2",
|
||||
attempts_remaining, dm_room_id
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Verification state update is not critical
|
||||
|
||||
|
||||
async def delete_verification_state(database, dm_room_id: str) -> None:
|
||||
"""Delete a verification state.
|
||||
|
||||
Args:
|
||||
database: Database instance
|
||||
dm_room_id: The DM room ID
|
||||
"""
|
||||
try:
|
||||
await database.execute(
|
||||
"DELETE FROM verification_states WHERE dm_room_id = $1",
|
||||
dm_room_id
|
||||
)
|
||||
except Exception as e:
|
||||
pass # Verification state deletion is not critical
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Decorators for common bot operations."""
|
||||
|
||||
import functools
|
||||
from typing import Callable, Any, Optional
|
||||
from mautrix.types import UserID, MessageEvent
|
||||
|
||||
|
||||
def require_permission(min_level: int = 50, room_id: Optional[str] = None):
|
||||
"""Decorator to require user permission for command execution.
|
||||
|
||||
Args:
|
||||
min_level: Minimum required power level (default 50 for moderator)
|
||||
room_id: Room ID to check permissions in (None for parent room)
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
if not await self.user_permitted(evt.sender, min_level, room_id):
|
||||
await evt.reply("You don't have permission to use this command")
|
||||
return
|
||||
return await func(self, evt, *args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def require_parent_room(func: Callable) -> Callable:
|
||||
"""Decorator to require parent room to be configured."""
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
if not await self.check_parent_room(evt):
|
||||
return
|
||||
return await func(self, evt, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
|
||||
def handle_errors(error_message: str = "An error occurred"):
|
||||
"""Decorator to handle common errors in command execution.
|
||||
|
||||
Args:
|
||||
error_message: Default error message to show to user
|
||||
"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
async def wrapper(self, evt: MessageEvent, *args, **kwargs) -> Any:
|
||||
try:
|
||||
return await func(self, evt, *args, **kwargs)
|
||||
except Exception as e:
|
||||
self.log.error(f"Error in {func.__name__}: {e}")
|
||||
await evt.reply(f"{error_message}: {e}")
|
||||
return wrapper
|
||||
return decorator
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Diagnostic utility functions for the community bot."""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from mautrix.types import EventType
|
||||
from mautrix.client import Client
|
||||
|
||||
|
||||
async def check_space_permissions(
|
||||
client: Client,
|
||||
parent_room: str,
|
||||
logger
|
||||
) -> Dict[str, Any]:
|
||||
"""Check bot permissions in the parent space.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
parent_room: Parent room ID
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
Dict containing space permission information
|
||||
"""
|
||||
try:
|
||||
space_power_levels = await client.get_state_event(
|
||||
parent_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
bot_level = space_power_levels.get_user_level(client.mxid)
|
||||
|
||||
space_info = {
|
||||
"room_id": parent_room,
|
||||
"bot_power_level": bot_level,
|
||||
"has_admin": bot_level >= 100,
|
||||
"users_higher_or_equal": [],
|
||||
"users_equal": [],
|
||||
"users_higher": []
|
||||
}
|
||||
|
||||
# Check for users with equal or higher power level
|
||||
for user, level in space_power_levels.users.items():
|
||||
if user != client.mxid and level >= bot_level:
|
||||
if level == bot_level:
|
||||
space_info["users_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
else:
|
||||
space_info["users_higher"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
space_info["users_higher_or_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
|
||||
return space_info
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check space permissions: {e}")
|
||||
return {
|
||||
"room_id": parent_room,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
async def check_room_permissions(
|
||||
client: Client,
|
||||
room_id: str,
|
||||
logger
|
||||
) -> Dict[str, Any]:
|
||||
"""Check bot permissions in a specific room.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
room_id: Room ID to check
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
Dict containing room permission information
|
||||
"""
|
||||
try:
|
||||
# Check if bot is in the room
|
||||
try:
|
||||
await client.get_state_event(room_id, EventType.ROOM_MEMBER, client.mxid)
|
||||
except:
|
||||
return {
|
||||
"room_id": room_id,
|
||||
"error": "Bot not in room"
|
||||
}
|
||||
|
||||
# Get power levels
|
||||
room_power_levels = await client.get_state_event(
|
||||
room_id, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
bot_level = room_power_levels.get_user_level(client.mxid)
|
||||
|
||||
# Get room name if available
|
||||
room_name = room_id
|
||||
try:
|
||||
from .common_utils import get_room_name
|
||||
room_name = await get_room_name(client, room_id, logger) or room_id
|
||||
except:
|
||||
pass
|
||||
|
||||
# Get room version and creators
|
||||
from .room_utils import get_room_version_and_creators
|
||||
room_version, creators = await get_room_version_and_creators(client, room_id, logger)
|
||||
|
||||
# Check if bot has unlimited power (creator in modern room versions)
|
||||
from .room_utils import user_has_unlimited_power
|
||||
bot_has_unlimited_power = await user_has_unlimited_power(client, client.mxid, room_id)
|
||||
|
||||
room_report = {
|
||||
"room_id": room_id,
|
||||
"room_name": room_name,
|
||||
"room_version": room_version,
|
||||
"creators": creators,
|
||||
"bot_power_level": bot_level,
|
||||
"has_admin": bot_level >= 100 or bot_has_unlimited_power,
|
||||
"bot_has_unlimited_power": bot_has_unlimited_power,
|
||||
"users_higher_or_equal": [],
|
||||
"users_equal": [],
|
||||
"users_higher": []
|
||||
}
|
||||
|
||||
# Check for users with equal or higher power level
|
||||
for user, level in room_power_levels.users.items():
|
||||
if user != client.mxid and level >= bot_level:
|
||||
if level == bot_level:
|
||||
room_report["users_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
else:
|
||||
room_report["users_higher"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
room_report["users_higher_or_equal"].append({
|
||||
"user": user,
|
||||
"level": level
|
||||
})
|
||||
|
||||
return room_report
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check room permissions for {room_id}: {e}")
|
||||
return {
|
||||
"room_id": room_id,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def analyze_room_data(
|
||||
room_data: Dict[str, Any],
|
||||
is_modern_room_version_func
|
||||
) -> Tuple[str, str, bool, bool, bool]:
|
||||
"""Analyze room data to determine status and categorization.
|
||||
|
||||
Args:
|
||||
room_data: Room data dictionary
|
||||
is_modern_room_version_func: Function to check if room version is modern
|
||||
|
||||
Returns:
|
||||
Tuple of (status, category, is_admin, is_modern, has_error)
|
||||
"""
|
||||
if "error" in room_data:
|
||||
if room_data["error"] == "Bot not in room":
|
||||
return "not_in_room", "error", False, False, True
|
||||
else:
|
||||
return "error", "error", False, False, True
|
||||
|
||||
# Check if modern room version
|
||||
is_modern = is_modern_room_version_func(room_data.get("room_version", "1"))
|
||||
|
||||
# Check admin status
|
||||
is_admin = room_data.get("has_admin", False)
|
||||
|
||||
if is_admin:
|
||||
return "admin", "admin", True, is_modern, False
|
||||
else:
|
||||
return "no_admin", "problematic", False, is_modern, False
|
||||
|
||||
|
||||
def generate_space_summary(
|
||||
space_data: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Generate HTML summary for space permissions.
|
||||
|
||||
Args:
|
||||
space_data: Space permission data
|
||||
|
||||
Returns:
|
||||
str: HTML formatted space summary
|
||||
"""
|
||||
if "error" in space_data:
|
||||
return f"<h4>📋 Parent Space</h4><br />❌ <b>Error:</b> {space_data['error']}<br /><br />"
|
||||
|
||||
space_status = "✅" if space_data.get("has_admin", False) else "❌"
|
||||
response = f"<h4>📋 Parent Space</h4><br />"
|
||||
response += f"{space_status} <b>Administrative privileges:</b> {'Yes' if space_data['has_admin'] else 'No'} (level: {space_data['bot_power_level']})<br />"
|
||||
|
||||
if space_data.get("users_higher"):
|
||||
response += f"⚠️ <b>Users with higher power:</b> {', '.join([f'{u['user']} ({u['level']})' for u in space_data['users_higher']])}<br />"
|
||||
if space_data.get("users_equal"):
|
||||
response += f"⚠️ <b>Users with equal power:</b> {', '.join([f'{u['user']} ({u['level']})' for u in space_data['users_equal']])}<br />"
|
||||
|
||||
response += "<br />"
|
||||
return response
|
||||
|
||||
|
||||
def generate_room_summary(
|
||||
rooms_data: Dict[str, Any],
|
||||
is_modern_room_version_func
|
||||
) -> Tuple[str, Dict[str, int]]:
|
||||
"""Generate HTML summary for room permissions.
|
||||
|
||||
Args:
|
||||
rooms_data: Dictionary of room data
|
||||
is_modern_room_version_func: Function to check if room version is modern
|
||||
|
||||
Returns:
|
||||
Tuple of (HTML response, statistics dict)
|
||||
"""
|
||||
problematic_rooms = []
|
||||
stats = {
|
||||
"admin_rooms": 0,
|
||||
"non_admin_rooms": 0,
|
||||
"error_rooms": 0,
|
||||
"not_in_room_count": 0,
|
||||
"modern_rooms": 0,
|
||||
"legacy_rooms": 0
|
||||
}
|
||||
|
||||
for room_id, room_data in rooms_data.items():
|
||||
status, category, is_admin, is_modern, has_error = analyze_room_data(
|
||||
room_data, is_modern_room_version_func
|
||||
)
|
||||
|
||||
# Update statistics
|
||||
if has_error:
|
||||
stats["error_rooms"] += 1
|
||||
if room_data.get("error") == "Bot not in room":
|
||||
stats["not_in_room_count"] += 1
|
||||
else:
|
||||
if is_admin:
|
||||
stats["admin_rooms"] += 1
|
||||
else:
|
||||
stats["non_admin_rooms"] += 1
|
||||
|
||||
if is_modern:
|
||||
stats["modern_rooms"] += 1
|
||||
else:
|
||||
stats["legacy_rooms"] += 1
|
||||
|
||||
# Generate room info for problematic rooms
|
||||
if category in ["error", "problematic"] or (is_admin and (room_data.get("users_higher") or room_data.get("users_equal"))):
|
||||
if has_error:
|
||||
if room_data["error"] == "Bot not in room":
|
||||
problematic_rooms.append(f"❌ <b>{room_data.get('room_name', room_id)}</b> ({room_id}): Bot not in room")
|
||||
else:
|
||||
problematic_rooms.append(f"❌ <b>{room_data.get('room_name', room_id)}</b> ({room_id}): Error - {room_data['error']}")
|
||||
elif is_admin:
|
||||
# Show unlimited power status for modern rooms
|
||||
if room_data.get("bot_has_unlimited_power", False):
|
||||
room_info = f"✅ <b>{room_data['room_name']}</b> ({room_id}): Unlimited Power (Creator) [v{room_data.get('room_version', '1')}]"
|
||||
else:
|
||||
room_info = f"✅ <b>{room_data['room_name']}</b> ({room_id}): Admin: Yes (level: {room_data['bot_power_level']}) [v{room_data.get('room_version', '1')}]"
|
||||
|
||||
# Add power level conflict info
|
||||
if room_data.get("users_higher") or room_data.get("users_equal"):
|
||||
if room_data.get("bot_has_unlimited_power", False):
|
||||
room_info += f" - Note: Power level conflicts are irrelevant for creators with unlimited power"
|
||||
else:
|
||||
if room_data.get("users_higher"):
|
||||
room_info += f" - Higher power users: {len(room_data['users_higher'])}"
|
||||
if room_data.get("users_equal"):
|
||||
room_info += f" - Equal power users: {len(room_data['users_equal'])}"
|
||||
problematic_rooms.append(room_info)
|
||||
else:
|
||||
problematic_rooms.append(f"❌ <b>{room_data['room_name']}</b> ({room_id}): Admin: No (level: {room_data['bot_power_level']}) [v{room_data.get('room_version', '1')}]")
|
||||
|
||||
# Generate HTML response
|
||||
response = ""
|
||||
if problematic_rooms:
|
||||
response += f"<h4>🏠 Problematic Rooms ({len(problematic_rooms)} of {len(rooms_data)} total)</h4><br />"
|
||||
response += "<i>Use <code>!community doctor <room_id></code> for detailed analysis of specific rooms</i><br /><br />"
|
||||
for room_info in problematic_rooms:
|
||||
response += f"{room_info}<br />"
|
||||
response += "<br />"
|
||||
|
||||
return response, stats
|
||||
|
||||
|
||||
def generate_summary_stats(
|
||||
space_data: Dict[str, Any],
|
||||
room_stats: Dict[str, int]
|
||||
) -> str:
|
||||
"""Generate summary statistics HTML.
|
||||
|
||||
Args:
|
||||
space_data: Space permission data
|
||||
room_stats: Room statistics
|
||||
|
||||
Returns:
|
||||
str: HTML formatted summary statistics
|
||||
"""
|
||||
response = f"<h4>📊 Summary</h4><br />"
|
||||
response += f"• Parent space: {'✅ Admin' if space_data.get('has_admin', False) else '❌ No admin'}<br />"
|
||||
response += f"• Rooms with admin: {room_stats['admin_rooms']}<br />"
|
||||
response += f"• Rooms without admin: {room_stats['non_admin_rooms']}<br />"
|
||||
response += f"• Modern room versions (12+): {room_stats['modern_rooms']}<br />"
|
||||
response += f"• Legacy room versions (1-11): {room_stats['legacy_rooms']}<br />"
|
||||
|
||||
# Add note about unlimited power for modern rooms
|
||||
if room_stats['modern_rooms'] > 0:
|
||||
response += f"<br />ℹ️ <b>Note:</b> In modern room versions (12+), creators have unlimited power and cannot be restricted by power levels.<br />"
|
||||
|
||||
if room_stats['not_in_room_count'] > 0:
|
||||
response += f"• Rooms bot not in: {room_stats['not_in_room_count']}<br />"
|
||||
if room_stats['error_rooms'] > 0:
|
||||
response += f"• Rooms with errors: {room_stats['error_rooms']}<br />"
|
||||
|
||||
response += "<br />"
|
||||
return response
|
||||
|
||||
|
||||
def generate_issues_and_warnings(
|
||||
issues: List[str],
|
||||
warnings: List[str]
|
||||
) -> str:
|
||||
"""Generate issues and warnings HTML.
|
||||
|
||||
Args:
|
||||
issues: List of critical issues
|
||||
warnings: List of warnings
|
||||
|
||||
Returns:
|
||||
str: HTML formatted issues and warnings
|
||||
"""
|
||||
response = ""
|
||||
|
||||
if issues:
|
||||
response += f"<h4>🚨 Critical Issues</h4><br />"
|
||||
for issue in issues:
|
||||
response += f"• {issue}<br />"
|
||||
response += "<br />"
|
||||
|
||||
if warnings:
|
||||
response += f"<h4>⚠️ Warnings</h4><br />"
|
||||
for warning in warnings:
|
||||
response += f"• {warning}<br />"
|
||||
response += "<br />"
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def generate_all_clear_message() -> str:
|
||||
"""Generate all clear message HTML.
|
||||
|
||||
Returns:
|
||||
str: HTML formatted all clear message
|
||||
"""
|
||||
return "<h4>✅ All Clear</h4><br />No permission issues detected. The bot should be able to manage all rooms and users effectively.<br />"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Message and content utility functions."""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from mautrix.types import MessageType, MediaMessageEventContent
|
||||
|
||||
|
||||
def flag_message(msg, censor_wordlist: list, censor_files: bool) -> bool:
|
||||
"""Check if a message should be flagged for censorship.
|
||||
|
||||
Args:
|
||||
msg: The message event to check
|
||||
censor_wordlist: List of regex patterns to check against
|
||||
censor_files: Whether to flag file messages
|
||||
|
||||
Returns:
|
||||
bool: True if message should be flagged
|
||||
"""
|
||||
if msg.content.msgtype in [
|
||||
MessageType.FILE,
|
||||
MessageType.IMAGE,
|
||||
MessageType.VIDEO,
|
||||
]:
|
||||
return censor_files
|
||||
|
||||
for w in censor_wordlist:
|
||||
try:
|
||||
if bool(re.search(w, msg.content.body, re.IGNORECASE)):
|
||||
return True
|
||||
except Exception:
|
||||
# Skip invalid regex patterns
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def flag_instaban(msg, instaban_wordlist: list) -> bool:
|
||||
"""Check if a message should trigger an instant ban.
|
||||
|
||||
Args:
|
||||
msg: The message event to check
|
||||
instaban_wordlist: List of regex patterns that trigger instant ban
|
||||
|
||||
Returns:
|
||||
bool: True if message should trigger instant ban
|
||||
"""
|
||||
for w in instaban_wordlist:
|
||||
try:
|
||||
if bool(re.search(w, msg.content.body, re.IGNORECASE)):
|
||||
return True
|
||||
except Exception:
|
||||
# Skip invalid regex patterns
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def censor_room(msg, censor_config) -> bool:
|
||||
"""Check if a message should be censored based on room configuration.
|
||||
|
||||
Args:
|
||||
msg: The message event to check
|
||||
censor_config: Censor configuration (bool or list of room IDs)
|
||||
|
||||
Returns:
|
||||
bool: True if message should be censored
|
||||
"""
|
||||
if isinstance(censor_config, bool):
|
||||
return censor_config
|
||||
elif isinstance(censor_config, list):
|
||||
return msg.room_id in censor_config
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def sanitize_room_name(room_name: str) -> str:
|
||||
"""Sanitize a room name for use in aliases.
|
||||
|
||||
Args:
|
||||
room_name: The room name to sanitize
|
||||
|
||||
Returns:
|
||||
str: Sanitized room name (alphanumeric only, lowercase)
|
||||
"""
|
||||
return re.sub(r"[^a-zA-Z0-9]", "", room_name).lower()
|
||||
|
||||
|
||||
def generate_community_slug(community_name: str) -> str:
|
||||
"""Generate a community slug from the community name.
|
||||
|
||||
Args:
|
||||
community_name: The full community name
|
||||
|
||||
Returns:
|
||||
str: A slug made from the first letter of each word, lowercase
|
||||
"""
|
||||
words = community_name.strip().split()
|
||||
return ''.join(word[0].lower() for word in words if word)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Report generation and formatting utility functions."""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
import time
|
||||
|
||||
|
||||
def generate_activity_report(database_results: Dict[str, List[Dict]]) -> Dict[str, Any]:
|
||||
"""Generate an activity report from database results.
|
||||
|
||||
Args:
|
||||
database_results: Dictionary containing 'active', 'inactive', 'ignored' results
|
||||
|
||||
Returns:
|
||||
dict: Formatted activity report
|
||||
"""
|
||||
report = {}
|
||||
|
||||
# Process active users
|
||||
active_results = database_results.get("active", [])
|
||||
report["active"] = [row["mxid"] for row in active_results] or ["none"]
|
||||
|
||||
# Process inactive users
|
||||
inactive_results = database_results.get("inactive", [])
|
||||
report["inactive"] = [row["mxid"] for row in inactive_results] or ["none"]
|
||||
|
||||
# Process ignored users
|
||||
ignored_results = database_results.get("ignored", [])
|
||||
report["ignored"] = [row["mxid"] for row in ignored_results] or ["none"]
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def split_doctor_report(report_text: str, max_chunk_size: int = 4000) -> List[str]:
|
||||
"""Split a doctor report into chunks that fit within size limits.
|
||||
|
||||
Args:
|
||||
report_text: The full report text
|
||||
max_chunk_size: Maximum size per chunk
|
||||
|
||||
Returns:
|
||||
list: List of report chunks
|
||||
"""
|
||||
if len(report_text) <= max_chunk_size:
|
||||
return [report_text]
|
||||
|
||||
# Try to split by sections first
|
||||
sections = _split_by_sections(report_text, max_chunk_size)
|
||||
if sections:
|
||||
return sections
|
||||
|
||||
# Fall back to character-based splitting
|
||||
chunks = []
|
||||
current_chunk = ""
|
||||
|
||||
for line in report_text.split('\n'):
|
||||
if len(current_chunk) + len(line) + 1 > max_chunk_size:
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
current_chunk = line
|
||||
else:
|
||||
# Single line is too long, split it
|
||||
chunks.append(line[:max_chunk_size])
|
||||
current_chunk = line[max_chunk_size:]
|
||||
else:
|
||||
if current_chunk:
|
||||
current_chunk += '\n' + line
|
||||
else:
|
||||
current_chunk = line
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(current_chunk.strip())
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def _split_by_sections(text: str, max_size: int) -> List[str]:
|
||||
"""Split text by sections (lines starting with specific patterns).
|
||||
|
||||
Args:
|
||||
text: The text to split
|
||||
max_size: Maximum size per section
|
||||
|
||||
Returns:
|
||||
list: List of text sections
|
||||
"""
|
||||
section_headers = ["Active users:", "Inactive users:", "Ignored users:"]
|
||||
sections = []
|
||||
current_section = ""
|
||||
|
||||
lines = text.split('\n')
|
||||
for line in lines:
|
||||
if any(line.startswith(header) for header in section_headers):
|
||||
if current_section and len(current_section) > max_size:
|
||||
# Current section is too big, need to split it further
|
||||
return []
|
||||
if current_section:
|
||||
sections.append(current_section.strip())
|
||||
current_section = line
|
||||
else:
|
||||
if len(current_section) + len(line) + 1 > max_size:
|
||||
# This section would be too big
|
||||
return []
|
||||
if current_section:
|
||||
current_section += '\n' + line
|
||||
else:
|
||||
current_section = line
|
||||
|
||||
if current_section:
|
||||
sections.append(current_section.strip())
|
||||
|
||||
return sections if all(len(s) <= max_size for s in sections) else []
|
||||
|
||||
|
||||
def format_ban_results(ban_event_map: Dict[str, List[str]]) -> str:
|
||||
"""Format ban results for display.
|
||||
|
||||
Args:
|
||||
ban_event_map: Dictionary containing ban results
|
||||
|
||||
Returns:
|
||||
str: Formatted ban results
|
||||
"""
|
||||
ban_list = ban_event_map.get("ban_list", {})
|
||||
error_list = ban_event_map.get("error_list", {})
|
||||
|
||||
result_parts = []
|
||||
|
||||
for user, rooms in ban_list.items():
|
||||
if rooms:
|
||||
result_parts.append(f"Banned {user} from: {', '.join(rooms)}")
|
||||
|
||||
for user, rooms in error_list.items():
|
||||
if rooms:
|
||||
result_parts.append(f"Failed to ban {user} from: {', '.join(rooms)}")
|
||||
|
||||
return '\n'.join(result_parts) if result_parts else "No ban operations performed"
|
||||
|
||||
|
||||
def format_sync_results(sync_results: Dict[str, List[str]]) -> str:
|
||||
"""Format sync results for display.
|
||||
|
||||
Args:
|
||||
sync_results: Dictionary containing sync results
|
||||
|
||||
Returns:
|
||||
str: Formatted sync results
|
||||
"""
|
||||
added = sync_results.get("added", [])
|
||||
dropped = sync_results.get("dropped", [])
|
||||
|
||||
added_str = "<br />".join(added) if added else "none"
|
||||
dropped_str = "<br />".join(dropped) if dropped else "none"
|
||||
|
||||
return f"Added: {added_str}<br /><br />Dropped: {dropped_str}"
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Response building utilities for the community bot."""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from mautrix.types import MessageEvent
|
||||
|
||||
|
||||
class ResponseBuilder:
|
||||
"""Builder for consistent response formatting."""
|
||||
|
||||
@staticmethod
|
||||
def build_html_response(title: str, content: str, allow_html: bool = True) -> str:
|
||||
"""Build an HTML formatted response.
|
||||
|
||||
Args:
|
||||
title: Response title
|
||||
content: Response content
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted response
|
||||
"""
|
||||
if allow_html:
|
||||
return f"<p><b>{title}</b><br />{content}</p>"
|
||||
else:
|
||||
return f"{title}\n{content}"
|
||||
|
||||
@staticmethod
|
||||
def build_error_response(error: str, allow_html: bool = True) -> str:
|
||||
"""Build an error response.
|
||||
|
||||
Args:
|
||||
error: Error message
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted error response
|
||||
"""
|
||||
if allow_html:
|
||||
return f"<p><b>Error:</b> {error}</p>"
|
||||
else:
|
||||
return f"Error: {error}"
|
||||
|
||||
@staticmethod
|
||||
def build_success_response(message: str, allow_html: bool = True) -> str:
|
||||
"""Build a success response.
|
||||
|
||||
Args:
|
||||
message: Success message
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted success response
|
||||
"""
|
||||
if allow_html:
|
||||
return f"<p><b>Success:</b> {message}</p>"
|
||||
else:
|
||||
return f"Success: {message}"
|
||||
|
||||
@staticmethod
|
||||
def build_list_response(title: str, items: List[str], allow_html: bool = True) -> str:
|
||||
"""Build a list response.
|
||||
|
||||
Args:
|
||||
title: List title
|
||||
items: List items
|
||||
allow_html: Whether to allow HTML formatting
|
||||
|
||||
Returns:
|
||||
str: Formatted list response
|
||||
"""
|
||||
if not items:
|
||||
return ResponseBuilder.build_html_response(title, "No items found.", allow_html)
|
||||
|
||||
if allow_html:
|
||||
items_html = "<br />".join(items)
|
||||
return f"<p><b>{title}</b><br />{items_html}</p>"
|
||||
else:
|
||||
items_text = "\n".join(f"- {item}" for item in items)
|
||||
return f"{title}\n{items_text}"
|
||||
|
||||
@staticmethod
|
||||
def build_room_link(alias: str, server: str) -> str:
|
||||
"""Build a Matrix room link.
|
||||
|
||||
Args:
|
||||
alias: Room alias
|
||||
server: Server name
|
||||
|
||||
Returns:
|
||||
str: HTML room link
|
||||
"""
|
||||
return f"<a href='https://matrix.to/#/#{alias}:{server}'>#{alias}:{server}</a>"
|
||||
|
||||
@staticmethod
|
||||
def build_user_link(user_id: str) -> str:
|
||||
"""Build a Matrix user link.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
|
||||
Returns:
|
||||
str: HTML user link
|
||||
"""
|
||||
return f"<a href='https://matrix.to/#/{user_id}'>{user_id}</a>"
|
||||
|
||||
@staticmethod
|
||||
def build_activity_report_response(report: Dict[str, List[str]], config: Dict[str, Any]) -> str:
|
||||
"""Build an activity report response.
|
||||
|
||||
Args:
|
||||
report: Activity report data
|
||||
config: Bot configuration
|
||||
|
||||
Returns:
|
||||
str: Formatted activity report
|
||||
"""
|
||||
warn_threshold = config.get("warn_threshold_days", 30)
|
||||
kick_threshold = config.get("kick_threshold_days", 60)
|
||||
|
||||
response_parts = []
|
||||
|
||||
if report.get("warn_inactive"):
|
||||
warn_list = "<br />".join(report["warn_inactive"])
|
||||
response_parts.append(
|
||||
f"<p><b>Users inactive for between {warn_threshold} and {kick_threshold} days:</b><br />"
|
||||
f"{warn_list}<br /></p>"
|
||||
)
|
||||
|
||||
if report.get("kick_inactive"):
|
||||
kick_list = "<br />".join(report["kick_inactive"])
|
||||
response_parts.append(
|
||||
f"<p><b>Users inactive for at least {kick_threshold} days:</b><br />"
|
||||
f"{kick_list}<br /></p>"
|
||||
)
|
||||
|
||||
if report.get("ignored"):
|
||||
ignored_list = "<br />".join(report["ignored"])
|
||||
response_parts.append(
|
||||
f"<p><b>Ignored users:</b><br />{ignored_list}</p>"
|
||||
)
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
@staticmethod
|
||||
def build_ban_results_response(results: Dict[str, Any]) -> str:
|
||||
"""Build a ban results response.
|
||||
|
||||
Args:
|
||||
results: Ban results data
|
||||
|
||||
Returns:
|
||||
str: Formatted ban results
|
||||
"""
|
||||
ban_list = results.get("ban_list", [])
|
||||
error_list = results.get("error_list", [])
|
||||
|
||||
response_parts = []
|
||||
|
||||
if ban_list:
|
||||
ban_list_html = "<br />".join(ban_list)
|
||||
response_parts.append(f"<p><b>Users banned:</b><br /><code>{ban_list_html}</code></p>")
|
||||
|
||||
if error_list:
|
||||
error_list_html = "<br />".join(error_list)
|
||||
response_parts.append(f"<p><b>Errors:</b><br /><code>{error_list_html}</code></p>")
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append("<p>No users were banned.</p>")
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
@staticmethod
|
||||
def build_sync_results_response(results: Dict[str, List[str]]) -> str:
|
||||
"""Build a sync results response.
|
||||
|
||||
Args:
|
||||
results: Sync results data
|
||||
|
||||
Returns:
|
||||
str: Formatted sync results
|
||||
"""
|
||||
added = results.get("added", [])
|
||||
dropped = results.get("dropped", [])
|
||||
|
||||
response_parts = []
|
||||
|
||||
if added:
|
||||
added_html = "<br />".join(added)
|
||||
response_parts.append(f"<p><b>Added:</b><br />{added_html}</p>")
|
||||
|
||||
if dropped:
|
||||
dropped_html = "<br />".join(dropped)
|
||||
response_parts.append(f"<p><b>Dropped:</b><br />{dropped_html}</p>")
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append("<p>No changes made.</p>")
|
||||
|
||||
return "".join(response_parts)
|
||||
|
||||
@staticmethod
|
||||
def build_doctor_report_response(report: Dict[str, Any]) -> str:
|
||||
"""Build a doctor report response.
|
||||
|
||||
Args:
|
||||
report: Doctor report data
|
||||
|
||||
Returns:
|
||||
str: Formatted doctor report
|
||||
"""
|
||||
response_parts = []
|
||||
|
||||
# Space information
|
||||
if report.get("space"):
|
||||
space = report["space"]
|
||||
space_info = f"<b>Space:</b> {space.get('room_id', 'Unknown')}<br />"
|
||||
space_info += f"Bot Power Level: {space.get('bot_power_level', 'Unknown')}<br />"
|
||||
space_info += f"Has Admin: {space.get('has_admin', False)}<br />"
|
||||
response_parts.append(f"<p>{space_info}</p>")
|
||||
|
||||
# Room information
|
||||
if report.get("rooms"):
|
||||
rooms_info = "<b>Rooms:</b><br />"
|
||||
for room_id, room_data in report["rooms"].items():
|
||||
rooms_info += f"- {room_id}: {room_data.get('status', 'Unknown')}<br />"
|
||||
response_parts.append(f"<p>{rooms_info}</p>")
|
||||
|
||||
# Issues
|
||||
if report.get("issues"):
|
||||
issues_html = "<br />".join(report["issues"])
|
||||
response_parts.append(f"<p><b>Issues:</b><br />{issues_html}</p>")
|
||||
|
||||
# Warnings
|
||||
if report.get("warnings"):
|
||||
warnings_html = "<br />".join(report["warnings"])
|
||||
response_parts.append(f"<p><b>Warnings:</b><br />{warnings_html}</p>")
|
||||
|
||||
if not response_parts:
|
||||
response_parts.append("<p>No issues found.</p>")
|
||||
|
||||
return "".join(response_parts)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Room creation utility functions for the community bot."""
|
||||
|
||||
import re
|
||||
import asyncio
|
||||
from typing import Optional, Tuple, List, Dict, Any
|
||||
from mautrix.types import MessageEvent, PowerLevelStateEventContent, EventType
|
||||
from mautrix.client import Client
|
||||
|
||||
|
||||
async def validate_room_creation_params(
|
||||
roomname: str,
|
||||
config: dict,
|
||||
evt: Optional[MessageEvent] = None
|
||||
) -> Tuple[str, bool, bool, str]:
|
||||
"""Validate and process room creation parameters.
|
||||
|
||||
Args:
|
||||
roomname: Original room name
|
||||
config: Bot configuration
|
||||
evt: Optional MessageEvent for error responses
|
||||
|
||||
Returns:
|
||||
Tuple of (sanitized_name, force_encryption, force_unencryption, error_msg)
|
||||
"""
|
||||
# Check for encryption flags
|
||||
encrypted_flag_regex = re.compile(r"(\s+|^)-+encrypt(ed)?\s?")
|
||||
unencrypted_flag_regex = re.compile(r"(\s+|^)-+unencrypt(ed)?\s?")
|
||||
force_encryption = bool(encrypted_flag_regex.search(roomname))
|
||||
force_unencryption = bool(unencrypted_flag_regex.search(roomname))
|
||||
|
||||
# Clean up room name
|
||||
if force_encryption:
|
||||
roomname = encrypted_flag_regex.sub("", roomname)
|
||||
if force_unencryption:
|
||||
roomname = unencrypted_flag_regex.sub("", roomname)
|
||||
|
||||
sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", roomname).lower()
|
||||
|
||||
# Check if community slug is configured
|
||||
if not config.get("community_slug"):
|
||||
error_msg = "No community slug configured. Please run initialize command first."
|
||||
return sanitized_name, force_encryption, force_unencryption, error_msg
|
||||
|
||||
return sanitized_name, force_encryption, force_unencryption, ""
|
||||
|
||||
|
||||
async def prepare_room_creation_data(
|
||||
sanitized_name: str,
|
||||
config: dict,
|
||||
client: Client,
|
||||
invitees: Optional[List[str]] = None
|
||||
) -> Tuple[str, str, List[str], str]:
|
||||
"""Prepare data needed for room creation.
|
||||
|
||||
Args:
|
||||
sanitized_name: Sanitized room name
|
||||
config: Bot configuration
|
||||
client: Matrix client
|
||||
invitees: Optional list of users to invite
|
||||
|
||||
Returns:
|
||||
Tuple of (alias_localpart, server, room_invitees, parent_room)
|
||||
"""
|
||||
# Create alias with community slug
|
||||
alias_localpart = f"{sanitized_name}-{config['community_slug']}"
|
||||
|
||||
# Get server and invitees
|
||||
server = client.parse_user_id(client.mxid)[1]
|
||||
room_invitees = invitees if invitees is not None else config["invitees"]
|
||||
parent_room = config["parent_room"]
|
||||
|
||||
return alias_localpart, server, room_invitees, parent_room
|
||||
|
||||
|
||||
async def prepare_power_levels(
|
||||
client: Client,
|
||||
config: dict,
|
||||
parent_room: str,
|
||||
power_level_override: Optional[PowerLevelStateEventContent] = None
|
||||
) -> PowerLevelStateEventContent:
|
||||
"""Prepare power levels for room creation.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
config: Bot configuration
|
||||
parent_room: Parent room ID
|
||||
power_level_override: Optional existing power level override
|
||||
|
||||
Returns:
|
||||
PowerLevelStateEventContent for room creation
|
||||
"""
|
||||
if power_level_override:
|
||||
return power_level_override
|
||||
|
||||
if parent_room:
|
||||
# Get parent room power levels to extract user power levels
|
||||
parent_power_levels = await client.get_state_event(
|
||||
parent_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
|
||||
# Create new power levels with server defaults, not copying all permissions from space
|
||||
power_levels = PowerLevelStateEventContent()
|
||||
|
||||
# Copy only user power levels from parent space, not the entire permission set
|
||||
if parent_power_levels.users:
|
||||
user_power_levels = parent_power_levels.users.copy()
|
||||
# Ensure bot has highest power
|
||||
user_power_levels[client.mxid] = 1000
|
||||
power_levels.users = user_power_levels
|
||||
else:
|
||||
power_levels.users = {
|
||||
client.mxid: 1000, # Bot gets highest power
|
||||
}
|
||||
|
||||
# Set explicit config values
|
||||
power_levels.invite = config["invite_power_level"]
|
||||
|
||||
return power_levels
|
||||
else:
|
||||
# If no parent room, create default power levels
|
||||
power_levels = PowerLevelStateEventContent()
|
||||
power_levels.users = {
|
||||
client.mxid: 1000, # Bot gets highest power
|
||||
}
|
||||
power_levels.invite = config["invite_power_level"]
|
||||
return power_levels
|
||||
|
||||
|
||||
def prepare_initial_state(
|
||||
config: dict,
|
||||
parent_room: str,
|
||||
server: str,
|
||||
force_encryption: bool,
|
||||
force_unencryption: bool,
|
||||
creation_content: Optional[Dict[str, Any]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Prepare initial state events for room creation.
|
||||
|
||||
Args:
|
||||
config: Bot configuration
|
||||
parent_room: Parent room ID
|
||||
server: Server name
|
||||
force_encryption: Whether to force encryption
|
||||
force_unencryption: Whether to force no encryption
|
||||
creation_content: Optional creation content
|
||||
|
||||
Returns:
|
||||
List of initial state events
|
||||
"""
|
||||
initial_state = []
|
||||
|
||||
# Only add space parent state if we have a parent room
|
||||
if parent_room:
|
||||
initial_state.extend([
|
||||
{
|
||||
"type": str(EventType.SPACE_PARENT),
|
||||
"state_key": parent_room,
|
||||
"content": {
|
||||
"via": [server],
|
||||
"canonical": True
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": str(EventType.ROOM_JOIN_RULES),
|
||||
"content": {
|
||||
"join_rule": "restricted",
|
||||
"allow": [{
|
||||
"type": "m.room_membership",
|
||||
"room_id": parent_room
|
||||
}]
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
# Add encryption if needed
|
||||
if (config.get("encrypt", False) and not force_unencryption) or force_encryption:
|
||||
initial_state.append({
|
||||
"type": str(EventType.ROOM_ENCRYPTION),
|
||||
"content": {
|
||||
"algorithm": "m.megolm.v1.aes-sha2"
|
||||
}
|
||||
})
|
||||
|
||||
# Add history visibility if specified in creation_content
|
||||
if creation_content and "m.room.history_visibility" in creation_content:
|
||||
initial_state.append({
|
||||
"type": str(EventType.ROOM_HISTORY_VISIBILITY),
|
||||
"content": {
|
||||
"history_visibility": creation_content["m.room.history_visibility"]
|
||||
}
|
||||
})
|
||||
|
||||
return initial_state
|
||||
|
||||
|
||||
def adjust_power_levels_for_modern_rooms(
|
||||
power_levels: PowerLevelStateEventContent,
|
||||
room_version: str
|
||||
) -> PowerLevelStateEventContent:
|
||||
"""Adjust power levels for modern room versions.
|
||||
|
||||
Args:
|
||||
power_levels: Power level state content
|
||||
room_version: Room version string
|
||||
|
||||
Returns:
|
||||
Adjusted power level state content
|
||||
"""
|
||||
# For modern room versions (12+), remove the bot from power levels
|
||||
# as creators have unlimited power by default and cannot appear in power levels
|
||||
if room_version and int(room_version) >= 12 and power_levels:
|
||||
if power_levels.users:
|
||||
# Remove bot from users list but keep other important settings
|
||||
power_levels.users.pop("bot_mxid", None) # Will be replaced with actual bot mxid
|
||||
|
||||
return power_levels
|
||||
|
||||
|
||||
async def add_room_to_space(
|
||||
client: Client,
|
||||
parent_room: str,
|
||||
room_id: str,
|
||||
server: str,
|
||||
sleep_duration: float
|
||||
) -> None:
|
||||
"""Add created room to parent space.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
parent_room: Parent room ID
|
||||
room_id: Created room ID
|
||||
server: Server name
|
||||
sleep_duration: Sleep duration between operations
|
||||
"""
|
||||
if parent_room:
|
||||
await client.send_state_event(
|
||||
parent_room,
|
||||
EventType.SPACE_CHILD,
|
||||
{
|
||||
"via": [server],
|
||||
"suggested": False
|
||||
},
|
||||
state_key=room_id
|
||||
)
|
||||
await asyncio.sleep(sleep_duration)
|
||||
|
||||
|
||||
async def verify_room_creation(
|
||||
client: Client,
|
||||
room_id: str,
|
||||
expected_version: str,
|
||||
logger
|
||||
) -> None:
|
||||
"""Verify that room was created with correct settings.
|
||||
|
||||
Args:
|
||||
client: Matrix client
|
||||
room_id: Created room ID
|
||||
expected_version: Expected room version
|
||||
logger: Logger instance
|
||||
"""
|
||||
try:
|
||||
from .room_utils import get_room_version_and_creators
|
||||
actual_version, actual_creators = await get_room_version_and_creators(client, room_id, logger)
|
||||
logger.info(f"Room {room_id} created with version {actual_version} (requested: {expected_version})")
|
||||
if actual_version != expected_version:
|
||||
logger.warning(f"Room version mismatch: requested {expected_version}, got {actual_version}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not verify room version for {room_id}: {e}")
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Room and space utility functions."""
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple, List
|
||||
from mautrix.types import EventType, PowerLevelStateEventContent
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
|
||||
async def validate_room_alias(client, alias_localpart: str, server: str) -> bool:
|
||||
"""Check if a room alias already exists.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
alias_localpart: The localpart of the alias (without # and :server)
|
||||
server: The server domain
|
||||
|
||||
Returns:
|
||||
bool: True if alias is available, False if it already exists
|
||||
"""
|
||||
try:
|
||||
full_alias = f"#{alias_localpart}:{server}"
|
||||
await client.resolve_room_alias(full_alias)
|
||||
# If we get here, the alias exists
|
||||
return False
|
||||
except MNotFound:
|
||||
# Alias doesn't exist, so it's available
|
||||
return True
|
||||
except Exception as e:
|
||||
# For other errors, assume alias is available to be safe
|
||||
return True
|
||||
|
||||
|
||||
async def validate_room_aliases(client, room_names: list[str], community_slug: str, server: str) -> Tuple[bool, List[str]]:
|
||||
"""Validate that all room aliases are available.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_names: List of room names to validate
|
||||
community_slug: The community slug to append
|
||||
server: The server domain
|
||||
|
||||
Returns:
|
||||
tuple: (is_valid, list_of_conflicting_aliases)
|
||||
"""
|
||||
if not community_slug:
|
||||
return False, []
|
||||
|
||||
conflicting_aliases = []
|
||||
|
||||
for room_name in room_names:
|
||||
# Clean the room name and create alias
|
||||
from .message_utils import sanitize_room_name
|
||||
sanitized_name = sanitize_room_name(room_name)
|
||||
alias_localpart = f"{sanitized_name}-{community_slug}"
|
||||
|
||||
# Check if alias is available
|
||||
is_available = await validate_room_alias(client, alias_localpart, server)
|
||||
if not is_available:
|
||||
conflicting_aliases.append(f"#{alias_localpart}:{server}")
|
||||
|
||||
return len(conflicting_aliases) == 0, conflicting_aliases
|
||||
|
||||
|
||||
async def get_room_version_and_creators(client, room_id: str) -> Tuple[str, List[str]]:
|
||||
"""Get the room version and creators for a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
room_id: The room ID to check
|
||||
|
||||
Returns:
|
||||
tuple: (room_version, list_of_creators)
|
||||
"""
|
||||
try:
|
||||
# Get all state events to find the creation event
|
||||
state_events = await client.get_state(room_id)
|
||||
|
||||
# Find the m.room.create event
|
||||
creation_event = None
|
||||
for event in state_events:
|
||||
if event.type == EventType.ROOM_CREATE:
|
||||
creation_event = event
|
||||
break
|
||||
|
||||
if not creation_event:
|
||||
# Default to version 1 if no creation event found
|
||||
return "1", []
|
||||
|
||||
room_version = creation_event.content.get("room_version", "1")
|
||||
creators = []
|
||||
|
||||
# Add the sender of the creation event as a creator
|
||||
if creation_event.sender:
|
||||
creators.append(creation_event.sender)
|
||||
|
||||
# Add any additional creators from the content
|
||||
additional_creators = creation_event.content.get("additional_creators", [])
|
||||
if isinstance(additional_creators, list):
|
||||
creators.extend(additional_creators)
|
||||
|
||||
return room_version, creators
|
||||
|
||||
except Exception:
|
||||
# Default to version 1 if there's an error
|
||||
return "1", []
|
||||
|
||||
|
||||
def is_modern_room_version(room_version: str) -> bool:
|
||||
"""Check if a room version is 12 or newer (modern room versions).
|
||||
|
||||
Args:
|
||||
room_version: The room version string to check
|
||||
|
||||
Returns:
|
||||
bool: True if room version is 12 or newer
|
||||
"""
|
||||
try:
|
||||
version_num = int(room_version)
|
||||
return version_num >= 12
|
||||
except (ValueError, TypeError):
|
||||
# If we can't parse the version, assume it's not modern
|
||||
return False
|
||||
|
||||
|
||||
async def user_has_unlimited_power(client, user_id: str, room_id: str) -> bool:
|
||||
"""Check if a user has unlimited power in a room (creator in modern room versions).
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user_id: The user ID to check
|
||||
room_id: The room ID to check in
|
||||
|
||||
Returns:
|
||||
bool: True if user has unlimited power
|
||||
"""
|
||||
try:
|
||||
room_version, creators = await get_room_version_and_creators(client, room_id)
|
||||
|
||||
# In modern room versions (12+), creators have unlimited power
|
||||
if is_modern_room_version(room_version):
|
||||
return user_id in creators
|
||||
|
||||
# In older room versions, creators don't have special unlimited power
|
||||
return False
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_moderators_and_above(client, parent_room: str) -> List[str]:
|
||||
"""Get list of users with moderator or higher permissions from the parent space.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
parent_room: The parent room ID
|
||||
|
||||
Returns:
|
||||
list: List of user IDs with power level >= 50 (moderator or above)
|
||||
"""
|
||||
try:
|
||||
power_levels = await client.get_state_event(
|
||||
parent_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
moderators = []
|
||||
for user, level in power_levels.users.items():
|
||||
if level >= 50: # Moderator level or above
|
||||
moderators.append(user)
|
||||
return moderators
|
||||
except Exception:
|
||||
return []
|
||||
@@ -0,0 +1,199 @@
|
||||
"""User management utility functions."""
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
import time
|
||||
from typing import List, Dict, Tuple
|
||||
from mautrix.types import EventType, UserID
|
||||
from mautrix.errors import MNotFound
|
||||
|
||||
|
||||
async def check_if_banned(client, userid: str, banlists: List[str], logger) -> bool:
|
||||
"""Check if a user is banned according to banlists.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
userid: The user ID to check
|
||||
banlists: List of banlist room IDs or aliases
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
bool: True if user is banned
|
||||
"""
|
||||
is_banned = False
|
||||
myrooms = await client.get_joined_rooms()
|
||||
banlist_roomids = await get_banlist_roomids(client, banlists, logger)
|
||||
|
||||
for list_id in banlist_roomids:
|
||||
if list_id not in myrooms:
|
||||
logger.error(
|
||||
f"Bot must be in {list_id} before attempting to use it as a banlist."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
list_state = await client.get_state(list_id)
|
||||
user_policies = list(
|
||||
filter(lambda p: p.type.t == "m.policy.rule.user", list_state)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
continue
|
||||
|
||||
for rule in user_policies:
|
||||
try:
|
||||
if bool(
|
||||
fnmatch.fnmatch(userid, rule["content"]["entity"])
|
||||
) and bool(re.search("ban$", rule["content"]["recommendation"])):
|
||||
return True
|
||||
except Exception:
|
||||
# Skip invalid rules
|
||||
pass
|
||||
|
||||
return is_banned
|
||||
|
||||
|
||||
async def get_banlist_roomids(client, banlists: List[str], logger) -> List[str]:
|
||||
"""Get room IDs for all configured banlists.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
banlists: List of banlist room IDs or aliases
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
list: List of room IDs for banlists
|
||||
"""
|
||||
banlist_roomids = []
|
||||
for banlist in banlists:
|
||||
if banlist.startswith("#"):
|
||||
try:
|
||||
room_info = await client.resolve_room_alias(banlist)
|
||||
list_id = room_info["room_id"]
|
||||
banlist_roomids.append(list_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Banlist fetching failed for {banlist}: {e}")
|
||||
continue
|
||||
else:
|
||||
list_id = banlist
|
||||
banlist_roomids.append(list_id)
|
||||
|
||||
return banlist_roomids
|
||||
|
||||
|
||||
async def ban_user_from_rooms(client, user: str, roomlist: List[str], reason: str = "banned",
|
||||
all_rooms: bool = False, redact_on_ban: bool = False,
|
||||
get_messages_to_redact_func=None, database=None,
|
||||
sleep_time: float = 0.1, logger=None) -> Dict:
|
||||
"""Ban a user from a list of rooms.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user: User ID to ban
|
||||
roomlist: List of room IDs to ban from
|
||||
reason: Reason for the ban
|
||||
all_rooms: Whether to ban even if user is not in room
|
||||
redact_on_ban: Whether to queue messages for redaction
|
||||
get_messages_to_redact_func: Function to get messages to redact
|
||||
database: Database instance for redaction tasks
|
||||
sleep_time: Sleep time between operations
|
||||
logger: Logger instance
|
||||
|
||||
Returns:
|
||||
dict: Ban results with success/error lists
|
||||
"""
|
||||
ban_event_map = {"ban_list": {}, "error_list": {}}
|
||||
ban_event_map["ban_list"][user] = []
|
||||
|
||||
for room in roomlist:
|
||||
try:
|
||||
roomname = None
|
||||
try:
|
||||
roomnamestate = await client.get_state_event(room, "m.room.name")
|
||||
roomname = roomnamestate["name"]
|
||||
except:
|
||||
pass
|
||||
|
||||
# ban user even if they're not in the room!
|
||||
if not all_rooms:
|
||||
await client.get_state_event(room, EventType.ROOM_MEMBER, user)
|
||||
|
||||
await client.ban_user(room, user, reason=reason)
|
||||
if roomname:
|
||||
ban_event_map["ban_list"][user].append(roomname)
|
||||
else:
|
||||
ban_event_map["ban_list"][user].append(room)
|
||||
time.sleep(sleep_time)
|
||||
except MNotFound:
|
||||
pass
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.warning(e)
|
||||
ban_event_map["error_list"][user] = []
|
||||
ban_event_map["error_list"][user].append(roomname or room)
|
||||
|
||||
if redact_on_ban and get_messages_to_redact_func and database:
|
||||
messages = await get_messages_to_redact_func(room, user)
|
||||
# Queue messages for redaction
|
||||
for msg in messages:
|
||||
await database.execute(
|
||||
"INSERT INTO redaction_tasks (event_id, room_id) VALUES ($1, $2)",
|
||||
msg.event_id,
|
||||
room,
|
||||
)
|
||||
if logger:
|
||||
logger.info(
|
||||
f"Queued {len(messages)} messages for redaction in {roomname or room}"
|
||||
)
|
||||
|
||||
return ban_event_map
|
||||
|
||||
|
||||
async def user_permitted(client, user_id: UserID, parent_room: str, min_level: int = 50,
|
||||
room_id: str = None, logger=None) -> bool:
|
||||
"""Check if a user has sufficient power level in a room.
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user_id: The Matrix ID of the user to check
|
||||
parent_room: The parent room ID
|
||||
min_level: Minimum required power level (default 50 for moderator)
|
||||
room_id: The room ID to check permissions in. If None, uses parent room.
|
||||
logger: Logger instance for error reporting
|
||||
|
||||
Returns:
|
||||
bool: True if user has sufficient power level
|
||||
"""
|
||||
try:
|
||||
target_room = room_id or parent_room
|
||||
|
||||
# First check if user has unlimited power (creator in modern room versions)
|
||||
from .room_utils import user_has_unlimited_power
|
||||
if await user_has_unlimited_power(client, user_id, target_room):
|
||||
return True
|
||||
|
||||
# Then check power level
|
||||
power_levels = await client.get_state_event(
|
||||
target_room, EventType.ROOM_POWER_LEVELS
|
||||
)
|
||||
user_level = power_levels.get_user_level(user_id)
|
||||
return user_level >= min_level
|
||||
except Exception as e:
|
||||
if logger:
|
||||
logger.error(f"Failed to check user power level: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def user_has_unlimited_power(client, user_id: str, room_id: str) -> bool:
|
||||
"""Check if a user has unlimited power in a room (creator in modern room versions).
|
||||
|
||||
Args:
|
||||
client: Matrix client instance
|
||||
user_id: The user ID to check
|
||||
room_id: The room ID to check in
|
||||
|
||||
Returns:
|
||||
bool: True if user has unlimited power
|
||||
"""
|
||||
from .room_utils import user_has_unlimited_power as room_user_has_unlimited_power
|
||||
return await room_user_has_unlimited_power(client, user_id, room_id)
|
||||
Reference in New Issue
Block a user