Fix custom command mentions not triggering notifications

- Use channel.send() instead of followup.send() for custom command output
  (webhook-based followup messages don't trigger mention notifications)
- Add ephemeral "Sending..." confirmation to satisfy interaction response
- Add utils/mentions.py for converting text @mentions to Discord format
- Add tests for mention conversion utility

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-01-28 15:45:38 -06:00
parent d6ec2a11ec
commit cf5df1a619
3 changed files with 364 additions and 19 deletions

View File

@ -18,6 +18,7 @@ from models.custom_command import CustomCommandSearchFilters
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from utils.decorators import logged_command from utils.decorators import logged_command
from utils.permissions import requires_team from utils.permissions import requires_team
from utils.mentions import convert_mentions
from views.embeds import EmbedTemplate, EmbedColors from views.embeds import EmbedTemplate, EmbedColors
from views.custom_commands import ( from views.custom_commands import (
CustomCommandCreateModal, CustomCommandCreateModal,
@ -43,7 +44,7 @@ class CustomCommandsCommands(commands.Cog):
@logged_command("/cc") @logged_command("/cc")
async def execute_custom_command(self, interaction: discord.Interaction, name: str): async def execute_custom_command(self, interaction: discord.Interaction, name: str):
"""Execute a custom command.""" """Execute a custom command."""
await interaction.response.defer() await interaction.response.defer(ephemeral=True)
try: try:
# Execute the command and get response # Execute the command and get response
@ -57,27 +58,21 @@ class CustomCommandsCommands(commands.Cog):
await interaction.followup.send(embed=embed, ephemeral=True) await interaction.followup.send(embed=embed, ephemeral=True)
return return
# # Create embed with the response # Convert text mentions (@RoleName, @Username) to proper Discord format (<@&id>, <@id>)
# embed = EmbedTemplate.create_base_embed( converted_content = convert_mentions(response_content, interaction.guild)
# title=f"🎮 {command.name}",
# description=response_content,
# color=EmbedColors.PRIMARY
# )
# # Add creator info in footer await interaction.followup.send(f"Sending `{name}`...", ephemeral=True)
# embed.set_footer(
# text=f"Created by {command.creator.username} • Used {command.use_count} times"
# )
# Send with mentions enabled (users and roles, but not @everyone/@here) # Use channel.send() instead of followup.send() so mentions trigger notifications
await interaction.followup.send( if interaction.channel:
content=response_content, await interaction.channel.send(
allowed_mentions=discord.AllowedMentions( content=converted_content,
users=True, # Allow user mentions (<@123456789>) allowed_mentions=discord.AllowedMentions(
roles=True, # Allow role mentions (<@&987654321>) users=True, # Allow user mentions (<@123456789>)
everyone=False # Block @everyone/@here (already validated) roles=True, # Allow role mentions (<@&987654321>)
everyone=False # Block @everyone/@here
)
) )
)
@execute_custom_command.autocomplete('name') @execute_custom_command.autocomplete('name')
async def execute_custom_command_autocomplete( async def execute_custom_command_autocomplete(

View File

@ -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()

136
utils/mentions.py Normal file
View File

@ -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)