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