Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions apps/discord_bot/src/five08/discord_bot/cogs/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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.
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The match_candidates docstring still says "The response is posted publicly in the thread.", but the new private arg can now make results ephemeral. Update the docstring to reflect the new behavior (public by default; ephemeral when private is truthy) so it doesn’t mislead future maintainers.

Suggested change
The response is posted publicly in the thread.
By default, the response is posted publicly in the thread; when ``private`` is truthy,
results are sent ephemerally instead.

Copilot uses AI. Check for mistakes.
"""
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
):
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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,
Expand Down
119 changes: 119 additions & 0 deletions tests/unit/test_crm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down