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