@@ -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