From b50af6cdbd226c3c301ba58b49b8c8b434a5dd02 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 00:32:14 +0800 Subject: [PATCH 01/15] feat: mention role IDs in match-candidates --- .../src/five08/discord_bot/cogs/crm.py | 66 +++++++++++++++++-- tests/unit/test_crm.py | 39 +++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 73a7aa1a..6e5f2806 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6403,12 +6403,39 @@ async def match_candidates( lines: list[str] = [] header_parts: list[str] = [] + role_mentions: list[str] = [] + role_objects: list[discord.Role] = [] if requirements.title: header_parts.append(f"**{requirements.title}**") if requirements.discord_role_types: - header_parts.append( - "Role: " + ", ".join(f"`{r}`" for r in requirements.discord_role_types) - ) + if interaction.guild is not None: + role_id_map = self._get_role_id_cache().get(interaction.guild.id) + if role_id_map is None: + self._refresh_role_id_cache(interaction.guild) + role_id_map = self._get_role_id_cache().get( + interaction.guild.id, {} + ) + + for role_name in requirements.discord_role_types: + role_id = role_id_map.get(role_name.casefold()) + if role_id is not None: + role_mentions.append(f"<@&{role_id}>") + role = interaction.guild.get_role(role_id) + if role is not None: + role_objects.append(role) + else: + role = discord.utils.get( + interaction.guild.roles, name=role_name + ) + if role is not None: + role_objects.append(role) + role_mentions.append(role.mention) + else: + role_mentions.append(f"`{role_name}`") + else: + role_mentions = [f"`{r}`" for r in requirements.discord_role_types] + + header_parts.append("Discord roles: " + ", ".join(role_mentions)) if requirements.required_skills: header_parts.append( "Skills: " @@ -6484,7 +6511,11 @@ async def match_candidates( messages.append(current.rstrip()) for msg in messages: - await interaction.followup.send(msg) + if role_objects: + allowed_mentions = discord.AllowedMentions(roles=role_objects) + await interaction.followup.send(msg, allowed_mentions=allowed_mentions) + else: + await interaction.followup.send(msg) if resume_options: await interaction.followup.send( "Resume download:", @@ -6553,10 +6584,37 @@ async def _bulk_sync_guild_roles( skipped += 1 return updated, skipped, failed + def _get_role_id_cache(self) -> dict[int, dict[str, int]]: + cache = getattr(self, "_role_id_cache", None) + if cache is None: + cache = {} + setattr(self, "_role_id_cache", cache) + return cache + + def _refresh_role_id_cache(self, guild: discord.Guild) -> None: + self._get_role_id_cache()[guild.id] = { + role.name.casefold(): role.id for role in guild.roles + } + + @commands.Cog.listener() + async def on_guild_role_create(self, role: discord.Role) -> None: + self._refresh_role_id_cache(role.guild) + + @commands.Cog.listener() + async def on_guild_role_delete(self, role: discord.Role) -> None: + self._refresh_role_id_cache(role.guild) + + @commands.Cog.listener() + async def on_guild_role_update( + self, before: discord.Role, after: discord.Role + ) -> None: + self._refresh_role_id_cache(after.guild) + @commands.Cog.listener() async def on_ready(self) -> None: """Bulk-sync all guild member roles on startup.""" for guild in self.bot.guilds: + self._refresh_role_id_cache(guild) try: updated, skipped, failed = await self._bulk_sync_guild_roles(guild) logger.info( diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 4baf1c7a..422e946c 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -213,6 +213,45 @@ async def test_download_and_send_resume_api_error(self, crm_cog, mock_interactio "❌ Failed to download resume: API Error" ) + def test_role_id_cache_initializes_empty(self, crm_cog): + """Role ID cache should initialize empty on first access.""" + cache = crm_cog._get_role_id_cache() + + assert cache == {} + + def test_refresh_role_id_cache_builds_casefold_map(self, crm_cog): + """Role ID cache should map casefolded role names to IDs.""" + role_frontend = Mock() + role_frontend.name = "Frontend" + role_frontend.id = 111 + + role_full_stack = Mock() + role_full_stack.name = "Full Stack" + role_full_stack.id = 222 + + guild = Mock() + guild.id = 42 + guild.roles = [role_frontend, role_full_stack] + + crm_cog._refresh_role_id_cache(guild) + + cache = crm_cog._get_role_id_cache() + assert cache[42] == {"frontend": 111, "full stack": 222} + + @pytest.mark.asyncio + async def test_on_guild_role_update_refreshes_cache(self, crm_cog): + """Role update events should refresh the role ID cache.""" + guild = Mock() + before = Mock() + before.guild = guild + after = Mock() + after.guild = guild + + with patch.object(crm_cog, "_refresh_role_id_cache") as refresh: + await crm_cog.on_guild_role_update(before, after) + + refresh.assert_called_once_with(guild) + @pytest.mark.asyncio async def test_search_contacts_success( self, crm_cog, mock_interaction, mock_member_role From ce8baaa46b2c181b6e301b233755eedde0050e71 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 00:50:18 +0800 Subject: [PATCH 02/15] fix: skip seniority roles in mentions --- .../src/five08/discord_bot/cogs/crm.py | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 6e5f2806..34b33fda 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6408,34 +6408,48 @@ async def match_candidates( if requirements.title: header_parts.append(f"**{requirements.title}**") if requirements.discord_role_types: - if interaction.guild is not None: - role_id_map = self._get_role_id_cache().get(interaction.guild.id) - if role_id_map is None: - self._refresh_role_id_cache(interaction.guild) - role_id_map = self._get_role_id_cache().get( - interaction.guild.id, {} - ) - - for role_name in requirements.discord_role_types: - role_id = role_id_map.get(role_name.casefold()) - if role_id is not None: - role_mentions.append(f"<@&{role_id}>") - role = interaction.guild.get_role(role_id) - if role is not None: - role_objects.append(role) - else: - role = discord.utils.get( - interaction.guild.roles, name=role_name + seniority_role_names = { + "junior", + "mid-level", + "midlevel", + "senior", + "staff", + "principal", + } + role_types = [ + role_name + for role_name in requirements.discord_role_types + if role_name.casefold() not in seniority_role_names + ] + if role_types: + if interaction.guild is not None: + role_id_map = self._get_role_id_cache().get(interaction.guild.id) + if role_id_map is None: + self._refresh_role_id_cache(interaction.guild) + role_id_map = self._get_role_id_cache().get( + interaction.guild.id, {} ) - if role is not None: - role_objects.append(role) - role_mentions.append(role.mention) + + for role_name in role_types: + role_id = role_id_map.get(role_name.casefold()) + if role_id is not None: + role_mentions.append(f"<@&{role_id}>") + role = interaction.guild.get_role(role_id) + if role is not None: + role_objects.append(role) else: - role_mentions.append(f"`{role_name}`") - else: - role_mentions = [f"`{r}`" for r in requirements.discord_role_types] + role = discord.utils.get( + interaction.guild.roles, name=role_name + ) + if role is not None: + role_objects.append(role) + role_mentions.append(role.mention) + else: + role_mentions.append(f"`{role_name}`") + else: + role_mentions = [f"`{r}`" for r in role_types] - header_parts.append("Discord roles: " + ", ".join(role_mentions)) + header_parts.append("Discord roles: " + ", ".join(role_mentions)) if requirements.required_skills: header_parts.append( "Skills: " From 7a8f8cb881e2c1a41ae66f191b9af738a90b0c48 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 00:59:57 +0800 Subject: [PATCH 03/15] fix: restrict role mentions --- .../src/five08/discord_bot/cogs/crm.py | 48 +++++++++++++------ tests/unit/test_crm.py | 6 ++- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 34b33fda..dc6dfa97 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6404,7 +6404,7 @@ async def match_candidates( header_parts: list[str] = [] role_mentions: list[str] = [] - role_objects: list[discord.Role] = [] + role_mentions_line: str | None = None if requirements.title: header_parts.append(f"**{requirements.title}**") if requirements.discord_role_types: @@ -6434,22 +6434,19 @@ async def match_candidates( role_id = role_id_map.get(role_name.casefold()) if role_id is not None: role_mentions.append(f"<@&{role_id}>") - role = interaction.guild.get_role(role_id) - if role is not None: - role_objects.append(role) else: role = discord.utils.get( interaction.guild.roles, name=role_name ) if role is not None: - role_objects.append(role) role_mentions.append(role.mention) else: role_mentions.append(f"`{role_name}`") else: role_mentions = [f"`{r}`" for r in role_types] - header_parts.append("Discord roles: " + ", ".join(role_mentions)) + if role_mentions: + role_mentions_line = "Discord roles: " + ", ".join(role_mentions) if requirements.required_skills: header_parts.append( "Skills: " @@ -6462,10 +6459,25 @@ async def match_candidates( elif requirements.raw_location_text: header_parts.append(f"📍 {requirements.raw_location_text}") - lines.append("## Job Match Results") + header_lines: list[str] = ["## Job Match Results"] if header_parts: - lines.append(" · ".join(header_parts)) - lines.append(f"Found **{len(candidates)}** candidate(s).\n") + header_lines.append(" · ".join(header_parts)) + if role_mentions_line: + header_lines.append(role_mentions_line) + header_lines.append(f"Found **{len(candidates)}** candidate(s).") + + header_message = "\n".join(header_lines) + if role_mentions_line: + await interaction.followup.send( + header_message, + allowed_mentions=discord.AllowedMentions( + roles=True, + users=False, + everyone=False, + ), + ) + else: + await interaction.followup.send(header_message) crm_base = settings.espo_base_url.rstrip("/") resume_options: list[tuple[str, str, str]] = [] @@ -6525,11 +6537,14 @@ async def match_candidates( messages.append(current.rstrip()) for msg in messages: - if role_objects: - allowed_mentions = discord.AllowedMentions(roles=role_objects) - await interaction.followup.send(msg, allowed_mentions=allowed_mentions) - else: - await interaction.followup.send(msg) + await interaction.followup.send( + msg, + allowed_mentions=discord.AllowedMentions( + roles=False, + users=True, + everyone=False, + ), + ) if resume_options: await interaction.followup.send( "Resume download:", @@ -6606,8 +6621,11 @@ def _get_role_id_cache(self) -> dict[int, dict[str, int]]: return cache def _refresh_role_id_cache(self, guild: discord.Guild) -> None: + excluded_names = {name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC} self._get_role_id_cache()[guild.id] = { - role.name.casefold(): role.id for role in guild.roles + role.name.casefold(): role.id + for role in guild.roles + if role.name.casefold() not in excluded_names } @commands.Cog.listener() diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 422e946c..5a05fba7 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -229,9 +229,13 @@ def test_refresh_role_id_cache_builds_casefold_map(self, crm_cog): role_full_stack.name = "Full Stack" role_full_stack.id = 222 + role_excluded = Mock() + role_excluded.name = "Bots" + role_excluded.id = 333 + guild = Mock() guild.id = 42 - guild.roles = [role_frontend, role_full_stack] + guild.roles = [role_frontend, role_full_stack, role_excluded] crm_cog._refresh_role_id_cache(guild) From 6529befec81fd200ce1979b56c57b356815464ed Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 01:34:39 +0800 Subject: [PATCH 04/15] fix: tighten role mention handling --- .../src/five08/discord_bot/cogs/crm.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index dc6dfa97..765be349 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6435,8 +6435,13 @@ async def match_candidates( if role_id is not None: role_mentions.append(f"<@&{role_id}>") else: - role = discord.utils.get( - interaction.guild.roles, name=role_name + role = next( + ( + candidate + for candidate in interaction.guild.roles + if candidate.name.casefold() == role_name.casefold() + ), + None, ) if role is not None: role_mentions.append(role.mention) @@ -6462,22 +6467,26 @@ async def match_candidates( header_lines: list[str] = ["## Job Match Results"] if header_parts: header_lines.append(" · ".join(header_parts)) - if role_mentions_line: - header_lines.append(role_mentions_line) header_lines.append(f"Found **{len(candidates)}** candidate(s).") header_message = "\n".join(header_lines) + await interaction.followup.send( + header_message, + allowed_mentions=discord.AllowedMentions( + roles=False, + users=False, + everyone=False, + ), + ) if role_mentions_line: await interaction.followup.send( - header_message, + role_mentions_line, allowed_mentions=discord.AllowedMentions( roles=True, users=False, everyone=False, ), ) - else: - await interaction.followup.send(header_message) crm_base = settings.espo_base_url.rstrip("/") resume_options: list[tuple[str, str, str]] = [] From 260f76c89ae7056fa0cfd8296b5f167dcf1d9658 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 08:41:51 +0800 Subject: [PATCH 05/15] fix: skip excluded roles in fallback --- apps/discord_bot/src/five08/discord_bot/cogs/crm.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 765be349..a0e9b17a 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6422,6 +6422,9 @@ async def match_candidates( if role_name.casefold() not in seniority_role_names ] if role_types: + excluded_role_names = { + name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC + } if interaction.guild is not None: role_id_map = self._get_role_id_cache().get(interaction.guild.id) if role_id_map is None: @@ -6431,7 +6434,10 @@ async def match_candidates( ) for role_name in role_types: - role_id = role_id_map.get(role_name.casefold()) + normalized_role_name = role_name.casefold() + if normalized_role_name in excluded_role_names: + continue + role_id = role_id_map.get(normalized_role_name) if role_id is not None: role_mentions.append(f"<@&{role_id}>") else: @@ -6439,7 +6445,9 @@ async def match_candidates( ( candidate for candidate in interaction.guild.roles - if candidate.name.casefold() == role_name.casefold() + if candidate.name.casefold() == normalized_role_name + and candidate.name.casefold() + not in excluded_role_names ), None, ) From 2d57dd1dd658e3d35ed759a93410a8cdb72d6fe0 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 08:48:37 +0800 Subject: [PATCH 06/15] feat: mention locality roles --- .../src/five08/discord_bot/cogs/crm.py | 167 ++++++++++++++---- 1 file changed, 130 insertions(+), 37 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index a0e9b17a..f9f1c551 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6403,8 +6403,58 @@ async def match_candidates( lines: list[str] = [] header_parts: list[str] = [] - role_mentions: list[str] = [] role_mentions_line: str | None = None + locality_mentions_line: str | None = None + excluded_role_names = { + name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC + } + + def dedupe_role_names(role_names: list[str]) -> list[str]: + seen: set[str] = set() + deduped: list[str] = [] + for role_name in role_names: + key = role_name.casefold() + if key in seen: + continue + seen.add(key) + deduped.append(role_name) + return deduped + + def build_role_mentions(role_names: list[str]) -> list[str]: + if not role_names: + return [] + if interaction.guild is None: + return [f"`{r}`" for r in role_names] + + role_id_map = self._get_role_id_cache().get(interaction.guild.id) + if role_id_map is None: + self._refresh_role_id_cache(interaction.guild) + role_id_map = self._get_role_id_cache().get(interaction.guild.id, {}) + + mentions: list[str] = [] + for role_name in role_names: + normalized_role_name = role_name.casefold() + if normalized_role_name in excluded_role_names: + continue + role_id = role_id_map.get(normalized_role_name) + if role_id is not None: + mentions.append(f"<@&{role_id}>") + continue + role = next( + ( + candidate + for candidate in interaction.guild.roles + if candidate.name.casefold() == normalized_role_name + and candidate.name.casefold() not in excluded_role_names + ), + None, + ) + if role is not None: + mentions.append(role.mention) + else: + mentions.append(f"`{role_name}`") + return mentions + if requirements.title: header_parts.append(f"**{requirements.title}**") if requirements.discord_role_types: @@ -6422,44 +6472,78 @@ async def match_candidates( if role_name.casefold() not in seniority_role_names ] if role_types: - excluded_role_names = { - name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC - } - if interaction.guild is not None: - role_id_map = self._get_role_id_cache().get(interaction.guild.id) - if role_id_map is None: - self._refresh_role_id_cache(interaction.guild) - role_id_map = self._get_role_id_cache().get( - interaction.guild.id, {} - ) - - for role_name in role_types: - normalized_role_name = role_name.casefold() - if normalized_role_name in excluded_role_names: - continue - role_id = role_id_map.get(normalized_role_name) - if role_id is not None: - role_mentions.append(f"<@&{role_id}>") - else: - role = next( - ( - candidate - for candidate in interaction.guild.roles - if candidate.name.casefold() == normalized_role_name - and candidate.name.casefold() - not in excluded_role_names - ), - None, - ) - if role is not None: - role_mentions.append(role.mention) - else: - role_mentions.append(f"`{role_name}`") - else: - role_mentions = [f"`{r}`" for r in role_types] - + role_mentions = build_role_mentions(dedupe_role_names(role_types)) if role_mentions: role_mentions_line = "Discord roles: " + ", ".join(role_mentions) + + locality_role_names: list[str] = [] + location_text_parts: list[str] = [] + if requirements.raw_location_text: + location_text_parts.append(requirements.raw_location_text) + if requirements.preferred_timezones: + location_text_parts.extend(requirements.preferred_timezones) + location_text = " ".join(location_text_parts).casefold() + + if requirements.location_type == "us_only" or "united states" in location_text: + locality_role_names.append("USA") + if "usa" in location_text: + locality_role_names.append("USA") + if ( + "europe" in location_text + or "emea" in location_text + or "e.u." in location_text + ): + locality_role_names.append("Europe") + if ( + "americas" in location_text + or "latin america" in location_text + or "latam" in location_text + ): + locality_role_names.append("Americas") + if "north america" in location_text or "south america" in location_text: + locality_role_names.append("Americas") + if ( + "asia" in location_text + or "apac" in location_text + or "asia pacific" in location_text + ): + locality_role_names.append("Asia") + if "japan" in location_text: + locality_role_names.append("Japan") + if "taiwan" in location_text: + locality_role_names.append("Taiwan") + if "africa" in location_text: + locality_role_names.append("Africa") + + if requirements.preferred_timezones: + for tz in requirements.preferred_timezones: + tz_prefix = ( + tz.split("/", 1)[0].casefold() if "/" in tz else tz.casefold() + ) + if tz_prefix == "europe": + locality_role_names.append("Europe") + elif tz_prefix == "america": + locality_role_names.append("Americas") + elif tz_prefix == "asia": + locality_role_names.append("Asia") + elif tz_prefix == "africa": + locality_role_names.append("Africa") + if tz.casefold() == "asia/tokyo": + locality_role_names.append("Japan") + if tz.casefold() == "asia/taipei": + locality_role_names.append("Taiwan") + + locality_role_names = [ + role_name + for role_name in dedupe_role_names(locality_role_names) + if role_name.casefold() not in excluded_role_names + ] + if locality_role_names: + locality_mentions = build_role_mentions(locality_role_names) + if locality_mentions: + locality_mentions_line = "Locality roles: " + ", ".join( + locality_mentions + ) if requirements.required_skills: header_parts.append( "Skills: " @@ -6495,6 +6579,15 @@ async def match_candidates( everyone=False, ), ) + if locality_mentions_line: + await interaction.followup.send( + locality_mentions_line, + allowed_mentions=discord.AllowedMentions( + roles=True, + users=False, + everyone=False, + ), + ) crm_base = settings.espo_base_url.rstrip("/") resume_options: list[tuple[str, str, str]] = [] From 4d88737967877adc9114ca231acd47ecbd731c2a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 08:51:29 +0800 Subject: [PATCH 07/15] fix: normalize role mentions --- .../src/five08/discord_bot/cogs/crm.py | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index f9f1c551..ff57315b 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6413,11 +6413,14 @@ def dedupe_role_names(role_names: list[str]) -> list[str]: seen: set[str] = set() deduped: list[str] = [] for role_name in role_names: - key = role_name.casefold() + cleaned = role_name.strip() + if not cleaned: + continue + key = cleaned.casefold() if key in seen: continue seen.add(key) - deduped.append(role_name) + deduped.append(cleaned) return deduped def build_role_mentions(role_names: list[str]) -> list[str]: @@ -6432,13 +6435,17 @@ def build_role_mentions(role_names: list[str]) -> list[str]: role_id_map = self._get_role_id_cache().get(interaction.guild.id, {}) mentions: list[str] = [] + seen_mentions: set[str] = set() for role_name in role_names: normalized_role_name = role_name.casefold() if normalized_role_name in excluded_role_names: continue role_id = role_id_map.get(normalized_role_name) if role_id is not None: - mentions.append(f"<@&{role_id}>") + mention = f"<@&{role_id}>" + if mention not in seen_mentions: + seen_mentions.add(mention) + mentions.append(mention) continue role = next( ( @@ -6450,9 +6457,14 @@ def build_role_mentions(role_names: list[str]) -> list[str]: None, ) if role is not None: - mentions.append(role.mention) + if role.mention not in seen_mentions: + seen_mentions.add(role.mention) + mentions.append(role.mention) else: - mentions.append(f"`{role_name}`") + mention = f"`{role_name}`" + if mention not in seen_mentions: + seen_mentions.add(mention) + mentions.append(mention) return mentions if requirements.title: @@ -6466,13 +6478,21 @@ def build_role_mentions(role_names: list[str]) -> list[str]: "staff", "principal", } - role_types = [ - role_name - for role_name in requirements.discord_role_types - if role_name.casefold() not in seniority_role_names - ] + role_types: list[str] = [] + seen_role_types: set[str] = set() + for raw_role_name in requirements.discord_role_types: + cleaned_role_name = raw_role_name.strip() + if not cleaned_role_name: + continue + normalized_role_name = cleaned_role_name.casefold() + if normalized_role_name in seniority_role_names: + continue + if normalized_role_name in seen_role_types: + continue + seen_role_types.add(normalized_role_name) + role_types.append(cleaned_role_name) if role_types: - role_mentions = build_role_mentions(dedupe_role_names(role_types)) + role_mentions = build_role_mentions(role_types) if role_mentions: role_mentions_line = "Discord roles: " + ", ".join(role_mentions) From 1e4942b0c1a1c63fe14a4a84bb7cdf3d74c3a95a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 09:21:10 +0800 Subject: [PATCH 08/15] refactor: centralize role exclusions --- .../src/five08/discord_bot/cogs/crm.py | 25 +++---------------- 1 file changed, 4 insertions(+), 21 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index ff57315b..5e47759e 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6408,6 +6408,9 @@ async def match_candidates( excluded_role_names = { name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC } + excluded_role_names.update( + {"junior", "mid-level", "midlevel", "senior", "staff", "principal"} + ) def dedupe_role_names(role_names: list[str]) -> list[str]: seen: set[str] = set() @@ -6470,27 +6473,7 @@ def build_role_mentions(role_names: list[str]) -> list[str]: if requirements.title: header_parts.append(f"**{requirements.title}**") if requirements.discord_role_types: - seniority_role_names = { - "junior", - "mid-level", - "midlevel", - "senior", - "staff", - "principal", - } - role_types: list[str] = [] - seen_role_types: set[str] = set() - for raw_role_name in requirements.discord_role_types: - cleaned_role_name = raw_role_name.strip() - if not cleaned_role_name: - continue - normalized_role_name = cleaned_role_name.casefold() - if normalized_role_name in seniority_role_names: - continue - if normalized_role_name in seen_role_types: - continue - seen_role_types.add(normalized_role_name) - role_types.append(cleaned_role_name) + role_types = dedupe_role_names(requirements.discord_role_types) if role_types: role_mentions = build_role_mentions(role_types) if role_mentions: From 75323bd2880be9d4b02aed46e72a4c48723094aa Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 10:59:05 +0800 Subject: [PATCH 09/15] fix: harden mentions and role cache --- .../src/five08/discord_bot/cogs/crm.py | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 5e47759e..076b56e3 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6597,8 +6597,8 @@ def build_role_mentions(role_names: list[str]) -> list[str]: for i, c in enumerate(candidates, start=1): label = "**[Member]**" if c.is_member else "[Prospect]" - name = c.name or "Unknown" - email = c.email_508 or c.email or "—" + name = discord.utils.escape_mentions(c.name or "Unknown") + email = discord.utils.escape_mentions(c.email_508 or c.email or "—") crm_link = ( f"{crm_base}/#Contact/view/{c.crm_contact_id}" if c.has_crm_link and c.crm_contact_id @@ -6614,8 +6614,9 @@ def build_role_mentions(role_names: list[str]) -> list[str]: if c.linkedin: parts.append(f"[LinkedIn](<{c.linkedin}>)") if c.latest_resume_id and c.latest_resume_name: - parts.append(f"Resume: `{c.latest_resume_name}`") - resume_options.append((name, c.latest_resume_id, c.latest_resume_name)) + safe_resume_name = discord.utils.escape_mentions(c.latest_resume_name) + parts.append(f"Resume: `{safe_resume_name}`") + resume_options.append((name, c.latest_resume_id, safe_resume_name)) skill_info: list[str] = [] skill_info.append(f"score: {c.match_score:.1f}") @@ -6654,7 +6655,7 @@ def build_role_mentions(role_names: list[str]) -> list[str]: msg, allowed_mentions=discord.AllowedMentions( roles=False, - users=True, + users=False, everyone=False, ), ) @@ -6735,11 +6736,17 @@ def _get_role_id_cache(self) -> dict[int, dict[str, int]]: def _refresh_role_id_cache(self, guild: discord.Guild) -> None: excluded_names = {name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC} - self._get_role_id_cache()[guild.id] = { - role.name.casefold(): role.id - for role in guild.roles - if role.name.casefold() not in excluded_names - } + role_id_map: dict[str, int] = {} + sorted_roles = sorted( + guild.roles, + key=lambda role: (-getattr(role, "position", 0), role.id), + ) + for role in sorted_roles: + normalized_name = role.name.casefold() + if normalized_name in excluded_names or normalized_name in role_id_map: + continue + role_id_map[normalized_name] = role.id + self._get_role_id_cache()[guild.id] = role_id_map @commands.Cog.listener() async def on_guild_role_create(self, role: discord.Role) -> None: From b837ac1ba98b6bc9618e165358e5bb161ef2b79a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 11:19:11 +0800 Subject: [PATCH 10/15] fix: add role positions in test --- tests/unit/test_crm.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 5a05fba7..23c803ce 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -224,14 +224,17 @@ def test_refresh_role_id_cache_builds_casefold_map(self, crm_cog): role_frontend = Mock() role_frontend.name = "Frontend" role_frontend.id = 111 + role_frontend.position = 3 role_full_stack = Mock() role_full_stack.name = "Full Stack" role_full_stack.id = 222 + role_full_stack.position = 2 role_excluded = Mock() role_excluded.name = "Bots" role_excluded.id = 333 + role_excluded.position = 1 guild = Mock() guild.id = 42 From 087f4247235ebcb2ff9f21099ef4bc357e33fc22 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 11:25:36 +0800 Subject: [PATCH 11/15] test: cover match_candidates role mentions --- tests/unit/test_crm.py | 114 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 23c803ce..d0bf0523 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -259,6 +259,120 @@ async def test_on_guild_role_update_refreshes_cache(self, crm_cog): refresh.assert_called_once_with(guild) + @pytest.mark.asyncio + async def test_match_candidates_sends_role_and_locality_mentions( + self, crm_cog, mock_interaction + ): + """Match candidates should emit role/locality mention lines safely.""" + role_frontend = Mock() + role_frontend.name = "Frontend" + role_frontend.id = 111 + role_frontend.position = 3 + + role_usa = Mock() + role_usa.name = "USA" + role_usa.id = 222 + role_usa.position = 2 + + guild = Mock() + guild.id = 55 + guild.roles = [role_frontend, role_usa] + + mock_interaction.guild = guild + mock_interaction.user.id = 999 + mock_interaction.user.name = "Requester" + mock_interaction.user.roles = [Mock(name="Member")] + + class DummyThread: + pass + + mock_interaction.channel = DummyThread() + + requirements = Mock() + requirements.title = "Frontend Engineer" + requirements.discord_role_types = [" Frontend ", "Senior"] + requirements.raw_location_text = "USA" + requirements.preferred_timezones = [] + requirements.location_type = "us_only" + requirements.required_skills = ["python"] + requirements.preferred_skills = [] + requirements.seniority = "Senior" + + candidate = Mock() + candidate.is_member = True + candidate.name = "Alice" + candidate.email_508 = "alice@508.dev" + candidate.email = None + candidate.crm_contact_id = None + candidate.has_crm_link = False + candidate.discord_user_id = 12345 + candidate.linkedin = None + candidate.latest_resume_id = None + candidate.latest_resume_name = None + candidate.match_score = 9.2 + candidate.matched_required_skills = ["python"] + candidate.matched_discord_roles = ["Frontend"] + candidate.seniority = "Senior" + candidate.timezone = "America/New_York" + + crm_cog._refresh_role_id_cache(guild) + + with ( + patch( + "five08.discord_bot.cogs.crm.extract_job_requirements", + return_value=requirements, + ), + patch( + "five08.discord_bot.cogs.crm.search_candidates", + return_value=[candidate], + ), + patch( + "five08.discord_bot.cogs.crm.settings.espo_base_url", + "https://crm.example.com", + ), + patch("five08.discord_bot.cogs.crm.discord.Thread", DummyThread), + patch.object(crm_cog, "_audit_command"), + ): + await crm_cog.match_candidates.callback( + crm_cog, mock_interaction, "Example job" + ) + + def assert_mentions_disabled(call): + allowed = call.kwargs["allowed_mentions"] + assert allowed.roles is False + assert allowed.users is False + assert allowed.everyone is False + + calls = mock_interaction.followup.send.call_args_list + header_call = calls[0] + assert header_call.args[0].startswith("## Job Match Results") + assert_mentions_disabled(header_call) + + role_call = next( + call + for call in calls + if call.args and call.args[0].startswith("Discord roles:") + ) + assert "<@&111>" in role_call.args[0] + assert role_call.kwargs["allowed_mentions"].roles is True + assert role_call.kwargs["allowed_mentions"].users is False + assert role_call.kwargs["allowed_mentions"].everyone is False + + locality_call = next( + call + for call in calls + if call.args and call.args[0].startswith("Locality roles:") + ) + assert "<@&222>" in locality_call.args[0] + assert locality_call.kwargs["allowed_mentions"].roles is True + assert locality_call.kwargs["allowed_mentions"].users is False + assert locality_call.kwargs["allowed_mentions"].everyone is False + + candidate_call = next( + call for call in calls if call.args and call.args[0].startswith("1. ") + ) + assert_mentions_disabled(candidate_call) + @pytest.mark.asyncio async def test_search_contacts_success( self, crm_cog, mock_interaction, mock_member_role From 565e25d0ba7d121e99bf74894877dfdaeec913f9 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 11:31:28 +0800 Subject: [PATCH 12/15] fix: tighten role mentions and cache eviction --- .../src/five08/discord_bot/cogs/crm.py | 69 ++++++++++++++----- tests/unit/test_crm.py | 12 +++- 2 files changed, 60 insertions(+), 21 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 99870f1d..318d6b15 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -6427,12 +6427,11 @@ async def match_candidates( header_parts: list[str] = [] role_mentions_line: str | None = None locality_mentions_line: str | None = None + role_mentions_role_ids: list[int] = [] + locality_mentions_role_ids: list[int] = [] excluded_role_names = { name.casefold() for name in DISCORD_ROLES_EXCLUDE_FROM_SYNC } - excluded_role_names.update( - {"junior", "mid-level", "midlevel", "senior", "staff", "principal"} - ) def dedupe_role_names(role_names: list[str]) -> list[str]: seen: set[str] = set() @@ -6448,11 +6447,11 @@ def dedupe_role_names(role_names: list[str]) -> list[str]: deduped.append(cleaned) return deduped - def build_role_mentions(role_names: list[str]) -> list[str]: + def build_role_mentions(role_names: list[str]) -> tuple[list[str], list[int]]: if not role_names: - return [] + return [], [] if interaction.guild is None: - return [f"`{r}`" for r in role_names] + return [f"`{r}`" for r in role_names], [] role_id_map = self._get_role_id_cache().get(interaction.guild.id) if role_id_map is None: @@ -6461,6 +6460,8 @@ def build_role_mentions(role_names: list[str]) -> list[str]: mentions: list[str] = [] seen_mentions: set[str] = set() + allowed_role_ids: list[int] = [] + seen_role_ids: set[int] = set() for role_name in role_names: normalized_role_name = role_name.casefold() if normalized_role_name in excluded_role_names: @@ -6471,6 +6472,9 @@ def build_role_mentions(role_names: list[str]) -> list[str]: if mention not in seen_mentions: seen_mentions.add(mention) mentions.append(mention) + if role_id not in seen_role_ids: + seen_role_ids.add(role_id) + allowed_role_ids.append(role_id) continue role = next( ( @@ -6485,21 +6489,25 @@ def build_role_mentions(role_names: list[str]) -> list[str]: if role.mention not in seen_mentions: seen_mentions.add(role.mention) mentions.append(role.mention) + if role.id not in seen_role_ids: + seen_role_ids.add(role.id) + allowed_role_ids.append(role.id) else: mention = f"`{role_name}`" if mention not in seen_mentions: seen_mentions.add(mention) mentions.append(mention) - return mentions + return mentions, allowed_role_ids if requirements.title: header_parts.append(f"**{requirements.title}**") if requirements.discord_role_types: role_types = dedupe_role_names(requirements.discord_role_types) if role_types: - role_mentions = build_role_mentions(role_types) + role_mentions, role_ids = build_role_mentions(role_types) if role_mentions: role_mentions_line = "Discord roles: " + ", ".join(role_mentions) + role_mentions_role_ids = role_ids locality_role_names: list[str] = [] location_text_parts: list[str] = [] @@ -6564,11 +6572,12 @@ def build_role_mentions(role_names: list[str]) -> list[str]: if role_name.casefold() not in excluded_role_names ] if locality_role_names: - locality_mentions = build_role_mentions(locality_role_names) + locality_mentions, role_ids = build_role_mentions(locality_role_names) if locality_mentions: locality_mentions_line = "Locality roles: " + ", ".join( locality_mentions ) + locality_mentions_role_ids = role_ids if requirements.required_skills: header_parts.append( "Skills: " @@ -6596,22 +6605,42 @@ def build_role_mentions(role_names: list[str]) -> list[str]: ), ) if role_mentions_line: + allowed_role_mentions = ( + discord.AllowedMentions( + roles=[discord.Object(id=rid) for rid in role_mentions_role_ids], + users=False, + everyone=False, + ) + if role_mentions_role_ids + else discord.AllowedMentions( + roles=False, + users=False, + everyone=False, + ) + ) await interaction.followup.send( role_mentions_line, - allowed_mentions=discord.AllowedMentions( - roles=True, + allowed_mentions=allowed_role_mentions, + ) + if locality_mentions_line: + allowed_locality_mentions = ( + discord.AllowedMentions( + roles=[ + discord.Object(id=rid) for rid in locality_mentions_role_ids + ], users=False, everyone=False, - ), + ) + if locality_mentions_role_ids + else discord.AllowedMentions( + roles=False, + users=False, + everyone=False, + ) ) - if locality_mentions_line: await interaction.followup.send( locality_mentions_line, - allowed_mentions=discord.AllowedMentions( - roles=True, - users=False, - everyone=False, - ), + allowed_mentions=allowed_locality_mentions, ) crm_base = settings.espo_base_url.rstrip("/") @@ -6788,6 +6817,10 @@ async def on_guild_role_update( ) -> None: self._refresh_role_id_cache(after.guild) + @commands.Cog.listener() + async def on_guild_remove(self, guild: discord.Guild) -> None: + self._get_role_id_cache().pop(guild.id, None) + @commands.Cog.listener() async def on_ready(self) -> None: """Bulk-sync all guild member roles on startup.""" diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index d0bf0523..556c1481 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -240,7 +240,11 @@ def test_refresh_role_id_cache_builds_casefold_map(self, crm_cog): guild.id = 42 guild.roles = [role_frontend, role_full_stack, role_excluded] - crm_cog._refresh_role_id_cache(guild) + with patch( + "five08.discord_bot.cogs.crm.DISCORD_ROLES_EXCLUDE_FROM_SYNC", + {"Bots"}, + ): + crm_cog._refresh_role_id_cache(guild) cache = crm_cog._get_role_id_cache() assert cache[42] == {"frontend": 111, "full stack": 222} @@ -354,7 +358,8 @@ def assert_mentions_disabled(call): if call.args and call.args[0].startswith("Discord roles:") ) assert "<@&111>" in role_call.args[0] - assert role_call.kwargs["allowed_mentions"].roles is True + role_allowed = role_call.kwargs["allowed_mentions"] + assert [r.id for r in role_allowed.roles] == [111] assert role_call.kwargs["allowed_mentions"].users is False assert role_call.kwargs["allowed_mentions"].everyone is False @@ -364,7 +369,8 @@ def assert_mentions_disabled(call): if call.args and call.args[0].startswith("Locality roles:") ) assert "<@&222>" in locality_call.args[0] - assert locality_call.kwargs["allowed_mentions"].roles is True + locality_allowed = locality_call.kwargs["allowed_mentions"] + assert [r.id for r in locality_allowed.roles] == [222] assert locality_call.kwargs["allowed_mentions"].users is False assert locality_call.kwargs["allowed_mentions"].everyone is False From 7802d127fe74916f193e83d5b7c7ed0619f81a0f Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 11:55:05 +0800 Subject: [PATCH 13/15] fix: correct test setup for match_candidates role/locality mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use mock_member_role fixture (not Mock(name=...)) so the @require_role check passes and the command runs to completion - Give DummyThread the required attributes (starter_message, applied_tags, id) so match_candidates can read the thread opening message - Remove spurious "Example job" positional arg from callback call - Rename loop variable candidate → guild_role in build_role_mentions for clarity Co-Authored-By: Claude Sonnet 4.6 --- .../src/five08/discord_bot/cogs/crm.py | 17 ++++++++-------- tests/unit/test_crm.py | 20 ++++++++++++------- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 6691e056..48b48518 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -7390,15 +7390,14 @@ def build_role_mentions(role_names: list[str]) -> tuple[list[str], list[int]]: seen_role_ids.add(role_id) allowed_role_ids.append(role_id) continue - role = next( - ( - candidate - for candidate in interaction.guild.roles - if candidate.name.casefold() == normalized_role_name - and candidate.name.casefold() not in excluded_role_names - ), - None, - ) + role = None + for guild_role in interaction.guild.roles: + guild_role_name = guild_role.name.casefold() + if guild_role_name in excluded_role_names: + continue + if guild_role_name == normalized_role_name: + role = guild_role + break if role is not None: if role.mention not in seen_mentions: seen_mentions.add(role.mention) diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index 469d9d1e..f4d8119f 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -339,7 +339,7 @@ async def test_on_guild_role_update_refreshes_cache(self, crm_cog): @pytest.mark.asyncio async def test_match_candidates_sends_role_and_locality_mentions( - self, crm_cog, mock_interaction + self, crm_cog, mock_interaction, mock_member_role ): """Match candidates should emit role/locality mention lines safely.""" role_frontend = Mock() @@ -359,12 +359,20 @@ async def test_match_candidates_sends_role_and_locality_mentions( mock_interaction.guild = guild mock_interaction.user.id = 999 mock_interaction.user.name = "Requester" - mock_interaction.user.roles = [Mock(name="Member")] + mock_interaction.user.roles = [mock_member_role] + + starter_msg = Mock() + starter_msg.content = "Example job" + starter_msg.attachments = [] + starter_msg.embeds = [] class DummyThread: - pass + id = 123 + applied_tags = [] - mock_interaction.channel = DummyThread() + thread_instance = DummyThread() + thread_instance.starter_message = starter_msg + mock_interaction.channel = thread_instance requirements = Mock() requirements.title = "Frontend Engineer" @@ -411,9 +419,7 @@ class DummyThread: patch("five08.discord_bot.cogs.crm.discord.Thread", DummyThread), patch.object(crm_cog, "_audit_command"), ): - await crm_cog.match_candidates.callback( - crm_cog, mock_interaction, "Example job" - ) + await crm_cog.match_candidates.callback(crm_cog, mock_interaction) def assert_mentions_disabled(call): allowed = call.kwargs["allowed_mentions"] From 93de828c67d8851502ee3da392bbf1d8168f2b2d Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 12:08:03 +0800 Subject: [PATCH 14/15] fix: guard candidate pagination against oversized blocks Split candidate_block entries exceeding 1900 chars before merging into the current chunk, and skip appending empty strings when current is blank. Previously a single long block could produce an empty message or a chunk exceeding Discord's followup limit. Co-Authored-By: Claude Sonnet 4.6 --- apps/discord_bot/src/five08/discord_bot/cogs/crm.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index 48b48518..f74d8d13 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -7612,8 +7612,15 @@ def build_role_mentions(role_names: list[str]) -> tuple[list[str], list[int]]: current = "" for line in lines: candidate_block = line + "\n" + while len(candidate_block) > 1900: + if current: + messages.append(current.rstrip()) + current = "" + messages.append(candidate_block[:1900].rstrip()) + candidate_block = candidate_block[1900:] if len(current) + len(candidate_block) > 1900: - messages.append(current.rstrip()) + if current: + messages.append(current.rstrip()) current = candidate_block else: current += candidate_block From 41c6fb778426123d7111e4647016893b0e78bf89 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Thu, 5 Mar 2026 12:15:32 +0800 Subject: [PATCH 15/15] fix: replace raw user mention with Discord ID and harden auto-match rendering - Replace <@discord_user_id> with "Discord ID: ..." in both the manual match_candidates path and _render_match_candidates_messages; the raw mention was always suppressed by allowed_mentions.users=False so it rendered as literal text anyway, and in auto-match it would have pinged users since thread.send had no allowed_mentions guard - Add escape_mentions to name/email fields in _render_match_candidates_messages to match the manual path - Pass allowed_mentions=AllowedMentions(roles=False, users=False, everyone=False) to thread.send in _run_auto_match_candidates_for_thread - Apply the same oversized-block pagination fix to _render_match_candidates_messages Co-Authored-By: Claude Sonnet 4.6 --- .../src/five08/discord_bot/cogs/crm.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py index f74d8d13..a1076378 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/crm.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/crm.py @@ -1990,8 +1990,10 @@ def _render_match_candidates_messages( for i, candidate in enumerate(candidates, start=1): label = "**[Member]**" if candidate.is_member else "[Prospect]" - name = candidate.name or "Unknown" - email = candidate.email_508 or candidate.email or "—" + name = discord.utils.escape_mentions(candidate.name or "Unknown") + email = discord.utils.escape_mentions( + candidate.email_508 or candidate.email or "—" + ) crm_link = ( f"{crm_base}/#Contact/view/{candidate.crm_contact_id}" if candidate.has_crm_link and candidate.crm_contact_id @@ -2002,7 +2004,7 @@ def _render_match_candidates_messages( else: parts = [f"{i}. {label} {name} · {email}"] if candidate.discord_user_id: - parts.append(f"Discord: <@{candidate.discord_user_id}>") + parts.append(f"Discord ID: {candidate.discord_user_id}") if candidate.linkedin: parts.append(f"[LinkedIn](<{candidate.linkedin}>)") @@ -2042,8 +2044,15 @@ def _render_match_candidates_messages( current = "" for line in lines: candidate_block = line + "\n" + while len(candidate_block) > 1900: + if current: + messages.append(current.rstrip()) + current = "" + messages.append(candidate_block[:1900].rstrip()) + candidate_block = candidate_block[1900:] if len(current) + len(candidate_block) > 1900: - messages.append(current.rstrip()) + if current: + messages.append(current.rstrip()) current = candidate_block else: current += candidate_block @@ -2141,8 +2150,11 @@ async def _run_auto_match_candidates_for_thread( requirements=requirements, candidates=candidates, ) + safe_mentions = discord.AllowedMentions( + roles=False, users=False, everyone=False + ) for msg in messages: - await thread.send(msg) + await thread.send(msg, allowed_mentions=safe_mentions) def _backend_headers(self) -> dict[str, str]: """Build auth headers for internal backend API calls.""" @@ -7573,7 +7585,7 @@ def build_role_mentions(role_names: list[str]) -> tuple[list[str], list[int]]: else: parts = [f"{i}. {label} {name} · {email}"] if c.discord_user_id: - parts.append(f"Discord: <@{c.discord_user_id}>") + parts.append(f"Discord ID: {c.discord_user_id}") if c.linkedin: parts.append(f"[LinkedIn](<{c.linkedin}>)")