diff --git a/github-pages-site/docs/messaging/generic-replacements.md b/github-pages-site/docs/messaging/generic-replacements.md index ca3a24c..a0c0c61 100644 --- a/github-pages-site/docs/messaging/generic-replacements.md +++ b/github-pages-site/docs/messaging/generic-replacements.md @@ -43,17 +43,17 @@ Generic replacements can be used in embeds, join/leave messages, role messages, Any channel referenced with `#channel` format (for example, `#welcome` or `#rules`) will be automatically converted to a proper Discord channel mention. -## Examples +## Role Replacements -Welcome message example: -``` -Welcome @mention to @server! Please check out #rules to get started! -``` +Any text matching `@RoleName` (for example, `@Moderator`) will be automatically converted to a mention of the server role with that exact name. -This would appear as: -``` -Welcome @discord-user to Awesome Server! Please check out #rules to get started! -``` +- The fixed placeholders on this page (`@mention`, `@server`, `@owner`, `@time`, etc.) always take priority. If a role happens to share a name with one of them, the fixed placeholder wins. +- To reference a role unambiguously (like when its name collides with a fixed placeholder), use the explicit form: `@role:RoleName` (for example, `@role:server`). +- The `@everyone` role is never matched by name. +- If no role matches the name given, the text is left as-is. + +{: .info } +These role mentions don't trigger a notification ping for members of that role. If you need notifications, some commands like `/create embed` provide additional arguments to do that. ## Best-Effort Replacements diff --git a/src/components/utils.py b/src/components/utils.py index 4c97b11..ae2f8c1 100644 --- a/src/components/utils.py +++ b/src/components/utils.py @@ -371,6 +371,13 @@ def apply_generic_replacements( @joindate on a leave message, or @owner when the guild owner isn't cached — see get_guild_owner). See "Best-Effort Replacements" in github-pages-site/docs/messaging/generic-replacements.md. + + Role replacements: "@RoleName" is replaced with a mention for the + guild role of that exact name (the @everyone role is never matched this + way). The fixed placeholders above and custom_replacements always take + priority over a role of the same name — use the explicit "@role:RoleName" + form to unambiguously reference a role whose name collides with one of + them. Role names that don't match any role are left as literal text. """ def _apply_text_transform(transform, include_url: bool = True) -> None: # nextcord's .footer/.fields accessors return throwaway EmbedProxy objects, @@ -420,7 +427,22 @@ def _apply_text_transform(transform, include_url: bool = True) -> None: from core.bot import get_bot owner = get_bot().get_user(guild.owner_id) replacements["@owner"] = owner.display_name if owner else "Unknown" - + + # Add role replacements. The @everyone role is excluded since it isn't a + # name anyone is referring to by typing "@everyone" here. Explicit + # "@role:Name" is added alongside the bare "@Name" form so a role can + # still be referenced even if its name collides with a fixed placeholder + # above (fixed placeholders always win — only add the bare key if unused). + for role in guild.roles: + if role == guild.default_role: + continue + explicit_key = f"@role:{role.name}" + if explicit_key not in replacements: + replacements[explicit_key] = role.mention + bare_key = f"@{role.name}" + if bare_key not in replacements: + replacements[bare_key] = role.mention + # Add time and date replacements epoch = int(datetime.datetime.now().timestamp()) @@ -429,9 +451,13 @@ def _apply_text_transform(transform, include_url: bool = True) -> None: replacements["@datelong"] = f"" replacements["@date"] = f"" - # Replace placeholders with values + # Replace placeholders with values. Sorted longest-key-first so that, e.g., a + # role named "Mod" can't eat the front of "@Moderator" before it's matched + # (role/member names are arbitrary, unlike the small fixed set of placeholders, + # so this can't be avoided by careful insertion order alone). + sorted_replacements = sorted(replacements.items(), key=lambda item: len(item[0]), reverse=True) def replace_placeholders(text: str) -> str: - for key, value in replacements.items(): + for key, value in sorted_replacements: text = text.replace(key, value) return text diff --git a/src/tests.py b/src/tests.py index ef9ec28..42023e1 100644 --- a/src/tests.py +++ b/src/tests.py @@ -13,11 +13,12 @@ import time import unittest import uuid +from unittest.mock import Mock from typing import Any, List, Dict from zoneinfo import ZoneInfo import core.db_manager as db_manager -from components.utils import feature_is_active +from components.utils import apply_generic_replacements, feature_is_active from config.file_manager import JSONFile, update_base_path as file_manager_update_base_path, read_txt_to_list from config.global_settings import required_permissions, get_global_kill_status from config.member import Member @@ -2191,6 +2192,88 @@ def test_required_permissions_validity(self) -> None: if not hasattr(nextcord.Permissions, backend_perm): self.fail(f"Required permission `{backend_perm}` does not exist in nextcord.Permissions") + def make_mock_role(self, name: str, mention: str) -> Mock: + """Builds a mock nextcord.Role with just the attributes apply_generic_replacements reads.""" + role = Mock(spec=nextcord.Role) + role.name = name + role.mention = mention + return role + + def make_mock_guild(self, roles: list) -> Mock: + """ + Builds a mock nextcord.Guild carrying the given roles (plus an + @everyone default role), with the other guild-replacement fields + (name, id, member_count, owner) set to simple fixed values. + """ + default_role = self.make_mock_role("@everyone", "@everyone") + + guild = Mock(spec=nextcord.Guild) + guild.id = 1 + guild.name = "Test Guild" + guild.member_count = 10 + guild.owner = Mock(display_name="GuildOwner") + guild.owner_id = 999 + guild.default_role = default_role + guild.roles = [default_role] + roles + return guild + + def apply_role_replacements(self, guild, text: str) -> str: + """Runs apply_generic_replacements on a single-description embed and returns the result.""" + embed = nextcord.Embed(description=text) + result = apply_generic_replacements(embed, None, guild, skip_channel_replacement=True) + return result.description + + def test_role_replacement_bare_name(self) -> None: + """A bare @RoleName is replaced with that role's mention.""" + guild = self.make_mock_guild([self.make_mock_role("Moderator", "<@&111>")]) + + result = self.apply_role_replacements(guild, "Hello @Moderator!") + + self.assertEqual(result, "Hello <@&111>!") + + def test_role_replacement_no_match_left_literal(self) -> None: + """A name with no matching role is left untouched.""" + guild = self.make_mock_guild([self.make_mock_role("Moderator", "<@&111>")]) + + result = self.apply_role_replacements(guild, "Hello @NoSuchRole!") + + self.assertEqual(result, "Hello @NoSuchRole!") + + def test_role_replacement_everyone_excluded(self) -> None: + """The @everyone default role is never matched by name.""" + guild = self.make_mock_guild([]) + + result = self.apply_role_replacements(guild, "Welcome @everyone!") + + self.assertEqual(result, "Welcome @everyone!") + + def test_role_replacement_reserved_placeholder_wins(self) -> None: + """A role sharing a name with a reserved placeholder (e.g. "server") loses to the placeholder.""" + guild = self.make_mock_guild([self.make_mock_role("server", "<@&222>")]) + + result = self.apply_role_replacements(guild, "Welcome to @server!") + + self.assertEqual(result, "Welcome to Test Guild!") + + def test_role_replacement_explicit_syntax_disambiguates(self) -> None: + """@role:Name reaches a role even when its bare name collides with a reserved placeholder.""" + guild = self.make_mock_guild([self.make_mock_role("server", "<@&222>")]) + + result = self.apply_role_replacements(guild, "Welcome to @role:server!") + + self.assertEqual(result, "Welcome to <@&222>!") + + def test_role_replacement_longest_name_first(self) -> None: + """A shorter role name (e.g. "Mod") must not eat the front of a longer one (e.g. "Moderator").""" + guild = self.make_mock_guild([ + self.make_mock_role("Mod", "<@&111>"), + self.make_mock_role("Moderator", "<@&222>"), + ]) + + result = self.apply_role_replacements(guild, "Ping @Moderator now, not @Mod.") + + self.assertEqual(result, "Ping <@&222> now, not <@&111>.") + class TestDailyDBMaintenance(unittest.TestCase): # Most of the processes conducted in daily_db_maintenance are previously tested. # Only the other, untested processes are tested here.