diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
old mode 100755
new mode 100644
diff --git a/.gitignore b/.gitignore
old mode 100755
new mode 100644
diff --git a/LICENSE.txt b/LICENSE.txt
old mode 100755
new mode 100644
diff --git a/README.md b/README.md
old mode 100755
new mode 100644
diff --git a/REFACTORING.md b/REFACTORING.md
old mode 100755
new mode 100644
diff --git a/base-config.yaml b/base-config.yaml
old mode 100755
new mode 100644
index 5ddddb4..ed2df71
--- a/base-config.yaml
+++ b/base-config.yaml
@@ -69,10 +69,10 @@ invitees: []
# set to {} if you don't care about greetings
greetings:
generic: |
- Welcome {user_id}! Please be sure to read the topic for helpful links and information.
+ Welcome {user}! Please be sure to read the topic for helpful links and information.
Use Google for all other queries ;)
encrypted: |
- welcome {user_id}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
+ welcome {user}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
alarmed.
# which of the above greetings should be used in which rooms? use the exact name of each greeting
@@ -100,7 +100,7 @@ notification_room:
# - {room_link}: clickable matrix.to-compatible link to the room
# - {room_id}: raw room ID
join_notification_message: |
- {user_link} ({user_id}) has joined {room_link}.
+ {user} has joined {room}.
# whether to censor files/messages
# can be boolean (true/false) for all-or-nothing behavior,
@@ -190,8 +190,10 @@ verification_message: |
Please send a message to this chat with the content: "{phrase}"
+# prefixes used for the clickable {user} and {room} placeholders in rendered HTML notices.
+# set either value to "" for a cleaner look without a visible prefix.
# Base URL for Matrix permalink generation.
# This is used for placeholders such as {user_link} and {room_link}.
# Set this to your own matrix.to-compatible instance if you do not want to use https://matrix.to.
-matrix_to_base_url: 'https://matrix.to'
+matrix_to_base_url: "https://matrix.to"
diff --git a/community/__init__.py b/community/__init__.py
old mode 100755
new mode 100644
diff --git a/community/bot.py b/community/bot.py
old mode 100755
new mode 100644
index f1bc1cc..f7a1dc8
--- a/community/bot.py
+++ b/community/bot.py
@@ -1,15 +1,20 @@
# kickbot - a maubot plugin to track user activity and remove inactive users from rooms/spaces.
-from typing import Awaitable, Type, Optional, Tuple, Dict
+from typing import Awaitable, Type, Optional, Tuple, Dict, Any
import json
import time
import re
import fnmatch
import asyncio
from html import escape
+from urllib.parse import quote
import random
import asyncpg.exceptions
from datetime import datetime
+from dataclasses import dataclass
+
+DEFAULT_USER_PILL_PREFIX = ""
+DEFAULT_ROOM_PILL_PREFIX = ""
from mautrix.client import (
Client,
@@ -66,6 +71,15 @@ from .helpers import (
)
+@dataclass(frozen=True)
+class RenderContext:
+ user_id: str
+ user_display: str
+ room_id: Optional[str] = None
+ room_text: Optional[str] = None
+ event_id: Optional[str] = None
+
+
class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("sleep")
@@ -107,11 +121,6 @@ class CommunityBot(Plugin):
return str(self.config.get("matrix_to_base_url", "https://matrix.to")).rstrip("/")
- _redaction_tasks: asyncio.Task = None
- _verification_states: Dict[str, Dict] = {}
- _report_counts: Dict[str, set] = {}
-
-
def _matrix_to_url(self, target: str) -> str:
base_url = self._get_matrix_to_base_url()
return f"{base_url}/#/{target}"
@@ -120,6 +129,22 @@ class CommunityBot(Plugin):
def _render_html_link(self, target: str, label: str) -> str:
return f"{escape(label)}"
+
+ def _encode_matrix_uri_part(self, value: str, sigils: str = "@!#$") -> str:
+ """Encode an MXID-like identifier for safe use inside a matrix: URI path."""
+ return quote(str(value).lstrip(sigils), safe=":.=_+-")
+
+ def _matrix_uri_user(self, user_id: str) -> str:
+ return f"matrix:u/{self._encode_matrix_uri_part(user_id, '@')}?action=chat"
+
+ def _matrix_uri_room(self, room_id: str) -> str:
+ return f"matrix:roomid/{self._encode_matrix_uri_part(room_id, '!')}"
+
+ def _matrix_uri_event(self, room_id: str, event_id: str) -> str:
+ encoded_room_id = self._encode_matrix_uri_part(room_id, "!")
+ encoded_event_id = self._encode_matrix_uri_part(event_id, "$")
+ return f"matrix:roomid/{encoded_room_id}/e/{encoded_event_id}"
+
async def _get_user_display_name(self, room_id: RoomID, user_id: str) -> str:
try:
member_state = await self.client.get_state_event(
@@ -130,14 +155,278 @@ class CommunityBot(Plugin):
displayname = getattr(member_state, "displayname", None)
if displayname:
return str(displayname)
- except Exception:
- pass
+ except Exception as e:
+ self.log.debug(f"Failed to fetch display name for {user_id} in {room_id}: {e}")
try:
return self.client.parse_user_id(user_id)[0]
except Exception:
return user_id
+
+ def _matrix_user_uri(self, user_id: str) -> str:
+ """Build a Matrix URI for a user."""
+ return self._matrix_uri_user(user_id)
+
+ def _matrix_room_uri(self, room_id: str, room_alias: str | None = None) -> str:
+ """Build a Matrix URI for a room, preferring canonical alias when available."""
+ if room_alias:
+ return f"matrix:r/{self._encode_matrix_uri_part(room_alias, '#')}"
+ return self._matrix_uri_room(room_id)
+
+ def _matrix_event_uri(self, room_id: str, event_id: str) -> str:
+ """Build a Matrix URI for an event inside a room."""
+ return self._matrix_uri_event(room_id, event_id)
+
+
+ async def _get_room_display_text(self, room_id: RoomID) -> str:
+ """Return a human-friendly room name, falling back to the room ID."""
+ try:
+ room_name_event = await self.client.get_state_event(room_id, EventType.ROOM_NAME)
+ room_name = getattr(room_name_event, "name", None)
+ if room_name:
+ return str(room_name)
+ except Exception as e:
+ self.log.debug(f"Failed to fetch room name for {room_id}: {e}")
+ return str(room_id)
+
+ async def _build_render_context(self, room_id: RoomID, user_id: str) -> RenderContext:
+ """Fetch the values needed for template rendering once per join event."""
+ user_display = await self._get_user_display_name(room_id, user_id)
+ room_text = await self._get_room_display_text(room_id)
+ return RenderContext(
+ user_id=str(user_id),
+ user_display=user_display,
+ room_id=str(room_id),
+ room_text=room_text,
+ )
+
+ async def _send_rendered_notice(
+ self,
+ target_room_id: RoomID,
+ template: str,
+ context: RenderContext,
+ ) -> None:
+ """Render a template once and send plaintext + HTML variants."""
+ plain_text, html_message = self._render_message_template(
+ template,
+ context.user_id,
+ context.user_display,
+ context.room_id,
+ context.room_text,
+ )
+ await self.client.send_notice(target_room_id, plain_text, html=html_message)
+
+ async def _handle_join_notifications(self, evt: StateEvent) -> None:
+ """Send configured greetings and join notifications for a new member."""
+ room_id = str(evt.room_id)
+ if room_id not in self.config["greeting_rooms"]:
+ return
+
+ greeting_name = self.config["greeting_rooms"][room_id]
+ context = await self._build_render_context(evt.room_id, evt.sender)
+
+ if greeting_name != "none":
+ greeting_map = self.config["greetings"]
+ await self._sleep_if_configured(self.config["welcome_sleep"])
+ await self._send_rendered_notice(evt.room_id, greeting_map[greeting_name], context)
+
+ if self.config["notification_room"]:
+ await self._send_rendered_notice(
+ self.config["notification_room"],
+ self.config["join_notification_message"],
+ context,
+ )
+
+ def _is_human_verification_enabled_for_room(self, room_id: RoomID) -> bool:
+ configured = self.config["check_if_human"]
+ if isinstance(configured, bool):
+ return configured
+ if isinstance(configured, list):
+ return room_id in configured
+ return False
+
+ async def _user_requires_human_verification(
+ self, user_id: UserID, room_id: RoomID
+ ) -> Optional[int]:
+ """Return the required message power level if verification should proceed."""
+ if await self.user_has_unlimited_power(user_id, room_id):
+ self.log.debug(
+ f"User {user_id} has unlimited power in {room_id}, skipping verification"
+ )
+ return None
+
+ power_levels = await self.client.get_state_event(room_id, EventType.ROOM_POWER_LEVELS)
+ user_level = power_levels.get_user_level(user_id)
+ required_level = power_levels.events.get(
+ str(EventType.ROOM_MESSAGE), power_levels.events_default
+ )
+ self.log.debug(
+ f"User {user_id} has power level {user_level}, required level is {required_level}"
+ )
+ if user_level >= required_level:
+ self.log.debug(
+ f"User {user_id} already has sufficient power level ({user_level} >= {required_level})"
+ )
+ return None
+ return required_level
+
+ async def _create_verification_dm(
+ self, user_id: UserID, roomname: str
+ ) -> Optional[RoomID]:
+ """Create a DM room for human verification with bounded retries."""
+ max_retries = 3
+ retry_delay = 1
+
+ for attempt in range(max_retries):
+ try:
+ dm_room = await self.client.create_room(
+ preset=RoomCreatePreset.PRIVATE,
+ invitees=[user_id],
+ 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 {user_id}")
+ return dm_room
+ except Exception as e:
+ if attempt < max_retries - 1:
+ 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 None
+
+ async def _maybe_start_human_verification(
+ self, evt: StateEvent, room_label: str
+ ) -> None:
+ """Run the human verification flow for a newly joined member when configured."""
+ if not (self.config["check_if_human"] and self.config["verification_phrases"]):
+ return
+
+ verification_enabled = self._is_human_verification_enabled_for_room(evt.room_id)
+ self.log.debug(
+ f"Verification enabled for room {evt.room_id}: {verification_enabled}"
+ )
+ if not verification_enabled:
+ return
+
+ try:
+ required_level = await self._user_requires_human_verification(
+ evt.sender, evt.room_id
+ )
+ except Exception as e:
+ self.log.error(f"Failed to check user power level: {e}")
+ return
+
+ if required_level is None:
+ return
+
+ dm_room = await self._create_verification_dm(evt.sender, room_label)
+ if not dm_room:
+ return
+
+ verification_phrase = random.choice(self.config["verification_phrases"])
+ 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)
+
+ greeting = self.config["verification_message"].format(
+ room=room_label, phrase=verification_phrase
+ )
+ await self.client.send_notice(dm_room, html=greeting)
+ self.log.info(
+ f"Started verification process for {evt.sender} in room {evt.room_id} for room {room_label}"
+ )
+
+ async def _resolve_room_identifier(self, room: str) -> str:
+ """Resolve either a room alias or a room ID into a room ID."""
+ if room.startswith("#"):
+ resolved = await self.client.resolve_room_alias(room)
+ room_id = resolved["room_id"]
+ self.log.info(f"Resolved alias '{room}' to room ID: {room_id}")
+ return room_id
+ self.log.info(f"Using direct room ID: {room}")
+ return room
+
+ async def _get_room_metadata(self, room_id: str) -> Dict[str, Optional[str]]:
+ """Fetch the room name and topic once for room replacement flows."""
+ metadata: Dict[str, Optional[str]] = {"room_name": None, "room_topic": None}
+ try:
+ room_name_event = await self.client.get_state_event(room_id, EventType.ROOM_NAME)
+ metadata["room_name"] = getattr(room_name_event, "name", None)
+ except Exception as e:
+ self.log.warning(f"Failed to get room name: {e}")
+
+ try:
+ room_topic_event = await self.client.get_state_event(room_id, EventType.ROOM_TOPIC)
+ metadata["room_topic"] = getattr(room_topic_event, "topic", None)
+ except Exception as e:
+ self.log.warning(f"Failed to get room topic: {e}")
+
+ return metadata
+
+ async def _detect_space_type(self, room_id: str) -> bool:
+ """Return True when the target room is a space."""
+ try:
+ state_events = await self.client.get_state(room_id)
+ for state_event in state_events:
+ if str(state_event.type) != "m.room.create":
+ continue
+ space_type = state_event.content.get("type")
+ is_space = space_type == "m.space"
+ self.log.info(f"Detected room type {space_type!r} for {room_id}")
+ return is_space
+ except Exception as e:
+ self.log.error(f"Failed to detect room type for {room_id}: {e}")
+ return False
+
+
+ def _format_user_pill(
+ self,
+ user_id: str,
+ user_display: Optional[str] = None,
+ ) -> Tuple[str, str]:
+ """Return plaintext and HTML variants for the {user} placeholder."""
+ safe_user_display = user_display or user_id
+ prefix = DEFAULT_USER_PILL_PREFIX
+ label = f"{prefix}{safe_user_display}"
+ href = self._matrix_uri_user(str(user_id))
+
+ return label, f"{escape(label)}"
+
+ def _format_room_pill(
+ self,
+ room_id: Optional[str] = None,
+ room_text: Optional[str] = None,
+ ) -> Tuple[str, str]:
+ """Return plaintext and HTML variants for the {room} placeholder."""
+ safe_room_id = str(room_id or "")
+ safe_room_text = room_text or safe_room_id
+ prefix = DEFAULT_ROOM_PILL_PREFIX
+ label = f"{prefix}{safe_room_text}"
+
+ if safe_room_id:
+ href = self._matrix_uri_room(safe_room_id)
+ html = f"{escape(label)}"
+ else:
+ html = escape(label)
+
+ return label, html
+
def _render_message_template(
self,
template: str,
@@ -151,21 +440,23 @@ class CommunityBot(Plugin):
safe_room_id = room_id or ""
safe_room_text = room_text or safe_room_id
room_url = self._matrix_to_url(safe_room_id) if safe_room_id else ""
+ user_plain, user_html = self._format_user_pill(user_id, safe_user_display)
+ room_plain, room_html = self._format_room_pill(safe_room_id, safe_room_text)
plain_text = template.format(
- user=safe_user_display,
+ user=user_plain,
user_id=user_id,
user_link=user_url,
- room=safe_room_text,
+ room=room_plain,
room_link=room_url,
room_id=safe_room_id,
)
html_message = template.format(
- user=escape(safe_user_display),
+ user=user_html,
user_id=escape(user_id),
user_link=f"{escape(safe_user_display)}",
- room=escape(safe_room_text),
+ room=room_html,
room_link=(
f"{escape(safe_room_text)}"
if room_url
@@ -181,14 +472,14 @@ class CommunityBot(Plugin):
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
+ self._redaction_task: Optional[asyncio.Task] = None
+ self._report_counts: Dict[str, set[str]] = {}
+ self._redaction_task = asyncio.create_task(self._redaction_loop())
await self.cleanup_stale_verification_states()
async def stop(self) -> None:
- if self._redaction_tasks:
- self._redaction_tasks.cancel()
+ if self._redaction_task:
+ self._redaction_task.cancel()
await super().stop()
async def _sleep_if_configured(self, delay: float) -> None:
@@ -1055,212 +1346,35 @@ class CommunityBot(Plugin):
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']}"
- )
+ space_rooms = await self.get_space_roomlist()
+ if evt.room_id not in space_rooms:
+ return
- 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]
- user_display = await self._get_user_display_name(evt.room_id, evt.sender)
- try:
- roomnamestate = await self.client.get_state_event(
- evt.room_id, "m.room.name"
- )
- room_text = getattr(roomnamestate, "name", str(evt.room_id))
- except Exception:
- room_text = str(evt.room_id)
+ 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 greeting_name != "none":
- greeting_text, greeting_html = self._render_message_template(
- greeting_map[greeting_name],
- evt.sender,
- user_display,
- evt.room_id,
- room_text,
- )
- await self._sleep_if_configured(self.config["welcome_sleep"])
- await self.client.send_notice(
- evt.room_id,
- greeting_text,
- html=greeting_html,
- )
- else:
- pass
-
- if self.config["notification_room"]:
- try:
- roomnamestate = await self.client.get_state_event(
- evt.room_id, "m.room.name"
- )
-
- room_text = getattr(roomnamestate, "name", str(evt.room_id))
- except Exception:
- room_text = str(evt.room_id)
-
- user_display = await self._get_user_display_name(evt.room_id, evt.sender)
- notification_text, notification_html = self._render_message_template(
- self.config["join_notification_message"],
- evt.sender,
- user_display,
- evt.room_id,
- room_text,
- )
- await self.client.send_notice(
- self.config["notification_room"],
- notification_text,
- html=notification_html,
- )
+ if on_banlist:
+ await self.ban_this_user(evt.sender)
+ return
- # 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"]
- )
+ if evt.room_id == self.config["parent_room"]:
+ await self.do_sync()
- self.log.debug(
- f"Verification enabled for room {room_id}: {verification_enabled}"
- )
+ self.log.debug(f"New join in room {evt.room_id} by {evt.sender}")
+ await self._handle_join_notifications(evt)
- if not verification_enabled:
- return
+ if not (self.config["check_if_human"] and self.config["verification_phrases"]):
+ 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}")
+ try:
+ room_label = await common_utils.get_room_name(self.client, evt.room_id, self.log)
+ await self._maybe_start_human_verification(evt, room_label)
+ 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:
@@ -1447,11 +1561,10 @@ class CommunityBot(Plugin):
room_text = str(evt.room_id)
reporter_display = await self._get_user_display_name(evt.room_id, evt.sender)
- room_url = self._matrix_to_url(evt.room_id)
- message_url = self._matrix_to_url(f"{evt.room_id}/{target_event_id}")
-
- room_link_html = self._render_html_link(evt.room_id, room_text)
- reporter_link_html = self._render_html_link(evt.sender, reporter_display)
+ room_plain, room_link_html = self._format_room_pill(str(evt.room_id), room_text)
+ reporter_plain, reporter_link_html = self._format_user_pill(str(evt.sender), reporter_display)
+ room_url = room_plain
+ message_url = self._matrix_uri_event(str(evt.room_id), str(target_event_id))
message_link_html = f"Original Event Link"
if self.config.get("auto_redact_majority", False):
@@ -1468,13 +1581,13 @@ class CommunityBot(Plugin):
)
notification_text = (
- "Message Auto-Redacted 🗑️\n"
+ "🗑️ Message Auto-Redacted\n"
f"Room: {room_url}\n"
f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).\n"
f"Context: {message_url}"
)
notification_html = (
- f"Message Auto-Redacted 🗑️
"
+ f"🗑️ Message Auto-Redacted
"
f"Room: {room_link_html}
"
f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).
"
f"Context: {message_link_html}"
@@ -1492,14 +1605,14 @@ class CommunityBot(Plugin):
if current_reports == 1:
notification_text = (
- "Message Reported 🚨\n"
- f"First Reporter: {reporter_display} ({evt.sender})\n"
+ "🚨 Message Reported\n"
+ f"First Reporter: {reporter_plain}\n"
f"Room: {room_url}\n"
f"Action: {message_url}"
)
notification_html = (
- f"Message Reported 🚨
"
- f"First Reporter: {reporter_link_html} ({escape(evt.sender)})
"
+ f"🚨 Message Reported
"
+ f"First Reporter: {reporter_link_html}
"
f"Room: {room_link_html}
"
f"Action: Click here to inspect and moderate"
)
@@ -2159,176 +2272,40 @@ class CommunityBot(Plugin):
@decorators.require_parent_room
@decorators.require_permission(min_level=100)
async def room_replace(self, evt: MessageEvent, room: str) -> None:
- self.log.info(f"=== REPLACEROOM COMMAND STARTED ===")
- self.log.info(f"Command arguments: room='{room}', evt.room_id='{evt.room_id}'")
-
await evt.mark_read()
if not room:
room = evt.room_id
- # first we need to get relevant room state of the room we want to replace
- # this includes the room name, alias, and join rules
- if room.startswith("#"):
- room_id = await self.client.resolve_room_alias(room)
- room_id = room_id["room_id"]
- self.log.info(f"Resolved alias '{room}' to room ID: {room_id}")
- else:
- room_id = room
- self.log.info(f"Using direct room ID: {room_id}")
- # Check bot permissions in the old room
- self.log.info(f"=== CHECKING BOT PERMISSIONS ===")
+ room_id = await self._resolve_room_identifier(room)
+
has_perms, error_msg, _ = await self.check_bot_permissions(
room_id, evt, ["state", "tombstone", "power_levels"]
)
- self.log.info(
- f"Bot permissions check result: has_perms={has_perms}, error_msg='{error_msg}'"
- )
if not has_perms:
await evt.respond(f"Cannot replace room: {error_msg}")
- self.log.info("Bot permissions check failed, returning")
return
- # Get the room name from the state event
- room_name = None
- try:
- room_name_event = await self.client.get_state_event(
- room_id, EventType.ROOM_NAME
- )
- room_name = room_name_event.name
- self.log.info(f"Retrieved room name: '{room_name}'")
- except Exception as e:
- self.log.warning(f"Failed to get room name: {e}")
- # room_name remains None
-
- # get the room topic from the state event
- room_topic = None
- try:
- room_topic_event = await self.client.get_state_event(
- room_id, EventType.ROOM_TOPIC
- )
- room_topic = room_topic_event.topic
- except Exception as e:
- self.log.warning(f"Failed to get room topic: {e}")
- # room_topic remains None
-
- # Check if the room being replaced is a space
- is_space = False
- self.log.info(f"=== ABOUT TO START SPACE DETECTION ===")
- self.log.info(f"=== SPACE DETECTION DEBUG START ===")
- self.log.info(f"Room ID being checked: {room_id}")
- self.log.info(f"EventType module: {EventType}")
- self.log.info(
- f"EventType.ROOM_CREATE exists: {hasattr(EventType, 'ROOM_CREATE')}"
- )
- if hasattr(EventType, "ROOM_CREATE"):
- self.log.info(
- f"EventType.ROOM_CREATE value: {getattr(EventType, 'ROOM_CREATE')}"
- )
- else:
- self.log.warning("EventType.ROOM_CREATE does not exist!")
-
- try:
- # Get the room creation event to check if it's a space
- state_events = await self.client.get_state(room_id)
- self.log.info(
- f"Retrieved {len(state_events)} state events for space detection"
- )
-
- # Log all event types for debugging
- event_types = [event.type for event in state_events]
- self.log.info(f"Event types found: {event_types}")
-
- # Debug EventType.ROOM_CREATE constant
- self.log.info(f"EventType.ROOM_CREATE value: {EventType.ROOM_CREATE}")
- self.log.info(f"EventType.ROOM_CREATE type: {type(EventType.ROOM_CREATE)}")
-
- # Also try string comparison as fallback
- room_create_string = "m.room.create"
- self.log.info(f"String comparison value: {room_create_string}")
-
- # Try to find the room creation event using multiple methods
- room_create_event = None
-
- for i, event in enumerate(state_events):
- self.log.info(
- f"Event {i}: type={event.type} (type: {type(event.type)})"
- )
-
- # Try multiple comparison methods
- if (
- hasattr(EventType, "ROOM_CREATE")
- and event.type == EventType.ROOM_CREATE
- ):
- self.log.info(f"✓ Matched EventType.ROOM_CREATE")
- room_create_event = event
- break
- elif str(event.type) == room_create_string:
- self.log.info(f"✓ Matched string comparison 'm.room.create'")
- room_create_event = event
- break
- elif event.type == "m.room.create":
- self.log.info(f"✓ Matched direct string comparison")
- room_create_event = event
- break
- else:
- self.log.info(f"✗ No match for event {i}")
-
- # Now process the room creation event if found
- if room_create_event:
- space_type = room_create_event.content.get("type")
- self.log.info(f"Found ROOM_CREATE event with type: {space_type}")
- self.log.info(f"Full ROOM_CREATE content: {room_create_event.content}")
- is_space = space_type == "m.space"
- self.log.info(f"Space detection result: {is_space}")
- else:
- self.log.warning("No ROOM_CREATE event found using any method")
-
- if is_space:
- self.log.info(
- f"✓ FINAL RESULT: Room {room_id} IS a space - will create new space"
- )
- else:
- self.log.info(
- f"✗ FINAL RESULT: Room {room_id} is NOT a space - will create regular room"
- )
-
- except Exception as e:
- self.log.error(f"❌ ERROR during space detection: {e}")
- import traceback
-
- self.log.error(f"Traceback: {traceback.format_exc()}")
- # Assume it's not a space if we can't determine
- is_space = False
-
- self.log.info(f"=== SPACE DETECTION DEBUG END - is_space={is_space} ===")
-
- # Get list of aliases to transfer while removing them from the old room
+ metadata = await self._get_room_metadata(room_id)
+ room_name = metadata["room_name"]
+ room_topic = metadata["room_topic"]
+ is_space = await self._detect_space_type(room_id)
aliases_to_transfer = await self.remove_room_aliases(room_id, evt)
- # Check if community slug is configured
if self.config["use_community_slug"] and not self.config["community_slug"]:
await evt.respond(
"No community slug configured. Please run initialize command first."
)
return
- # Inform user about what type of room is being replaced
if not room_name:
- room_name = f"Room {room_id[:8]}..." # Fallback name
+ room_name = f"Room {room_id[:8]}..."
self.log.warning(f"Using fallback room name: {room_name}")
- self.log.info(
- f"Final decision - is_space: {is_space}, room_name: '{room_name}'"
- )
- self.log.info(f"About to send user message - is_space: {is_space}")
-
if is_space:
await evt.respond(f"Replacing space '{room_name}' with a new space...")
- self.log.info(f"✓ Sent 'Replacing space' message to user")
else:
await evt.respond(f"Replacing room '{room_name}' with a new room...")
- self.log.info(f"✗ Sent 'Replacing room' message to user")
# Validate that the new room alias is available
is_valid, conflicting_aliases = await self.validate_room_aliases(
diff --git a/community/db.py b/community/db.py
old mode 100755
new mode 100644
diff --git a/community/helpers/__init__.py b/community/helpers/__init__.py
old mode 100755
new mode 100644
diff --git a/community/helpers/base_command_handler.py b/community/helpers/base_command_handler.py
old mode 100755
new mode 100644
diff --git a/community/helpers/common_utils.py b/community/helpers/common_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/config_manager.py b/community/helpers/config_manager.py
old mode 100755
new mode 100644
diff --git a/community/helpers/database_utils.py b/community/helpers/database_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/decorators.py b/community/helpers/decorators.py
old mode 100755
new mode 100644
diff --git a/community/helpers/diagnostic_utils.py b/community/helpers/diagnostic_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/message_utils.py b/community/helpers/message_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/report_utils.py b/community/helpers/report_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/response_builder.py b/community/helpers/response_builder.py
old mode 100755
new mode 100644
diff --git a/community/helpers/room_creation_utils.py b/community/helpers/room_creation_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/room_utils.py b/community/helpers/room_utils.py
old mode 100755
new mode 100644
diff --git a/community/helpers/user_utils.py b/community/helpers/user_utils.py
old mode 100755
new mode 100644
diff --git a/example-standalone-config.yaml b/example-standalone-config.yaml
old mode 100755
new mode 100644
index 546064a..258fb00
--- a/example-standalone-config.yaml
+++ b/example-standalone-config.yaml
@@ -126,10 +126,10 @@ plugin_config:
# set to {} if you don't care about greetings
greetings:
generic: |
- Welcome {user_link}! Please be sure to read the topic for helpful links and information.
+ Welcome {user}! Please be sure to read the topic for helpful links and information.
Use Google for all other queries ;)
encrypted: |
- welcome {user_link}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
+ welcome {user}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
alarmed.
# which of the above greetings should be used in which rooms? use the exact name of each greeting
@@ -150,9 +150,9 @@ plugin_config:
# message to send to the notification room when someone joins one of the above rooms:
join_notification_message: |
- {user_link} has joined {room_link} ({user_id}).
+ {user} has joined {room}.
- # Base URL for Matrix permalink generation.
+# Base URL for Matrix permalink generation.
# This is used for placeholders such as {user_link} and {room_link}.
# Set this to your own matrix.to-compatible instance if you do not want to use https://matrix.to.
matrix_to_base_url: "https://matrix.to"
diff --git a/maubot.yaml b/maubot.yaml
old mode 100755
new mode 100644
index 4861768..f005917
--- a/maubot.yaml
+++ b/maubot.yaml
@@ -1,6 +1,6 @@
maubot: 0.1.0
id: org.jobmachine.communitybot
-version: 0.5.0
+version: 0.6.0
license: MIT
modules:
- community
diff --git a/pytest.ini b/pytest.ini
old mode 100755
new mode 100644
diff --git a/requirements.txt b/requirements.txt
old mode 100755
new mode 100644
diff --git a/run-standalone.sh b/run-standalone.sh
old mode 100755
new mode 100644
diff --git a/run_tests.py b/run_tests.py
old mode 100755
new mode 100644
diff --git a/tests/__init__.py b/tests/__init__.py
old mode 100755
new mode 100644
diff --git a/tests/test_bot_commands.py b/tests/test_bot_commands.py
old mode 100755
new mode 100644
diff --git a/tests/test_bot_events.py b/tests/test_bot_events.py
old mode 100755
new mode 100644
diff --git a/tests/test_database_utils.py b/tests/test_database_utils.py
old mode 100755
new mode 100644
diff --git a/tests/test_matrix_uri_helpers.py b/tests/test_matrix_uri_helpers.py
new file mode 100644
index 0000000..0c1543e
--- /dev/null
+++ b/tests/test_matrix_uri_helpers.py
@@ -0,0 +1,56 @@
+import html
+from types import SimpleNamespace
+
+import pytest
+
+from community.bot import CommunityBot, DEFAULT_ROOM_PILL_PREFIX, DEFAULT_USER_PILL_PREFIX
+
+
+@pytest.fixture()
+def bot() -> CommunityBot:
+ plugin = CommunityBot.__new__(CommunityBot)
+ plugin.config = {}
+ return plugin
+
+
+def test_user_uri_helper_strips_at_and_uses_chat_action(bot: CommunityBot) -> None:
+ assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
+
+
+def test_room_uri_helper_prefers_alias(bot: CommunityBot) -> None:
+ assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
+
+
+def test_room_uri_helper_falls_back_to_room_id_without_bang(bot: CommunityBot) -> None:
+ assert bot._matrix_room_uri("!roomid:example.org", None) == "matrix:roomid/roomid:example.org"
+
+
+def test_event_uri_helper_strips_prefixes(bot: CommunityBot) -> None:
+ assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
+
+
+def test_format_user_pill_uses_clean_default_prefix(bot: CommunityBot) -> None:
+ plain, formatted = bot._format_user_pill("@alice:example.org", "Alice")
+ assert plain == f"{DEFAULT_USER_PILL_PREFIX}Alice"
+ assert 'href="matrix:u/alice:example.org?action=chat"' in formatted
+ assert ">Alice<" in formatted
+
+
+def test_format_room_pill_uses_alias_when_available(bot: CommunityBot) -> None:
+ plain, formatted = bot._format_room_pill("!roomid:example.org", "General", "#general:example.org")
+ assert plain == f"{DEFAULT_ROOM_PILL_PREFIX}General"
+ assert formatted == 'General'
+
+
+def test_format_room_pill_falls_back_to_room_id(bot: CommunityBot) -> None:
+ plain, formatted = bot._format_room_pill("!roomid:example.org", "General", None)
+ assert plain == f"{DEFAULT_ROOM_PILL_PREFIX}General"
+ assert formatted == 'General'
+
+
+def test_format_user_pill_escapes_displayname(bot: CommunityBot) -> None:
+ plain, formatted = bot._format_user_pill("@alice:example.org", '')
+ assert plain == f"{DEFAULT_USER_PILL_PREFIX}"
+ # Keep this broad enough to avoid coupling to quote style.
+ assert "matrix:u/alice:example.org?action=chat" in formatted
+ assert html.escape('') in formatted
diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py
old mode 100755
new mode 100644
diff --git a/tests/test_quality_regressions.py b/tests/test_quality_regressions.py
new file mode 100644
index 0000000..27822e8
--- /dev/null
+++ b/tests/test_quality_regressions.py
@@ -0,0 +1,10 @@
+from community.bot import CommunityBot
+
+
+def test_matrix_uri_wrappers_delegate_to_canonical_helpers() -> None:
+ bot = CommunityBot.__new__(CommunityBot)
+ bot.config = {}
+ assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
+ assert bot._matrix_room_uri("!roomid:example.org") == "matrix:roomid/roomid:example.org"
+ assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
+ assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
diff --git a/tests/test_report_utils.py b/tests/test_report_utils.py
old mode 100755
new mode 100644
diff --git a/tests/test_room_utils.py b/tests/test_room_utils.py
old mode 100755
new mode 100644
diff --git a/tests/test_space_creation_simple.py b/tests/test_space_creation_simple.py
old mode 100755
new mode 100644
diff --git a/tests/test_template_rendering.py b/tests/test_template_rendering.py
old mode 100755
new mode 100644
index 820e4b6..6eb44a1
--- a/tests/test_template_rendering.py
+++ b/tests/test_template_rendering.py
@@ -12,7 +12,11 @@ from community.bot import CommunityBot
def bot():
bot = CommunityBot.__new__(CommunityBot)
bot.client = Mock()
- bot.config = {"matrix_to_base_url": "https://matrix.to"}
+ bot.config = {
+ "matrix_to_base_url": "https://matrix.to",
+ "user_pill_prefix": "@",
+ "room_pill_prefix": "#",
+ }
return bot
@@ -51,7 +55,50 @@ def test_render_message_template_supports_user_id_and_user_link(bot):
"General",
)
- assert plain == "Alice / @alice:example.org / https://matrix.to/#/@alice:example.org"
- assert "Alice / @alice:example.org / " in html
+ assert plain == "@Alice / @alice:example.org / https://matrix.to/#/@alice:example.org"
+ assert "@Alice / @alice:example.org / " in html
assert 'Alice' in html
+
+
+def test_render_message_template_uses_configurable_user_and_room_pill_prefixes(bot):
+ bot.config["user_pill_prefix"] = ""
+ bot.config["room_pill_prefix"] = ""
+
+ plain, html = bot._render_message_template(
+ "{user} has joined {room}.",
+ "@alice:example.org",
+ "Alice",
+ "!room:example.org",
+ "General",
+ )
+
+ assert plain == "Alice has joined General."
+ assert "Alice" in html
+ assert "General" in html
+
+
+def test_render_message_template_defaults_to_prefixed_user_and_room_pills(bot):
+ plain, html = bot._render_message_template(
+ "{user} has joined {room}.",
+ "@alice:example.org",
+ "Alice",
+ "!room:example.org",
+ "General",
+ )
+
+ assert plain == "@Alice has joined #General."
+ assert "@Alice" in html
+ assert "#General" in html
+
+
+def test_matrix_uri_helpers_are_consistent():
+ from community.bot import CommunityBot
+
+ bot = CommunityBot.__new__(CommunityBot)
+ bot.config = {}
+
+ assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
+ assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
+ assert bot._matrix_room_uri("!roomid:example.org", None) == "matrix:roomid/roomid:example.org"
+ assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
diff --git a/tests/test_user_utils.py b/tests/test_user_utils.py
old mode 100755
new mode 100644