diff --git a/commands/custom_commands/main.py b/commands/custom_commands/main.py index ae30f3a..0b3fb34 100644 --- a/commands/custom_commands/main.py +++ b/commands/custom_commands/main.py @@ -18,6 +18,7 @@ from models.custom_command import CustomCommandSearchFilters from utils.logging import get_contextual_logger from utils.decorators import logged_command from utils.permissions import requires_team +from utils.mentions import convert_mentions from views.embeds import EmbedTemplate, EmbedColors from views.custom_commands import ( CustomCommandCreateModal, @@ -43,7 +44,7 @@ class CustomCommandsCommands(commands.Cog): @logged_command("/cc") async def execute_custom_command(self, interaction: discord.Interaction, name: str): """Execute a custom command.""" - await interaction.response.defer() + await interaction.response.defer(ephemeral=True) try: # Execute the command and get response @@ -57,27 +58,21 @@ class CustomCommandsCommands(commands.Cog): await interaction.followup.send(embed=embed, ephemeral=True) return - # # Create embed with the response - # embed = EmbedTemplate.create_base_embed( - # title=f"🎮 {command.name}", - # description=response_content, - # color=EmbedColors.PRIMARY - # ) + # Convert text mentions (@RoleName, @Username) to proper Discord format (<@&id>, <@id>) + converted_content = convert_mentions(response_content, interaction.guild) - # # Add creator info in footer - # embed.set_footer( - # text=f"Created by {command.creator.username} • Used {command.use_count} times" - # ) + await interaction.followup.send(f"Sending `{name}`...", ephemeral=True) - # Send with mentions enabled (users and roles, but not @everyone/@here) - await interaction.followup.send( - content=response_content, - allowed_mentions=discord.AllowedMentions( - users=True, # Allow user mentions (<@123456789>) - roles=True, # Allow role mentions (<@&987654321>) - everyone=False # Block @everyone/@here (already validated) + # Use channel.send() instead of followup.send() so mentions trigger notifications + if interaction.channel: + await interaction.channel.send( + content=converted_content, + allowed_mentions=discord.AllowedMentions( + users=True, # Allow user mentions (<@123456789>) + roles=True, # Allow role mentions (<@&987654321>) + everyone=False # Block @everyone/@here + ) ) - ) @execute_custom_command.autocomplete('name') async def execute_custom_command_autocomplete( diff --git a/tests/test_utils_mentions.py b/tests/test_utils_mentions.py new file mode 100644 index 0000000..5319e14 --- /dev/null +++ b/tests/test_utils_mentions.py @@ -0,0 +1,214 @@ +""" +Tests for the mention conversion utilities. + +These tests verify that human-readable @mentions are properly converted +to Discord's ID-based format for triggering notifications. +""" +import pytest +from unittest.mock import MagicMock, AsyncMock +from utils.mentions import convert_mentions, convert_mentions_async + + +class TestConvertMentions: + """Tests for the synchronous convert_mentions function.""" + + def test_converts_role_mention(self): + """Test that @RoleName is converted to <@&role_id>.""" + # Create mock guild with a role + mock_role = MagicMock() + mock_role.name = "Waikiki Whale Sharks" + mock_role.id = 123456789 + + mock_guild = MagicMock() + mock_guild.roles = [mock_role] + mock_guild.members = [] + + content = "@Waikiki Whale Sharks" + result = convert_mentions(content, mock_guild) + + assert result == "<@&123456789>" + + def test_converts_role_mention_case_insensitive(self): + """Test that role matching is case-insensitive.""" + mock_role = MagicMock() + mock_role.name = "Waikiki Whale Sharks" + mock_role.id = 123456789 + + mock_guild = MagicMock() + mock_guild.roles = [mock_role] + mock_guild.members = [] + + content = "@waikiki whale sharks" + result = convert_mentions(content, mock_guild) + + assert result == "<@&123456789>" + + def test_converts_user_mention_by_display_name(self): + """Test that @DisplayName is converted to <@user_id>.""" + mock_member = MagicMock() + mock_member.display_name = "CalCorum" + mock_member.name = "cal" + mock_member.id = 987654321 + + mock_guild = MagicMock() + mock_guild.roles = [] + mock_guild.members = [mock_member] + + content = "@CalCorum" + result = convert_mentions(content, mock_guild) + + assert result == "<@987654321>" + + def test_converts_user_mention_by_username(self): + """Test that @username is converted to <@user_id>.""" + mock_member = MagicMock() + mock_member.display_name = "Cal Corum" + mock_member.name = "calcorum" + mock_member.id = 987654321 + + mock_guild = MagicMock() + mock_guild.roles = [] + mock_guild.members = [mock_member] + + content = "@calcorum" + result = convert_mentions(content, mock_guild) + + assert result == "<@987654321>" + + def test_converts_multiple_mentions(self): + """Test that multiple mentions in same content are all converted.""" + mock_role1 = MagicMock() + mock_role1.name = "Team A" + mock_role1.id = 111 + + mock_role2 = MagicMock() + mock_role2.name = "Team B" + mock_role2.id = 222 + + mock_guild = MagicMock() + mock_guild.roles = [mock_role1, mock_role2] + mock_guild.members = [] + + content = "@Team A vs @Team B" + result = convert_mentions(content, mock_guild) + + assert result == "<@&111> vs <@&222>" + + def test_preserves_already_formatted_mentions(self): + """Test that <@123> and <@&456> are not modified.""" + mock_guild = MagicMock() + mock_guild.roles = [] + mock_guild.members = [] + + content = "Already formatted: <@123456789> and <@&987654321>" + result = convert_mentions(content, mock_guild) + + assert result == content + + def test_preserves_unrecognized_mentions(self): + """Test that @mentions with no match are left unchanged.""" + mock_guild = MagicMock() + mock_guild.roles = [] + mock_guild.members = [] + + content = "@UnknownRole says hello" + result = convert_mentions(content, mock_guild) + + assert result == "@UnknownRole says hello" + + def test_handles_none_guild(self): + """Test that None guild returns content unchanged.""" + content = "@SomeRole" + result = convert_mentions(content, None) + + assert result == content + + def test_handles_empty_content(self): + """Test that empty content returns empty.""" + mock_guild = MagicMock() + result = convert_mentions("", mock_guild) + + assert result == "" + + def test_handles_mentions_with_punctuation(self): + """Test that mentions followed by punctuation are handled.""" + mock_role = MagicMock() + mock_role.name = "TestRole" + mock_role.id = 123 + + mock_guild = MagicMock() + mock_guild.roles = [mock_role] + mock_guild.members = [] + + content = "Hello @TestRole!" + result = convert_mentions(content, mock_guild) + + assert result == "Hello <@&123>!" + + def test_role_takes_priority_over_user(self): + """Test that when a name matches both role and user, role wins.""" + mock_role = MagicMock() + mock_role.name = "SameName" + mock_role.id = 111 + + mock_member = MagicMock() + mock_member.display_name = "SameName" + mock_member.name = "samename" + mock_member.id = 222 + + mock_guild = MagicMock() + mock_guild.roles = [mock_role] + mock_guild.members = [mock_member] + + content = "@SameName" + result = convert_mentions(content, mock_guild) + + # Role should be matched first + assert result == "<@&111>" + + +class TestConvertMentionsAsync: + """Tests for the async convert_mentions_async function.""" + + @pytest.mark.asyncio + async def test_async_converts_role_mention(self): + """Test async version converts mentions.""" + mock_role = MagicMock() + mock_role.name = "TestRole" + mock_role.id = 123 + + mock_guild = MagicMock() + mock_guild.roles = [mock_role] + mock_guild.members = [] + mock_guild.chunked = True + + content = "@TestRole" + result = await convert_mentions_async(content, mock_guild) + + assert result == "<@&123>" + + @pytest.mark.asyncio + async def test_async_chunks_guild_when_requested(self): + """Test that guild.chunk() is called when fetch_members=True and not chunked.""" + mock_guild = MagicMock() + mock_guild.roles = [] + mock_guild.members = [] + mock_guild.chunked = False + mock_guild.chunk = AsyncMock() + + await convert_mentions_async("@Test", mock_guild, fetch_members=True) + + mock_guild.chunk.assert_called_once() + + @pytest.mark.asyncio + async def test_async_skips_chunk_when_already_chunked(self): + """Test that guild.chunk() is not called when already chunked.""" + mock_guild = MagicMock() + mock_guild.roles = [] + mock_guild.members = [] + mock_guild.chunked = True + mock_guild.chunk = AsyncMock() + + await convert_mentions_async("@Test", mock_guild, fetch_members=True) + + mock_guild.chunk.assert_not_called() diff --git a/utils/mentions.py b/utils/mentions.py new file mode 100644 index 0000000..0c12b4b --- /dev/null +++ b/utils/mentions.py @@ -0,0 +1,136 @@ +""" +Mention conversion utilities for Discord Bot v2.0 + +Converts human-readable @mentions (e.g., @RoleName, @Username) to proper Discord +mention format that triggers notifications (<@&role_id>, <@user_id>). + +Why this is needed: +Discord only sends notifications for mentions that use the ID-based format. +Text like "@Waikiki Whale Sharks" will be styled/highlighted (because Discord +recognizes it matches a role name), but won't actually notify anyone. +""" +from typing import Optional +import discord + + +def convert_mentions(content: str, guild: Optional[discord.Guild]) -> str: + """ + Convert human-readable @mentions to proper Discord mention format. + + Transforms: + - @RoleName -> <@&role_id> (if role exists in guild) + - @Username -> <@user_id> (if member exists in guild) + + Args: + content: The message content potentially containing @mentions + guild: The Discord guild to look up roles/members in + + Returns: + Content with text mentions converted to ID-based mentions where possible. + Unrecognized mentions are left unchanged. + + Note: + - Already-formatted mentions (<@123>, <@&456>) are left unchanged + - Case-insensitive matching for role names + - Matches display names and usernames for users + - Multi-word names are supported (e.g., @Waikiki Whale Sharks) + """ + if not guild or not content: + return content + + # Build lookup dictionaries for fast matching (lowercase -> original) + role_lookup: dict[str, discord.Role] = { + role.name.lower(): role for role in guild.roles + } + member_lookup: dict[str, discord.Member] = {} + for member in guild.members: + # Index by both display name and username + member_lookup[member.display_name.lower()] = member + member_lookup[member.name.lower()] = member + + # Sort names by length (longest first) to match "Team Alpha Beta" before "Team Alpha" + all_role_names = sorted(role_lookup.keys(), key=len, reverse=True) + all_member_names = sorted(member_lookup.keys(), key=len, reverse=True) + + result = [] + i = 0 + + while i < len(content): + # Check if this is an @ that's not already part of a Discord mention + if content[i] == '@': + # Skip if this is part of <@ (already formatted mention) + if i > 0 and content[i - 1] == '<': + result.append(content[i]) + i += 1 + continue + + # Get the text after @ + remaining = content[i + 1:] + remaining_lower = remaining.lower() + + # Try to match against role names (longest first) + matched = False + for role_name_lower in all_role_names: + if remaining_lower.startswith(role_name_lower): + # Verify it's a word boundary (end of string, or non-alphanumeric follows) + end_pos = len(role_name_lower) + if end_pos >= len(remaining) or not remaining[end_pos].isalnum(): + role = role_lookup[role_name_lower] + result.append(f'<@&{role.id}>') + i += 1 + len(role_name_lower) # Skip @ and the role name + matched = True + break + + if matched: + continue + + # Try to match against member names (longest first) + for member_name_lower in all_member_names: + if remaining_lower.startswith(member_name_lower): + # Verify it's a word boundary + end_pos = len(member_name_lower) + if end_pos >= len(remaining) or not remaining[end_pos].isalnum(): + member = member_lookup[member_name_lower] + result.append(f'<@{member.id}>') + i += 1 + len(member_name_lower) # Skip @ and the member name + matched = True + break + + if matched: + continue + + # No match or not an @, just append the character + result.append(content[i]) + i += 1 + + return ''.join(result) + + +async def convert_mentions_async( + content: str, + guild: Optional[discord.Guild], + fetch_members: bool = False +) -> str: + """ + Async version of convert_mentions with optional member fetching. + + Use this when guild.members might not be fully populated (large guilds + without member intent or chunking). + + Args: + content: The message content potentially containing @mentions + guild: The Discord guild to look up roles/members in + fetch_members: If True, fetches all members first (slower but complete) + + Returns: + Content with text mentions converted to ID-based mentions. + """ + if not guild or not content: + return content + + if fetch_members: + # Ensure we have all members + if not guild.chunked: + await guild.chunk() + + return convert_mentions(content, guild)