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
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
# ratelimits imposed on its homeserver, otherwise you may want to increase this
# to avoid errors.
@@ -54,16 +58,21 @@ invitees: []
# auto-greet users in rooms with these messages
# map greeting messages to a room
# you can use {user} to reference the joining user in this message using a
# matrix.to link (rendered as a "pill" in element clients)
# 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
# html formatting is supported
# set to {} if you don't care about greetings
greetings:
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 ;)
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.
# 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:
# 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: |
{user} has joined {room_link}.
{user_link} ({user_id}) has joined {room_link}.
# whether to censor files/messages
# 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.
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
+180 -58
View File
@@ -6,6 +6,7 @@ import time
import re
import fnmatch
import asyncio
from html import escape
import random
import asyncpg.exceptions
from datetime import datetime
@@ -71,6 +72,7 @@ class Config(BaseProxyConfig):
helper.copy("welcome_sleep")
helper.copy("parent_room")
helper.copy("community_slug")
helper.copy("use_community_slug")
helper.copy("track_users")
helper.copy("warn_threshold_days")
helper.copy("kick_threshold_days")
@@ -78,6 +80,7 @@ class Config(BaseProxyConfig):
helper.copy("invitees")
helper.copy("notification_room")
helper.copy("join_notification_message")
helper.copy("matrix_to_base_url")
helper.copy_dict("greeting_rooms")
helper.copy_dict("greetings")
helper.copy("censor")
@@ -100,10 +103,79 @@ class Config(BaseProxyConfig):
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
_verification_states: Dict[str, Dict] = {}
_report_counts: Dict[str, set] = {}
def _matrix_to_url(self, target: str) -> str:
base_url = self._get_matrix_to_base_url()
return f"{base_url}/#/{target}"
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:
await super().start()
self.config.load_and_update()
@@ -119,6 +191,11 @@ class CommunityBot(Plugin):
self._redaction_tasks.cancel()
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(
self, user_id: UserID, min_level: int = 50, room_id: str = None
) -> bool:
@@ -178,7 +255,9 @@ class CommunityBot(Plugin):
Returns:
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:
await evt.respond(
"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]
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]:
@@ -328,8 +411,9 @@ class CommunityBot(Plugin):
self.log.warning(f"Could not verify space creation: {e}")
if evt:
space_alias = f"#{sanitized_name}:{server}"
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,
allow_html=True,
)
@@ -864,7 +948,7 @@ class CommunityBot(Plugin):
)
failed_rooms.append(roomname or room_id)
time.sleep(self.config["sleep"])
await self._sleep_if_configured(self.config["sleep"])
except Exception as e:
self.log.warning(f"Failed to update power levels in {room_id}: {e}")
@@ -1004,14 +1088,29 @@ class CommunityBot(Plugin):
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
)
user_display = await self._get_user_display_name(evt.room_id, evt.sender)
try:
roomnamestate = await self.client.get_state_event(
evt.room_id, "m.room.name"
)
room_text = getattr(roomnamestate, "name", str(evt.room_id))
except Exception:
room_text = str(evt.room_id)
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)
greeting_text, greeting_html = self._render_message_template(
greeting_map[greeting_name],
evt.sender,
user_display,
evt.room_id,
room_text,
)
await self._sleep_if_configured(self.config["welcome_sleep"])
await self.client.send_notice(
evt.room_id,
greeting_text,
html=greeting_html,
)
else:
pass
@@ -1025,18 +1124,18 @@ class CommunityBot(Plugin):
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
user_display = await self._get_user_display_name(evt.room_id, evt.sender)
notification_text, notification_html = self._render_message_template(
self.config["join_notification_message"],
evt.sender,
user_display,
evt.room_id,
room_text,
)
await self.client.send_notice(
self.config["notification_room"], html=notification_message
self.config["notification_room"],
notification_text,
html=notification_html,
)
# Human verification logic
@@ -1306,7 +1405,7 @@ class CommunityBot(Plugin):
@event.on(EventType.REACTION)
async def handle_reactions(self, evt: MessageEvent) -> None:
if evt.sender == self.client.mxid:
return
@@ -1331,29 +1430,30 @@ class CommunityBot(Plugin):
return
target_event_id = relates_to.event_id
if target_event_id not in self._report_counts:
self._report_counts[target_event_id] = set()
if evt.sender in self._report_counts[target_event_id]:
return
self._report_counts[target_event_id].add(evt.sender)
current_reports = len(self._report_counts[target_event_id])
try:
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)
except:
except Exception:
room_text = str(evt.room_id)
# Klickable Links
room = room_text
room_link = f"<a href='https://matrix.to/#/{evt.room_id}'>{room_text}</a>"
message_link = f"https://matrix.to/#/{evt.room_id}/{target_event_id}"
# --- AUTO-REDACT LOGIC ---
reporter_display = await self._get_user_display_name(evt.room_id, evt.sender)
room_url = self._matrix_to_url(evt.room_id)
message_url = self._matrix_to_url(f"{evt.room_id}/{target_event_id}")
room_link_html = self._render_html_link(evt.room_id, room_text)
reporter_link_html = self._render_html_link(evt.sender, reporter_display)
message_link_html = f"<a href='{escape(message_url, quote=True)}'>Original Event Link</a>"
if self.config.get("auto_redact_majority", False):
try:
members = await self.client.get_joined_members(evt.room_id)
@@ -1362,33 +1462,53 @@ class CommunityBot(Plugin):
if current_reports > threshold:
await self.client.redact(
evt.room_id,
target_event_id,
evt.room_id,
target_event_id,
reason=f"Auto-redacted: Reached majority vote ({current_reports}/{human_count} users)"
)
notification = (
f"<b>Message Auto-Redacted</b> 🗑️<br>"
f"<b>Room:</b> {room_link}<br>"
f"<b>Reason:</b> Community majority vote reached ({current_reports} out of {human_count} members).<br>"
f"<b>Context:</b> <a href='{message_link}'>Original Event Link</a>"
notification_text = (
"Message Auto-Redacted 🗑️\n"
f"Room: {room_url}\n"
f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).\n"
f"Context: {message_url}"
)
await self.client.send_notice(self.config["notification_room"], html=notification)
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,
)
del self._report_counts[target_event_id]
return
except Exception as e:
self.log.error(f"Failed to auto-redact reported message: {e}")
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>First Reporter:</b> {evt.sender}<br>"
f"<b>Room:</b> {room_link}<br>"
f"<b>Action:</b> <a href='{message_link}'>Click here to inspect and moderate</a>"
f"<b>First Reporter:</b> {reporter_link_html} ({escape(evt.sender)})<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:
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:
self.log.error(f"Failed to send report notification: {e}")
@@ -1784,7 +1904,7 @@ class CommunityBot(Plugin):
kick_list[user].append(roomname)
else:
kick_list[user].append(room)
time.sleep(self.config["sleep"])
await self._sleep_if_configured(self.config["sleep"])
except MNotFound:
pass
except Exception as e:
@@ -1934,8 +2054,9 @@ class CommunityBot(Plugin):
)
if evt:
room_alias = f"#{alias_localpart}:{server}"
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,
allow_html=True,
)
@@ -1980,7 +2101,7 @@ class CommunityBot(Plugin):
return
# 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(
"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)
# 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(
"No community slug configured. Please run initialize command first."
)
@@ -3124,7 +3245,7 @@ class CommunityBot(Plugin):
try:
# 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)
self.config["community_slug"] = community_slug
self.log.info(f"Generated community slug: {community_slug}")
@@ -3306,11 +3427,12 @@ class CommunityBot(Plugin):
await evt.respond(
f"Community space initialized successfully!<br /><br />"
f"Community Slug: {self.config['community_slug']}<br />"
f"Room Version: {self.config['room_version']}<br />"
f"Space: <a href='https://matrix.to/#/{space_alias}'>{space_alias}</a><br />"
f"Moderators Room: <a href='https://matrix.to/#/{mod_room_alias}'>{mod_room_alias}</a><br />"
f"Waiting Room: <a href='https://matrix.to/#/{waiting_room_alias}'>{waiting_room_alias}</a>{warning_msg}",
f"Community Slug: {escape(str(self.config['community_slug']))}<br />"
f"Use Community Slug: {escape(str(self.config['use_community_slug']))}"
f"Room Version: {escape(str(self.config['room_version']))}<br />"
f"Space: {self._render_html_link(space_alias, space_alias)}<br />"
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,
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")
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]:
"""Get the parent room ID.
@@ -201,7 +209,12 @@ class ConfigManager:
Returns:
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 = []
for config_key in required_configs:
@@ -231,6 +244,7 @@ class ConfigManager:
return {
"room_version": self.get_room_version(),
"community_slug": self.get_community_slug(),
"use_community_slug": self.get_use_community_slug(),
"invitees": self.get_invitees(),
"invite_power_level": self.get_invite_power_level(),
"encrypt": self.is_encryption_enabled(),
View File
Regular → Executable
View File
View File
View File
View File
View File
+5 -2
View File
@@ -38,7 +38,7 @@ async def validate_room_creation_params(
sanitized_name = re.sub(r"[^a-zA-Z0-9]", "", roomname).lower()
# 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."
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)
"""
# Create alias with community slug
alias_localpart = f"{sanitized_name}-{config.get('community_slug', '')}"
if config.get("use_community_slug", True):
alias_localpart = f"{sanitized_name}-{config.get('community_slug', '')}"
else:
alias_localpart = sanitized_name
# Get server and invitees
server = client.parse_user_id(client.mxid)[1]
Regular → Executable
+11 -3
View File
@@ -31,7 +31,11 @@ async def validate_room_alias(client, alias_localpart: str, server: str) -> bool
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]]:
"""Validate that all room aliases are available.
@@ -39,12 +43,13 @@ async def validate_room_aliases(
client: Matrix client instance
room_names: List of room names to validate
community_slug: The community slug to append
use_community_slug: Whether to append a community slug
server: The server domain
Returns:
tuple: (is_valid, list_of_conflicting_aliases)
"""
if not community_slug:
if use_community_slug and not community_slug:
return False, []
conflicting_aliases = []
@@ -54,7 +59,10 @@ async def validate_room_aliases(
from .message_utils import sanitize_room_name
sanitized_name = sanitize_room_name(room_name)
alias_localpart = f"{sanitized_name}-{community_slug}"
if use_community_slug:
alias_localpart = f"{sanitized_name}-{community_slug}"
else:
alias_localpart = sanitized_name
# Check if alias is available
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
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
# 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
# map greeting messages to a room
# you can use {user} to reference the joining user in this message using a
# matrix.to link (rendered as a "pill" in element clients)
# 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
# html formatting is supported
# set to {} if you don't care about greetings
greetings:
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 ;)
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.
# 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:
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
# can be boolean (true/false) for all-or-nothing behavior,
Regular → Executable
+1 -1
View File
@@ -1,6 +1,6 @@
maubot: 0.1.0
id: org.jobmachine.communitybot
version: 0.4.1
version: 0.5.0
license: MIT
modules:
- 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
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
async def test_update_message_timestamp_tracking_enabled(self, bot, mock_message_evt):
"""Test message timestamp update with tracking enabled."""
@@ -450,3 +491,104 @@ class TestBotEvents:
# Should not update user timestamp
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
+49 -16
View File
@@ -46,36 +46,69 @@ class TestRoomUtils:
assert result == True
@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."""
client = Mock()
result = await validate_room_aliases(client, ["room1", "room2"], "", "example.com")
assert result == (False, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_success(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", "example.com")
result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
assert result == (True, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_conflicts(self):
async def test_validate_room_aliases_slug_not_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", False, "example.com")
assert result == (True, [])
@pytest.mark.asyncio
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."""
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", "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"])
@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