diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
old mode 100644
new mode 100755
diff --git a/.gitignore b/.gitignore
old mode 100644
new mode 100755
diff --git a/LICENSE.txt b/LICENSE.txt
old mode 100644
new mode 100755
diff --git a/README.md b/README.md
old mode 100644
new mode 100755
diff --git a/REFACTORING.md b/REFACTORING.md
old mode 100644
new mode 100755
diff --git a/base-config.yaml b/base-config.yaml
old mode 100644
new mode 100755
index 2cab45f..472f72a
--- a/base-config.yaml
+++ b/base-config.yaml
@@ -58,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 Google 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
@@ -87,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,
@@ -177,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"
diff --git a/community/__init__.py b/community/__init__.py
old mode 100644
new mode 100755
diff --git a/community/bot.py b/community/bot.py
old mode 100644
new mode 100755
index 2823073..f1bc1cc
--- a/community/bot.py
+++ b/community/bot.py
@@ -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
@@ -79,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")
@@ -101,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"{escape(label)}"
+
+ 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"{escape(safe_user_display)}",
+ room=escape(safe_room_text),
+ room_link=(
+ f"{escape(safe_room_text)}"
+ 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()
@@ -120,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:
@@ -335,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"#{sanitized_name}:{server} has been created.",
+ f"{self._render_html_link(space_alias, space_alias)} has been created.",
edits=mymsg,
allow_html=True,
)
@@ -871,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}")
@@ -1011,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 = '{nick}'.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
@@ -1032,18 +1124,18 @@ class CommunityBot(Plugin):
except Exception:
room_text = str(evt.room_id)
- room_link = f"{room_text}"
-
- 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
@@ -1313,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
@@ -1338,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"{room_text}"
- 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"Original Event Link"
+
if self.config.get("auto_redact_majority", False):
try:
members = await self.client.get_joined_members(evt.room_id)
@@ -1369,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"Message Auto-Redacted 🗑️
"
- f"Room: {room_link}
"
- f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).
"
- f"Context: Original Event Link"
+
+ 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"Message Auto-Redacted 🗑️
"
+ f"Room: {room_link_html}
"
+ f"Reason: Community majority vote reached ({current_reports} out of {human_count} members).
"
+ f"Context: {message_link_html}"
+ )
+ 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"Message Reported 🚨
"
- f"First Reporter: {evt.sender}
"
- f"Room: {room_link}
"
- f"Action: Click here to inspect and moderate"
+ f"First Reporter: {reporter_link_html} ({escape(evt.sender)})
"
+ f"Room: {room_link_html}
"
+ f"Action: Click here to inspect and moderate"
)
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}")
@@ -1791,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:
@@ -1941,8 +2054,9 @@ class CommunityBot(Plugin):
)
if evt:
+ room_alias = f"#{alias_localpart}:{server}"
await evt.respond(
- f"#{alias_localpart}:{server} 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,
)
@@ -3313,12 +3427,12 @@ class CommunityBot(Plugin):
await evt.respond(
f"Community space initialized successfully!
"
- f"Community Slug: {self.config['community_slug']}
"
- f"Use Community Slug: {self.config['use_community_slug']}"
- f"Room Version: {self.config['room_version']}
"
- f"Space: {space_alias}
"
- f"Moderators Room: {mod_room_alias}
"
- f"Waiting Room: {waiting_room_alias}{warning_msg}",
+ f"Community Slug: {escape(str(self.config['community_slug']))}
"
+ f"Use Community Slug: {escape(str(self.config['use_community_slug']))}"
+ f"Room Version: {escape(str(self.config['room_version']))}
"
+ f"Space: {self._render_html_link(space_alias, space_alias)}
"
+ f"Moderators Room: {self._render_html_link(mod_room_alias, mod_room_alias)}
"
+ f"Waiting Room: {self._render_html_link(waiting_room_alias, waiting_room_alias)}{warning_msg}",
edits=msg,
allow_html=True,
)
diff --git a/community/db.py b/community/db.py
old mode 100644
new mode 100755
diff --git a/community/helpers/__init__.py b/community/helpers/__init__.py
old mode 100644
new mode 100755
diff --git a/community/helpers/base_command_handler.py b/community/helpers/base_command_handler.py
old mode 100644
new mode 100755
diff --git a/community/helpers/common_utils.py b/community/helpers/common_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/config_manager.py b/community/helpers/config_manager.py
old mode 100644
new mode 100755
diff --git a/community/helpers/database_utils.py b/community/helpers/database_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/decorators.py b/community/helpers/decorators.py
old mode 100644
new mode 100755
diff --git a/community/helpers/diagnostic_utils.py b/community/helpers/diagnostic_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/message_utils.py b/community/helpers/message_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/report_utils.py b/community/helpers/report_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/response_builder.py b/community/helpers/response_builder.py
old mode 100644
new mode 100755
diff --git a/community/helpers/room_creation_utils.py b/community/helpers/room_creation_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/room_utils.py b/community/helpers/room_utils.py
old mode 100644
new mode 100755
diff --git a/community/helpers/user_utils.py b/community/helpers/user_utils.py
old mode 100644
new mode 100755
diff --git a/example-standalone-config.yaml b/example-standalone-config.yaml
old mode 100644
new mode 100755
index 7151991..546064a
--- a/example-standalone-config.yaml
+++ b/example-standalone-config.yaml
@@ -115,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 Google 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
@@ -145,7 +150,12 @@ plugin_config:
# message to send to the notification room when someone joins one of the above rooms:
join_notification_message: |
- User {user} has joined {room}.
+ {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,
diff --git a/maubot.yaml b/maubot.yaml
old mode 100644
new mode 100755
index 18206a0..4861768
--- a/maubot.yaml
+++ b/maubot.yaml
@@ -1,6 +1,6 @@
maubot: 0.1.0
id: org.jobmachine.communitybot
-version: 0.4.1
+version: 0.5.0
license: MIT
modules:
- community
diff --git a/pytest.ini b/pytest.ini
old mode 100644
new mode 100755
diff --git a/requirements.txt b/requirements.txt
old mode 100644
new mode 100755
diff --git a/tests/__init__.py b/tests/__init__.py
old mode 100644
new mode 100755
diff --git a/tests/test_bot_commands.py b/tests/test_bot_commands.py
old mode 100644
new mode 100755
diff --git a/tests/test_bot_events.py b/tests/test_bot_events.py
old mode 100644
new mode 100755
index 9ccb5b3..8272d7e
--- a/tests/test_bot_events.py
+++ b/tests/test_bot_events.py
@@ -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" in kwargs["html"]
+ assert "https://matrix.to/#/!room:example.com" in kwargs["html"]
+ assert ">Test Room" 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 "alice" in html_text
+ assert "Test Room" 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 / "
+ "alice"
+ )
+
diff --git a/tests/test_database_utils.py b/tests/test_database_utils.py
old mode 100644
new mode 100755
diff --git a/tests/test_message_utils.py b/tests/test_message_utils.py
old mode 100644
new mode 100755
diff --git a/tests/test_report_utils.py b/tests/test_report_utils.py
old mode 100644
new mode 100755
diff --git a/tests/test_room_utils.py b/tests/test_room_utils.py
old mode 100644
new mode 100755
diff --git a/tests/test_space_creation_simple.py b/tests/test_space_creation_simple.py
old mode 100644
new mode 100755
diff --git a/tests/test_template_rendering.py b/tests/test_template_rendering.py
new file mode 100755
index 0000000..820e4b6
--- /dev/null
+++ b/tests/test_template_rendering.py
@@ -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 'Alice' in html
diff --git a/tests/test_user_utils.py b/tests/test_user_utils.py
old mode 100644
new mode 100755