Skip to content

Commit ad8db68

Browse files
committed
Support Discord DM agent requests
1 parent b9cd975 commit ad8db68

3 files changed

Lines changed: 296 additions & 36 deletions

File tree

apps/discord_bot/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,10 @@ Long-running service changes should be implemented as PR-based workflows rather
6363
than direct production mutations. Task reads require an explicit project filter
6464
to avoid guild-wide task enumeration.
6565

66-
Mention flow is opt-in per message: the bot runs the agent only when directly
67-
mentioned in a server channel or thread. Mention-triggered agent results and
66+
Mention flow is opt-in per message: the bot runs the agent when directly
67+
mentioned in a server channel or thread, or when a user sends the bot a DM.
68+
DM requests are accepted only after resolving the sender as a current member of
69+
the configured 508 Discord server. Mention-triggered agent results and
6870
confirmation buttons are sent by DM to avoid leaking task or plan details into
6971
public channels. A follow-up in the same thread should mention the bot again so
7072
the bot has an explicit user trigger and fresh Discord role context for that

apps/discord_bot/src/five08/discord_bot/cogs/agent.py

Lines changed: 175 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,9 @@ async def _confirmation_context(
204204
guild_id=str(original_guild_id),
205205
user_id=interaction.user.id,
206206
)
207-
context["roles"] = fresh_roles or self._original_roles()
207+
context["roles"] = (
208+
self._original_roles() if fresh_roles is None else fresh_roles
209+
)
208210
original_message_id = self.context.get("message_id")
209211
if original_message_id:
210212
context["message_id"] = original_message_id
@@ -312,15 +314,12 @@ async def agent_command(
312314

313315
@commands.Cog.listener("on_message")
314316
async def agent_mention(self, message: discord.Message) -> None:
315-
"""Handle natural-language agent requests when the bot is mentioned."""
317+
"""Handle natural-language agent requests from mentions or DMs."""
316318
bot_user = self.bot.user
317319
if bot_user is None or message.author.bot:
318320
return
319321
if message.guild is None:
320-
await message.reply(
321-
"Agent mentions only work in servers.",
322-
mention_author=False,
323-
)
322+
await self._handle_agent_dm(message=message, bot_user_id=bot_user.id)
324323
return
325324
bot_mentioned = any(user.id == bot_user.id for user in message.mentions)
326325
agent_thread = self._is_agent_thread(message.channel, bot_user.id)
@@ -347,31 +346,99 @@ async def agent_mention(self, message: discord.Message) -> None:
347346
)
348347
return
349348

350-
local_response = self._local_agent_response(
349+
await self._handle_agent_message_request(
350+
message=message,
351351
request=request,
352-
roles=self._role_names_from_user(message.author),
353-
transport="mention",
352+
context=self._build_agent_context_from_message(message),
353+
source="mention",
354354
)
355-
if local_response is not None:
356-
await self._send_mention_public_response(
355+
356+
async def _handle_agent_dm(
357+
self,
358+
*,
359+
message: discord.Message,
360+
bot_user_id: int,
361+
) -> None:
362+
request = self._extract_mention_request(message.content, bot_user_id)
363+
if not request:
364+
return
365+
366+
member_context = await self._resolve_dm_member_context(message.author.id)
367+
if member_context is None:
368+
self._audit_message_safe(
357369
message=message,
358-
request=request,
359-
content=local_response,
370+
action="agent.dm",
371+
result="denied",
372+
metadata={"reason": "member_not_in_configured_guild"},
373+
)
374+
await message.reply(
375+
"I can only run DM workflows for current members of the "
376+
"configured 508 server.",
377+
mention_author=False,
378+
)
379+
return
380+
381+
guild, member = member_context
382+
if self._mention_rate_limited(message.author.id):
383+
self._audit_message_safe(
384+
message=message,
385+
action="agent.dm",
386+
result="denied",
387+
metadata={"reason": "rate_limited"},
360388
)
389+
await message.reply(
390+
"Too many agent requests. Try again in a minute.",
391+
mention_author=False,
392+
)
393+
return
394+
395+
await self._handle_agent_message_request(
396+
message=message,
397+
request=request,
398+
context=self._build_agent_context_from_message(
399+
message,
400+
guild=guild,
401+
user=member,
402+
),
403+
source="dm",
404+
)
405+
406+
async def _handle_agent_message_request(
407+
self,
408+
*,
409+
message: discord.Message,
410+
request: str,
411+
context: dict[str, Any],
412+
source: Literal["mention", "dm"],
413+
) -> None:
414+
transport: Literal["mention", "dm"] = source
415+
local_response = self._local_agent_response(
416+
request=request,
417+
roles=context["roles"],
418+
transport=transport,
419+
)
420+
if local_response is not None:
421+
if source == "dm":
422+
await message.reply(local_response, mention_author=False)
423+
else:
424+
await self._send_mention_public_response(
425+
message=message,
426+
request=request,
427+
content=local_response,
428+
)
361429
return
362430

363-
context = self._build_agent_context_from_message(message)
364431
try:
365432
async with message.channel.typing():
366433
response = await self._post_agent_request(
367434
message=request,
368435
context=context,
369436
)
370437
except Exception as exc:
371-
logger.warning("Agent mention request failed: %s", exc)
438+
logger.warning("Agent %s request failed: %s", source, exc)
372439
self._audit_message_safe(
373440
message=message,
374-
action="agent.mention",
441+
action=f"agent.{source}",
375442
result="error",
376443
metadata={"error": str(exc)},
377444
)
@@ -381,9 +448,10 @@ async def agent_mention(self, message: discord.Message) -> None:
381448
)
382449
return
383450

384-
self._audit_agent_mention_response_safe(
451+
self._audit_agent_message_response_safe(
385452
message=message,
386453
response=response,
454+
action=f"agent.{source}",
387455
metadata={
388456
"status": response.get("status"),
389457
"error": response.get("error"),
@@ -403,6 +471,14 @@ async def agent_mention(self, message: discord.Message) -> None:
403471
context=context,
404472
)
405473

474+
if source == "dm":
475+
await self._send_agent_dm_response(
476+
message=message,
477+
response=response,
478+
view=view,
479+
)
480+
return
481+
406482
if self._should_reply_publicly_to_mention(response=response, view=view):
407483
await self._send_mention_public_response(
408484
message=message,
@@ -454,19 +530,20 @@ def _mention_rate_limited(self, user_id: int) -> bool:
454530
self._mention_request_timestamps[user_id] = timestamps
455531
return False
456532

457-
def _audit_agent_mention_response_safe(
533+
def _audit_agent_message_response_safe(
458534
self,
459535
*,
460536
message: discord.Message,
461537
response: dict[str, Any],
538+
action: str,
462539
metadata: dict[str, Any] | None = None,
463540
) -> None:
464541
result = self._audit_result_for_agent_response(response)
465542
if result == "success":
466543
return
467544
self._audit_message_safe(
468545
message=message,
469-
action="agent.mention",
546+
action=action,
470547
result=result,
471548
metadata=metadata,
472549
)
@@ -497,7 +574,7 @@ def _local_agent_response(
497574
*,
498575
request: str,
499576
roles: list[str],
500-
transport: Literal["slash", "mention"],
577+
transport: Literal["slash", "mention", "dm"],
501578
) -> str | None:
502579
if self._is_agent_help_request(request):
503580
return self._agent_capabilities_message(roles=roles, transport=transport)
@@ -514,6 +591,11 @@ def _local_agent_response(
514591
"That report includes member identity/linkage data, so use "
515592
"`/unlinked-discord-users` for the dedicated report."
516593
)
594+
if transport == "dm":
595+
return (
596+
"That report includes member identity/linkage data, so use "
597+
"`/unlinked-discord-users` in the 508 server."
598+
)
517599
return (
518600
"That report includes member identity/linkage data, so use "
519601
"`/unlinked-discord-users` for the private ephemeral response."
@@ -525,6 +607,12 @@ def _local_agent_response(
525607
"`/view-onboarding-queue` for the dedicated queue view. "
526608
"For targeted lookup, keep using `/agent`."
527609
)
610+
if transport == "dm":
611+
return (
612+
"That is CRM people/onboarding data, so use "
613+
"`/view-onboarding-queue` in the 508 server. "
614+
"For targeted lookup, use `/search-members`."
615+
)
528616
return (
529617
"That is CRM people/onboarding data, so use "
530618
"`/view-onboarding-queue` for the private ephemeral queue view. "
@@ -552,7 +640,7 @@ def _matches_smalltalk(normalized: str, phrases: frozenset[str]) -> bool:
552640
def _agent_capabilities_message(
553641
*,
554642
roles: list[str],
555-
transport: Literal["slash", "mention"] = "mention",
643+
transport: Literal["slash", "mention", "dm"] = "mention",
556644
) -> str:
557645
normalized_roles = {role.strip().casefold() for role in roles}
558646
is_admin = bool(normalized_roles & {"admin", "owner", "steering committee"})
@@ -747,6 +835,19 @@ async def _send_mention_response_dm(
747835
)
748836
return False
749837

838+
async def _send_agent_dm_response(
839+
self,
840+
*,
841+
message: discord.Message,
842+
response: dict[str, Any],
843+
view: AgentConfirmationView | None,
844+
) -> None:
845+
formatted_response = self._format_agent_response(response)
846+
if view is None:
847+
await message.reply(formatted_response, mention_author=False)
848+
return
849+
await message.reply(formatted_response, view=view, mention_author=False)
850+
750851
def _build_agent_context(self, interaction: discord.Interaction) -> dict[str, Any]:
751852
role_names = self._role_names_from_user(interaction.user)
752853

@@ -791,8 +892,13 @@ def _build_agent_context(self, interaction: discord.Interaction) -> dict[str, An
791892
def _build_agent_context_from_message(
792893
self,
793894
message: discord.Message,
895+
*,
896+
guild: discord.Guild | None = None,
897+
user: discord.abc.User | None = None,
794898
) -> dict[str, Any]:
795-
guild_id = message.guild.id if message.guild is not None else None
899+
context_guild = guild or message.guild
900+
context_user = user or message.author
901+
guild_id = context_guild.id if context_guild is not None else None
796902
channel_id = getattr(message.channel, "id", None)
797903
return {
798904
"discord_user_id": str(message.author.id),
@@ -806,7 +912,7 @@ def _build_agent_context_from_message(
806912
"response_destination_visibility": (
807913
self._response_destination_visibility_from_message(message)
808914
),
809-
"roles": self._role_names_from_user(message.author),
915+
"roles": self._role_names_from_user(context_user),
810916
"scopes": [],
811917
"impersonation": False,
812918
"interaction_id": None,
@@ -856,19 +962,59 @@ def _cached_guild_role_names(self, *, guild_id: str, user_id: int) -> list[str]:
856962
return []
857963
return self._role_names_from_user(member)
858964

859-
async def _guild_role_names(self, *, guild_id: str, user_id: int) -> list[str]:
860-
try:
861-
guild = self.bot.get_guild(int(guild_id))
862-
except (TypeError, ValueError):
863-
return []
965+
def _resolve_target_guild(self) -> discord.Guild | None:
966+
configured_guild_id = str(settings.discord_server_id or "").strip()
967+
if configured_guild_id:
968+
try:
969+
return self.bot.get_guild(int(configured_guild_id))
970+
except ValueError:
971+
return None
972+
973+
guilds = getattr(self.bot, "guilds", [])
974+
if len(guilds) == 1:
975+
return guilds[0]
976+
return None
977+
978+
async def _resolve_dm_member_context(
979+
self,
980+
user_id: int,
981+
) -> tuple[discord.Guild, discord.Member] | None:
982+
guild = self._resolve_target_guild()
864983
if guild is None:
865-
return []
984+
return None
985+
986+
member = await self._member_from_guild(guild=guild, user_id=user_id)
987+
if member is None:
988+
return None
989+
return guild, member
990+
991+
async def _member_from_guild(
992+
self,
993+
*,
994+
guild: discord.Guild,
995+
user_id: int,
996+
) -> discord.Member | None:
866997
member = guild.get_member(user_id)
867998
if member is None and hasattr(guild, "fetch_member"):
868999
try:
8691000
member = await guild.fetch_member(user_id)
8701001
except (discord.HTTPException, discord.NotFound, discord.Forbidden):
8711002
member = None
1003+
return member
1004+
1005+
async def _guild_role_names(
1006+
self,
1007+
*,
1008+
guild_id: str,
1009+
user_id: int,
1010+
) -> list[str] | None:
1011+
try:
1012+
guild = self.bot.get_guild(int(guild_id))
1013+
except (TypeError, ValueError):
1014+
return None
1015+
if guild is None:
1016+
return None
1017+
member = await self._member_from_guild(guild=guild, user_id=user_id)
8721018
if member is None:
8731019
return []
8741020
return self._role_names_from_user(member)

0 commit comments

Comments
 (0)