major-domo-v2/utils/mentions.py
Cal Corum cf5df1a619 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>
2026-01-28 15:45:38 -06:00

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)