5 Commits

Author SHA1 Message Date
Dome edd3eee178 feat: native matrix URI pills for {user}/{room} + major rendering & codebase refactor
This change introduces native `matrix:` URI-based rendering for `{user}` and `{room}` placeholders,
replacing previous plaintext and matrix.to-based links. Users and rooms are now rendered as clickable
pills in supporting clients, with a clean display using display names and room names (no @/# prefixes).

Reporting, moderation, and auto-redaction messages have been updated to use the same rendering logic.
Inspect and event links now also use native `matrix:` URIs for direct in-client navigation.

Internally, URI generation and rendering logic have been unified via central helper functions,
ensuring consistent handling of user IDs, room IDs, aliases, and event IDs.

This commit also includes a broader refactor of the codebase:
- decomposed complex flows (e.g. join handling) into smaller helpers
- moved mutable class-level state to instance-level
- reduced duplicate API calls and redundant logic
- improved overall structure and maintainability

Test coverage has been extended for URI helpers and rendering logic to prevent regressions.

No breaking changes to existing template parameters like `{user_link}` or `{room_link}`.
2026-04-11 20:21:33 +02:00
Dome 933865d80c Update base-config.yaml 2026-04-11 00:55:47 +02:00
Dome b2541c4054 feat: add configurable matrix permalink base, unify user placeholders, and refactor notification rendering
feat: add configurable matrix permalink base, unify user placeholders, and refactor notification rendering
2026-04-10 23:55:17 +02:00
Dome bc490bd084 Merge pull request #1 from ReK42/main
Add `use_community_slug` option to support disabling the slug suffix
2026-04-10 20:58:18 +02:00
ReK42 1e653c60e3 Add use_community_slug option to support disabling the slug suffix 2026-02-18 18:34:07 -08:00
14 changed files with 913 additions and 407 deletions
+27 -3
View File
@@ -10,6 +10,10 @@ parent_room: ''
# leave blank to generate an acronym of your community name during initialization # leave blank to generate an acronym of your community name during initialization
community_slug: '' community_slug: ''
# use_community_slug
# whether to use the community slug as a suffix for room aliases
use_community_slug: true
# sleep time between actions. you can drop this to 0 if your bot has no # sleep time between actions. you can drop this to 0 if your bot has no
# ratelimits imposed on its homeserver, otherwise you may want to increase this # ratelimits imposed on its homeserver, otherwise you may want to increase this
# to avoid errors. # to avoid errors.
@@ -54,8 +58,13 @@ invitees: []
# auto-greet users in rooms with these messages # auto-greet users in rooms with these messages
# map greeting messages to a room # map greeting messages to a room
# you can use {user} to reference the joining user in this message using a # available placeholders:
# matrix.to link (rendered as a "pill" in element clients) # - {user}: display name of the joining user (falls back to localpart or user ID)
# - {user_id}: full Matrix user ID
# - {user_link}: clickable matrix.to-compatible link to the joining user
# - {room}: room name (or room ID if no name is set)
# - {room_link}: clickable matrix.to-compatible link to the room
# - {room_id}: raw room ID
# html formatting is supported # html formatting is supported
# set to {} if you don't care about greetings # set to {} if you don't care about greetings
greetings: greetings:
@@ -83,8 +92,15 @@ welcome_sleep: 0
notification_room: notification_room:
# message to send to the notification room when someone joins one of the above rooms: # message to send to the notification room when someone joins one of the above rooms:
# available placeholders:
# - {user}: display name of the joining user (falls back to localpart or user ID)
# - {user_id}: full Matrix user ID
# - {user_link}: clickable matrix.to-compatible link to the joining user
# - {room}: room name (or room ID if no name is set)
# - {room_link}: clickable matrix.to-compatible link to the room
# - {room_id}: raw room ID
join_notification_message: | join_notification_message: |
{user} has joined {room_link}. {user} has joined {room}.
# whether to censor files/messages # whether to censor files/messages
# can be boolean (true/false) for all-or-nothing behavior, # can be boolean (true/false) for all-or-nothing behavior,
@@ -173,3 +189,11 @@ verification_message: |
Thank you for joining {room}. As an anti-spam measure, you must demonstrate that you are a real person before you can send messages in its rooms. Thank you for joining {room}. As an anti-spam measure, you must demonstrate that you are a real person before you can send messages in its rooms.
Please send a message to this chat with the content: "{phrase}" 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"
+455 -356
View File
@@ -1,14 +1,20 @@
# kickbot - a maubot plugin to track user activity and remove inactive users from rooms/spaces. # 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 json
import time import time
import re import re
import fnmatch import fnmatch
import asyncio import asyncio
from html import escape
from urllib.parse import quote
import random import random
import asyncpg.exceptions import asyncpg.exceptions
from datetime import datetime from datetime import datetime
from dataclasses import dataclass
DEFAULT_USER_PILL_PREFIX = ""
DEFAULT_ROOM_PILL_PREFIX = ""
from mautrix.client import ( from mautrix.client import (
Client, Client,
@@ -65,12 +71,22 @@ 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): class Config(BaseProxyConfig):
def do_update(self, helper: ConfigUpdateHelper) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
helper.copy("sleep") helper.copy("sleep")
helper.copy("welcome_sleep") helper.copy("welcome_sleep")
helper.copy("parent_room") helper.copy("parent_room")
helper.copy("community_slug") helper.copy("community_slug")
helper.copy("use_community_slug")
helper.copy("track_users") helper.copy("track_users")
helper.copy("warn_threshold_days") helper.copy("warn_threshold_days")
helper.copy("kick_threshold_days") helper.copy("kick_threshold_days")
@@ -78,6 +94,7 @@ class Config(BaseProxyConfig):
helper.copy("invitees") helper.copy("invitees")
helper.copy("notification_room") helper.copy("notification_room")
helper.copy("join_notification_message") helper.copy("join_notification_message")
helper.copy("matrix_to_base_url")
helper.copy_dict("greeting_rooms") helper.copy_dict("greeting_rooms")
helper.copy_dict("greetings") helper.copy_dict("greetings")
helper.copy("censor") helper.copy("censor")
@@ -100,25 +117,376 @@ class Config(BaseProxyConfig):
class CommunityBot(Plugin): class CommunityBot(Plugin):
_redaction_tasks: asyncio.Task = None def _get_matrix_to_base_url(self) -> str:
_verification_states: Dict[str, Dict] = {} return str(self.config.get("matrix_to_base_url", "https://matrix.to")).rstrip("/")
_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}"
def _render_html_link(self, target: str, label: str) -> str:
return f"<a href='{escape(self._matrix_to_url(target), quote=True)}'>{escape(label)}</a>"
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(
room_id,
EventType.ROOM_MEMBER,
state_key=user_id,
)
displayname = getattr(member_state, "displayname", None)
if displayname:
return str(displayname)
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"<a href='{href}'>{escape(label)}</a>"
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"<a href='{href}'>{escape(label)}</a>"
else:
html = escape(label)
return label, html
def _render_message_template(
self,
template: str,
user_id: str,
user_display: Optional[str] = None,
room_id: Optional[str] = None,
room_text: Optional[str] = None,
) -> Tuple[str, str]:
user_url = self._matrix_to_url(user_id)
safe_user_display = user_display or user_id
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=user_plain,
user_id=user_id,
user_link=user_url,
room=room_plain,
room_link=room_url,
room_id=safe_room_id,
)
html_message = template.format(
user=user_html,
user_id=escape(user_id),
user_link=f"<a href='{user_url}'>{escape(safe_user_display)}</a>",
room=room_html,
room_link=(
f"<a href='{room_url}'>{escape(safe_room_text)}</a>"
if room_url
else escape(safe_room_text)
),
room_id=escape(safe_room_id),
)
return plain_text, html_message
async def start(self) -> None: async def start(self) -> None:
await super().start() await super().start()
self.config.load_and_update() self.config.load_and_update()
self.config_manager = config_manager.ConfigManager(self.config) self.config_manager = config_manager.ConfigManager(self.config)
self.client.add_dispatcher(MembershipEventDispatcher) self.client.add_dispatcher(MembershipEventDispatcher)
# Start background redaction task self._redaction_task: Optional[asyncio.Task] = None
self._redaction_tasks = asyncio.create_task(self._redaction_loop()) self._report_counts: Dict[str, set[str]] = {}
# Clean up stale verification states self._redaction_task = asyncio.create_task(self._redaction_loop())
await self.cleanup_stale_verification_states() await self.cleanup_stale_verification_states()
async def stop(self) -> None: async def stop(self) -> None:
if self._redaction_tasks: if self._redaction_task:
self._redaction_tasks.cancel() self._redaction_task.cancel()
await super().stop() await super().stop()
async def _sleep_if_configured(self, delay: float) -> None:
"""Sleep without blocking the event loop when a delay is configured."""
if delay and delay > 0:
await asyncio.sleep(delay)
async def user_permitted( async def user_permitted(
self, user_id: UserID, min_level: int = 50, room_id: str = None self, user_id: UserID, min_level: int = 50, room_id: str = None
) -> bool: ) -> bool:
@@ -178,7 +546,9 @@ class CommunityBot(Plugin):
Returns: Returns:
tuple: (is_valid, list_of_conflicting_aliases) tuple: (is_valid, list_of_conflicting_aliases)
""" """
if not self.config.get("community_slug", ""): if self.config.get("use_community_slug", True) and not self.config.get(
"community_slug", ""
):
if evt: if evt:
await evt.respond( await evt.respond(
"Error: No community slug configured. Please run initialize command first." "Error: No community slug configured. Please run initialize command first."
@@ -187,7 +557,11 @@ class CommunityBot(Plugin):
server = self.client.parse_user_id(self.client.mxid)[1] server = self.client.parse_user_id(self.client.mxid)[1]
return await room_utils.validate_room_aliases( return await room_utils.validate_room_aliases(
self.client, room_names, self.config.get("community_slug", ""), server self.client,
room_names,
self.config.get("community_slug", ""),
self.config.get("use_community_slug", True),
server,
) )
async def get_moderators_and_above(self) -> list[str]: async def get_moderators_and_above(self) -> list[str]:
@@ -328,8 +702,9 @@ class CommunityBot(Plugin):
self.log.warning(f"Could not verify space creation: {e}") self.log.warning(f"Could not verify space creation: {e}")
if evt: if evt:
space_alias = f"#{sanitized_name}:{server}"
await evt.respond( await evt.respond(
f"<a href='https://matrix.to/#/#{sanitized_name}:{server}'>#{sanitized_name}:{server}</a> has been created.", f"{self._render_html_link(space_alias, space_alias)} has been created.",
edits=mymsg, edits=mymsg,
allow_html=True, allow_html=True,
) )
@@ -864,7 +1239,7 @@ class CommunityBot(Plugin):
) )
failed_rooms.append(roomname or room_id) failed_rooms.append(roomname or room_id)
time.sleep(self.config["sleep"]) await self._sleep_if_configured(self.config["sleep"])
except Exception as e: except Exception as e:
self.log.warning(f"Failed to update power levels in {room_id}: {e}") self.log.warning(f"Failed to update power levels in {room_id}: {e}")
@@ -971,10 +1346,7 @@ class CommunityBot(Plugin):
async def newjoin(self, evt: StateEvent) -> None: async def newjoin(self, evt: StateEvent) -> None:
if evt.source & SyncStream.STATE: if evt.source & SyncStream.STATE:
return 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() space_rooms = await self.get_space_roomlist()
if evt.room_id not in space_rooms: if evt.room_id not in space_rooms:
return return
@@ -984,182 +1356,23 @@ class CommunityBot(Plugin):
except Exception as e: except Exception as e:
self.log.error(f"Failed to check if {evt.sender} is banned: {e}") self.log.error(f"Failed to check if {evt.sender} is banned: {e}")
on_banlist = False on_banlist = False
if on_banlist: if on_banlist:
await self.ban_this_user(evt.sender) await self.ban_this_user(evt.sender)
return return
# passive sync of tracking db
if evt.room_id == self.config["parent_room"]: if evt.room_id == self.config["parent_room"]:
await self.do_sync() 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"]: self.log.debug(f"New join in room {evt.room_id} by {evt.sender}")
if on_banlist: await self._handle_join_notifications(evt)
if not (self.config["check_if_human"] and self.config["verification_phrases"]):
return return
greeting_map = self.config["greetings"]
greeting_name = self.config["greeting_rooms"][room_id]
nick = self.client.parse_user_id(evt.sender)[0]
pill = '<a href="https://matrix.to/#/{mxid}">{nick}</a>'.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"]:
try: try:
roomnamestate = await self.client.get_state_event( room_label = await common_utils.get_room_name(self.client, evt.room_id, self.log)
evt.room_id, "m.room.name" await self._maybe_start_human_verification(evt, room_label)
)
room_text = getattr(roomnamestate, "name", str(evt.room_id))
except Exception:
room_text = str(evt.room_id)
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>"
notification_message = self.config[
"join_notification_message"
].format(
user=evt.sender,
room=room_text,
room_link=room_link,
room_id=evt.room_id
)
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: except Exception as e:
self.log.error(f"Failed to start verification process: {e}") self.log.error(f"Failed to start verification process: {e}")
@@ -1343,17 +1556,17 @@ class CommunityBot(Plugin):
try: try:
roomnamestate = await self.client.get_state_event(evt.room_id, "m.room.name") roomnamestate = await self.client.get_state_event(evt.room_id, "m.room.name")
# Wir nennen es intern erst einmal room_text
room_text = roomnamestate.get("name") if roomnamestate else str(evt.room_id) room_text = roomnamestate.get("name") if roomnamestate else str(evt.room_id)
except: except Exception:
room_text = str(evt.room_id) room_text = str(evt.room_id)
# Klickable Links reporter_display = await self._get_user_display_name(evt.room_id, evt.sender)
room = room_text room_plain, room_link_html = self._format_room_pill(str(evt.room_id), room_text)
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>" reporter_plain, reporter_link_html = self._format_user_pill(str(evt.sender), reporter_display)
message_link = f"https://matrix.to/#/{evt.room_id}/{target_event_id}" room_url = room_plain
message_url = self._matrix_uri_event(str(evt.room_id), str(target_event_id))
message_link_html = f"<a href='{escape(message_url, quote=True)}'>Original Event Link</a>"
# --- AUTO-REDACT LOGIC ---
if self.config.get("auto_redact_majority", False): if self.config.get("auto_redact_majority", False):
try: try:
members = await self.client.get_joined_members(evt.room_id) members = await self.client.get_joined_members(evt.room_id)
@@ -1367,13 +1580,23 @@ class CommunityBot(Plugin):
reason=f"Auto-redacted: Reached majority vote ({current_reports}/{human_count} users)" reason=f"Auto-redacted: Reached majority vote ({current_reports}/{human_count} users)"
) )
notification = ( notification_text = (
f"<b>Message Auto-Redacted</b> 🗑️<br>" "🗑️ Message Auto-Redacted\n"
f"<b>Room:</b> {room_link}<br>" f"Room: {room_url}\n"
f"<b>Reason:</b> Community majority vote reached ({current_reports} out of {human_count} members).<br>" f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).\n"
f"<b>Context:</b> <a href='{message_link}'>Original Event Link</a>" f"Context: {message_url}"
)
notification_html = (
f"🗑️ <b>Message Auto-Redacted</b><br>"
f"<b>Room:</b> {room_link_html}<br>"
f"<b>Reason:</b> Community majority vote reached ({current_reports} out of {human_count} members).<br>"
f"<b>Context:</b> {message_link_html}"
)
await self.client.send_notice(
self.config["notification_room"],
notification_text,
html=notification_html,
) )
await self.client.send_notice(self.config["notification_room"], html=notification)
del self._report_counts[target_event_id] del self._report_counts[target_event_id]
return return
@@ -1381,14 +1604,24 @@ class CommunityBot(Plugin):
self.log.error(f"Failed to auto-redact reported message: {e}") self.log.error(f"Failed to auto-redact reported message: {e}")
if current_reports == 1: if current_reports == 1:
notification = ( notification_text = (
f"<b>Message Reported</b> 🚨<br>" "🚨 Message Reported\n"
f"<b>First Reporter:</b> {evt.sender}<br>" f"First Reporter: {reporter_plain}\n"
f"<b>Room:</b> {room_link}<br>" f"Room: {room_url}\n"
f"<b>Action:</b> <a href='{message_link}'>Click here to inspect and moderate</a>" f"Action: {message_url}"
)
notification_html = (
f"🚨 <b>Message Reported</b><br>"
f"<b>First Reporter:</b> {reporter_link_html}<br>"
f"<b>Room:</b> {room_link_html}<br>"
f"<b>Action:</b> <a href='{escape(message_url, quote=True)}'>Click here to inspect and moderate</a>"
) )
try: try:
await self.client.send_notice(self.config["notification_room"], html=notification) await self.client.send_notice(
self.config["notification_room"],
notification_text,
html=notification_html,
)
except Exception as e: except Exception as e:
self.log.error(f"Failed to send report notification: {e}") self.log.error(f"Failed to send report notification: {e}")
@@ -1784,7 +2017,7 @@ class CommunityBot(Plugin):
kick_list[user].append(roomname) kick_list[user].append(roomname)
else: else:
kick_list[user].append(room) kick_list[user].append(room)
time.sleep(self.config["sleep"]) await self._sleep_if_configured(self.config["sleep"])
except MNotFound: except MNotFound:
pass pass
except Exception as e: except Exception as e:
@@ -1934,8 +2167,9 @@ class CommunityBot(Plugin):
) )
if evt: if evt:
room_alias = f"#{alias_localpart}:{server}"
await evt.respond( await evt.respond(
f"<a href='https://matrix.to/#/#{alias_localpart}:{server}'>#{alias_localpart}:{server}</a> has been created and added to the space.", f"{self._render_html_link(room_alias, room_alias)} has been created and added to the space.",
edits=mymsg, edits=mymsg,
allow_html=True, allow_html=True,
) )
@@ -1980,7 +2214,7 @@ class CommunityBot(Plugin):
return return
# Check if community slug is configured # Check if community slug is configured
if not self.config["community_slug"]: if self.config["use_community_slug"] and not self.config["community_slug"]:
await evt.reply( await evt.reply(
"No community slug configured. Please run initialize command first." "No community slug configured. Please run initialize command first."
) )
@@ -2038,176 +2272,40 @@ class CommunityBot(Plugin):
@decorators.require_parent_room @decorators.require_parent_room
@decorators.require_permission(min_level=100) @decorators.require_permission(min_level=100)
async def room_replace(self, evt: MessageEvent, room: str) -> None: 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() await evt.mark_read()
if not room: if not room:
room = evt.room_id 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 room_id = await self._resolve_room_identifier(room)
self.log.info(f"=== CHECKING BOT PERMISSIONS ===")
has_perms, error_msg, _ = await self.check_bot_permissions( has_perms, error_msg, _ = await self.check_bot_permissions(
room_id, evt, ["state", "tombstone", "power_levels"] 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: if not has_perms:
await evt.respond(f"Cannot replace room: {error_msg}") await evt.respond(f"Cannot replace room: {error_msg}")
self.log.info("Bot permissions check failed, returning")
return return
# Get the room name from the state event metadata = await self._get_room_metadata(room_id)
room_name = None room_name = metadata["room_name"]
try: room_topic = metadata["room_topic"]
room_name_event = await self.client.get_state_event( is_space = await self._detect_space_type(room_id)
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
aliases_to_transfer = await self.remove_room_aliases(room_id, evt) 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"]:
if not self.config["community_slug"]:
await evt.respond( await evt.respond(
"No community slug configured. Please run initialize command first." "No community slug configured. Please run initialize command first."
) )
return return
# Inform user about what type of room is being replaced
if not room_name: 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.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: if is_space:
await evt.respond(f"Replacing space '{room_name}' with a new space...") await evt.respond(f"Replacing space '{room_name}' with a new space...")
self.log.info(f"✓ Sent 'Replacing space' message to user")
else: else:
await evt.respond(f"Replacing room '{room_name}' with a new room...") 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 # Validate that the new room alias is available
is_valid, conflicting_aliases = await self.validate_room_aliases( is_valid, conflicting_aliases = await self.validate_room_aliases(
@@ -3124,7 +3222,7 @@ class CommunityBot(Plugin):
try: try:
# Generate community slug if not already set # Generate community slug if not already set
if not self.config["community_slug"]: if self.config["use_community_slug"] and not self.config["community_slug"]:
community_slug = self.generate_community_slug(community_name) community_slug = self.generate_community_slug(community_name)
self.config["community_slug"] = community_slug self.config["community_slug"] = community_slug
self.log.info(f"Generated community slug: {community_slug}") self.log.info(f"Generated community slug: {community_slug}")
@@ -3306,11 +3404,12 @@ class CommunityBot(Plugin):
await evt.respond( await evt.respond(
f"Community space initialized successfully!<br /><br />" f"Community space initialized successfully!<br /><br />"
f"Community Slug: {self.config['community_slug']}<br />" f"Community Slug: {escape(str(self.config['community_slug']))}<br />"
f"Room Version: {self.config['room_version']}<br />" f"Use Community Slug: {escape(str(self.config['use_community_slug']))}"
f"Space: <a href='https://matrix.to/#/{space_alias}'>{space_alias}</a><br />" f"Room Version: {escape(str(self.config['room_version']))}<br />"
f"Moderators Room: <a href='https://matrix.to/#/{mod_room_alias}'>{mod_room_alias}</a><br />" f"Space: {self._render_html_link(space_alias, space_alias)}<br />"
f"Waiting Room: <a href='https://matrix.to/#/{waiting_room_alias}'>{waiting_room_alias}</a>{warning_msg}", f"Moderators Room: {self._render_html_link(mod_room_alias, mod_room_alias)}<br />"
f"Waiting Room: {self._render_html_link(waiting_room_alias, waiting_room_alias)}{warning_msg}",
edits=msg, edits=msg,
allow_html=True, allow_html=True,
) )
+15 -1
View File
@@ -99,6 +99,14 @@ class ConfigManager:
""" """
return self.config.get("community_slug") return self.config.get("community_slug")
def get_use_community_slug(self) -> Optional[str]:
"""Get the community slug suffix setting.
Returns:
bool: Whether to use the community slug as a room suffix
"""
return self.config.get("use_community_slug")
def get_parent_room(self) -> Optional[str]: def get_parent_room(self) -> Optional[str]:
"""Get the parent room ID. """Get the parent room ID.
@@ -201,7 +209,12 @@ class ConfigManager:
Returns: Returns:
List[str]: List of missing required configuration keys List[str]: List of missing required configuration keys
""" """
required_configs = ["parent_room", "room_version", "community_slug"] required_configs = [
"parent_room",
"room_version",
"community_slug",
"use_community_slug",
]
missing = [] missing = []
for config_key in required_configs: for config_key in required_configs:
@@ -231,6 +244,7 @@ class ConfigManager:
return { return {
"room_version": self.get_room_version(), "room_version": self.get_room_version(),
"community_slug": self.get_community_slug(), "community_slug": self.get_community_slug(),
"use_community_slug": self.get_use_community_slug(),
"invitees": self.get_invitees(), "invitees": self.get_invitees(),
"invite_power_level": self.get_invite_power_level(), "invite_power_level": self.get_invite_power_level(),
"encrypt": self.is_encryption_enabled(), "encrypt": self.is_encryption_enabled(),
+4 -1
View File
@@ -38,7 +38,7 @@ async def validate_room_creation_params(
sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", roomname).lower() sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", roomname).lower()
# Check if community slug is configured # Check if community slug is configured
if not config.get("community_slug", ""): if config.get("use_community_slug", True) and not config.get("community_slug", ""):
error_msg = "No community slug configured. Please run initialize command first." error_msg = "No community slug configured. Please run initialize command first."
return sanitized_name, force_encryption, force_unencryption, error_msg, roomname return sanitized_name, force_encryption, force_unencryption, error_msg, roomname
@@ -63,7 +63,10 @@ async def prepare_room_creation_data(
Tuple of (alias_localpart, server, room_invitees, parent_room) Tuple of (alias_localpart, server, room_invitees, parent_room)
""" """
# Create alias with community slug # Create alias with community slug
if config.get("use_community_slug", True):
alias_localpart = f"{sanitized_name}-{config.get('community_slug', '')}" alias_localpart = f"{sanitized_name}-{config.get('community_slug', '')}"
else:
alias_localpart = sanitized_name
# Get server and invitees # Get server and invitees
server = client.parse_user_id(client.mxid)[1] server = client.parse_user_id(client.mxid)[1]
+10 -2
View File
@@ -31,7 +31,11 @@ async def validate_room_alias(client, alias_localpart: str, server: str) -> bool
async def validate_room_aliases( async def validate_room_aliases(
client, room_names: list[str], community_slug: str, server: str client,
room_names: list[str],
community_slug: str,
use_community_slug: bool,
server: str,
) -> Tuple[bool, List[str]]: ) -> Tuple[bool, List[str]]:
"""Validate that all room aliases are available. """Validate that all room aliases are available.
@@ -39,12 +43,13 @@ async def validate_room_aliases(
client: Matrix client instance client: Matrix client instance
room_names: List of room names to validate room_names: List of room names to validate
community_slug: The community slug to append community_slug: The community slug to append
use_community_slug: Whether to append a community slug
server: The server domain server: The server domain
Returns: Returns:
tuple: (is_valid, list_of_conflicting_aliases) tuple: (is_valid, list_of_conflicting_aliases)
""" """
if not community_slug: if use_community_slug and not community_slug:
return False, [] return False, []
conflicting_aliases = [] conflicting_aliases = []
@@ -54,7 +59,10 @@ async def validate_room_aliases(
from .message_utils import sanitize_room_name from .message_utils import sanitize_room_name
sanitized_name = sanitize_room_name(room_name) sanitized_name = sanitize_room_name(room_name)
if use_community_slug:
alias_localpart = f"{sanitized_name}-{community_slug}" alias_localpart = f"{sanitized_name}-{community_slug}"
else:
alias_localpart = sanitized_name
# Check if alias is available # Check if alias is available
is_available = await validate_room_alias(client, alias_localpart, server) is_available = await validate_room_alias(client, alias_localpart, server)
+16 -3
View File
@@ -67,6 +67,9 @@ plugin_config:
# leave blank to generate an acronym of your community name during initialization # leave blank to generate an acronym of your community name during initialization
community_slug: '' community_slug: ''
# use_community_slug
# whether to use the community slug as a suffix for room aliases
use_community_slug: true
# sleep time between actions. you can drop this to 0 if your bot has no # sleep time between actions. you can drop this to 0 if your bot has no
# ratelimits imposed on its homeserver, otherwise you may want to increase this # ratelimits imposed on its homeserver, otherwise you may want to increase this
@@ -112,8 +115,13 @@ plugin_config:
# auto-greet users in rooms with these messages # auto-greet users in rooms with these messages
# map greeting messages to a room # map greeting messages to a room
# you can use {user} to reference the joining user in this message using a # available placeholders:
# matrix.to link (rendered as a "pill" in element clients) # - {user}: display name of the joining user (falls back to localpart or user ID)
# - {user_id}: full Matrix user ID
# - {user_link}: clickable matrix.to-compatible link to the joining user
# - {room}: room name (or room ID if no name is set)
# - {room_link}: clickable matrix.to-compatible link to the room
# - {room_id}: raw room ID
# html formatting is supported # html formatting is supported
# set to {} if you don't care about greetings # set to {} if you don't care about greetings
greetings: greetings:
@@ -142,7 +150,12 @@ plugin_config:
# message to send to the notification room when someone joins one of the above rooms: # message to send to the notification room when someone joins one of the above rooms:
join_notification_message: | join_notification_message: |
User <code>{user}</code> has joined <code>{room}</code>. {user} has joined {room}.
# 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"
# whether to censor files/messages # whether to censor files/messages
# can be boolean (true/false) for all-or-nothing behavior, # can be boolean (true/false) for all-or-nothing behavior,
+1 -1
View File
@@ -1,6 +1,6 @@
maubot: 0.1.0 maubot: 0.1.0
id: org.jobmachine.communitybot id: org.jobmachine.communitybot
version: 0.4.1 version: 0.6.0
license: MIT license: MIT
modules: modules:
- community - community
Executable → Regular
View File
Executable → Regular
View File
+142
View File
@@ -264,6 +264,47 @@ class TestBotEvents:
# Should update user timestamp # Should update user timestamp
real_bot.upsert_user_timestamp.assert_called() real_bot.upsert_user_timestamp.assert_called()
@pytest.mark.asyncio
async def test_newjoin_notification_supports_user_link(self, bot, mock_state_evt):
"""Test join notification formatting with user_link placeholder."""
from community.bot import CommunityBot
real_bot = CommunityBot()
real_bot.config = {
**bot.config,
"greeting_rooms": {"!room:example.com": "none"},
"greetings": {},
"notification_room": "!notifications:example.com",
"join_notification_message": "{user_link} joined {room_link} ({room_id})"
}
real_bot.client = bot.client
real_bot.database = bot.database
real_bot.log = bot.log
real_bot.client.parse_user_id = Mock(return_value=("alice", "example.com"))
room_name_state = Mock()
room_name_state.name = "Test Room"
real_bot.client.get_state_event = AsyncMock(return_value=room_name_state)
real_bot.client.send_notice = AsyncMock()
real_bot.database.execute = AsyncMock()
mock_state_evt.source = 0
with patch.object(real_bot, 'get_space_roomlist', return_value=["!room:example.com"]), \
patch.object(real_bot, 'check_if_banned', return_value=False), \
patch.object(real_bot, 'upsert_user_timestamp', return_value=None):
await real_bot.newjoin(mock_state_evt)
real_bot.client.send_notice.assert_called_once()
args, kwargs = real_bot.client.send_notice.call_args
assert args[0] == "!notifications:example.com"
assert args[1] == "https://matrix.to/#/@user:example.com joined https://matrix.to/#/!room:example.com (!room:example.com)"
assert "https://matrix.to/#/@user:example.com" in kwargs["html"]
assert ">alice</a>" in kwargs["html"]
assert "https://matrix.to/#/!room:example.com" in kwargs["html"]
assert ">Test Room</a>" in kwargs["html"]
assert "(!room:example.com)" in kwargs["html"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_update_message_timestamp_tracking_enabled(self, bot, mock_message_evt): async def test_update_message_timestamp_tracking_enabled(self, bot, mock_message_evt):
"""Test message timestamp update with tracking enabled.""" """Test message timestamp update with tracking enabled."""
@@ -450,3 +491,104 @@ class TestBotEvents:
# Should not update user timestamp # Should not update user timestamp
real_bot.upsert_user_timestamp.assert_not_called() real_bot.upsert_user_timestamp.assert_not_called()
@pytest.mark.asyncio
async def test_sleep_if_configured_uses_asyncio_sleep(self):
"""Test that configured delays use asyncio.sleep without blocking."""
real_bot = CommunityBot()
with patch("community.bot.asyncio.sleep", new=AsyncMock()) as sleep_mock:
await real_bot._sleep_if_configured(1.5)
sleep_mock.assert_awaited_once_with(1.5)
@pytest.mark.asyncio
async def test_newjoin_uses_async_delay_for_greeting(self, bot, mock_state_evt):
"""Test that join greetings use the async delay helper."""
real_bot = CommunityBot()
real_bot.client = Mock()
real_bot.client.parse_user_id.return_value = ("alice", "example.com")
real_bot.client.send_notice = AsyncMock()
real_bot.client.get_state_event = AsyncMock(return_value=Mock(name="Test Room"))
real_bot.database = bot.database
real_bot.log = bot.log
real_bot.config = {
**bot.config,
"parent_room": "!parent:example.com",
"greeting_rooms": {"!room:example.com": "default"},
"greetings": {"default": "Welcome {user}"},
"welcome_sleep": 2,
"notification_room": "!notif:example.com",
"join_notification_message": "{user_link} joined {room_link}",
}
real_bot._sleep_if_configured = AsyncMock()
real_bot.check_if_banned = AsyncMock(return_value=False)
real_bot.ban_this_user = AsyncMock()
real_bot.do_sync = AsyncMock()
real_bot.get_space_roomlist = AsyncMock(return_value=["!room:example.com"])
await real_bot.newjoin(mock_state_evt)
real_bot._sleep_if_configured.assert_awaited_once_with(2)
assert real_bot.client.send_notice.await_count == 2
@pytest.mark.asyncio
async def test_user_kick_uses_async_delay(self, bot):
"""Test that user kicking uses the async delay helper between rooms."""
real_bot = CommunityBot()
real_bot.client = Mock()
real_bot.client.get_state_event = AsyncMock(side_effect=[
{"name": "Room One"},
{}, # membership lookup
])
real_bot.client.kick_user = AsyncMock()
real_bot.database = bot.database
real_bot.log = bot.log
real_bot.config = {**bot.config, "sleep": 0.5, "parent_room": "!room:example.com"}
real_bot._sleep_if_configured = AsyncMock()
evt = Mock(spec=MessageEvent)
evt.mark_read = AsyncMock()
evt.respond = AsyncMock()
evt.reply = AsyncMock()
with patch.object(real_bot, "get_space_roomlist", AsyncMock(return_value=[])):
await real_bot.user_kick(evt, "@user:example.com")
real_bot._sleep_if_configured.assert_awaited_once_with(0.5)
def test_render_message_template_uses_consistent_user_placeholders(self):
"""{user} should stay plain while {user_link} becomes clickable."""
real_bot = CommunityBot()
plain_text, html_text = real_bot._render_message_template(
"{user} | {user_link} | {room} | {room_link} | {room_id}",
"@alice:example.com",
"alice",
"!room:example.com",
"Test Room",
)
assert plain_text == (
"@alice:example.com | https://matrix.to/#/@alice:example.com | "
"Test Room | https://matrix.to/#/!room:example.com | !room:example.com"
)
assert "@alice:example.com" in html_text
assert "<a href='https://matrix.to/#/@alice:example.com'>alice</a>" in html_text
assert "<a href='https://matrix.to/#/!room:example.com'>Test Room</a>" in html_text
def test_render_message_template_without_room_keeps_room_placeholders_safe(self):
"""Greeting templates without room data should not break placeholder rendering."""
real_bot = CommunityBot()
plain_text, html_text = real_bot._render_message_template(
"Welcome {user} / {user_link}",
"@alice:example.com",
"alice",
)
assert plain_text == "Welcome @alice:example.com / https://matrix.to/#/@alice:example.com"
assert html_text == (
"Welcome @alice:example.com / "
"<a href='https://matrix.to/#/@alice:example.com'>alice</a>"
)
+56
View File
@@ -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 == '<a href="matrix:r/general:example.org">General</a>'
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 == '<a href="matrix:roomid/roomid:example.org">General</a>'
def test_format_user_pill_escapes_displayname(bot: CommunityBot) -> None:
plain, formatted = bot._format_user_pill("@alice:example.org", '<Admin & Ops>')
assert plain == f"{DEFAULT_USER_PILL_PREFIX}<Admin & Ops>"
# Keep this broad enough to avoid coupling to quote style.
assert "matrix:u/alice:example.org?action=chat" in formatted
assert html.escape('<Admin & Ops>') in formatted
+10
View File
@@ -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"
+40 -7
View File
@@ -46,24 +46,41 @@ class TestRoomUtils:
assert result == True assert result == True
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_validate_room_aliases_no_slug(self): async def test_validate_room_aliases_slug_not_required_with_no_slug(self):
"""Test alias validation without community slug.""" """Test alias validation without community slug."""
client = Mock() client = Mock()
result = await validate_room_aliases(client, ["room1", "room2"], "", "example.com") result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
assert result == (False, []) assert result == (True, [])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_validate_room_aliases_success(self): async def test_validate_room_aliases_slug_not_required_with_slug(self):
"""Test successful alias validation.""" """Test successful alias validation."""
client = Mock() client = Mock()
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404)) client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
result = await validate_room_aliases(client, ["room1", "room2"], "test", "example.com") result = await validate_room_aliases(client, ["room1", "room2"], "test", False, "example.com")
assert result == (True, []) assert result == (True, [])
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_validate_room_aliases_conflicts(self): async def test_validate_room_aliases_slug_required_with_no_slug(self):
"""Test alias validation without community slug."""
client = Mock()
result = await validate_room_aliases(client, ["room1", "room2"], "", True, "example.com")
assert result == (False, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_slug_required_with_slug(self):
"""Test successful alias validation."""
client = Mock()
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
result = await validate_room_aliases(client, ["room1", "room2"], "test", True, "example.com")
assert result == (True, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_conflicts_slug_not_required(self):
"""Test alias validation with conflicts.""" """Test alias validation with conflicts."""
client = Mock() client = Mock()
@@ -75,7 +92,23 @@ class TestRoomUtils:
client.resolve_room_alias = AsyncMock(side_effect=resolve_side_effect) client.resolve_room_alias = AsyncMock(side_effect=resolve_side_effect)
result = await validate_room_aliases(client, ["room1", "room2"], "test", "example.com") result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
assert result == (False, ["#room1:example.com"])
@pytest.mark.asyncio
async def test_validate_room_aliases_conflicts_slug_required(self):
"""Test alias validation with conflicts."""
client = Mock()
def resolve_side_effect(alias):
if "room1" in alias:
return {"room_id": "!room1:example.com"} # Exists
else:
raise MNotFound() # Doesn't exist
client.resolve_room_alias = AsyncMock(side_effect=resolve_side_effect)
result = await validate_room_aliases(client, ["room1", "room2"], "test", True, "example.com")
assert result == (False, ["#room1-test:example.com"]) assert result == (False, ["#room1-test:example.com"])
@pytest.mark.asyncio @pytest.mark.asyncio
+104
View File
@@ -0,0 +1,104 @@
"""Tests for notification and greeting template rendering."""
from unittest.mock import AsyncMock, Mock
import pytest
from mautrix.types import EventType, RoomID
from community.bot import CommunityBot
@pytest.fixture
def bot():
bot = CommunityBot.__new__(CommunityBot)
bot.client = Mock()
bot.config = {
"matrix_to_base_url": "https://matrix.to",
"user_pill_prefix": "@",
"room_pill_prefix": "#",
}
return bot
@pytest.mark.asyncio
async def test_get_user_display_name_uses_member_displayname(bot):
member_state = Mock()
member_state.displayname = "Alice"
bot.client.get_state_event = AsyncMock(return_value=member_state)
result = await bot._get_user_display_name(RoomID("!room:example.org"), "@alice:example.org")
assert result == "Alice"
bot.client.get_state_event.assert_awaited_once_with(
RoomID("!room:example.org"),
EventType.ROOM_MEMBER,
state_key="@alice:example.org",
)
@pytest.mark.asyncio
async def test_get_user_display_name_falls_back_to_localpart(bot):
bot.client.get_state_event = AsyncMock(side_effect=Exception("missing"))
bot.client.parse_user_id = Mock(return_value=("alice", "example.org"))
result = await bot._get_user_display_name(RoomID("!room:example.org"), "@alice:example.org")
assert result == "alice"
def test_render_message_template_supports_user_id_and_user_link(bot):
plain, html = bot._render_message_template(
"{user} / {user_id} / {user_link}",
"@alice:example.org",
"Alice",
"!room:example.org",
"General",
)
assert plain == "@Alice / @alice:example.org / https://matrix.to/#/@alice:example.org"
assert "@Alice / @alice:example.org / " in html
assert '<a href=' in html
assert '>Alice</a>' 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 "<a href='matrix:u/alice:example.org?action=chat'>Alice</a>" in html
assert "<a href='matrix:roomid/room:example.org'>General</a>" 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 "<a href='matrix:u/alice:example.org?action=chat'>@Alice</a>" in html
assert "<a href='matrix:roomid/room:example.org'>#General</a>" 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"