diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/jobs.py b/apps/discord_bot/src/five08/discord_bot/cogs/jobs.py index eed977b..b26bac8 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/jobs.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/jobs.py @@ -72,12 +72,28 @@ "notion.site", ) AUTO_MATCH_DEDUPE_MAX = 10_000 +MATCH_CANDIDATES_PRIVATE_TRUTHY = frozenset({"true", "1", "yes", "y", "on"}) # Exclude known-bad resume artifact from auto-match rendering. AUTO_MATCH_EXCLUDED_RESUME_NAMES = frozenset({"Vladyslav_Stryzhak.pdf"}) IPAddress = ipaddress.IPv4Address | ipaddress.IPv6Address JobWatchChannel = discord.ForumChannel +def _parse_match_candidates_private(private_flag: str | None) -> bool | None: + """Parse the `private` arg into a bool, or return None for invalid values.""" + if private_flag is None: + return False + + normalized = private_flag.strip().lower() + if not normalized: + return None + + if normalized in MATCH_CANDIDATES_PRIVATE_TRUTHY: + return True + + return None + + class MatchResumeSelectView(discord.ui.View): """View containing a resume download select for match results.""" @@ -1441,16 +1457,28 @@ async def unregister_jobs_channel( name="match-candidates", description="Reads this thread's opening message, attachments, and JD links, then returns ranked matching candidates.", ) + @app_commands.describe( + private="Set to `true`, `1`, `yes`, `y`, or `on` to post privately." + ) @require_role("Member") async def match_candidates( self, interaction: discord.Interaction, + private: str | None = None, ) -> None: """Parse the thread's starter message and find matching candidates ranked by fit. Must be invoked inside a thread. The starter message is used as the job posting text. The response is posted publicly in the thread. """ + is_private = _parse_match_candidates_private(private) + if is_private is None: + await interaction.response.send_message( + "⚠️ Invalid value for `private`. Use `true`, `1`, `yes`, `y`, or `on`.", + ephemeral=True, + ) + return + if not isinstance(interaction.channel, discord.Thread) or not isinstance( interaction.channel.parent, discord.ForumChannel ): @@ -1498,7 +1526,7 @@ async def match_candidates( ) return - await interaction.response.defer(ephemeral=False) + await interaction.response.defer(ephemeral=is_private) posting, posting_metadata = await self._build_match_candidates_posting(starter) if not posting.strip(): @@ -1581,8 +1609,13 @@ async def match_candidates( ) return + async def _send_match_result(message: str, **kwargs: Any) -> None: + if is_private: + kwargs["ephemeral"] = True + await interaction.followup.send(message, **kwargs) + await self._publish_match_results( - send=interaction.followup.send, + send=_send_match_result, requirements=requirements, candidates=candidates, guild=interaction.guild, diff --git a/tests/unit/test_crm.py b/tests/unit/test_crm.py index cc995ee..7b2a0a0 100644 --- a/tests/unit/test_crm.py +++ b/tests/unit/test_crm.py @@ -1729,6 +1729,125 @@ def assert_mentions_disabled(call): assert "Alice (Nickname)" in candidate_call.args[0] assert "alice@508.dev" not in candidate_call.args[0] assert_mentions_disabled(candidate_call) + assert mock_interaction.response.defer.call_args.kwargs["ephemeral"] is False + + @pytest.mark.asyncio + async def test_match_candidates_private_arg_private_mode( + self, jobs_cog, mock_interaction, mock_member_role + ): + """Passing a truthy private arg should send all results ephemerally.""" + 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_member_role] + + starter_msg = Mock() + starter_msg.content = "Example job" + starter_msg.attachments = [] + starter_msg.embeds = [] + + class DummyForumChannel: + def __init__(self, channel_id: int) -> None: + self.id = channel_id + + class DummyThread: + id = 123 + applied_tags = [] + + def __init__(self, parent: DummyForumChannel) -> None: + self.parent = parent + + thread_instance = DummyThread(DummyForumChannel(456)) + thread_instance.starter_message = starter_msg + mock_interaction.channel = thread_instance + + 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 (Nickname)" + 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" + + jobs_cog._refresh_role_id_cache(guild) + + with ( + patch( + "five08.discord_bot.cogs.jobs.extract_job_requirements", + return_value=requirements, + ), + patch( + "five08.discord_bot.cogs.jobs.search_candidates", + return_value=[candidate], + ), + patch( + "five08.discord_bot.cogs.jobs.settings.espo_base_url", + "https://crm.example.com", + ), + patch("five08.discord_bot.cogs.jobs.discord.Thread", DummyThread), + patch( + "five08.discord_bot.cogs.jobs.discord.ForumChannel", + DummyForumChannel, + ), + patch.object(jobs_cog, "_audit_command"), + ): + await jobs_cog.match_candidates.callback(jobs_cog, mock_interaction, "yes") + + assert mock_interaction.response.defer.call_args.kwargs["ephemeral"] is True + + calls = mock_interaction.followup.send.call_args_list + assert calls + for call in calls: + assert call.kwargs["ephemeral"] is True + + @pytest.mark.asyncio + async def test_match_candidates_private_arg_rejects_falsey_value( + self, jobs_cog, mock_interaction, mock_member_role + ): + """Passing a non-truthy private arg should be rejected.""" + mock_interaction.user.roles = [mock_member_role] + + await jobs_cog.match_candidates.callback(jobs_cog, mock_interaction, "no") + + mock_interaction.response.send_message.assert_called_once_with( + "⚠️ Invalid value for `private`. Use `true`, `1`, `yes`, `y`, or `on`.", + ephemeral=True, + ) + mock_interaction.response.defer.assert_not_called() + mock_interaction.followup.send.assert_not_called() @pytest.mark.asyncio async def test_search_contacts_success(