- 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>
137 lines
4.9 KiB
Python
137 lines
4.9 KiB
Python
"""
|
|
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)
|