From 6569528889766020dc7a1b602845466952ea442b Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Mon, 25 May 2026 08:34:55 +0900 Subject: [PATCH] Support Discord DM agent requests --- apps/discord_bot/README.md | 17 +- .../discord_bot/src/five08/discord_bot/bot.py | 14 +- .../src/five08/discord_bot/cogs/agent.py | 278 +++++++++++++++--- tests/unit/test_agent_cog.py | 222 +++++++++++++- tests/unit/test_bot.py | 5 + 5 files changed, 482 insertions(+), 54 deletions(-) diff --git a/apps/discord_bot/README.md b/apps/discord_bot/README.md index d3ea4494..08690880 100644 --- a/apps/discord_bot/README.md +++ b/apps/discord_bot/README.md @@ -63,12 +63,17 @@ Long-running service changes should be implemented as PR-based workflows rather than direct production mutations. Task reads require an explicit project filter to avoid guild-wide task enumeration. -Mention flow is opt-in per message: the bot runs the agent only when directly -mentioned in a server channel or thread. Mention-triggered agent results and -confirmation buttons are sent by DM to avoid leaking task or plan details into -public channels. A follow-up in the same thread should mention the bot again so -the bot has an explicit user trigger and fresh Discord role context for that -request. +Mention flow is opt-in per message: the bot runs the agent when directly +mentioned in a server channel or thread, or when a user sends the bot a DM. +DM requests are accepted only after resolving the sender as a current member of +the configured 508 Discord server. The `/agent` slash command is also registered +for bot DMs and uses the same configured-server membership and role resolution. +Other slash commands default to guild-only registration unless they are +explicitly reviewed and opted in to DM contexts. +Mention-triggered agent results and confirmation buttons are sent by DM to avoid +leaking task or plan details into public channels. A follow-up in the same +thread should mention the bot again so the bot has an explicit user trigger and +fresh Discord role context for that request. Production mention handling depends on Discord gateway and channel access: The bot requests all intents in code, but the production Discord application diff --git a/apps/discord_bot/src/five08/discord_bot/bot.py b/apps/discord_bot/src/five08/discord_bot/bot.py index 785f1ddc..f6c6e152 100644 --- a/apps/discord_bot/src/five08/discord_bot/bot.py +++ b/apps/discord_bot/src/five08/discord_bot/bot.py @@ -63,7 +63,19 @@ class Bot508(commands.Bot): def __init__(self) -> None: intents = discord.Intents.all() # Use a prefix that won't accidentally trigger since we're using slash commands - super().__init__(command_prefix="$508$", intents=intents) + super().__init__( + command_prefix="$508$", + intents=intents, + allowed_contexts=discord.app_commands.AppCommandContext( + guild=True, + dm_channel=False, + private_channel=False, + ), + allowed_installs=discord.app_commands.AppInstallationType( + guild=True, + user=False, + ), + ) # Remove the default help command since we're using slash commands self.remove_command("help") self.http_server: Optional[BotHTTPServer] = None diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/agent.py b/apps/discord_bot/src/five08/discord_bot/cogs/agent.py index 07aa8c85..59377cdc 100644 --- a/apps/discord_bot/src/five08/discord_bot/cogs/agent.py +++ b/apps/discord_bot/src/five08/discord_bot/cogs/agent.py @@ -204,7 +204,9 @@ async def _confirmation_context( guild_id=str(original_guild_id), user_id=interaction.user.id, ) - context["roles"] = fresh_roles or self._original_roles() + context["roles"] = ( + self._original_roles() if fresh_roles is None else fresh_roles + ) original_message_id = self.context.get("message_id") if original_message_id: context["message_id"] = original_message_id @@ -236,6 +238,8 @@ def __init__(self, bot: commands.Bot) -> None: name="agent", description="Run an approved English workflow through the agent gateway", ) + @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=False) + @app_commands.allowed_installs(guilds=True, users=False) @app_commands.describe(request="The workflow to plan or execute") async def agent_command( self, @@ -243,18 +247,42 @@ async def agent_command( request: str, ) -> None: """Send a natural-language request to the backend agent gateway.""" - await interaction.response.defer(ephemeral=True) + response_ephemeral = self._interaction_response_ephemeral(interaction) + await interaction.response.defer(ephemeral=response_ephemeral) + + slash_context = await self._resolve_slash_context(interaction) + if slash_context is None: + self._audit_command_safe( + interaction=interaction, + action="agent.request", + result="denied", + metadata={"reason": "member_not_in_configured_guild"}, + ) + await interaction.followup.send( + "I can only run DM workflows for current members of the " + "configured 508 server.", + ephemeral=response_ephemeral, + ) + return + slash_guild, slash_user = slash_context local_response = self._local_agent_response( request=request, - roles=self._role_names_from_user(interaction.user), + roles=self._role_names_from_user(slash_user), transport="slash", ) if local_response is not None: - await interaction.followup.send(local_response, ephemeral=True) + await interaction.followup.send( + local_response, + ephemeral=response_ephemeral, + ) return - context = self._build_agent_context(interaction) + context = self._build_agent_context( + interaction, + guild=slash_guild, + user=slash_user, + ) try: response = await self._post_agent_request( message=request, @@ -270,7 +298,7 @@ async def agent_command( ) await interaction.followup.send( "Agent gateway request failed. Check backend API configuration.", - ephemeral=True, + ephemeral=response_ephemeral, ) return @@ -300,27 +328,24 @@ async def agent_command( if view is None: await interaction.followup.send( self._format_agent_response(response), - ephemeral=True, + ephemeral=response_ephemeral, ) return await interaction.followup.send( self._format_agent_response(response), view=view, - ephemeral=True, + ephemeral=response_ephemeral, ) @commands.Cog.listener("on_message") async def agent_mention(self, message: discord.Message) -> None: - """Handle natural-language agent requests when the bot is mentioned.""" + """Handle natural-language agent requests from mentions or DMs.""" bot_user = self.bot.user if bot_user is None or message.author.bot: return if message.guild is None: - await message.reply( - "Agent mentions only work in servers.", - mention_author=False, - ) + await self._handle_agent_dm(message=message, bot_user_id=bot_user.id) return bot_mentioned = any(user.id == bot_user.id for user in message.mentions) agent_thread = self._is_agent_thread(message.channel, bot_user.id) @@ -347,20 +372,88 @@ async def agent_mention(self, message: discord.Message) -> None: ) return - local_response = self._local_agent_response( + await self._handle_agent_message_request( + message=message, request=request, - roles=self._role_names_from_user(message.author), - transport="mention", + context=self._build_agent_context_from_message(message), + source="mention", ) - if local_response is not None: - await self._send_mention_public_response( + + async def _handle_agent_dm( + self, + *, + message: discord.Message, + bot_user_id: int, + ) -> None: + request = self._extract_mention_request(message.content, bot_user_id) + if not request: + return + + member_context = await self._resolve_dm_member_context(message.author.id) + if member_context is None: + self._audit_message_safe( message=message, - request=request, - content=local_response, + action="agent.dm", + result="denied", + metadata={"reason": "member_not_in_configured_guild"}, + ) + await message.reply( + "I can only run DM workflows for current members of the " + "configured 508 server.", + mention_author=False, + ) + return + + guild, member = member_context + if self._mention_rate_limited(message.author.id): + self._audit_message_safe( + message=message, + action="agent.dm", + result="denied", + metadata={"reason": "rate_limited"}, + ) + await message.reply( + "Too many agent requests. Try again in a minute.", + mention_author=False, ) return - context = self._build_agent_context_from_message(message) + await self._handle_agent_message_request( + message=message, + request=request, + context=self._build_agent_context_from_message( + message, + guild=guild, + user=member, + ), + source="dm", + ) + + async def _handle_agent_message_request( + self, + *, + message: discord.Message, + request: str, + context: dict[str, Any], + source: Literal["mention", "dm"], + ) -> None: + transport: Literal["mention", "dm"] = source + local_response = self._local_agent_response( + request=request, + roles=context["roles"], + transport=transport, + ) + if local_response is not None: + if source == "dm": + await message.reply(local_response, mention_author=False) + else: + await self._send_mention_public_response( + message=message, + request=request, + content=local_response, + ) + return + try: async with message.channel.typing(): response = await self._post_agent_request( @@ -368,10 +461,10 @@ async def agent_mention(self, message: discord.Message) -> None: context=context, ) except Exception as exc: - logger.warning("Agent mention request failed: %s", exc) + logger.warning("Agent %s request failed: %s", source, exc) self._audit_message_safe( message=message, - action="agent.mention", + action=f"agent.{source}", result="error", metadata={"error": str(exc)}, ) @@ -381,9 +474,10 @@ async def agent_mention(self, message: discord.Message) -> None: ) return - self._audit_agent_mention_response_safe( + self._audit_agent_message_response_safe( message=message, response=response, + action=f"agent.{source}", metadata={ "status": response.get("status"), "error": response.get("error"), @@ -403,6 +497,14 @@ async def agent_mention(self, message: discord.Message) -> None: context=context, ) + if source == "dm": + await self._send_agent_dm_response( + message=message, + response=response, + view=view, + ) + return + if self._should_reply_publicly_to_mention(response=response, view=view): await self._send_mention_public_response( message=message, @@ -454,11 +556,12 @@ def _mention_rate_limited(self, user_id: int) -> bool: self._mention_request_timestamps[user_id] = timestamps return False - def _audit_agent_mention_response_safe( + def _audit_agent_message_response_safe( self, *, message: discord.Message, response: dict[str, Any], + action: str, metadata: dict[str, Any] | None = None, ) -> None: result = self._audit_result_for_agent_response(response) @@ -466,7 +569,7 @@ def _audit_agent_mention_response_safe( return self._audit_message_safe( message=message, - action="agent.mention", + action=action, result=result, metadata=metadata, ) @@ -497,7 +600,7 @@ def _local_agent_response( *, request: str, roles: list[str], - transport: Literal["slash", "mention"], + transport: Literal["slash", "mention", "dm"], ) -> str | None: if self._is_agent_help_request(request): return self._agent_capabilities_message(roles=roles, transport=transport) @@ -514,6 +617,11 @@ def _local_agent_response( "That report includes member identity/linkage data, so use " "`/unlinked-discord-users` for the dedicated report." ) + if transport == "dm": + return ( + "That report includes member identity/linkage data, so use " + "`/unlinked-discord-users` in the 508 server." + ) return ( "That report includes member identity/linkage data, so use " "`/unlinked-discord-users` for the private ephemeral response." @@ -525,6 +633,12 @@ def _local_agent_response( "`/view-onboarding-queue` for the dedicated queue view. " "For targeted lookup, keep using `/agent`." ) + if transport == "dm": + return ( + "That is CRM people/onboarding data, so use " + "`/view-onboarding-queue` in the 508 server. " + "For targeted lookup, use `/search-members`." + ) return ( "That is CRM people/onboarding data, so use " "`/view-onboarding-queue` for the private ephemeral queue view. " @@ -552,7 +666,7 @@ def _matches_smalltalk(normalized: str, phrases: frozenset[str]) -> bool: def _agent_capabilities_message( *, roles: list[str], - transport: Literal["slash", "mention"] = "mention", + transport: Literal["slash", "mention", "dm"] = "mention", ) -> str: normalized_roles = {role.strip().casefold() for role in roles} is_admin = bool(normalized_roles & {"admin", "owner", "steering committee"}) @@ -747,8 +861,45 @@ async def _send_mention_response_dm( ) return False - def _build_agent_context(self, interaction: discord.Interaction) -> dict[str, Any]: - role_names = self._role_names_from_user(interaction.user) + async def _send_agent_dm_response( + self, + *, + message: discord.Message, + response: dict[str, Any], + view: AgentConfirmationView | None, + ) -> None: + formatted_response = self._format_agent_response(response) + if view is None: + await message.reply(formatted_response, mention_author=False) + return + await message.reply(formatted_response, view=view, mention_author=False) + + @staticmethod + def _interaction_response_ephemeral(interaction: discord.Interaction) -> bool: + if not hasattr(interaction, "guild_id"): + return True + return interaction.guild_id is not None + + async def _resolve_slash_context( + self, + interaction: discord.Interaction, + ) -> tuple[discord.Guild | None, discord.abc.User] | None: + if not hasattr(interaction, "guild_id") or interaction.guild_id is not None: + return getattr(interaction, "guild", None), interaction.user + return await self._resolve_dm_member_context(interaction.user.id) + + def _build_agent_context( + self, + interaction: discord.Interaction, + *, + guild: discord.Guild | None = None, + user: discord.abc.User | None = None, + ) -> dict[str, Any]: + context_user = user or interaction.user + role_names = self._role_names_from_user(context_user) + guild_id = ( + guild.id if guild is not None else getattr(interaction, "guild_id", None) + ) # Slash commands do not have a Discord message id; button interactions do. # Keep message_id as the visible Discord message when present and use @@ -765,10 +916,8 @@ def _build_agent_context(self, interaction: discord.Interaction) -> dict[str, An "discord_user_id": str(interaction.user.id), "operation_id": str(uuid4()), "internal_user_id": None, - "organization_id": str(interaction.guild_id) - if interaction.guild_id - else None, - "guild_id": str(interaction.guild_id) if interaction.guild_id else None, + "organization_id": str(guild_id) if guild_id else None, + "guild_id": str(guild_id) if guild_id else None, "channel_id": ( str(interaction.channel_id) if interaction.channel_id is not None @@ -791,8 +940,13 @@ def _build_agent_context(self, interaction: discord.Interaction) -> dict[str, An def _build_agent_context_from_message( self, message: discord.Message, + *, + guild: discord.Guild | None = None, + user: discord.abc.User | None = None, ) -> dict[str, Any]: - guild_id = message.guild.id if message.guild is not None else None + context_guild = guild or message.guild + context_user = user or message.author + guild_id = context_guild.id if context_guild is not None else None channel_id = getattr(message.channel, "id", None) return { "discord_user_id": str(message.author.id), @@ -806,7 +960,7 @@ def _build_agent_context_from_message( "response_destination_visibility": ( self._response_destination_visibility_from_message(message) ), - "roles": self._role_names_from_user(message.author), + "roles": self._role_names_from_user(context_user), "scopes": [], "impersonation": False, "interaction_id": None, @@ -856,19 +1010,59 @@ def _cached_guild_role_names(self, *, guild_id: str, user_id: int) -> list[str]: return [] return self._role_names_from_user(member) - async def _guild_role_names(self, *, guild_id: str, user_id: int) -> list[str]: - try: - guild = self.bot.get_guild(int(guild_id)) - except (TypeError, ValueError): - return [] + def _resolve_target_guild(self) -> discord.Guild | None: + configured_guild_id = str(settings.discord_server_id or "").strip() + if configured_guild_id: + try: + return self.bot.get_guild(int(configured_guild_id)) + except ValueError: + return None + + guilds = getattr(self.bot, "guilds", []) + if len(guilds) == 1: + return guilds[0] + return None + + async def _resolve_dm_member_context( + self, + user_id: int, + ) -> tuple[discord.Guild, discord.Member] | None: + guild = self._resolve_target_guild() if guild is None: - return [] + return None + + member = await self._member_from_guild(guild=guild, user_id=user_id) + if member is None: + return None + return guild, member + + async def _member_from_guild( + self, + *, + guild: discord.Guild, + user_id: int, + ) -> discord.Member | None: member = guild.get_member(user_id) if member is None and hasattr(guild, "fetch_member"): try: member = await guild.fetch_member(user_id) except (discord.HTTPException, discord.NotFound, discord.Forbidden): member = None + return member + + async def _guild_role_names( + self, + *, + guild_id: str, + user_id: int, + ) -> list[str] | None: + try: + guild = self.bot.get_guild(int(guild_id)) + except (TypeError, ValueError): + return None + if guild is None: + return None + member = await self._member_from_guild(guild=guild, user_id=user_id) if member is None: return [] return self._role_names_from_user(member) diff --git a/tests/unit/test_agent_cog.py b/tests/unit/test_agent_cog.py index 51656a95..06d7df3a 100644 --- a/tests/unit/test_agent_cog.py +++ b/tests/unit/test_agent_cog.py @@ -476,6 +476,19 @@ def test_extract_mention_request_strips_bot_mentions() -> None: ) +def test_agent_command_is_registered_for_bot_dms() -> None: + contexts = AgentCog.agent_command.allowed_contexts + installs = AgentCog.agent_command.allowed_installs + + assert contexts is not None + assert contexts.guild is True + assert contexts.dm_channel is True + assert contexts.private_channel is False + assert installs is not None + assert installs.guild is True + assert installs.user is False + + def test_build_agent_context_from_message_uses_thread_message_context() -> None: cog = AgentCog.__new__(AgentCog) message = SimpleNamespace( @@ -689,6 +702,93 @@ async def test_agent_command_member_info_lookup_reaches_gateway() -> None: cog._audit_command_safe.assert_called_once() +@pytest.mark.asyncio +async def test_agent_command_in_dm_uses_configured_guild_member_context( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.agent.settings", + SimpleNamespace(discord_server_id="456"), + ) + member = SimpleNamespace( + id=123, + roles=[SimpleNamespace(name="@everyone"), SimpleNamespace(name="Admin")], + ) + guild = SimpleNamespace(id=456, get_member=Mock(return_value=member)) + cog = AgentCog.__new__(AgentCog) + cog.bot = SimpleNamespace(get_guild=Mock(return_value=guild), guilds=[guild]) + cog._post_agent_request = AsyncMock( + return_value={"status": "executed", "message": "Done"} + ) + cog._audit_command_safe = Mock() + interaction = SimpleNamespace( + id=999, + guild=None, + guild_id=None, + channel_id=789, + channel=SimpleNamespace(id=789), + message=None, + response=SimpleNamespace(defer=AsyncMock()), + followup=SimpleNamespace(send=AsyncMock()), + user=SimpleNamespace(id=123, roles=[]), + ) + + await AgentCog.agent_command.callback(cog, interaction, "Look up info on Caleb") + + interaction.response.defer.assert_awaited_once_with(ephemeral=False) + cog.bot.get_guild.assert_called_once_with(456) + cog._post_agent_request.assert_awaited_once() + context = cog._post_agent_request.await_args.kwargs["context"] + assert context["guild_id"] == "456" + assert context["organization_id"] == "456" + assert context["channel_id"] == "789" + assert context["roles"] == ["@everyone", "Admin"] + assert interaction.followup.send.await_args.kwargs["ephemeral"] is False + cog._audit_command_safe.assert_called_once() + + +@pytest.mark.asyncio +async def test_agent_command_in_dm_refuses_user_outside_configured_guild( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.agent.settings", + SimpleNamespace(discord_server_id="456"), + ) + guild = SimpleNamespace( + id=456, + get_member=Mock(return_value=None), + fetch_member=AsyncMock(return_value=None), + ) + cog = AgentCog.__new__(AgentCog) + cog.bot = SimpleNamespace(get_guild=Mock(return_value=guild), guilds=[guild]) + cog._post_agent_request = AsyncMock() + cog._audit_command_safe = Mock() + interaction = SimpleNamespace( + id=999, + guild=None, + guild_id=None, + channel_id=789, + channel=SimpleNamespace(id=789), + message=None, + response=SimpleNamespace(defer=AsyncMock()), + followup=SimpleNamespace(send=AsyncMock()), + user=SimpleNamespace(id=123, roles=[]), + ) + + await AgentCog.agent_command.callback(cog, interaction, "Look up info on Caleb") + + interaction.response.defer.assert_awaited_once_with(ephemeral=False) + cog._post_agent_request.assert_not_awaited() + interaction.followup.send.assert_awaited_once_with( + "I can only run DM workflows for current members of the configured 508 server.", + ephemeral=False, + ) + cog._audit_command_safe.assert_called_once() + assert cog._audit_command_safe.call_args.kwargs["action"] == "agent.request" + assert cog._audit_command_safe.call_args.kwargs["result"] == "denied" + + @pytest.mark.asyncio async def test_confirmation_context_in_dm_uses_cached_original_guild_roles() -> None: member = SimpleNamespace(roles=[SimpleNamespace(name="Member")]) @@ -800,6 +900,43 @@ async def test_confirmation_context_in_dm_preserves_original_roles_when_guild_mi assert context["message_id"] == "555" +@pytest.mark.asyncio +async def test_confirmation_context_in_dm_clears_roles_when_member_left_guild() -> None: + guild = SimpleNamespace( + get_member=Mock(return_value=None), + fetch_member=AsyncMock(return_value=None), + ) + cog = AgentCog.__new__(AgentCog) + cog.bot = SimpleNamespace(get_guild=Mock(return_value=guild)) + view = AgentConfirmationView( + cog=cog, + requester_id=123, + plan_id="plan-1", + context={ + "discord_user_id": "123", + "organization_id": "456", + "guild_id": "456", + "channel_id": "789", + "message_id": "555", + "roles": ["Admin", "Member"], + }, + ) + interaction = SimpleNamespace( + id=999, + guild_id=None, + channel_id=111, + message=SimpleNamespace(id=222), + user=SimpleNamespace(id=123), + ) + + context = await view._confirmation_context(interaction) + + assert context["organization_id"] == "456" + assert context["guild_id"] == "456" + assert context["roles"] == [] + assert context["message_id"] == "555" + + def test_mention_rate_limit_prunes_expired_user_entries() -> None: cog = AgentCog.__new__(AgentCog) cog._mention_request_timestamps = { @@ -1335,14 +1472,86 @@ async def test_agent_mention_still_audits_errors() -> None: @pytest.mark.asyncio -async def test_agent_mention_ignores_dms() -> None: +async def test_agent_dm_sends_agent_response_to_current_guild_member( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.agent.settings", + SimpleNamespace(discord_server_id="456"), + ) + member = SimpleNamespace( + id=123, + roles=[SimpleNamespace(name="@everyone"), SimpleNamespace(name="Member")], + ) + guild = SimpleNamespace( + id=456, + get_member=Mock(return_value=member), + ) cog = AgentCog.__new__(AgentCog) - cog.bot = SimpleNamespace(user=SimpleNamespace(id=999)) + cog.bot = SimpleNamespace( + user=SimpleNamespace(id=999), + get_guild=Mock(return_value=guild), + guilds=[guild], + ) + cog._post_agent_request = AsyncMock( + return_value={"status": "executed", "message": "Done"} + ) + cog._audit_message_safe = Mock() + cog._format_agent_response = Mock(return_value="Agent status: executed") + message = SimpleNamespace( + id=555, + content="show tasks for project Atlas", + author=SimpleNamespace(id=123, bot=False, roles=[]), + mentions=[], + guild=None, + channel=SimpleNamespace(id=789, typing=Mock(return_value=_AsyncTyping())), + reply=AsyncMock(), + ) + + await cog.agent_mention(message) + + cog.bot.get_guild.assert_called_once_with(456) + cog._post_agent_request.assert_awaited_once() + assert cog._post_agent_request.await_args.kwargs["message"] == ( + "show tasks for project Atlas" + ) + context = cog._post_agent_request.await_args.kwargs["context"] + assert context["guild_id"] == "456" + assert context["organization_id"] == "456" + assert context["roles"] == ["@everyone", "Member"] + message.reply.assert_awaited_once_with( + "Agent status: executed", + mention_author=False, + ) + cog._audit_message_safe.assert_not_called() + + +@pytest.mark.asyncio +async def test_agent_dm_refuses_user_who_is_not_in_configured_guild( + monkeypatch, +) -> None: + monkeypatch.setattr( + "five08.discord_bot.cogs.agent.settings", + SimpleNamespace(discord_server_id="456"), + ) + guild = SimpleNamespace( + id=456, + get_member=Mock(return_value=None), + fetch_member=AsyncMock(return_value=None), + ) + cog = AgentCog.__new__(AgentCog) + cog.bot = SimpleNamespace( + user=SimpleNamespace(id=999), + get_guild=Mock(return_value=guild), + guilds=[guild], + ) cog._post_agent_request = AsyncMock() + cog._audit_message_safe = Mock() message = SimpleNamespace( - content="<@999> show tasks for project Atlas", + id=555, + content="show tasks for project Atlas", author=SimpleNamespace(id=123, bot=False, roles=[]), - mentions=[SimpleNamespace(id=999)], + mentions=[], guild=None, reply=AsyncMock(), ) @@ -1351,9 +1560,12 @@ async def test_agent_mention_ignores_dms() -> None: cog._post_agent_request.assert_not_awaited() message.reply.assert_awaited_once_with( - "Agent mentions only work in servers.", + "I can only run DM workflows for current members of the configured 508 server.", mention_author=False, ) + cog._audit_message_safe.assert_called_once() + assert cog._audit_message_safe.call_args.kwargs["action"] == "agent.dm" + assert cog._audit_message_safe.call_args.kwargs["result"] == "denied" def test_post_backend_json_returns_structured_failed_response( diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index f6745ce5..05f5689d 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -32,6 +32,11 @@ def test_bot_initialization(self): assert bot.command_prefix == "$508$" assert bot.intents.value == discord.Intents.all().value + assert bot.tree.allowed_contexts.guild is True + assert bot.tree.allowed_contexts.dm_channel is False + assert bot.tree.allowed_contexts.private_channel is False + assert bot.tree.allowed_installs.guild is True + assert bot.tree.allowed_installs.user is False @pytest.mark.asyncio async def test_setup_hook_calls_load_extensions(self):