5 Commits

Author SHA1 Message Date
Dome edd3eee178 feat: native matrix URI pills for {user}/{room} + major rendering & codebase refactor
This change introduces native `matrix:` URI-based rendering for `{user}` and `{room}` placeholders,
replacing previous plaintext and matrix.to-based links. Users and rooms are now rendered as clickable
pills in supporting clients, with a clean display using display names and room names (no @/# prefixes).

Reporting, moderation, and auto-redaction messages have been updated to use the same rendering logic.
Inspect and event links now also use native `matrix:` URIs for direct in-client navigation.

Internally, URI generation and rendering logic have been unified via central helper functions,
ensuring consistent handling of user IDs, room IDs, aliases, and event IDs.

This commit also includes a broader refactor of the codebase:
- decomposed complex flows (e.g. join handling) into smaller helpers
- moved mutable class-level state to instance-level
- reduced duplicate API calls and redundant logic
- improved overall structure and maintainability

Test coverage has been extended for URI helpers and rendering logic to prevent regressions.

No breaking changes to existing template parameters like `{user_link}` or `{room_link}`.
2026-04-11 20:21:33 +02:00
Dome 933865d80c Update base-config.yaml 2026-04-11 00:55:47 +02:00
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
14 changed files with 913 additions and 407 deletions
+27 -3
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,8 +58,13 @@ 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:
@@ -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} has joined {room}.
# whether to censor files/messages
# can be boolean (true/false) for all-or-nothing behavior,
@@ -173,3 +189,11 @@ 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}"
# prefixes used for the clickable {user} and {room} placeholders in rendered HTML notices.
# set either value to "" for a cleaner look without a visible prefix.
# 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"
+468 -369
View File
File diff suppressed because it is too large Load Diff
+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(),
+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]
+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)
+16 -3
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,8 +115,13 @@ 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:
@@ -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} has joined {room}.
# 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,
+1 -1
View File
@@ -1,6 +1,6 @@
maubot: 0.1.0
id: org.jobmachine.communitybot
version: 0.4.1
version: 0.6.0
license: MIT
modules:
- community
Executable → Regular
View File
Executable → Regular
View File
+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>"
)
+56
View File
@@ -0,0 +1,56 @@
import html
from types import SimpleNamespace
import pytest
from community.bot import CommunityBot, DEFAULT_ROOM_PILL_PREFIX, DEFAULT_USER_PILL_PREFIX
@pytest.fixture()
def bot() -> CommunityBot:
plugin = CommunityBot.__new__(CommunityBot)
plugin.config = {}
return plugin
def test_user_uri_helper_strips_at_and_uses_chat_action(bot: CommunityBot) -> None:
assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
def test_room_uri_helper_prefers_alias(bot: CommunityBot) -> None:
assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
def test_room_uri_helper_falls_back_to_room_id_without_bang(bot: CommunityBot) -> None:
assert bot._matrix_room_uri("!roomid:example.org", None) == "matrix:roomid/roomid:example.org"
def test_event_uri_helper_strips_prefixes(bot: CommunityBot) -> None:
assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
def test_format_user_pill_uses_clean_default_prefix(bot: CommunityBot) -> None:
plain, formatted = bot._format_user_pill("@alice:example.org", "Alice")
assert plain == f"{DEFAULT_USER_PILL_PREFIX}Alice"
assert 'href="matrix:u/alice:example.org?action=chat"' in formatted
assert ">Alice<" in formatted
def test_format_room_pill_uses_alias_when_available(bot: CommunityBot) -> None:
plain, formatted = bot._format_room_pill("!roomid:example.org", "General", "#general:example.org")
assert plain == f"{DEFAULT_ROOM_PILL_PREFIX}General"
assert formatted == '<a href="matrix:r/general:example.org">General</a>'
def test_format_room_pill_falls_back_to_room_id(bot: CommunityBot) -> None:
plain, formatted = bot._format_room_pill("!roomid:example.org", "General", None)
assert plain == f"{DEFAULT_ROOM_PILL_PREFIX}General"
assert formatted == '<a href="matrix:roomid/roomid:example.org">General</a>'
def test_format_user_pill_escapes_displayname(bot: CommunityBot) -> None:
plain, formatted = bot._format_user_pill("@alice:example.org", '<Admin & Ops>')
assert plain == f"{DEFAULT_USER_PILL_PREFIX}<Admin & Ops>"
# Keep this broad enough to avoid coupling to quote style.
assert "matrix:u/alice:example.org?action=chat" in formatted
assert html.escape('<Admin & Ops>') in formatted
+10
View File
@@ -0,0 +1,10 @@
from community.bot import CommunityBot
def test_matrix_uri_wrappers_delegate_to_canonical_helpers() -> None:
bot = CommunityBot.__new__(CommunityBot)
bot.config = {}
assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
assert bot._matrix_room_uri("!roomid:example.org") == "matrix:roomid/roomid:example.org"
assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"
+40 -7
View File
@@ -46,24 +46,41 @@ 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, [])
result = await validate_room_aliases(client, ["room1", "room2"], "", False, "example.com")
assert result == (True, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_success(self):
async def test_validate_room_aliases_slug_not_required_with_slug(self):
"""Test successful alias validation."""
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"], "test", False, "example.com")
assert result == (True, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_conflicts(self):
async def test_validate_room_aliases_slug_required_with_no_slug(self):
"""Test alias validation without community slug."""
client = Mock()
result = await validate_room_aliases(client, ["room1", "room2"], "", True, "example.com")
assert result == (False, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_slug_required_with_slug(self):
"""Test successful alias validation."""
client = Mock()
client.resolve_room_alias = AsyncMock(side_effect=MNotFound("Room not found", 404))
result = await validate_room_aliases(client, ["room1", "room2"], "test", True, "example.com")
assert result == (True, [])
@pytest.mark.asyncio
async def test_validate_room_aliases_conflicts_slug_not_required(self):
"""Test alias validation with conflicts."""
client = Mock()
@@ -75,7 +92,23 @@ class TestRoomUtils:
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
+104
View File
@@ -0,0 +1,104 @@
"""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",
"user_pill_prefix": "@",
"room_pill_prefix": "#",
}
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
def test_render_message_template_uses_configurable_user_and_room_pill_prefixes(bot):
bot.config["user_pill_prefix"] = ""
bot.config["room_pill_prefix"] = ""
plain, html = bot._render_message_template(
"{user} has joined {room}.",
"@alice:example.org",
"Alice",
"!room:example.org",
"General",
)
assert plain == "Alice has joined General."
assert "<a href='matrix:u/alice:example.org?action=chat'>Alice</a>" in html
assert "<a href='matrix:roomid/room:example.org'>General</a>" in html
def test_render_message_template_defaults_to_prefixed_user_and_room_pills(bot):
plain, html = bot._render_message_template(
"{user} has joined {room}.",
"@alice:example.org",
"Alice",
"!room:example.org",
"General",
)
assert plain == "@Alice has joined #General."
assert "<a href='matrix:u/alice:example.org?action=chat'>@Alice</a>" in html
assert "<a href='matrix:roomid/room:example.org'>#General</a>" in html
def test_matrix_uri_helpers_are_consistent():
from community.bot import CommunityBot
bot = CommunityBot.__new__(CommunityBot)
bot.config = {}
assert bot._matrix_user_uri("@alice:example.org") == "matrix:u/alice:example.org?action=chat"
assert bot._matrix_room_uri("!roomid:example.org", "#general:example.org") == "matrix:r/general:example.org"
assert bot._matrix_room_uri("!roomid:example.org", None) == "matrix:roomid/roomid:example.org"
assert bot._matrix_event_uri("!roomid:example.org", "$eventid") == "matrix:roomid/roomid:example.org/e/eventid"