Skip to content
18 changes: 18 additions & 0 deletions apps/discord_bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ The `/agent` command and explicit bot mentions send natural-language requests to
the backend agent gateway. The bot does not execute agent tool calls directly and
does not hold extra service credentials for agent actions.

Job discovery is a read-only shortcut handled by the bot before the agent
gateway. Members can use `/find-jobs query:...`, `/agent request:...`, or ask in
a bot mention, for example “find React roles with APAC hours.” It searches
recent posts in visible `#gigs` and `#fulltime-roles` forums, plus staged posts
in visible `#unqualified-leads`. Registered gigs must be marked recruiting;
staged leads are labeled unverified. Results must have been posted within 45
days and must still have an open Discord thread. This conservative window can
miss older roles that remain open and does not independently verify the employer's
application deadline. Slash command results are private; mention results go by
DM so lead titles are not disclosed in another channel.

Agent command flow:

```text
Expand Down Expand Up @@ -170,12 +181,19 @@ and result snippets are not audit logged.
- Backend policy checks scopes and tenant context before every tool execution.
- Agent write actions are audited and require confirmation.

- `/find-jobs query:...`
- Description: Find recent job posts using a natural-language description of
skills, work type, location, or hours.
- Behavior: Returns up to five links in a private response, ranked by matching
terms. APAC and remote requests require supporting text in the post.

- `@bot ...`
- Description: Run the same agent gateway from a normal channel or thread message.
- Behavior:
- Strips the bot mention and sends the remaining text as the agent request.
- Posts only fixed public-safe or organization-safe answers in the channel;
other results and confirmation controls are sent by DM.
- Sends job-search results by DM and acknowledges in the channel.
- Supports the same confirmation buttons for writes.
- Example: `@508.dev Bot show tasks for project Atlas`
- Knowledge examples:
Expand Down
199 changes: 199 additions & 0 deletions apps/discord_bot/src/five08/discord_bot/cogs/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
collect_thread_context,
)
from five08.discord_bot.utils.memory_views import MemoryFactsView
from five08.engagements import EngagementStatus, parse_status_from_title
from five08.job_discovery import (
JOB_SEARCH_MAX_AGE_DAYS,
parse_proximity_location,
search_job_posts,
)
from five08.tls import default_ca_bundle_path

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -580,6 +586,19 @@ async def agent_command(
await interaction.followup.send(local_response, ephemeral=True)
return

if self._is_job_search_request(request):
content = await self._search_jobs_for_member(
guild=interaction.guild,
member=interaction.user,
query=request,
)
await interaction.followup.send(
content,
ephemeral=True,
allowed_mentions=discord.AllowedMentions.none(),
)
return

context = self._build_agent_context(interaction)
try:
response = await self._post_agent_request(
Expand Down Expand Up @@ -685,6 +704,26 @@ async def ask_command(
ephemeral=True,
)

@app_commands.command(
name="find-jobs",
description="Find recent gigs and job leads matching your description",
)
@app_commands.describe(
query="Describe the work, skills, location, or hours you want"
)
async def find_jobs(self, interaction: discord.Interaction, query: str) -> None:
await interaction.response.defer(ephemeral=True)
content = await self._search_jobs_for_member(
guild=interaction.guild,
member=interaction.user,
query=query,
)
await interaction.followup.send(
content,
ephemeral=True,
allowed_mentions=discord.AllowedMentions.none(),
)

@commands.Cog.listener("on_message")
async def agent_mention(self, message: discord.Message) -> None:
"""Handle natural-language agent requests when the bot is mentioned."""
Expand Down Expand Up @@ -742,6 +781,29 @@ async def agent_mention(self, message: discord.Message) -> None:
)
return

if self._is_job_search_request(request):
async with message.channel.typing():
content = await self._search_jobs_for_member(
guild=message.guild,
member=message.author,
query=request,
)
try:
await message.author.send(
content, allowed_mentions=discord.AllowedMentions.none()
)
except discord.HTTPException:
await message.reply(
"I couldn't send a DM. Use `/find-jobs` for a private response.",
mention_author=False,
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
return
try:
await message.reply("I sent job matches by DM.", mention_author=False)
except discord.HTTPException:
logger.warning("Could not acknowledge job search DM", exc_info=True)
return

context = self._build_agent_context_from_message(message)
if self._is_knowledge_question(request):
await self._handle_knowledge_question_mention(
Expand Down Expand Up @@ -1324,6 +1386,140 @@ def _is_agent_help_request(request: str) -> bool:
normalized = request.casefold().strip(" ?!.")
return AgentCog._matches_smalltalk(normalized, _AGENT_HELP_REQUESTS)

@staticmethod
def _is_job_search_request(request: str) -> bool:
text = request.casefold()
return bool(
not re.search(
r"\b(?:create|update|delete|assign|publish|remember|forget)\b",
text,
)
and not re.search(
r"\b(?:post|close)\s+(?:(?:a|an|the|this)\s+)?"
r"(?:job|gig|role|position|opening)\b"
r"|\bsend\s+(?:(?:a|an|the|this)\s+)?"
r"(?:jobs?|gigs?|roles?|positions?|openings?)\s+to\b",
text,
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
and not re.search(
r"\b(?:add|open)\s+(?:(?:a|an)\s+)?(?:todo|issue)\b",
text,
)
and not re.search(
r"\b(?:search|find|list|show)\s+"
r"(?:(?:\w+|#\w+)\s+){0,3}"
r"(?:github\s+issues?|gh\s+issues?|issues?|todos?|tasks?|"
r"crm\s+contacts?|contacts?)\b",
text,
)
and re.search(
r"\b(?:jobs?|gigs?|roles?|positions?|openings?|opportunities)\b", text
)
and (
re.search(
r"\b(?:find|search|looking|show|any|recommend|match|suitable|fit)\b",
text,
)
or parse_proximity_location(request)[0]
)
)

Comment thread
michaelmwu marked this conversation as resolved.
Resolved
async def _search_jobs_for_member(
self,
*,
guild: discord.Guild | None,
member: discord.abc.User,
query: str,
) -> str:
if guild is None or not isinstance(member, discord.Member):
return "Search for jobs inside the 508 server."
proximity_requested, proximity_location = parse_proximity_location(query)
if proximity_requested and not proximity_location:
return "Please include a city or time zone so I can look for nearby jobs."
active_ids: list[str] = []
lead_ids: list[str] = []
lead_target = str(
settings.discord_unqualified_leads_forum_channel or ""
).strip()
lead_name = re.sub(r"[-_]+", " ", lead_target.casefold())
for channel in guild.channels:
if not isinstance(channel, discord.ForumChannel):
continue
if not channel.permissions_for(member).view_channel:
continue
if channel.name in {"gigs", "fulltime-roles"}:
active_ids.append(str(channel.id))
elif lead_target and (
str(channel.id) == lead_target
if lead_target.isdecimal()
else re.sub(r"[-_]+", " ", channel.name.casefold()) == lead_name
):
lead_ids.append(str(channel.id))
try:
candidates = await asyncio.to_thread(
search_job_posts,
settings,
guild_id=str(guild.id),
active_channel_ids=active_ids,
lead_channel_ids=lead_ids,
query=query,
limit=50,
)
Comment thread
michaelmwu marked this conversation as resolved.
Resolved
except Exception:
logger.warning(
"Job discovery search failed guild=%s", guild.id, exc_info=True
)
return "I couldn't search the job listings right now. Please try again."

lines = [f"Recent job matches (posted within {JOB_SEARCH_MAX_AGE_DAYS} days):"]
found = 0
for candidate in candidates:
thread_id = str(candidate.get("thread_id") or "")
channel_id = str(candidate.get("channel_id") or "")
if not thread_id.isdecimal() or not channel_id.isdecimal():
continue
try:
thread = guild.get_thread(
int(thread_id)
) or await self.bot.fetch_channel(int(thread_id))
except discord.HTTPException:
continue
if (
not isinstance(thread, discord.Thread)
or thread.archived
or thread.locked
):
continue
if thread.parent_id != int(channel_id):
continue
if parse_status_from_title(str(thread.name or "")) in {
EngagementStatus.FILLED,
EngagementStatus.LOST,
EngagementStatus.OUTDATED,
EngagementStatus.DUPLICATE,
}:
continue
title = discord.utils.escape_markdown(
discord.utils.escape_mentions(str(candidate.get("title") or "Job post"))
)[:110]
label = (
"Unverified lead"
if candidate.get("kind") == "unverified_lead"
else "Recruiting"
)
lines.append(
f"• **{title}** · {label} · https://discord.com/channels/{guild.id}/{thread_id}"
)
found += 1
if found == 5:
break
if not found:
return "I found no recent open posts matching that description in forums you can view. Try broader skills or hours."
lines.append(
"Unverified leads still need review; confirm availability in the post before applying."
)
return "\n".join(lines)[:1900]

@staticmethod
def _is_agent_presence_check(request: str) -> bool:
normalized = request.casefold().strip(" ?!.")
Expand Down Expand Up @@ -1407,6 +1603,9 @@ def _agent_capabilities_message(
)
)
capabilities: list[str] = []
capabilities.append(
"- Jobs: ask for work by skill, location, or hours; or use `/find-jobs`."
)
if "project:read" in scopes:
capabilities.append("- Tasks: search a project.")
task_writes: list[str] = []
Expand Down
Loading
Loading