Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions github-pages-site/docs/messaging/generic-replacements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 29 additions & 3 deletions src/components/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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())

Expand All @@ -429,9 +451,13 @@ def _apply_text_transform(transform, include_url: bool = True) -> None:
replacements["@datelong"] = f"<t:{epoch}:D>"
replacements["@date"] = f"<t:{epoch}:D>"

# 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

Expand Down
85 changes: 84 additions & 1 deletion src/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down