3 Commits

Author SHA1 Message Date
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
36 changed files with 505 additions and 91 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+27 -5
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,16 +58,21 @@ 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:
generic: | generic: |
Welcome {user}! Please be sure to read the topic for helpful links and information. Welcome {user_id}! Please be sure to read the topic for helpful links and information.
Use <a href="https://google.com">Google</a> for all other queries ;) Use <a href="https://google.com">Google</a> for all other queries ;)
encrypted: | encrypted: |
welcome {user}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be welcome {user_id}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
alarmed. alarmed.
# which of the above greetings should be used in which rooms? use the exact name of each greeting # which of the above greetings should be used in which rooms? use the exact name of each greeting
@@ -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_link} ({user_id}) has joined {room_link}.
# 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,9 @@ 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}"
# 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"
Regular → Executable
View File
Regular → Executable
+170 -48
View File
@@ -6,6 +6,7 @@ import time
import re import re
import fnmatch import fnmatch
import asyncio import asyncio
from html import escape
import random import random
import asyncpg.exceptions import asyncpg.exceptions
from datetime import datetime from datetime import datetime
@@ -71,6 +72,7 @@ class Config(BaseProxyConfig):
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 +80,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,10 +103,79 @@ class Config(BaseProxyConfig):
class CommunityBot(Plugin): class CommunityBot(Plugin):
def _get_matrix_to_base_url(self) -> str:
return str(self.config.get("matrix_to_base_url", "https://matrix.to")).rstrip("/")
_redaction_tasks: asyncio.Task = None _redaction_tasks: asyncio.Task = None
_verification_states: Dict[str, Dict] = {} _verification_states: Dict[str, Dict] = {}
_report_counts: Dict[str, set] = {} _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>"
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:
pass
try:
return self.client.parse_user_id(user_id)[0]
except Exception:
return user_id
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 ""
plain_text = template.format(
user=safe_user_display,
user_id=user_id,
user_link=user_url,
room=safe_room_text,
room_link=room_url,
room_id=safe_room_id,
)
html_message = template.format(
user=escape(safe_user_display),
user_id=escape(user_id),
user_link=f"<a href='{user_url}'>{escape(safe_user_display)}</a>",
room=escape(safe_room_text),
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()
@@ -119,6 +191,11 @@ class CommunityBot(Plugin):
self._redaction_tasks.cancel() self._redaction_tasks.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 +255,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 +266,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 +411,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 +948,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}")
@@ -1004,14 +1088,29 @@ class CommunityBot(Plugin):
return return
greeting_map = self.config["greetings"] greeting_map = self.config["greetings"]
greeting_name = self.config["greeting_rooms"][room_id] greeting_name = self.config["greeting_rooms"][room_id]
nick = self.client.parse_user_id(evt.sender)[0] user_display = await self._get_user_display_name(evt.room_id, evt.sender)
pill = '<a href="https://matrix.to/#/{mxid}">{nick}</a>'.format( try:
mxid=evt.sender, nick=nick 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)
if greeting_name != "none": if greeting_name != "none":
greeting = greeting_map[greeting_name].format(user=pill) greeting_text, greeting_html = self._render_message_template(
time.sleep(self.config["welcome_sleep"]) greeting_map[greeting_name],
await self.client.send_notice(evt.room_id, html=greeting) 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: else:
pass pass
@@ -1025,18 +1124,18 @@ class CommunityBot(Plugin):
except Exception: except Exception:
room_text = str(evt.room_id) room_text = str(evt.room_id)
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>" user_display = await self._get_user_display_name(evt.room_id, evt.sender)
notification_text, notification_html = self._render_message_template(
notification_message = self.config[ self.config["join_notification_message"],
"join_notification_message" evt.sender,
].format( user_display,
user=evt.sender, evt.room_id,
room=room_text, room_text,
room_link=room_link,
room_id=evt.room_id
) )
await self.client.send_notice( await self.client.send_notice(
self.config["notification_room"], html=notification_message self.config["notification_room"],
notification_text,
html=notification_html,
) )
# Human verification logic # Human verification logic
@@ -1343,17 +1442,18 @@ 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_url = self._matrix_to_url(evt.room_id)
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>" message_url = self._matrix_to_url(f"{evt.room_id}/{target_event_id}")
message_link = f"https://matrix.to/#/{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)
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 +1467,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 +1491,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 = (
"Message Reported 🚨\n"
f"First Reporter: {reporter_display} ({evt.sender})\n"
f"Room: {room_url}\n"
f"Action: {message_url}"
)
notification_html = (
f"<b>Message Reported</b> 🚨<br>" f"<b>Message Reported</b> 🚨<br>"
f"<b>First Reporter:</b> {evt.sender}<br>" f"<b>First Reporter:</b> {reporter_link_html} ({escape(evt.sender)})<br>"
f"<b>Room:</b> {room_link}<br>" f"<b>Room:</b> {room_link_html}<br>"
f"<b>Action:</b> <a href='{message_link}'>Click here to inspect and moderate</a>" 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 +1904,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 +2054,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 +2101,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."
) )
@@ -2186,7 +2307,7 @@ class CommunityBot(Plugin):
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 # 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.respond( await evt.respond(
"No community slug configured. Please run initialize command first." "No community slug configured. Please run initialize command first."
) )
@@ -3124,7 +3245,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 +3427,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,
) )
Regular → Executable
View File
Regular → Executable
View File
View File
View File
+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(),
View File
Regular → Executable
View File
View File
View File
View File
View File
+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]
Regular → Executable
+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)
Regular → Executable
View File
Regular → Executable
+18 -5
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,16 +115,21 @@ 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:
generic: | generic: |
Welcome {user}! Please be sure to read the topic for helpful links and information. Welcome {user_link}! Please be sure to read the topic for helpful links and information.
Use <a href="https://google.com">Google</a> for all other queries ;) Use <a href="https://google.com">Google</a> for all other queries ;)
encrypted: | encrypted: |
welcome {user}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be welcome {user_link}, this is an encrypted room, so you may not be able to see messages previously sent here. don't be
alarmed. alarmed.
# which of the above greetings should be used in which rooms? use the exact name of each greeting # which of the above greetings should be used in which rooms? use the exact name of each greeting
@@ -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_link} has joined {room_link} ({user_id}).
# 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,
Regular → Executable
+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.5.0
license: MIT license: MIT
modules: modules:
- community - community
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+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>"
)
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+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
View File
+57
View File
@@ -0,0 +1,57 @@
"""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"}
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
Regular → Executable
View File