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
This commit is contained in:
2026-04-10 23:55:17 +02:00
parent bc490bd084
commit b2541c4054
36 changed files with 406 additions and 65 deletions
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+23 -5
View File
@@ -58,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
@@ -87,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,
@@ -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. 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
+168 -54
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
@@ -79,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")
@@ -101,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()
@@ -120,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:
@@ -335,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,
) )
@@ -871,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}")
@@ -1011,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
@@ -1032,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
@@ -1313,7 +1405,7 @@ class CommunityBot(Plugin):
@event.on(EventType.REACTION) @event.on(EventType.REACTION)
async def handle_reactions(self, evt: MessageEvent) -> None: async def handle_reactions(self, evt: MessageEvent) -> None:
if evt.sender == self.client.mxid: if evt.sender == self.client.mxid:
return return
@@ -1338,29 +1430,30 @@ class CommunityBot(Plugin):
return return
target_event_id = relates_to.event_id target_event_id = relates_to.event_id
if target_event_id not in self._report_counts: if target_event_id not in self._report_counts:
self._report_counts[target_event_id] = set() self._report_counts[target_event_id] = set()
if evt.sender in self._report_counts[target_event_id]: if evt.sender in self._report_counts[target_event_id]:
return return
self._report_counts[target_event_id].add(evt.sender) self._report_counts[target_event_id].add(evt.sender)
current_reports = len(self._report_counts[target_event_id]) current_reports = len(self._report_counts[target_event_id])
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
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): 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)
@@ -1369,33 +1462,53 @@ class CommunityBot(Plugin):
if current_reports > threshold: if current_reports > threshold:
await self.client.redact( await self.client.redact(
evt.room_id, evt.room_id,
target_event_id, target_event_id,
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}"
) )
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] del self._report_counts[target_event_id]
return return
except Exception as e: except Exception as e:
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}")
@@ -1791,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:
@@ -1941,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,
) )
@@ -3313,12 +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"Use Community Slug: {self.config['use_community_slug']}" f"Use Community Slug: {escape(str(self.config['use_community_slug']))}"
f"Room Version: {self.config['room_version']}<br />" f"Room Version: {escape(str(self.config['room_version']))}<br />"
f"Space: <a href='https://matrix.to/#/{space_alias}'>{space_alias}</a><br />" f"Space: {self._render_html_link(space_alias, space_alias)}<br />"
f"Moderators Room: <a href='https://matrix.to/#/{mod_room_alias}'>{mod_room_alias}</a><br />" f"Moderators Room: {self._render_html_link(mod_room_alias, mod_room_alias)}<br />"
f"Waiting Room: <a href='https://matrix.to/#/{waiting_room_alias}'>{waiting_room_alias}</a>{warning_msg}", 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
View File
View File
Regular → Executable
View File
View File
View File
View File
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
+15 -5
View File
@@ -115,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
@@ -145,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
View File
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