fix(discord): register /approve and /deny slash commands, wire up button-based approval UI (#4800)

Two fixes for Discord exec approval:

1. Register /approve and /deny as native Discord slash commands so they
   appear in Discord's command picker (autocomplete). Previously they
   were only handled as text commands, so users saw 'no commands found'
   when typing /approve.

2. Wire up the existing ExecApprovalView button UI (was dead code):
   - ExecApprovalView now calls resolve_gateway_approval() to actually
     unblock the waiting agent thread when a button is clicked
   - Gateway's _approval_notify_sync() detects adapters with
     send_exec_approval() and routes through the button UI
   - Added 'Allow Session' button for parity with /approve session
   - send_exec_approval() now accepts session_key and metadata for
     thread support
   - Graceful fallback to text-based /approve prompt if button send fails

Also updates test mocks to include grey/secondary ButtonStyle and
purple Color (used by new button styles).
This commit is contained in:
Teknium 2026-04-03 10:24:07 -07:00 committed by GitHub
parent 5db630aae4
commit aecbf7fa4a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 92 additions and 35 deletions

View file

@ -1617,6 +1617,16 @@ class DiscordAdapter(BasePlatformAdapter):
async def slash_update(interaction: discord.Interaction): async def slash_update(interaction: discord.Interaction):
await self._run_simple_slash(interaction, "/update", "Update initiated~") await self._run_simple_slash(interaction, "/update", "Update initiated~")
@tree.command(name="approve", description="Approve a pending dangerous command")
@discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'")
async def slash_approve(interaction: discord.Interaction, scope: str = ""):
await self._run_simple_slash(interaction, f"/approve {scope}".strip())
@tree.command(name="deny", description="Deny a pending dangerous command")
@discord.app_commands.describe(scope="Optional: 'all' to deny all pending commands")
async def slash_deny(interaction: discord.Interaction, scope: str = ""):
await self._run_simple_slash(interaction, f"/deny {scope}".strip())
@tree.command(name="thread", description="Create a new thread and start a Hermes session in it") @tree.command(name="thread", description="Create a new thread and start a Hermes session in it")
@discord.app_commands.describe( @discord.app_commands.describe(
name="Thread name", name="Thread name",
@ -1860,33 +1870,41 @@ class DiscordAdapter(BasePlatformAdapter):
return None return None
async def send_exec_approval( async def send_exec_approval(
self, chat_id: str, command: str, approval_id: str self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
metadata: Optional[dict] = None,
) -> SendResult: ) -> SendResult:
""" """
Send a button-based exec approval prompt for a dangerous command. Send a button-based exec approval prompt for a dangerous command.
Returns SendResult. The approval is resolved when a user clicks a button. The buttons call ``resolve_gateway_approval()`` to unblock the waiting
agent thread this replaces the text-based ``/approve`` flow on Discord.
""" """
if not self._client or not DISCORD_AVAILABLE: if not self._client or not DISCORD_AVAILABLE:
return SendResult(success=False, error="Not connected") return SendResult(success=False, error="Not connected")
try: try:
channel = self._client.get_channel(int(chat_id)) # Resolve channel — use thread_id from metadata if present
target_id = chat_id
if metadata and metadata.get("thread_id"):
target_id = metadata["thread_id"]
channel = self._client.get_channel(int(target_id))
if not channel: if not channel:
channel = await self._client.fetch_channel(int(chat_id)) channel = await self._client.fetch_channel(int(target_id))
# Discord embed description limit is 4096; show full command up to that # Discord embed description limit is 4096; show full command up to that
max_desc = 4088 max_desc = 4088
cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..." cmd_display = command if len(command) <= max_desc else command[: max_desc - 3] + "..."
embed = discord.Embed( embed = discord.Embed(
title="Command Approval Required", title="⚠️ Command Approval Required",
description=f"```\n{cmd_display}\n```", description=f"```\n{cmd_display}\n```",
color=discord.Color.orange(), color=discord.Color.orange(),
) )
embed.set_footer(text=f"Approval ID: {approval_id}") embed.add_field(name="Reason", value=description, inline=False)
view = ExecApprovalView( view = ExecApprovalView(
approval_id=approval_id, session_key=session_key,
allowed_user_ids=self._allowed_user_ids, allowed_user_ids=self._allowed_user_ids,
) )
@ -2219,13 +2237,15 @@ if DISCORD_AVAILABLE:
""" """
Interactive button view for exec approval of dangerous commands. Interactive button view for exec approval of dangerous commands.
Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red). Shows four buttons: Allow Once, Allow Session, Always Allow, Deny.
Only users in the allowed list can click. The view times out after 5 minutes. Clicking a button calls ``resolve_gateway_approval()`` to unblock the
waiting agent thread the same mechanism as the text ``/approve`` flow.
Only users in the allowed list can click. Times out after 5 minutes.
""" """
def __init__(self, approval_id: str, allowed_user_ids: set): def __init__(self, session_key: str, allowed_user_ids: set):
super().__init__(timeout=300) # 5-minute timeout super().__init__(timeout=300) # 5-minute timeout
self.approval_id = approval_id self.session_key = session_key
self.allowed_user_ids = allowed_user_ids self.allowed_user_ids = allowed_user_ids
self.resolved = False self.resolved = False
@ -2236,9 +2256,10 @@ if DISCORD_AVAILABLE:
return str(interaction.user.id) in self.allowed_user_ids return str(interaction.user.id) in self.allowed_user_ids
async def _resolve( async def _resolve(
self, interaction: discord.Interaction, action: str, color: discord.Color self, interaction: discord.Interaction, choice: str,
color: discord.Color, label: str,
): ):
"""Resolve the approval and update the message.""" """Resolve the approval via the gateway approval queue and update the embed."""
if self.resolved: if self.resolved:
await interaction.response.send_message( await interaction.response.send_message(
"This approval has already been resolved~", ephemeral=True "This approval has already been resolved~", ephemeral=True
@ -2257,7 +2278,7 @@ if DISCORD_AVAILABLE:
embed = interaction.message.embeds[0] if interaction.message.embeds else None embed = interaction.message.embeds[0] if interaction.message.embeds else None
if embed: if embed:
embed.color = color embed.color = color
embed.set_footer(text=f"{action} by {interaction.user.display_name}") embed.set_footer(text=f"{label} by {interaction.user.display_name}")
# Disable all buttons # Disable all buttons
for child in self.children: for child in self.children:
@ -2265,33 +2286,40 @@ if DISCORD_AVAILABLE:
await interaction.response.edit_message(embed=embed, view=self) await interaction.response.edit_message(embed=embed, view=self)
# Store the approval decision # Unblock the waiting agent thread via the gateway approval queue
try: try:
from tools.approval import approve_permanent from tools.approval import resolve_gateway_approval
if action == "allow_once": count = resolve_gateway_approval(self.session_key, choice)
pass # One-time approval handled by gateway logger.info(
elif action == "allow_always": "Discord button resolved %d approval(s) for session %s (choice=%s, user=%s)",
approve_permanent(self.approval_id) count, self.session_key, choice, interaction.user.display_name,
except ImportError: )
pass except Exception as exc:
logger.error("Failed to resolve gateway approval from button: %s", exc)
@discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green) @discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green)
async def allow_once( async def allow_once(
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
await self._resolve(interaction, "allow_once", discord.Color.green()) await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
@discord.ui.button(label="Allow Session", style=discord.ButtonStyle.grey)
async def allow_session(
self, interaction: discord.Interaction, button: discord.ui.Button
):
await self._resolve(interaction, "session", discord.Color.blue(), "Approved for session")
@discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple) @discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple)
async def allow_always( async def allow_always(
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
await self._resolve(interaction, "allow_always", discord.Color.blue()) await self._resolve(interaction, "always", discord.Color.purple(), "Approved permanently")
@discord.ui.button(label="Deny", style=discord.ButtonStyle.red) @discord.ui.button(label="Deny", style=discord.ButtonStyle.red)
async def deny( async def deny(
self, interaction: discord.Interaction, button: discord.ui.Button self, interaction: discord.Interaction, button: discord.ui.Button
): ):
await self._resolve(interaction, "deny", discord.Color.red()) await self._resolve(interaction, "deny", discord.Color.red(), "Denied")
async def on_timeout(self): async def on_timeout(self):
"""Handle view timeout -- disable buttons and mark as expired.""" """Handle view timeout -- disable buttons and mark as expired."""

View file

@ -5851,10 +5851,39 @@ class GatewayRunner:
from tools.approval import register_gateway_notify, unregister_gateway_notify from tools.approval import register_gateway_notify, unregister_gateway_notify
def _approval_notify_sync(approval_data: dict) -> None: def _approval_notify_sync(approval_data: dict) -> None:
"""Send the approval request to the user from the agent thread.""" """Send the approval request to the user from the agent thread.
If the adapter supports interactive button-based approvals
(e.g. Discord's ``send_exec_approval``), use that for a richer
UX. Otherwise fall back to a plain text message with
``/approve`` instructions.
"""
cmd = approval_data.get("command", "") cmd = approval_data.get("command", "")
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
desc = approval_data.get("description", "dangerous command") desc = approval_data.get("description", "dangerous command")
# Prefer button-based approval when the adapter supports it.
# Check the *class* for the method, not the instance — avoids
# false positives from MagicMock auto-attribute creation in tests.
if getattr(type(_status_adapter), "send_exec_approval", None) is not None:
try:
asyncio.run_coroutine_threadsafe(
_status_adapter.send_exec_approval(
chat_id=_status_chat_id,
command=cmd,
session_key=_approval_session_key,
description=desc,
metadata=_status_thread_metadata,
),
_loop_for_step,
).result(timeout=15)
return
except Exception as _e:
logger.warning(
"Button-based approval failed, falling back to text: %s", _e
)
# Fallback: plain text approval prompt
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
msg = ( msg = (
f"⚠️ **Dangerous command requires approval:**\n" f"⚠️ **Dangerous command requires approval:**\n"
f"```\n{cmd_preview}\n```\n" f"```\n{cmd_preview}\n```\n"

View file

@ -34,8 +34,8 @@ def _ensure_discord_mock():
discord_mod.Thread = type("Thread", (), {}) discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object discord_mod.Interaction = object
discord_mod.Embed = MagicMock discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace( discord_mod.app_commands = SimpleNamespace(

View file

@ -23,8 +23,8 @@ def _ensure_discord_mock():
discord_mod.Thread = type("Thread", (), {}) discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object discord_mod.Interaction = object
discord_mod.Embed = MagicMock discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace( discord_mod.app_commands = SimpleNamespace(

View file

@ -19,8 +19,8 @@ def _ensure_discord_mock():
discord_mod.Thread = type("Thread", (), {}) discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object discord_mod.Interaction = object
discord_mod.Embed = MagicMock discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace( discord_mod.app_commands = SimpleNamespace(

View file

@ -25,8 +25,8 @@ def _ensure_discord_mock():
discord_mod.Thread = type("Thread", (), {}) discord_mod.Thread = type("Thread", (), {})
discord_mod.ForumChannel = type("ForumChannel", (), {}) discord_mod.ForumChannel = type("ForumChannel", (), {})
discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)
discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
discord_mod.Interaction = object discord_mod.Interaction = object
discord_mod.Embed = MagicMock discord_mod.Embed = MagicMock
discord_mod.app_commands = SimpleNamespace( discord_mod.app_commands = SimpleNamespace(