# kickbot - a maubot plugin to track user activity and remove inactive users from rooms/spaces.
from typing import Awaitable, Type, Optional, Tuple, Dict
import json
import time
import re
import fnmatch
import asyncio
import random
import asyncpg.exceptions
from datetime import datetime
from mautrix.client import (
Client,
InternalEventType,
MembershipEventDispatcher,
SyncStream,
)
from mautrix.types import (
Event,
StateEvent,
EventID,
UserID,
FileInfo,
EventType,
MediaMessageEventContent,
ReactionEvent,
RedactionEvent,
RoomID,
RoomAlias,
PowerLevelStateEventContent,
MessageType,
PaginationDirection,
SpaceChildStateEventContent,
SpaceParentStateEventContent,
JoinRulesStateEventContent,
JoinRule,
RoomCreatePreset
)
from mautrix.errors import MNotFound
from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
from maubot import Plugin, MessageEvent
from maubot.handlers import command, event
BAN_STATE_EVENT = EventType.find("m.policy.rule.user", EventType.Class.STATE)
# database table related things
from .db import upgrade_table
# Helper modules
from .helpers 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
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("sleep")
helper.copy("welcome_sleep")
helper.copy("parent_room")
helper.copy("community_slug")
helper.copy("track_users")
helper.copy("warn_threshold_days")
helper.copy("kick_threshold_days")
helper.copy("encrypt")
helper.copy("invitees")
helper.copy("notification_room")
helper.copy("join_notification_message")
helper.copy_dict("greeting_rooms")
helper.copy_dict("greetings")
helper.copy("censor")
helper.copy("uncensor_pl")
helper.copy("censor_wordlist")
helper.copy("censor_wordlist_instaban")
helper.copy("censor_files")
helper.copy("banlists")
helper.copy("proactive_banning")
helper.copy("redact_on_ban")
helper.copy("check_if_human")
helper.copy("verification_phrases")
helper.copy("verification_attempts")
helper.copy("verification_message")
helper.copy("invite_power_level")
helper.copy("room_version")
class CommunityBot(Plugin):
_redaction_tasks: asyncio.Task = None
_verification_states: Dict[str, Dict] = {}
async def start(self) -> None:
await super().start()
self.config.load_and_update()
self.config_manager = config_manager.ConfigManager(self.config)
self.client.add_dispatcher(MembershipEventDispatcher)
# Start background redaction task
self._redaction_tasks = asyncio.create_task(self._redaction_loop())
# Clean up stale verification states
await self.cleanup_stale_verification_states()
async def stop(self) -> None:
if self._redaction_tasks:
self._redaction_tasks.cancel()
await super().stop()
async def user_permitted(self, user_id: UserID, min_level: int = 50, room_id: str = None) -> bool:
"""Check if a user has sufficient power level in a room.
Args:
user_id: The Matrix ID of the user to check
min_level: Minimum required power level (default 50 for moderator)
room_id: The room ID to check permissions in. If None, uses parent room.
Returns:
bool: True if user has sufficient power level
"""
return await user_utils.user_permitted(
self.client, user_id, self.config["parent_room"], min_level, room_id, self.log
)
def generate_community_slug(self, 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
"""
return message_utils.generate_community_slug(community_name)
async def validate_room_alias(self, alias_localpart: str, server: str) -> bool:
"""Check if a room alias already exists.
Args:
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
"""
return await room_utils.validate_room_alias(self.client, alias_localpart, server)
async def validate_room_aliases(self, room_names: list[str], evt: MessageEvent = None) -> tuple[bool, list[str]]:
"""Validate that all room aliases are available.
Args:
room_names: List of room names to validate
evt: Optional MessageEvent for progress updates
Returns:
tuple: (is_valid, list_of_conflicting_aliases)
"""
if not self.config.get("community_slug", ""):
if evt:
await evt.respond("Error: No community slug configured. Please run initialize command first.")
return False, []
server = self.client.parse_user_id(self.client.mxid)[1]
return await room_utils.validate_room_aliases(
self.client, room_names, self.config.get("community_slug", ""), server
)
async def get_moderators_and_above(self) -> list[str]:
"""Get list of users with moderator or higher permissions from the parent space.
Returns:
list: List of user IDs with power level >= 50 (moderator or above)
"""
return await room_utils.get_moderators_and_above(self.client, self.config.get("parent_room", ""))
async def create_space(self, space_name: str, evt: MessageEvent = None, power_level_override: Optional[PowerLevelStateEventContent] = None) -> tuple[str, str]:
"""Create a new space without community slug suffix.
Args:
space_name: The name for the new space
evt: Optional MessageEvent for progress updates
power_level_override: Optional power levels to use
Returns:
tuple: (space_id, space_alias) if successful, None if failed
"""
mymsg = None
try:
sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", space_name).lower()
invitees = self.config.get("invitees", [])
server = self.client.parse_user_id(self.client.mxid)[1]
# Validate that the space alias is available
is_available = await self.validate_room_alias(sanitized_name, server)
if not is_available:
error_msg = f"Space alias #{sanitized_name}:{server} already exists. Cannot create space."
self.log.error(error_msg)
if evt:
await evt.respond(error_msg)
return None, None
if evt:
mymsg = await evt.respond(
f"creating space {sanitized_name} with room version {self.config.get('room_version', '1')}, give me a minute..."
)
# Prepare creation content with space type
# Spaces are created by setting the type to "m.space" in creation_content
creation_content = {
"type": "m.space",
"m.federate": True,
"m.room.history_visibility": "joined"
}
# 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 self.is_modern_room_version(self.config.get("room_version", "1")) and power_level_override:
self.log.info(f"Modern room version {self.config.get('room_version', '1')} detected - removing bot from power levels")
if power_level_override.users:
# Remove bot from users list but keep other important settings
power_level_override.users.pop(self.client.mxid, None)
# Create the space with space-specific content
# Note: room_version is set via the room_version parameter, not creation_content
self.log.info(f"Creating space with room_version={self.config.get('room_version', '1')}")
self.log.info(f"Creation content: {creation_content}")
self.log.info(f"Calling client.create_room with parameters:")
self.log.info(f" - alias_localpart: {sanitized_name}")
self.log.info(f" - name: {space_name}")
self.log.info(f" - invitees: {invitees}")
self.log.info(f" - power_level_override: {power_level_override}")
self.log.info(f" - creation_content: {creation_content}")
self.log.info(f" - room_version: {self.config.get('room_version', '1')}")
space_id = await self.client.create_room(
alias_localpart=sanitized_name,
name=space_name,
invitees=invitees,
power_level_override=power_level_override,
creation_content=creation_content,
room_version=self.config.get("room_version", "1")
)
# Verify the space version and type were set correctly
try:
actual_version, actual_creators = await self.get_room_version_and_creators(space_id)
self.log.info(f"Space {space_id} created with version {actual_version} (requested: {self.config.get('room_version', '1')})")
if actual_version != self.config.get("room_version", "1"):
self.log.warning(f"Space version mismatch: requested {self.config.get('room_version', '1')}, got {actual_version}")
# Verify the space type was set
state_events = await self.client.get_state(space_id)
space_type_set = False
for event in state_events:
if event.type == EventType.ROOM_CREATE:
space_type = event.content.get("type")
self.log.info(f"Space creation event type: {space_type}")
space_type_set = (space_type == "m.space")
break
if not space_type_set:
self.log.error(f"Space type was not set correctly in {space_id}")
# Try to set the space type after creation as a fallback
try:
self.log.info(f"Attempting to set space type after creation for {space_id}")
await self.client.send_state_event(
space_id,
EventType.ROOM_CREATE,
{"type": "m.space"},
state_key=""
)
self.log.info(f"Successfully set space type after creation for {space_id}")
except Exception as e2:
self.log.error(f"Failed to set space type after creation: {e2}")
else:
self.log.info(f"Space type verified as 'm.space' in {space_id}")
except Exception as e:
self.log.warning(f"Could not verify space creation: {e}")
if evt:
await evt.respond(
f"#{sanitized_name}:{server} has been created.",
edits=mymsg,
allow_html=True
)
self.log.info(f"Space creation completed successfully: {space_id}")
return space_id, f"#{sanitized_name}:{server}"
except Exception as e:
error_msg = f"Failed to create space: {e}"
self.log.error(error_msg)
if evt and mymsg:
await evt.respond(error_msg, edits=mymsg)
elif evt:
await evt.respond(error_msg)
return None, None
async def _redaction_loop(self) -> None:
while True:
try:
# Get all rooms with pending redactions
rooms = await self.database.fetch(
"SELECT DISTINCT room_id FROM redaction_tasks"
)
for room in rooms:
await self.redact_messages(room["room_id"])
await asyncio.sleep(60) # Run every minute
except asyncio.CancelledError:
break
except Exception as e:
self.log.error(f"Error in redaction loop: {e}")
await asyncio.sleep(60) # Wait a minute before retrying on error
async def do_sync(self) -> None:
if not self.config_manager.is_tracking_enabled():
return "user tracking is disabled"
try:
space_members_obj = await self.client.get_joined_members(
self.config["parent_room"]
)
space_members_list = space_members_obj.keys()
except asyncpg.exceptions.UniqueViolationError as e:
# If we hit a duplicate key error, log it and retry once
self.log.warning(f"Duplicate key error during member sync, retrying: {e}")
await asyncio.sleep(1) # Brief delay before retry
space_members_obj = await self.client.get_joined_members(
self.config["parent_room"]
)
space_members_list = space_members_obj.keys()
except Exception as e:
self.log.error(f"Failed to get space members: {e}")
return {"added": [], "dropped": []}
table_users = await self.database.fetch("SELECT mxid FROM user_events")
table_user_list = [row["mxid"] for row in table_users]
untracked_users = set(space_members_list) - set(table_user_list)
non_space_members = set(table_user_list) - set(space_members_list)
results = {}
results["added"] = []
results["dropped"] = []
try:
for user in untracked_users:
now = int(time.time() * 1000)
q = """
INSERT INTO user_events (mxid, last_message_timestamp)
VALUES ($1, $2)
"""
await self.database.execute(q, user, now)
results["added"].append(user)
self.log.info(f"{user} inserted into activity tracking table")
for user in non_space_members:
await self.database.execute(
"DELETE FROM user_events WHERE mxid = $1", user
)
self.log.info(
f"{user} is not a space member, dropped from activity tracking table"
)
results["dropped"].append(user)
except Exception as e:
self.log.error(f"Error syncing space members: {e}")
return results
async def get_space_roomlist(self) -> list[str]:
space = self.config["parent_room"]
rooms = []
# Check if parent room is configured
if not space:
self.log.warning("No parent room configured, cannot get space roomlist")
return rooms
try:
self.log.debug(f"DEBUG getting roomlist from {space} space")
state = await self.client.get_state(space)
for evt in state:
if evt.type == EventType.SPACE_CHILD:
# only look for rooms that include a via path, otherwise they
# are not really in the space!
if evt.content and evt.content.via:
rooms.append(evt.state_key)
except Exception as e:
self.log.error(f"Error getting space roomlist: {e}")
return rooms
async def generate_report(self) -> None:
now = int(time.time() * 1000)
warn_days_ago = now - (1000 * 60 * 60 * 24 * self.config["warn_threshold_days"])
kick_days_ago = now - (1000 * 60 * 60 * 24 * self.config["kick_threshold_days"])
warn_q = """
SELECT mxid FROM user_events WHERE last_message_timestamp <= $1 AND
last_message_timestamp >= $2
AND (ignore_inactivity < 1 OR ignore_inactivity IS NULL)
"""
kick_q = """
SELECT mxid FROM user_events WHERE last_message_timestamp <= $1
AND (ignore_inactivity < 1 OR ignore_inactivity IS NULL)
"""
ignored_q = """
SELECT mxid FROM user_events WHERE ignore_inactivity = 1
"""
warn_inactive_results = await self.database.fetch(
warn_q, warn_days_ago, kick_days_ago
)
kick_inactive_results = await self.database.fetch(kick_q, kick_days_ago)
ignored_results = await self.database.fetch(ignored_q)
database_results = {
"warn_inactive": warn_inactive_results,
"kick_inactive": kick_inactive_results,
"ignored": ignored_results
}
return report_utils.generate_activity_report(database_results)
def flag_message(self, msg):
return message_utils.flag_message(msg, self.config["censor_wordlist"], self.config["censor_files"])
def flag_instaban(self, msg):
return message_utils.flag_instaban(msg, self.config["censor_wordlist_instaban"])
def censor_room(self, msg):
return message_utils.censor_room(msg, self.config["censor"])
async def check_if_banned(self, userid):
return await user_utils.check_if_banned(self.client, userid, self.config["banlists"], self.log)
async def get_messages_to_redact(self, room_id, mxid):
return await database_utils.get_messages_to_redact(self.client, room_id, mxid, self.log)
async def redact_messages(self, room_id):
return await database_utils.redact_messages(self.client, self.database, room_id, self.config["sleep"], self.log)
async def check_bot_permissions(
self,
room_id: str,
evt: MessageEvent = None,
required_permissions: list[str] = None,
) -> tuple[bool, str, dict]:
"""Check if the bot has necessary permissions in a room.
Args:
room_id: The ID of the room to check permissions in
evt: Optional MessageEvent for progress updates
required_permissions: List of specific permissions to check. If None, checks basic room access.
Returns:
tuple: (has_permissions, error_message, permission_details)
"""
try:
# Check if bot is in the room
try:
await self.client.get_state_event(
room_id, EventType.ROOM_MEMBER, self.client.mxid
)
except MNotFound:
return False, "Bot is not a member of this room", {}
# Check if bot has unlimited power (creator in modern room versions)
if await self.user_has_unlimited_power(self.client.mxid, room_id):
return True, "", {"unlimited_power": True}
# Get power levels
power_levels = await self.client.get_state_event(
room_id, EventType.ROOM_POWER_LEVELS
)
bot_level = power_levels.users.get(
self.client.mxid, power_levels.users_default
)
# Define required power levels for different actions
permission_requirements = {
"redact": power_levels.redact,
"kick": power_levels.kick,
"ban": power_levels.ban,
"invite": power_levels.invite,
"tombstone": power_levels.events.get(
"m.room.tombstone", power_levels.events_default
),
"power_levels": power_levels.events.get(
"m.room.power_levels", power_levels.events_default
),
"state": power_levels.state_default,
}
# Check each required permission
permission_status = {}
if required_permissions:
for perm in required_permissions:
if perm in permission_requirements:
required_level = permission_requirements[perm]
permission_status[perm] = {
"has_permission": bot_level >= required_level,
"required_level": required_level,
"bot_level": bot_level,
}
# If no specific permissions requested, just check basic access
if not required_permissions:
if bot_level < 50: # Basic moderator level
return (
False,
"Bot does not have sufficient power level (needs at least moderator level)",
permission_status,
)
return True, "", permission_status
# Check if all requested permissions are granted
missing_permissions = [
perm
for perm, status in permission_status.items()
if not status["has_permission"]
]
if missing_permissions:
error_msg = "Bot is missing required permissions: " + ", ".join(
missing_permissions
)
return False, error_msg, permission_status
return True, "", permission_status
except Exception as e:
error_msg = f"Failed to check bot permissions: {e}"
self.log.error(error_msg)
if evt:
await evt.respond(error_msg)
return False, error_msg, {}
async def do_archive_room(
self, room_id: str, evt: MessageEvent = None, replacement_room: str = ""
) -> bool:
"""Handle common room archival activities like removing from space, removing aliases, and setting tombstone.
Args:
room_id: The ID of the room to archive
evt: Optional MessageEvent for progress updates
replacement_room: Optional room ID to point to in the tombstone event
Returns:
bool: True if all operations succeeded, False otherwise
"""
try:
# Check permissions for all required operations
has_perms, error_msg, _ = await self.check_bot_permissions(
room_id, evt, ["state", "tombstone", "power_levels"]
)
if not has_perms:
if evt:
await evt.respond(f"Cannot archive room: {error_msg}")
return False
# Try to remove the room from the space first
self.log.debug(f"DEBUG removing space state reference from {room_id}")
await self.client.send_state_event(
room_id=room_id,
event_type="m.space.parent",
content={}, # Empty content removes the state
state_key=self.config["parent_room"],
)
self.log.info(f"Removed parent space reference from room {room_id}")
# Remove the child reference from the space
self.log.debug(
f"DEBUG removing child state reference from {self.config['parent_room']}"
)
await self.client.send_state_event(
self.config["parent_room"],
event_type="m.space.child",
content={}, # Empty content removes the state
state_key=room_id,
)
self.log.info(
f"Removed child room reference from space {self.config['parent_room']}"
)
# Remove room aliases to release them
await self.remove_room_aliases(room_id, evt)
# Send the tombstone
tombstone_content = {
"body": (
"This room has been archived."
if not replacement_room
else "This room has been replaced. Please join the new room."
),
"replacement_room": replacement_room,
}
await self.client.send_state_event(
room_id=room_id,
event_type=EventType.ROOM_TOMBSTONE,
content=tombstone_content,
)
self.log.info(f"Successfully added tombstone to room {room_id}")
return True
except Exception as e:
error_msg = f"Failed to archive room: {e}"
self.log.error(error_msg)
if evt:
await evt.respond(error_msg)
return False
async def remove_room_aliases(self, room_id: str, evt: MessageEvent = None) -> list:
"""Remove all aliases from a room.
Args:
room_id: The ID of the room whose aliases to remove
evt: Optional MessageEvent for progress updates
Returns:
list: List of aliases that were successfully removed
"""
removed_aliases = []
try:
aliases = await self.client.get_state_event(
room_id=room_id, event_type=EventType.ROOM_CANONICAL_ALIAS
)
except Exception as e:
self.log.warning(f"Failed to get room alias state event, skipping: {e}")
return removed_aliases
if aliases.alt_aliases:
for alias in aliases.alt_aliases:
try:
await self.client.remove_room_alias(
alias_localpart=alias.split(":")[0].lstrip("#"),
)
self.log.info(f"Removed alias {alias} from room {room_id}")
removed_aliases.append(alias)
except Exception as e:
self.log.warning(f"Failed to remove alias {alias}: {e}")
if aliases.canonical_alias:
try:
await self.client.remove_room_alias(
alias_localpart=aliases.canonical_alias.split(":")[0].lstrip("#"),
)
self.log.info(
f"Removed canonical alias {aliases.canonical_alias} from room {room_id}"
)
removed_aliases.append(aliases.canonical_alias)
except Exception as e:
self.log.warning(f"Failed to remove canonical alias: {e}")
return removed_aliases
async def ban_this_user(self, user, reason="banned", all_rooms=False):
roomlist = await self.get_space_roomlist()
# don't forget to kick from the space itself
roomlist.append(self.config["parent_room"])
return await user_utils.ban_user_from_rooms(
self.client, user, roomlist, reason, all_rooms,
self.config["redact_on_ban"], self.get_messages_to_redact,
self.database, self.config["sleep"], self.log
)
async def get_banlist_roomids(self):
return await user_utils.get_banlist_roomids(self.client, self.config["banlists"], self.log)
async def get_room_version_and_creators(self, room_id: str) -> tuple[str, list[str]]:
"""Get the room version and creators for a room.
Args:
room_id: The room ID to check
Returns:
tuple: (room_version, list_of_creators)
"""
return await room_utils.get_room_version_and_creators(self.client, room_id)
def is_modern_room_version(self, 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
"""
return room_utils.is_modern_room_version(room_version)
async def user_has_unlimited_power(self, user_id: UserID, room_id: str) -> bool:
"""Check if a user has unlimited power in a room (creator in modern room versions).
Args:
user_id: The user ID to check
room_id: The room ID to check in
Returns:
bool: True if user has unlimited power
"""
return await room_utils.user_has_unlimited_power(self.client, user_id, room_id)
@event.on(BAN_STATE_EVENT)
async def check_ban_event(self, evt: StateEvent) -> None:
if not self.config["proactive_banning"]:
return
banlist_roomids = await self.get_banlist_roomids()
# we only care about ban events in rooms in the banlist
if evt.room_id not in banlist_roomids:
return
else:
try:
entity = evt.content["entity"]
recommendation = evt.content["recommendation"]
self.log.debug(
f"DEBUG new ban rule found: {entity} should have action {recommendation}"
)
if bool(re.search(r"[*?]", entity)):
self.log.debug(
f"DEBUG ban rule appears to be glob pattern, skipping proactive measures."
)
return
if bool(re.search("ban$", recommendation)):
await self.ban_this_user(entity)
except Exception as e:
self.log.error(e)
@event.on(EventType.ROOM_POWER_LEVELS)
async def sync_power_levels(self, evt: StateEvent) -> None:
# Only care about changes in the parent room
if evt.room_id != self.config["parent_room"]:
return
# Get the changed user and their new power level
try:
old_levels = evt.prev_content.get("users", {})
new_levels = evt.content.get("users", {})
# Find which user's power level changed
changed_users = {}
for user, new_level in new_levels.items():
if user not in old_levels or old_levels[user] != new_level:
changed_users[user] = new_level
if not changed_users:
return
# Get all rooms in the space
space_rooms = await self.client.get_joined_rooms()
success_rooms = []
failed_rooms = []
# Apply the same power level changes to each room
for room_id in space_rooms:
if room_id == self.config["parent_room"]:
continue
roomname = await common_utils.get_room_name(self.client, room_id, self.log)
# Get current power levels
try:
# Get current power levels
current_pl = await self.client.get_state_event(
room_id, EventType.ROOM_POWER_LEVELS
)
# Update existing power levels object with new levels
users = current_pl.get("users", {})
for user, level in changed_users.items():
users[user] = level
current_pl["users"] = users
# Send updated power levels
try:
await self.client.send_state_event(
room_id, EventType.ROOM_POWER_LEVELS, current_pl
)
success_rooms.append(roomname or room_id)
except Exception as e:
self.log.error(
f"Failed to send power levels to {roomname or room_id}: {e}"
)
failed_rooms.append(roomname or room_id)
time.sleep(self.config["sleep"])
except Exception as e:
self.log.warning(f"Failed to update power levels in {room_id}: {e}")
failed_rooms.append(room_id)
# Send notification if configured
if self.config["notification_room"]:
changes = ", ".join(
[f"{user} → {level}" for user, level in changed_users.items()]
)
notification = (
f"Power level changes ({changes}) propagated from parent room:
"
)
notification += (
f"Succeeded in: {', '.join(success_rooms)}
"
)
if failed_rooms:
notification += f"Failed in: {', '.join(failed_rooms)}"
await self.client.send_notice(
self.config["notification_room"], html=notification
)
except Exception as e:
self.log.error(f"Error syncing power levels: {e}")
async def handle_leave_events(self, evt: StateEvent) -> None:
"""Common logic for handling membership changes (leave/kick/ban)."""
if evt.source & SyncStream.STATE:
self.log.debug(f"Sync stream leave event for {evt.state_key} in {evt.room_id} detected")
return
else:
# check if the room the person left is protected by check_if_human
# kick and ban events are sent by other people, so we need to use the state_key
# when referring to the user who left
user_id = evt.state_key
self.log.debug(f"membership change event for {user_id} in {evt.room_id} detected")
if (
isinstance(self.config["check_if_human"], bool) and self.config["check_if_human"]
) or (
isinstance(self.config["check_if_human"], list) and evt.room_id in self.config["check_if_human"]
):
self.log.debug(f"Checking if {user_id} is a verified user in {evt.room_id}")
# Check if user has unlimited power (creator in modern room versions)
if await self.user_has_unlimited_power(user_id, evt.room_id):
self.log.debug(f"User {user_id} has unlimited power in {evt.room_id}, skipping power level cleanup")
return
pl_state = await self.client.get_state_event(evt.room_id, EventType.ROOM_POWER_LEVELS)
try:
user_level = pl_state.get_user_level(user_id)
except Exception as e:
self.log.error(f"Failed to get user level for {user_id} in {evt.room_id}: {e}")
return
default_level = pl_state.users_default
self.log.debug(f"User {user_id} has power level {user_level}, default level is {default_level}")
if user_level == ( default_level + 1 ): # indicates verified user
self.log.debug(f"Removing {user_id} from power levels state event in {evt.room_id}")
pl_state.users.pop(user_id)
try:
await self.client.send_state_event(evt.room_id, EventType.ROOM_POWER_LEVELS, pl_state)
except Exception as e:
self.log.error(f"Failed to update power levels state event in {evt.room_id}: {e}")
@event.on(InternalEventType.LEAVE)
async def handle_leave(self, evt: StateEvent) -> None:
"""Handle voluntary leave events."""
await self.handle_leave_events(evt)
@event.on(InternalEventType.KICK)
async def handle_kick(self, evt: StateEvent) -> None:
"""Handle kick events."""
await self.handle_leave_events(evt)
@event.on(InternalEventType.BAN)
async def handle_ban(self, evt: StateEvent) -> None:
"""Handle ban events."""
await self.handle_leave_events(evt)
@event.on(InternalEventType.JOIN)
async def newjoin(self, evt: StateEvent) -> None:
if evt.source & SyncStream.STATE:
return
else:
# we only care about join events in rooms in the space
# this avoids trying to verify users in other rooms the bot might be in,
# such as public banlist policy rooms
space_rooms = await self.get_space_roomlist()
if evt.room_id not in space_rooms:
return
try:
on_banlist = await self.check_if_banned(evt.sender)
except Exception as e:
self.log.error(f"Failed to check if {evt.sender} is banned: {e}")
on_banlist = False
if on_banlist:
await self.ban_this_user(evt.sender)
return
# passive sync of tracking db
if evt.room_id == self.config["parent_room"]:
await self.do_sync()
# greeting activities
room_id = str(evt.room_id)
self.log.debug(f"New join in room {room_id} by {evt.sender}")
self.log.debug(f"Greeting rooms config: {self.config['greeting_rooms']}")
self.log.debug(f"Check if human config: {self.config['check_if_human']}")
self.log.debug(f"Verification phrases config: {self.config['verification_phrases']}")
if room_id in self.config["greeting_rooms"]:
if on_banlist:
return
greeting_map = self.config["greetings"]
greeting_name = self.config["greeting_rooms"][room_id]
nick = self.client.parse_user_id(evt.sender)[0]
pill = '{nick}'.format(
mxid=evt.sender, nick=nick
)
if greeting_name != "none":
greeting = greeting_map[greeting_name].format(user=pill)
time.sleep(self.config["welcome_sleep"])
await self.client.send_notice(evt.room_id, html=greeting)
else:
pass
if self.config["notification_room"]:
roomnamestate = await self.client.get_state_event(
evt.room_id, "m.room.name"
)
roomname = roomnamestate["name"]
notification_message = self.config[
"join_notification_message"
].format(user=evt.sender, room=roomname)
await self.client.send_notice(
self.config["notification_room"], html=notification_message
)
# Human verification logic
if self.config["check_if_human"] and self.config["verification_phrases"]:
try:
# Check if verification is enabled for this room
verification_enabled = False
if isinstance(self.config["check_if_human"], bool):
verification_enabled = self.config["check_if_human"]
elif isinstance(self.config["check_if_human"], list):
verification_enabled = evt.room_id in self.config["check_if_human"]
self.log.debug(f"Verification enabled for room {room_id}: {verification_enabled}")
if not verification_enabled:
return
# Get room name for greeting
roomname = "this room"
roomname = await common_utils.get_room_name(self.client, evt.room_id, self.log)
# Check if user already has sufficient power level or unlimited power
try:
# First check if user has unlimited power (creator in modern room versions)
if await self.user_has_unlimited_power(evt.sender, evt.room_id):
self.log.debug(f"User {evt.sender} has unlimited power in {evt.room_id}, skipping verification")
return
power_levels = await self.client.get_state_event(
evt.room_id, EventType.ROOM_POWER_LEVELS
)
user_level = power_levels.get_user_level(evt.sender)
events_default = power_levels.events_default
events = power_levels.events
# Get the required power level for sending messages
required_level = events.get(str(EventType.ROOM_MESSAGE), events_default)
self.log.debug(f"User {evt.sender} has power level {user_level}, required level is {required_level}")
# If user already has sufficient power level, skip verification
if user_level >= required_level:
self.log.debug(f"User {evt.sender} already has sufficient power level ({user_level} >= {required_level})")
return
except Exception as e:
self.log.error(f"Failed to check user power level: {e}")
return
# Create DM room with name
max_retries = 3
retry_delay = 1 # seconds
last_error = None
for attempt in range(max_retries):
try:
dm_room = await self.client.create_room(
preset=RoomCreatePreset.PRIVATE,
invitees=[evt.sender],
is_direct=True,
initial_state=[
{
"type": str(EventType.ROOM_NAME),
"content": {"name": f"[{roomname}] join verification"}
}
]
)
self.log.info(f"Created DM room {dm_room} for {evt.sender}")
break
except Exception as e:
last_error = e
if attempt < max_retries - 1: # Don't sleep on the last attempt
self.log.warning(f"Failed to create DM room (attempt {attempt + 1}/{max_retries}): {e}")
await asyncio.sleep(retry_delay)
else:
self.log.error(f"Failed to initiate verification process after {max_retries} attempts: {e}")
return
# Select random verification phrase
verification_phrase = random.choice(self.config["verification_phrases"])
# Store verification state
verification_state = {
"user": evt.sender,
"target_room": evt.room_id,
"phrase": verification_phrase,
"attempts": self.config["verification_attempts"],
"required_level": required_level
}
await self.store_verification_state(dm_room, verification_state)
# Send greeting
greeting = self.config["verification_message"].format(
room=roomname,
phrase=verification_phrase
)
await self.client.send_notice(dm_room, html=greeting)
self.log.info(f"Started verification process for {evt.sender} in room {room_id} for room {roomname}")
except Exception as e:
self.log.error(f"Failed to start verification process: {e}")
@event.on(EventType.ROOM_MESSAGE)
async def handle_verification(self, evt: MessageEvent) -> None:
# Ignore messages from the bot itself
if evt.sender == self.client.mxid:
return
state = await self.get_verification_state(evt.room_id)
if not state:
# self.log.debug(f"No verification state stored for {evt.room_id}")
return
#self.log.debug(f"Checking verification for {evt.sender} in {evt.room_id}")
user_phrase = evt.content.body.strip().lower()
expected_phrase = state["phrase"].lower()
# Remove punctuation and compare
user_phrase = re.sub(r'[^\w\s]', '', user_phrase)
expected_phrase = re.sub(r'[^\w\s]', '', expected_phrase)
if user_phrase == expected_phrase:
try:
# confirm user is still in target room
members = await self.client.get_joined_members(state["target_room"])
if state["user"] not in members:
await self.client.send_notice(evt.room_id, "Looks like you've left the target room. Rejoin to try again.")
else:
# Update power levels in target room
power_levels = await self.client.get_state_event(
state["target_room"], EventType.ROOM_POWER_LEVELS
)
power_levels.users[state["user"]] = state["required_level"]
await self.client.send_state_event(
state["target_room"], EventType.ROOM_POWER_LEVELS, power_levels
)
await self.client.send_notice(evt.room_id, "Success! My work here is done. You can leave this room now.")
except Exception as e:
await self.client.send_notice(
evt.room_id,
f"Something went wrong: {str(e)}. Please report this to the room moderators."
)
if self.config["notification_room"]:
await self.client.send_notice(
self.config["notification_room"],
f"User verification failed for {evt.sender} in room {evt.room_id}, you may need to manually verify them."
)
finally:
await self.client.leave_room(evt.room_id)
await self.delete_verification_state(evt.room_id)
else:
state["attempts"] -= 1
if state["attempts"] <= 0:
await self.client.send_notice(
evt.room_id,
"You have run out of attempts. Please contact a room moderator for assistance."
)
if self.config["notification_room"]:
await self.client.send_notice(
self.config["notification_room"],
f"User verification failed for {evt.sender} in room {evt.room_id}, you may need to manually verify them."
)
await self.client.leave_room(evt.room_id)
await self.delete_verification_state(evt.room_id)
else:
await self.store_verification_state(evt.room_id, state)
await self.client.send_notice(
evt.room_id,
f"Phrase does not match, you have {state['attempts']} tries remaining."
)
async def upsert_user_timestamp(self, mxid: str, timestamp: int) -> None:
"""Database-agnostic upsert for user timestamp updates."""
await database_utils.upsert_user_timestamp(self.database, mxid, timestamp, self.log)
@event.on(EventType.ROOM_MESSAGE)
async def update_message_timestamp(self, evt: MessageEvent) -> None:
power_levels = await self.client.get_state_event(
evt.room_id, EventType.ROOM_POWER_LEVELS
)
user_level = power_levels.get_user_level(evt.sender)
# self.log.debug(f"DEBUGDEBUG user {evt.sender} has power level {user_level}")
if self.flag_message(evt):
# do we need to redact?
if (
not await self.user_permitted(evt.sender)
and evt.sender != self.client.mxid
and self.censor_room(evt)
):
try:
await self.client.redact(
evt.room_id, evt.event_id, reason="message flagged"
)
except Exception as e:
self.log.error(f"Flagged message could not be redacted: {e}")
if evt.content.msgtype in {
MessageType.TEXT,
MessageType.NOTICE,
MessageType.EMOTE,
}:
if self.flag_instaban(evt):
# do we need to redact?
if (
not await self.user_permitted(evt.sender)
and evt.sender != self.client.mxid
and self.censor_room(evt)
):
try:
await self.client.redact(
evt.room_id, evt.event_id, reason="message flagged"
)
except Exception as e:
self.log.error(f"Flagged message could not be redacted: {e}")
await self.ban_this_user(evt.sender, all_rooms=True)
if not self.config_manager.is_message_tracking_enabled():
pass
else:
rooms_to_manage = await self.get_space_roomlist()
# only attempt to track rooms in the space, ignore any other rooms
# the bot may happen to be in line banlist policy rooms etc.
if evt.room_id not in rooms_to_manage:
return
else:
await self.upsert_user_timestamp(evt.sender, evt.timestamp)
@event.on(EventType.REACTION)
async def update_reaction_timestamp(self, evt: MessageEvent) -> None:
if not self.config_manager.is_reaction_tracking_enabled():
pass
else:
rooms_to_manage = await self.get_space_roomlist()
# only attempt to track rooms in the space, ignore any other rooms
# the bot may happen to be in line banlist policy rooms etc.
if evt.room_id not in rooms_to_manage:
return
else:
await self.upsert_user_timestamp(evt.sender, evt.timestamp)
@command.new("community", help="manage rooms and members of a space")
async def community(self) -> None:
pass
async def check_parent_room(self, evt: MessageEvent) -> bool:
"""Check if parent room is configured and handle the response if not."""
if not self.config["parent_room"]:
await evt.reply(
"No parent room configured. Please use the 'initialize' command to set up your community space first."
)
return False
return True
@community.subcommand(
"bancheck", help="check subscribed banlists for a user's mxid"
)
@command.argument("mxid", "full matrix ID", required=True)
async def check_banlists(self, evt: MessageEvent, mxid: UserID) -> None:
if not await self.check_parent_room(evt):
return
ban_status = await self.check_if_banned(mxid)
await evt.reply(f"user on banlist: {ban_status}")
@community.subcommand(
"sync",
help="update the activity tracker with the current space members \
in case they are missing",
)
@decorators.require_parent_room
@decorators.require_permission()
async def sync_space_members(self, evt: MessageEvent) -> None:
# Power level sync is now handled through parent room inheritance
# Users should set power levels directly in the parent room
if not self.config["track_users"]:
await evt.respond("user tracking is disabled")
return
results = await self.do_sync()
added_str = "
".join(results["added"])
dropped_str = "
".join(results["dropped"])
await evt.respond(
f"Added: {added_str}
Dropped: {dropped_str}", allow_html=True
)
@community.subcommand(
"ignore", help="exclude a specific matrix ID from inactivity tracking"
)
@command.argument("mxid", "full matrix ID", required=True)
@decorators.require_parent_room
@decorators.require_permission()
@decorators.handle_errors("Failed to ignore user")
async def ignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None:
if not self.config_manager.is_tracking_enabled():
await evt.reply("user tracking is disabled")
return
Client.parse_user_id(mxid)
await self.database.execute(
"UPDATE user_events SET ignore_inactivity = 1 WHERE \
mxid = $1",
mxid,
)
self.log.info(f"{mxid} set to ignore inactivity")
await evt.react("✅")
@community.subcommand(
"unignore", help="re-enable activity tracking for a specific matrix ID"
)
@command.argument("mxid", "full matrix ID", required=True)
@decorators.require_parent_room
@decorators.require_permission()
@decorators.handle_errors("Failed to unignore user")
async def unignore_inactivity(self, evt: MessageEvent, mxid: UserID) -> None:
if not self.config_manager.is_tracking_enabled():
await evt.reply("user tracking is disabled")
return
Client.parse_user_id(mxid)
await self.database.execute(
"UPDATE user_events SET ignore_inactivity = 0 WHERE \
mxid = $1",
mxid,
)
self.log.info(f"{mxid} set to track inactivity")
await evt.react("✅")
@community.subcommand(
"report", help="generate reports of user activity and inactivity"
)
@decorators.require_parent_room
@decorators.require_permission()
async def report(self, evt: MessageEvent) -> None:
"""Main report command - shows full report by default"""
if not self.config_manager.is_tracking_enabled():
await evt.reply("user tracking is disabled")
return
sync_results = await self.do_sync()
report = await self.generate_report()
await evt.respond(
f"
Users inactive for between {self.config['warn_threshold_days']} and \
{self.config['kick_threshold_days']} days:
\
{'
'.join(report['warn_inactive'])}
Users inactive for at least {self.config['kick_threshold_days']} days:
\
{'
'.join(report['kick_inactive'])}
Ignored users:
\
{'
'.join(report['ignored'])}
Users inactive for between {self.config['warn_threshold_days']} and \
{self.config['kick_threshold_days']} days:
\
{'
'.join(report['warn_inactive'])}
Users inactive for at least {self.config['kick_threshold_days']} days:
\
{'
'.join(report['kick_inactive'])}
Ignored users:
\
{'
'.join(report['ignored'])}
Users inactive for between {self.config['warn_threshold_days']} and \
{self.config['kick_threshold_days']} days:
\
{'
'.join(report['warn_inactive'])}
Users inactive for at least {self.config['kick_threshold_days']} days:
\
{'
'.join(report['kick_inactive'])}
Ignored users:
\
{'
'.join(report['ignored'])}
{purge_list}
{error_list}
{purge_list}
{error_list}
{ban_list}
{error_list}
{unban_list}
{error_list}
{', '.join(success_list)}{', '.join(skipped_list)}{', '.join(error_list)}"
)
await evt.respond(results, allow_html=True, edits=msg)
except Exception as e:
error_msg = f"Failed to get parent room power levels: {e}"
self.log.error(error_msg)
await evt.respond(error_msg, edits=msg)
@community.subcommand(
"verify-migrate",
help="migrate a room to a verification-based permission model, ensuring current members can still send messages while new joiners require verification",
)
async def verify_migrate(self, evt: MessageEvent) -> None:
if not await self.check_parent_room(evt):
return
await evt.mark_read()
if not await self.user_permitted(evt.sender):
await evt.reply("You don't have permission to use this command")
return
msg = await evt.respond("Starting room migration...")
try:
# Get current room members
members = await self.client.get_joined_members(evt.room_id)
member_list = list(members.keys())
# Get current power levels
power_levels = await self.client.get_state_event(
evt.room_id, EventType.ROOM_POWER_LEVELS
)
# Get the required power level for sending messages
events_default = power_levels.events_default
events = power_levels.events
required_level = events.get(str(EventType.ROOM_MESSAGE), events_default)
# Set default power level to n-1 (usually 0)
power_levels.users_default = required_level - 1
# Set members to required level only if their current level is lower
# and they don't have unlimited power (creators in modern room versions)
for member in member_list:
# Check if member has unlimited power
if await self.user_has_unlimited_power(member, evt.room_id):
continue # Skip creators with unlimited power
current_level = power_levels.get_user_level(member)
if current_level < required_level:
power_levels.users[member] = required_level
# Send updated power levels
await self.client.send_state_event(
evt.room_id, EventType.ROOM_POWER_LEVELS, power_levels
)
await evt.respond(
f"Room migration complete. Current members can send messages, new joiners will require verification.",
edits=msg
)
except Exception as e:
error_msg = f"Failed to migrate room: {e}"
self.log.error(error_msg)
await evt.respond(error_msg, edits=msg)
async def store_verification_state(self, dm_room_id: str, state: dict) -> None:
"""Store verification state in the database."""
# Try to insert first, if it fails due to existing record, then update
try:
insert_query = """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)"""
await self.database.execute(insert_query, dm_room_id,
state["user"],
state["target_room"],
state["phrase"],
state["attempts"],
state["required_level"]
)
self.log.debug(f"Inserted new verification state for {dm_room_id}")
except Exception as e:
# If insert fails (likely due to existing record), try update
if "UNIQUE constraint failed" in str(e) or "duplicate key" in str(e).lower():
self.log.debug(f"Record exists for {dm_room_id}, updating instead")
update_query = """UPDATE verification_states
SET verification_phrase = $4, \
attempts_remaining = $5, \
required_power_level = $6, \
user_id = $2, \
target_room_id = $3 \
WHERE dm_room_id = $1"""
await self.database.execute(update_query, dm_room_id,
state["user"],
state["target_room"],
state["phrase"],
state["attempts"],
state["required_level"]
)
self.log.debug(f"Updated verification state for {dm_room_id}")
else:
# Re-raise if it's not a constraint violation
raise
async def get_verification_state(self, dm_room_id: str) -> Optional[dict]:
"""Retrieve verification state from the database."""
row = await self.database.fetchrow(
"SELECT * FROM verification_states WHERE dm_room_id = $1",
dm_room_id
)
if not row:
return None
return {
"user": row["user_id"],
"target_room": row["target_room_id"],
"phrase": row["verification_phrase"],
"attempts": row["attempts_remaining"],
"required_level": row["required_power_level"]
}
async def delete_verification_state(self, dm_room_id: str) -> None:
"""Delete verification state from the database."""
await self.database.execute(
"DELETE FROM verification_states WHERE dm_room_id = $1",
dm_room_id
)
async def cleanup_stale_verification_states(self) -> None:
"""Clean up verification states that are no longer valid."""
# Get all verification states
states = await self.database.fetch("SELECT * FROM verification_states")
for state in states:
try:
# Check if DM room still exists and bot is still in it
try:
await self.client.get_state_event(state["dm_room_id"], EventType.ROOM_MEMBER, self.client.mxid)
except Exception:
# Bot is not in the DM room anymore, state is stale
await self.delete_verification_state(state["dm_room_id"])
continue
# Check if user is still in the target room
try:
await self.client.get_state_event(state["target_room_id"], EventType.ROOM_MEMBER, state["user_id"])
except Exception:
# User is not in the target room anymore, state is stale
await self.delete_verification_state(state["dm_room_id"])
continue
# Check if verification is too old (older than 24 hours)
if (datetime.now() - state["created_at"]).total_seconds() > 86400:
await self.delete_verification_state(state["dm_room_id"])
continue
except Exception as e:
self.log.error(f"Error checking verification state {state['dm_room_id']}: {e}")
# If we can't check the state, assume it's stale
await self.delete_verification_state(state["dm_room_id"])
@classmethod
def get_db_upgrade_table(cls) -> None:
return upgrade_table
@classmethod
def get_config_class(cls) -> Type[BaseProxyConfig]:
return Config
@community.subcommand(
"initialize",
help="initialize a new community space with the given name. this command can only be used if no parent room is configured."
)
@command.argument("community_name", pass_raw=True, required=True)
async def initialize_community(self, evt: MessageEvent, community_name: str) -> None:
await evt.mark_read()
# Check if parent room is already configured
if self.config["parent_room"]:
await evt.reply("Cannot initialize: a parent room is already configured. Please remove the parent_room configuration first.")
return
# Validate community name
if not community_name or community_name.isspace():
await evt.reply("Please provide a community name. Usage: !community initialize