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:
parent
d6ec2a11ec
commit
cf5df1a619
@ -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(
|
||||||
|
|||||||
214
tests/test_utils_mentions.py
Normal file
214
tests/test_utils_mentions.py
Normal 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
136
utils/mentions.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user