mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
5db630aae4
commit
aecbf7fa4a
6 changed files with 92 additions and 35 deletions
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue