feat(gateway): approval buttons for Slack & Telegram + Slack thread context (#5890)
Slack: - Add Block Kit interactive buttons for command approval (Allow Once, Allow Session, Always Allow, Deny) via send_exec_approval() - Register @app.action handlers for each approval button - Add _fetch_thread_context() — fetches thread history via conversations.replies when bot is first @mentioned mid-thread - Fix _has_active_session_for_thread() to use build_session_key() instead of manual key construction (fixes session key mismatch bug where thread_sessions_per_user flag was ignored, ref PR #5833) Telegram: - Add InlineKeyboard approval buttons via send_exec_approval() - Add ea:* callback handling in _handle_callback_query() - Uses monotonic counter + _approval_state dict to map button clicks back to session keys (avoids 64-byte callback_data limit) Both platforms now auto-detected by the gateway runner's _approval_notify_sync() — any adapter with send_exec_approval() on its class gets button-based approval instead of text fallback. Inspired by community PRs #3898 (LevSky22), #2953 (ygd58), #5833 (heathley). Implemented fresh on current main. Tests: 24 new tests covering button rendering, action handling, thread context fetching, session key fix, double-click prevention.
This commit is contained in:
parent
187e90e425
commit
1a2a03ca69
@ -84,6 +84,9 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
self._seen_messages: Dict[str, float] = {}
|
self._seen_messages: Dict[str, float] = {}
|
||||||
self._SEEN_TTL = 300 # 5 minutes
|
self._SEEN_TTL = 300 # 5 minutes
|
||||||
self._SEEN_MAX = 2000 # prune threshold
|
self._SEEN_MAX = 2000 # prune threshold
|
||||||
|
# Track pending approval message_ts → resolved flag to prevent
|
||||||
|
# double-clicks on approval buttons.
|
||||||
|
self._approval_resolved: Dict[str, bool] = {}
|
||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
"""Connect to Slack via Socket Mode."""
|
"""Connect to Slack via Socket Mode."""
|
||||||
@ -176,6 +179,15 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
await ack()
|
await ack()
|
||||||
await self._handle_slash_command(command)
|
await self._handle_slash_command(command)
|
||||||
|
|
||||||
|
# Register Block Kit action handlers for approval buttons
|
||||||
|
for _action_id in (
|
||||||
|
"hermes_approve_once",
|
||||||
|
"hermes_approve_session",
|
||||||
|
"hermes_approve_always",
|
||||||
|
"hermes_deny",
|
||||||
|
):
|
||||||
|
self._app.action(_action_id)(self._handle_approval_action)
|
||||||
|
|
||||||
# Start Socket Mode handler in background
|
# Start Socket Mode handler in background
|
||||||
self._handler = AsyncSocketModeHandler(self._app, app_token)
|
self._handler = AsyncSocketModeHandler(self._app, app_token)
|
||||||
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
|
self._socket_mode_task = asyncio.create_task(self._handler.start_async())
|
||||||
@ -791,6 +803,24 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
# Strip the bot mention from the text
|
# Strip the bot mention from the text
|
||||||
text = text.replace(f"<@{bot_uid}>", "").strip()
|
text = text.replace(f"<@{bot_uid}>", "").strip()
|
||||||
|
|
||||||
|
# When first mentioned in an existing thread, fetch thread context
|
||||||
|
# so the agent understands the conversation it's joining.
|
||||||
|
event_thread_ts = event.get("thread_ts")
|
||||||
|
is_thread_reply = event_thread_ts and event_thread_ts != ts
|
||||||
|
if is_thread_reply and not self._has_active_session_for_thread(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=event_thread_ts,
|
||||||
|
user_id=user_id,
|
||||||
|
):
|
||||||
|
thread_context = await self._fetch_thread_context(
|
||||||
|
channel_id=channel_id,
|
||||||
|
thread_ts=event_thread_ts,
|
||||||
|
current_ts=ts,
|
||||||
|
team_id=team_id,
|
||||||
|
)
|
||||||
|
if thread_context:
|
||||||
|
text = thread_context + text
|
||||||
|
|
||||||
# Determine message type
|
# Determine message type
|
||||||
msg_type = MessageType.TEXT
|
msg_type = MessageType.TEXT
|
||||||
if text.startswith("/"):
|
if text.startswith("/"):
|
||||||
@ -912,6 +942,233 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
await self._remove_reaction(channel_id, ts, "eyes")
|
await self._remove_reaction(channel_id, ts, "eyes")
|
||||||
await self._add_reaction(channel_id, ts, "white_check_mark")
|
await self._add_reaction(channel_id, ts, "white_check_mark")
|
||||||
|
|
||||||
|
# ----- Approval button support (Block Kit) -----
|
||||||
|
|
||||||
|
async def send_exec_approval(
|
||||||
|
self, chat_id: str, command: str, session_key: str,
|
||||||
|
description: str = "dangerous command",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send a Block Kit approval prompt with interactive buttons.
|
||||||
|
|
||||||
|
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
|
||||||
|
agent thread — same mechanism as the text ``/approve`` flow.
|
||||||
|
"""
|
||||||
|
if not self._app:
|
||||||
|
return SendResult(success=False, error="Not connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
|
||||||
|
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||||
|
|
||||||
|
blocks = [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": (
|
||||||
|
f":warning: *Command Approval Required*\n"
|
||||||
|
f"```{cmd_preview}```\n"
|
||||||
|
f"Reason: {description}"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "actions",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "Allow Once"},
|
||||||
|
"style": "primary",
|
||||||
|
"action_id": "hermes_approve_once",
|
||||||
|
"value": session_key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "Allow Session"},
|
||||||
|
"action_id": "hermes_approve_session",
|
||||||
|
"value": session_key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "Always Allow"},
|
||||||
|
"action_id": "hermes_approve_always",
|
||||||
|
"value": session_key,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "button",
|
||||||
|
"text": {"type": "plain_text", "text": "Deny"},
|
||||||
|
"style": "danger",
|
||||||
|
"action_id": "hermes_deny",
|
||||||
|
"value": session_key,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"channel": chat_id,
|
||||||
|
"text": f"⚠️ Command approval required: {cmd_preview[:100]}",
|
||||||
|
"blocks": blocks,
|
||||||
|
}
|
||||||
|
if thread_ts:
|
||||||
|
kwargs["thread_ts"] = thread_ts
|
||||||
|
|
||||||
|
result = await self._get_client(chat_id).chat_postMessage(**kwargs)
|
||||||
|
msg_ts = result.get("ts", "")
|
||||||
|
if msg_ts:
|
||||||
|
self._approval_resolved[msg_ts] = False
|
||||||
|
|
||||||
|
return SendResult(success=True, message_id=msg_ts, raw_response=result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("[Slack] send_exec_approval failed: %s", e, exc_info=True)
|
||||||
|
return SendResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
async def _handle_approval_action(self, ack, body, action) -> None:
|
||||||
|
"""Handle an approval button click from Block Kit."""
|
||||||
|
await ack()
|
||||||
|
|
||||||
|
action_id = action.get("action_id", "")
|
||||||
|
session_key = action.get("value", "")
|
||||||
|
message = body.get("message", {})
|
||||||
|
msg_ts = message.get("ts", "")
|
||||||
|
channel_id = body.get("channel", {}).get("id", "")
|
||||||
|
user_name = body.get("user", {}).get("name", "unknown")
|
||||||
|
|
||||||
|
# Map action_id to approval choice
|
||||||
|
choice_map = {
|
||||||
|
"hermes_approve_once": "once",
|
||||||
|
"hermes_approve_session": "session",
|
||||||
|
"hermes_approve_always": "always",
|
||||||
|
"hermes_deny": "deny",
|
||||||
|
}
|
||||||
|
choice = choice_map.get(action_id, "deny")
|
||||||
|
|
||||||
|
# Prevent double-clicks
|
||||||
|
if self._approval_resolved.get(msg_ts, False):
|
||||||
|
return
|
||||||
|
self._approval_resolved[msg_ts] = True
|
||||||
|
|
||||||
|
# Update the message to show the decision and remove buttons
|
||||||
|
label_map = {
|
||||||
|
"once": f"✅ Approved once by {user_name}",
|
||||||
|
"session": f"✅ Approved for session by {user_name}",
|
||||||
|
"always": f"✅ Approved permanently by {user_name}",
|
||||||
|
"deny": f"❌ Denied by {user_name}",
|
||||||
|
}
|
||||||
|
decision_text = label_map.get(choice, f"Resolved by {user_name}")
|
||||||
|
|
||||||
|
# Get original text from the section block
|
||||||
|
original_text = ""
|
||||||
|
for block in message.get("blocks", []):
|
||||||
|
if block.get("type") == "section":
|
||||||
|
original_text = block.get("text", {}).get("text", "")
|
||||||
|
break
|
||||||
|
|
||||||
|
updated_blocks = [
|
||||||
|
{
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": original_text or "Command approval request",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "context",
|
||||||
|
"elements": [
|
||||||
|
{"type": "mrkdwn", "text": decision_text},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._get_client(channel_id).chat_update(
|
||||||
|
channel=channel_id,
|
||||||
|
ts=msg_ts,
|
||||||
|
text=decision_text,
|
||||||
|
blocks=updated_blocks,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[Slack] Failed to update approval message: %s", e)
|
||||||
|
|
||||||
|
# Resolve the approval — this unblocks the agent thread
|
||||||
|
try:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
count = resolve_gateway_approval(session_key, choice)
|
||||||
|
logger.info(
|
||||||
|
"Slack button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
||||||
|
count, session_key, choice, user_name,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to resolve gateway approval from Slack button: %s", exc)
|
||||||
|
|
||||||
|
# Clean up stale approval state
|
||||||
|
self._approval_resolved.pop(msg_ts, None)
|
||||||
|
|
||||||
|
# ----- Thread context fetching -----
|
||||||
|
|
||||||
|
async def _fetch_thread_context(
|
||||||
|
self, channel_id: str, thread_ts: str, current_ts: str,
|
||||||
|
team_id: str = "", limit: int = 30,
|
||||||
|
) -> str:
|
||||||
|
"""Fetch recent thread messages to provide context when the bot is
|
||||||
|
mentioned mid-thread for the first time.
|
||||||
|
|
||||||
|
Returns a formatted string with thread history, or empty string on
|
||||||
|
failure or if the thread is empty (just the parent message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
client = self._get_client(channel_id)
|
||||||
|
result = await client.conversations_replies(
|
||||||
|
channel=channel_id,
|
||||||
|
ts=thread_ts,
|
||||||
|
limit=limit + 1, # +1 because it includes the current message
|
||||||
|
inclusive=True,
|
||||||
|
)
|
||||||
|
messages = result.get("messages", [])
|
||||||
|
if not messages:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
context_parts = []
|
||||||
|
for msg in messages:
|
||||||
|
msg_ts = msg.get("ts", "")
|
||||||
|
# Skip the current message (the one that triggered this fetch)
|
||||||
|
if msg_ts == current_ts:
|
||||||
|
continue
|
||||||
|
# Skip bot messages from ourselves
|
||||||
|
if msg.get("bot_id") or msg.get("subtype") == "bot_message":
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg_user = msg.get("user", "unknown")
|
||||||
|
msg_text = msg.get("text", "").strip()
|
||||||
|
if not msg_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Strip bot mentions from context messages
|
||||||
|
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
|
||||||
|
if bot_uid:
|
||||||
|
msg_text = msg_text.replace(f"<@{bot_uid}>", "").strip()
|
||||||
|
|
||||||
|
# Mark the thread parent
|
||||||
|
is_parent = msg_ts == thread_ts
|
||||||
|
prefix = "[thread parent] " if is_parent else ""
|
||||||
|
|
||||||
|
# Resolve user name (cached)
|
||||||
|
name = await self._resolve_user_name(msg_user, chat_id=channel_id)
|
||||||
|
context_parts.append(f"{prefix}{name}: {msg_text}")
|
||||||
|
|
||||||
|
if not context_parts:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
"[Thread context — previous messages in this thread:]\n"
|
||||||
|
+ "\n".join(context_parts)
|
||||||
|
+ "\n[End of thread context]\n\n"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[Slack] Failed to fetch thread context: %s", e)
|
||||||
|
return ""
|
||||||
|
|
||||||
async def _handle_slash_command(self, command: dict) -> None:
|
async def _handle_slash_command(self, command: dict) -> None:
|
||||||
"""Handle /hermes slash command."""
|
"""Handle /hermes slash command."""
|
||||||
text = command.get("text", "").strip()
|
text = command.get("text", "").strip()
|
||||||
@ -960,50 +1217,44 @@ class SlackAdapter(BasePlatformAdapter):
|
|||||||
user_id: str,
|
user_id: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check if there's an active session for a thread.
|
"""Check if there's an active session for a thread.
|
||||||
|
|
||||||
Used to determine if thread replies without @mentions should be
|
Used to determine if thread replies without @mentions should be
|
||||||
processed (they should if there's an active session).
|
processed (they should if there's an active session).
|
||||||
|
|
||||||
Args:
|
Uses ``build_session_key()`` as the single source of truth for key
|
||||||
channel_id: The Slack channel ID
|
construction — avoids the bug where manual key building didn't
|
||||||
thread_ts: The thread timestamp (parent message ts)
|
respect ``thread_sessions_per_user`` and ``group_sessions_per_user``
|
||||||
user_id: The user ID of the sender
|
settings correctly.
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if there's an active session for this thread
|
|
||||||
"""
|
"""
|
||||||
session_store = getattr(self, "_session_store", None)
|
session_store = getattr(self, "_session_store", None)
|
||||||
if not session_store:
|
if not session_store:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
|
||||||
# Build a SessionSource for this thread
|
|
||||||
from gateway.session import SessionSource
|
|
||||||
|
|
||||||
# Generate the session key using the same logic as SessionStore
|
try:
|
||||||
# This mirrors the logic in build_session_key for group sessions
|
from gateway.session import SessionSource, build_session_key
|
||||||
key_parts = ["agent:main", "slack", "group", channel_id, thread_ts]
|
|
||||||
|
source = SessionSource(
|
||||||
# Include user_id if group_sessions_per_user is enabled
|
platform=Platform.SLACK,
|
||||||
# We check the session store config if available
|
chat_id=channel_id,
|
||||||
group_sessions_per_user = getattr(
|
chat_type="group",
|
||||||
session_store, "config", {}
|
user_id=user_id,
|
||||||
|
thread_id=thread_ts,
|
||||||
)
|
)
|
||||||
if hasattr(group_sessions_per_user, "group_sessions_per_user"):
|
|
||||||
group_sessions_per_user = group_sessions_per_user.group_sessions_per_user
|
# Read session isolation settings from the store's config
|
||||||
else:
|
store_cfg = getattr(session_store, "config", None)
|
||||||
group_sessions_per_user = True # Default
|
gspu = getattr(store_cfg, "group_sessions_per_user", True) if store_cfg else True
|
||||||
|
tspu = getattr(store_cfg, "thread_sessions_per_user", False) if store_cfg else False
|
||||||
if group_sessions_per_user and user_id:
|
|
||||||
key_parts.append(str(user_id))
|
session_key = build_session_key(
|
||||||
|
source,
|
||||||
session_key = ":".join(key_parts)
|
group_sessions_per_user=gspu,
|
||||||
|
thread_sessions_per_user=tspu,
|
||||||
# Check if the session exists in the store
|
)
|
||||||
|
|
||||||
session_store._ensure_loaded()
|
session_store._ensure_loaded()
|
||||||
return session_key in session_store._entries
|
return session_key in session_store._entries
|
||||||
except Exception:
|
except Exception:
|
||||||
# If anything goes wrong, default to False (require mention)
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
|
async def _download_slack_file(self, url: str, ext: str, audio: bool = False, team_id: str = "") -> str:
|
||||||
|
|||||||
@ -153,6 +153,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
|
self._dm_topics_config: List[Dict[str, Any]] = self.config.extra.get("dm_topics", [])
|
||||||
# Interactive model picker state per chat
|
# Interactive model picker state per chat
|
||||||
self._model_picker_state: Dict[str, dict] = {}
|
self._model_picker_state: Dict[str, dict] = {}
|
||||||
|
# Approval button state: message_id → session_key
|
||||||
|
self._approval_state: Dict[int, str] = {}
|
||||||
|
|
||||||
def _fallback_ips(self) -> list[str]:
|
def _fallback_ips(self) -> list[str]:
|
||||||
"""Return validated fallback IPs from config (populated by _apply_env_overrides)."""
|
"""Return validated fallback IPs from config (populated by _apply_env_overrides)."""
|
||||||
@ -1010,6 +1012,70 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
|
logger.warning("[%s] send_update_prompt failed: %s", self.name, e)
|
||||||
return SendResult(success=False, error=str(e))
|
return SendResult(success=False, error=str(e))
|
||||||
|
|
||||||
|
async def send_exec_approval(
|
||||||
|
self, chat_id: str, command: str, session_key: str,
|
||||||
|
description: str = "dangerous command",
|
||||||
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
) -> SendResult:
|
||||||
|
"""Send an inline-keyboard approval prompt with interactive buttons.
|
||||||
|
|
||||||
|
The buttons call ``resolve_gateway_approval()`` to unblock the waiting
|
||||||
|
agent thread — same mechanism as the text ``/approve`` flow.
|
||||||
|
"""
|
||||||
|
if not self._bot:
|
||||||
|
return SendResult(success=False, error="Not connected")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd_preview = command[:3800] + "..." if len(command) > 3800 else command
|
||||||
|
text = (
|
||||||
|
f"⚠️ *Command Approval Required*\n\n"
|
||||||
|
f"`{cmd_preview}`\n\n"
|
||||||
|
f"Reason: {description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve thread context for thread replies
|
||||||
|
thread_id = None
|
||||||
|
if metadata:
|
||||||
|
thread_id = metadata.get("thread_id") or metadata.get("message_thread_id")
|
||||||
|
|
||||||
|
# We'll use the message_id as part of callback_data to look up session_key
|
||||||
|
# Send a placeholder first, then update — or use a counter.
|
||||||
|
# Simpler: use a monotonic counter to generate short IDs.
|
||||||
|
import itertools
|
||||||
|
if not hasattr(self, "_approval_counter"):
|
||||||
|
self._approval_counter = itertools.count(1)
|
||||||
|
approval_id = next(self._approval_counter)
|
||||||
|
|
||||||
|
keyboard = InlineKeyboardMarkup([
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("✅ Allow Once", callback_data=f"ea:once:{approval_id}"),
|
||||||
|
InlineKeyboardButton("✅ Session", callback_data=f"ea:session:{approval_id}"),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
InlineKeyboardButton("✅ Always", callback_data=f"ea:always:{approval_id}"),
|
||||||
|
InlineKeyboardButton("❌ Deny", callback_data=f"ea:deny:{approval_id}"),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"chat_id": int(chat_id),
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": ParseMode.MARKDOWN,
|
||||||
|
"reply_markup": keyboard,
|
||||||
|
}
|
||||||
|
if thread_id:
|
||||||
|
kwargs["message_thread_id"] = int(thread_id)
|
||||||
|
|
||||||
|
msg = await self._bot.send_message(**kwargs)
|
||||||
|
|
||||||
|
# Store session_key keyed by approval_id for the callback handler
|
||||||
|
self._approval_state[approval_id] = session_key
|
||||||
|
|
||||||
|
return SendResult(success=True, message_id=str(msg.message_id))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("[%s] send_exec_approval failed: %s", self.name, e)
|
||||||
|
return SendResult(success=False, error=str(e))
|
||||||
|
|
||||||
async def send_model_picker(
|
async def send_model_picker(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@ -1321,6 +1387,56 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||||||
await self._handle_model_picker_callback(query, data, chat_id)
|
await self._handle_model_picker_callback(query, data, chat_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# --- Exec approval callbacks (ea:choice:id) ---
|
||||||
|
if data.startswith("ea:"):
|
||||||
|
parts = data.split(":", 2)
|
||||||
|
if len(parts) == 3:
|
||||||
|
choice = parts[1] # once, session, always, deny
|
||||||
|
try:
|
||||||
|
approval_id = int(parts[2])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
await query.answer(text="Invalid approval data.")
|
||||||
|
return
|
||||||
|
|
||||||
|
session_key = self._approval_state.pop(approval_id, None)
|
||||||
|
if not session_key:
|
||||||
|
await query.answer(text="This approval has already been resolved.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Map choice to human-readable label
|
||||||
|
label_map = {
|
||||||
|
"once": "✅ Approved once",
|
||||||
|
"session": "✅ Approved for session",
|
||||||
|
"always": "✅ Approved permanently",
|
||||||
|
"deny": "❌ Denied",
|
||||||
|
}
|
||||||
|
user_display = getattr(query.from_user, "first_name", "User")
|
||||||
|
label = label_map.get(choice, "Resolved")
|
||||||
|
|
||||||
|
await query.answer(text=label)
|
||||||
|
|
||||||
|
# Edit message to show decision, remove buttons
|
||||||
|
try:
|
||||||
|
await query.edit_message_text(
|
||||||
|
text=f"{label} by {user_display}",
|
||||||
|
parse_mode=ParseMode.MARKDOWN,
|
||||||
|
reply_markup=None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # non-fatal if edit fails
|
||||||
|
|
||||||
|
# Resolve the approval — unblocks the agent thread
|
||||||
|
try:
|
||||||
|
from tools.approval import resolve_gateway_approval
|
||||||
|
count = resolve_gateway_approval(session_key, choice)
|
||||||
|
logger.info(
|
||||||
|
"Telegram button resolved %d approval(s) for session %s (choice=%s, user=%s)",
|
||||||
|
count, session_key, choice, user_display,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to resolve gateway approval from Telegram button: %s", exc)
|
||||||
|
return
|
||||||
|
|
||||||
# --- Update prompt callbacks ---
|
# --- Update prompt callbacks ---
|
||||||
if not data.startswith("update_prompt:"):
|
if not data.startswith("update_prompt:"):
|
||||||
return
|
return
|
||||||
|
|||||||
373
tests/gateway/test_slack_approval_buttons.py
Normal file
373
tests/gateway/test_slack_approval_buttons.py
Normal file
@ -0,0 +1,373 @@
|
|||||||
|
"""Tests for Slack Block Kit approval buttons and thread context fetching."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ensure the repo root is importable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_repo = str(Path(__file__).resolve().parents[2])
|
||||||
|
if _repo not in sys.path:
|
||||||
|
sys.path.insert(0, _repo)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimal Slack SDK mock so SlackAdapter can be imported
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _ensure_slack_mock():
|
||||||
|
"""Wire up the minimal mocks required to import SlackAdapter."""
|
||||||
|
if "slack_bolt" in sys.modules:
|
||||||
|
return
|
||||||
|
slack_bolt = MagicMock()
|
||||||
|
slack_bolt.async_app.AsyncApp = MagicMock
|
||||||
|
sys.modules["slack_bolt"] = slack_bolt
|
||||||
|
sys.modules["slack_bolt.async_app"] = slack_bolt.async_app
|
||||||
|
handler_mod = MagicMock()
|
||||||
|
handler_mod.AsyncSocketModeHandler = MagicMock
|
||||||
|
sys.modules["slack_bolt.adapter"] = MagicMock()
|
||||||
|
sys.modules["slack_bolt.adapter.socket_mode"] = MagicMock()
|
||||||
|
sys.modules["slack_bolt.adapter.socket_mode.async_handler"] = handler_mod
|
||||||
|
sdk_mod = MagicMock()
|
||||||
|
sdk_mod.web = MagicMock()
|
||||||
|
sdk_mod.web.async_client = MagicMock()
|
||||||
|
sdk_mod.web.async_client.AsyncWebClient = MagicMock
|
||||||
|
sys.modules["slack_sdk"] = sdk_mod
|
||||||
|
sys.modules["slack_sdk.web"] = sdk_mod.web
|
||||||
|
sys.modules["slack_sdk.web.async_client"] = sdk_mod.web.async_client
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_slack_mock()
|
||||||
|
|
||||||
|
from gateway.platforms.slack import SlackAdapter
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter():
|
||||||
|
"""Create a SlackAdapter instance with mocked internals."""
|
||||||
|
config = PlatformConfig(enabled=True, token="xoxb-test-token")
|
||||||
|
adapter = SlackAdapter(config)
|
||||||
|
adapter._app = MagicMock()
|
||||||
|
adapter._bot_user_id = "U_BOT"
|
||||||
|
adapter._team_clients = {"T1": AsyncMock()}
|
||||||
|
adapter._team_bot_user_ids = {"T1": "U_BOT"}
|
||||||
|
adapter._channel_team = {"C1": "T1"}
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# send_exec_approval — Block Kit buttons
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestSlackExecApproval:
|
||||||
|
"""Test the send_exec_approval method sends Block Kit buttons."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_blocks_with_buttons(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1234.5678"})
|
||||||
|
|
||||||
|
result = await adapter.send_exec_approval(
|
||||||
|
chat_id="C1",
|
||||||
|
command="rm -rf /important",
|
||||||
|
session_key="agent:main:slack:group:C1:1111",
|
||||||
|
description="dangerous deletion",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.message_id == "1234.5678"
|
||||||
|
|
||||||
|
# Verify chat_postMessage was called with blocks
|
||||||
|
mock_client.chat_postMessage.assert_called_once()
|
||||||
|
kwargs = mock_client.chat_postMessage.call_args[1]
|
||||||
|
assert "blocks" in kwargs
|
||||||
|
blocks = kwargs["blocks"]
|
||||||
|
assert len(blocks) == 2
|
||||||
|
assert blocks[0]["type"] == "section"
|
||||||
|
assert "rm -rf /important" in blocks[0]["text"]["text"]
|
||||||
|
assert "dangerous deletion" in blocks[0]["text"]["text"]
|
||||||
|
assert blocks[1]["type"] == "actions"
|
||||||
|
elements = blocks[1]["elements"]
|
||||||
|
assert len(elements) == 4
|
||||||
|
action_ids = [e["action_id"] for e in elements]
|
||||||
|
assert "hermes_approve_once" in action_ids
|
||||||
|
assert "hermes_approve_session" in action_ids
|
||||||
|
assert "hermes_approve_always" in action_ids
|
||||||
|
assert "hermes_deny" in action_ids
|
||||||
|
# Each button carries the session key as value
|
||||||
|
for e in elements:
|
||||||
|
assert e["value"] == "agent:main:slack:group:C1:1111"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_in_thread(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1234.5678"})
|
||||||
|
|
||||||
|
await adapter.send_exec_approval(
|
||||||
|
chat_id="C1",
|
||||||
|
command="echo test",
|
||||||
|
session_key="test-session",
|
||||||
|
metadata={"thread_id": "9999.0000"},
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = mock_client.chat_postMessage.call_args[1]
|
||||||
|
assert kwargs.get("thread_ts") == "9999.0000"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_connected(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._app = None
|
||||||
|
result = await adapter.send_exec_approval(
|
||||||
|
chat_id="C1", command="ls", session_key="s"
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_truncates_long_command(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.chat_postMessage = AsyncMock(return_value={"ts": "1.2"})
|
||||||
|
|
||||||
|
long_cmd = "x" * 5000
|
||||||
|
await adapter.send_exec_approval(
|
||||||
|
chat_id="C1", command=long_cmd, session_key="s"
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = mock_client.chat_postMessage.call_args[1]
|
||||||
|
section_text = kwargs["blocks"][0]["text"]["text"]
|
||||||
|
assert "..." in section_text
|
||||||
|
assert len(section_text) < 5000
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _handle_approval_action — button click handler
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestSlackApprovalAction:
|
||||||
|
"""Test the approval button click handler."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolves_approval(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._approval_resolved["1234.5678"] = False
|
||||||
|
|
||||||
|
ack = AsyncMock()
|
||||||
|
body = {
|
||||||
|
"message": {
|
||||||
|
"ts": "1234.5678",
|
||||||
|
"blocks": [
|
||||||
|
{"type": "section", "text": {"type": "mrkdwn", "text": "original text"}},
|
||||||
|
{"type": "actions", "elements": []},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"channel": {"id": "C1"},
|
||||||
|
"user": {"name": "norbert"},
|
||||||
|
}
|
||||||
|
action = {
|
||||||
|
"action_id": "hermes_approve_once",
|
||||||
|
"value": "agent:main:slack:group:C1:1111",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.chat_update = AsyncMock()
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||||
|
await adapter._handle_approval_action(ack, body, action)
|
||||||
|
|
||||||
|
ack.assert_called_once()
|
||||||
|
mock_resolve.assert_called_once_with("agent:main:slack:group:C1:1111", "once")
|
||||||
|
|
||||||
|
# Message should be updated with decision
|
||||||
|
mock_client.chat_update.assert_called_once()
|
||||||
|
update_kwargs = mock_client.chat_update.call_args[1]
|
||||||
|
assert "Approved once by norbert" in update_kwargs["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_prevents_double_click(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._approval_resolved["1234.5678"] = True # Already resolved
|
||||||
|
|
||||||
|
ack = AsyncMock()
|
||||||
|
body = {
|
||||||
|
"message": {"ts": "1234.5678", "blocks": []},
|
||||||
|
"channel": {"id": "C1"},
|
||||||
|
"user": {"name": "norbert"},
|
||||||
|
}
|
||||||
|
action = {
|
||||||
|
"action_id": "hermes_approve_once",
|
||||||
|
"value": "some-session",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||||
|
await adapter._handle_approval_action(ack, body, action)
|
||||||
|
|
||||||
|
# Should have acked but NOT resolved
|
||||||
|
ack.assert_called_once()
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deny_action(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._approval_resolved["1.2"] = False
|
||||||
|
|
||||||
|
ack = AsyncMock()
|
||||||
|
body = {
|
||||||
|
"message": {"ts": "1.2", "blocks": [
|
||||||
|
{"type": "section", "text": {"type": "mrkdwn", "text": "cmd"}},
|
||||||
|
]},
|
||||||
|
"channel": {"id": "C1"},
|
||||||
|
"user": {"name": "alice"},
|
||||||
|
}
|
||||||
|
action = {"action_id": "hermes_deny", "value": "session-key"}
|
||||||
|
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.chat_update = AsyncMock()
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||||
|
await adapter._handle_approval_action(ack, body, action)
|
||||||
|
|
||||||
|
mock_resolve.assert_called_once_with("session-key", "deny")
|
||||||
|
update_kwargs = mock_client.chat_update.call_args[1]
|
||||||
|
assert "Denied by alice" in update_kwargs["text"]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _fetch_thread_context
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestSlackThreadContext:
|
||||||
|
"""Test thread context fetching."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetches_and_formats_context(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.conversations_replies = AsyncMock(return_value={
|
||||||
|
"messages": [
|
||||||
|
{"ts": "1000.0", "user": "U1", "text": "This is the parent message"},
|
||||||
|
{"ts": "1000.1", "user": "U2", "text": "I think we should refactor"},
|
||||||
|
{"ts": "1000.2", "user": "U1", "text": "Good idea, <@U_BOT> what do you think?"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Mock user name resolution
|
||||||
|
adapter._user_name_cache = {"U1": "Alice", "U2": "Bob"}
|
||||||
|
|
||||||
|
context = await adapter._fetch_thread_context(
|
||||||
|
channel_id="C1",
|
||||||
|
thread_ts="1000.0",
|
||||||
|
current_ts="1000.2", # The message that triggered the fetch
|
||||||
|
team_id="T1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "[Thread context" in context
|
||||||
|
assert "[thread parent] Alice: This is the parent message" in context
|
||||||
|
assert "Bob: I think we should refactor" in context
|
||||||
|
# Current message should be excluded
|
||||||
|
assert "what do you think" not in context
|
||||||
|
# Bot mention should be stripped from context
|
||||||
|
assert "<@U_BOT>" not in context
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_bot_messages(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.conversations_replies = AsyncMock(return_value={
|
||||||
|
"messages": [
|
||||||
|
{"ts": "1000.0", "user": "U1", "text": "Parent"},
|
||||||
|
{"ts": "1000.1", "bot_id": "B1", "text": "Bot reply (should be skipped)"},
|
||||||
|
{"ts": "1000.2", "user": "U1", "text": "Current"},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
adapter._user_name_cache = {"U1": "Alice"}
|
||||||
|
|
||||||
|
context = await adapter._fetch_thread_context(
|
||||||
|
channel_id="C1", thread_ts="1000.0", current_ts="1000.2", team_id="T1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Bot reply" not in context
|
||||||
|
assert "Alice: Parent" in context
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_thread(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.conversations_replies = AsyncMock(return_value={"messages": []})
|
||||||
|
|
||||||
|
context = await adapter._fetch_thread_context(
|
||||||
|
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
|
||||||
|
)
|
||||||
|
assert context == ""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_api_failure_returns_empty(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_client = adapter._team_clients["T1"]
|
||||||
|
mock_client.conversations_replies = AsyncMock(side_effect=Exception("API error"))
|
||||||
|
|
||||||
|
context = await adapter._fetch_thread_context(
|
||||||
|
channel_id="C1", thread_ts="1000.0", current_ts="1000.1", team_id="T1"
|
||||||
|
)
|
||||||
|
assert context == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _has_active_session_for_thread — session key fix (#5833)
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestSessionKeyFix:
|
||||||
|
"""Test that _has_active_session_for_thread uses build_session_key."""
|
||||||
|
|
||||||
|
def test_uses_build_session_key(self):
|
||||||
|
"""Verify the fix uses build_session_key instead of manual key construction."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
|
||||||
|
# Mock session store with a known entry
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store._entries = {
|
||||||
|
"agent:main:slack:group:C1:1000.0": MagicMock()
|
||||||
|
}
|
||||||
|
mock_store._ensure_loaded = MagicMock()
|
||||||
|
mock_store.config = MagicMock()
|
||||||
|
mock_store.config.group_sessions_per_user = False # threads don't include user_id
|
||||||
|
mock_store.config.thread_sessions_per_user = False
|
||||||
|
adapter._session_store = mock_store
|
||||||
|
|
||||||
|
# With the fix, build_session_key should be called which respects
|
||||||
|
# group_sessions_per_user=False (no user_id appended)
|
||||||
|
result = adapter._has_active_session_for_thread(
|
||||||
|
channel_id="C1", thread_ts="1000.0", user_id="U123"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should find the session because build_session_key with
|
||||||
|
# group_sessions_per_user=False doesn't append user_id
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_no_session_returns_false(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_store = MagicMock()
|
||||||
|
mock_store._entries = {}
|
||||||
|
mock_store._ensure_loaded = MagicMock()
|
||||||
|
mock_store.config = MagicMock()
|
||||||
|
mock_store.config.group_sessions_per_user = True
|
||||||
|
mock_store.config.thread_sessions_per_user = False
|
||||||
|
adapter._session_store = mock_store
|
||||||
|
|
||||||
|
result = adapter._has_active_session_for_thread(
|
||||||
|
channel_id="C1", thread_ts="1000.0", user_id="U123"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_no_session_store(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
# No _session_store attribute
|
||||||
|
result = adapter._has_active_session_for_thread(
|
||||||
|
channel_id="C1", thread_ts="1000.0", user_id="U123"
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
284
tests/gateway/test_telegram_approval_buttons.py
Normal file
284
tests/gateway/test_telegram_approval_buttons.py
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
"""Tests for Telegram inline keyboard approval buttons."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Ensure the repo root is importable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_repo = str(Path(__file__).resolve().parents[2])
|
||||||
|
if _repo not in sys.path:
|
||||||
|
sys.path.insert(0, _repo)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Minimal Telegram mock so TelegramAdapter can be imported
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def _ensure_telegram_mock():
|
||||||
|
"""Wire up the minimal mocks required to import TelegramAdapter."""
|
||||||
|
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
|
||||||
|
return
|
||||||
|
|
||||||
|
mod = MagicMock()
|
||||||
|
mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
|
||||||
|
mod.constants.ParseMode.MARKDOWN = "Markdown"
|
||||||
|
mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
|
||||||
|
mod.constants.ParseMode.HTML = "HTML"
|
||||||
|
mod.constants.ChatType.PRIVATE = "private"
|
||||||
|
mod.constants.ChatType.GROUP = "group"
|
||||||
|
mod.constants.ChatType.SUPERGROUP = "supergroup"
|
||||||
|
mod.constants.ChatType.CHANNEL = "channel"
|
||||||
|
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request", "telegram.error"):
|
||||||
|
sys.modules.setdefault(name, mod)
|
||||||
|
|
||||||
|
|
||||||
|
_ensure_telegram_mock()
|
||||||
|
|
||||||
|
from gateway.platforms.telegram import TelegramAdapter
|
||||||
|
from gateway.config import Platform, PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter():
|
||||||
|
"""Create a TelegramAdapter with mocked internals."""
|
||||||
|
config = PlatformConfig(enabled=True, token="test-token")
|
||||||
|
adapter = TelegramAdapter(config)
|
||||||
|
adapter._bot = AsyncMock()
|
||||||
|
adapter._app = MagicMock()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# send_exec_approval — inline keyboard buttons
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestTelegramExecApproval:
|
||||||
|
"""Test the send_exec_approval method sends InlineKeyboard buttons."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_inline_keyboard(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_msg = MagicMock()
|
||||||
|
mock_msg.message_id = 42
|
||||||
|
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
|
||||||
|
|
||||||
|
result = await adapter.send_exec_approval(
|
||||||
|
chat_id="12345",
|
||||||
|
command="rm -rf /important",
|
||||||
|
session_key="agent:main:telegram:group:12345:99",
|
||||||
|
description="dangerous deletion",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
assert result.message_id == "42"
|
||||||
|
|
||||||
|
adapter._bot.send_message.assert_called_once()
|
||||||
|
kwargs = adapter._bot.send_message.call_args[1]
|
||||||
|
assert kwargs["chat_id"] == 12345
|
||||||
|
assert "rm -rf /important" in kwargs["text"]
|
||||||
|
assert "dangerous deletion" in kwargs["text"]
|
||||||
|
assert kwargs["reply_markup"] is not None # InlineKeyboardMarkup
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stores_approval_state(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_msg = MagicMock()
|
||||||
|
mock_msg.message_id = 42
|
||||||
|
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
|
||||||
|
|
||||||
|
await adapter.send_exec_approval(
|
||||||
|
chat_id="12345",
|
||||||
|
command="echo test",
|
||||||
|
session_key="my-session-key",
|
||||||
|
)
|
||||||
|
|
||||||
|
# The approval_id should map to the session_key
|
||||||
|
assert len(adapter._approval_state) == 1
|
||||||
|
approval_id = list(adapter._approval_state.keys())[0]
|
||||||
|
assert adapter._approval_state[approval_id] == "my-session-key"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sends_in_thread(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_msg = MagicMock()
|
||||||
|
mock_msg.message_id = 42
|
||||||
|
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
|
||||||
|
|
||||||
|
await adapter.send_exec_approval(
|
||||||
|
chat_id="12345",
|
||||||
|
command="ls",
|
||||||
|
session_key="s",
|
||||||
|
metadata={"thread_id": "999"},
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = adapter._bot.send_message.call_args[1]
|
||||||
|
assert kwargs.get("message_thread_id") == 999
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_not_connected(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._bot = None
|
||||||
|
result = await adapter.send_exec_approval(
|
||||||
|
chat_id="12345", command="ls", session_key="s"
|
||||||
|
)
|
||||||
|
assert result.success is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_truncates_long_command(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
mock_msg = MagicMock()
|
||||||
|
mock_msg.message_id = 1
|
||||||
|
adapter._bot.send_message = AsyncMock(return_value=mock_msg)
|
||||||
|
|
||||||
|
long_cmd = "x" * 5000
|
||||||
|
await adapter.send_exec_approval(
|
||||||
|
chat_id="12345", command=long_cmd, session_key="s"
|
||||||
|
)
|
||||||
|
|
||||||
|
kwargs = adapter._bot.send_message.call_args[1]
|
||||||
|
assert "..." in kwargs["text"]
|
||||||
|
assert len(kwargs["text"]) < 5000
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# _handle_callback_query — approval button clicks
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class TestTelegramApprovalCallback:
|
||||||
|
"""Test the approval callback handling in _handle_callback_query."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_resolves_approval_on_click(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
# Set up approval state
|
||||||
|
adapter._approval_state[1] = "agent:main:telegram:group:12345:99"
|
||||||
|
|
||||||
|
# Mock callback query
|
||||||
|
query = AsyncMock()
|
||||||
|
query.data = "ea:once:1"
|
||||||
|
query.message = MagicMock()
|
||||||
|
query.message.chat_id = 12345
|
||||||
|
query.from_user = MagicMock()
|
||||||
|
query.from_user.first_name = "Norbert"
|
||||||
|
query.answer = AsyncMock()
|
||||||
|
query.edit_message_text = AsyncMock()
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.callback_query = query
|
||||||
|
context = MagicMock()
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||||
|
await adapter._handle_callback_query(update, context)
|
||||||
|
|
||||||
|
mock_resolve.assert_called_once_with("agent:main:telegram:group:12345:99", "once")
|
||||||
|
query.answer.assert_called_once()
|
||||||
|
query.edit_message_text.assert_called_once()
|
||||||
|
|
||||||
|
# State should be cleaned up
|
||||||
|
assert 1 not in adapter._approval_state
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deny_button(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
adapter._approval_state[2] = "some-session"
|
||||||
|
|
||||||
|
query = AsyncMock()
|
||||||
|
query.data = "ea:deny:2"
|
||||||
|
query.message = MagicMock()
|
||||||
|
query.message.chat_id = 12345
|
||||||
|
query.from_user = MagicMock()
|
||||||
|
query.from_user.first_name = "Alice"
|
||||||
|
query.answer = AsyncMock()
|
||||||
|
query.edit_message_text = AsyncMock()
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.callback_query = query
|
||||||
|
context = MagicMock()
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||||
|
await adapter._handle_callback_query(update, context)
|
||||||
|
|
||||||
|
mock_resolve.assert_called_once_with("some-session", "deny")
|
||||||
|
edit_kwargs = query.edit_message_text.call_args[1]
|
||||||
|
assert "Denied" in edit_kwargs["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_already_resolved(self):
|
||||||
|
adapter = _make_adapter()
|
||||||
|
# No state for approval_id 99 — already resolved
|
||||||
|
|
||||||
|
query = AsyncMock()
|
||||||
|
query.data = "ea:once:99"
|
||||||
|
query.message = MagicMock()
|
||||||
|
query.message.chat_id = 12345
|
||||||
|
query.from_user = MagicMock()
|
||||||
|
query.from_user.first_name = "Bob"
|
||||||
|
query.answer = AsyncMock()
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.callback_query = query
|
||||||
|
context = MagicMock()
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||||
|
await adapter._handle_callback_query(update, context)
|
||||||
|
|
||||||
|
# Should NOT resolve — already handled
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
|
# Should still ack with "already resolved" message
|
||||||
|
query.answer.assert_called_once()
|
||||||
|
assert "already been resolved" in query.answer.call_args[1]["text"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_model_picker_callback_not_affected(self):
|
||||||
|
"""Ensure model picker callbacks still route correctly."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
|
||||||
|
query = AsyncMock()
|
||||||
|
query.data = "mp:some_provider"
|
||||||
|
query.message = MagicMock()
|
||||||
|
query.message.chat_id = 12345
|
||||||
|
query.from_user = MagicMock()
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.callback_query = query
|
||||||
|
context = MagicMock()
|
||||||
|
|
||||||
|
# Model picker callback should be handled (not crash)
|
||||||
|
# We just verify it doesn't try to resolve an approval
|
||||||
|
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||||
|
with patch.object(adapter, "_handle_model_picker_callback", new_callable=AsyncMock):
|
||||||
|
await adapter._handle_callback_query(update, context)
|
||||||
|
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_prompt_callback_not_affected(self):
|
||||||
|
"""Ensure update prompt callbacks still work."""
|
||||||
|
adapter = _make_adapter()
|
||||||
|
|
||||||
|
query = AsyncMock()
|
||||||
|
query.data = "update_prompt:y"
|
||||||
|
query.message = MagicMock()
|
||||||
|
query.message.chat_id = 12345
|
||||||
|
query.from_user = MagicMock()
|
||||||
|
query.from_user.id = 123
|
||||||
|
query.answer = AsyncMock()
|
||||||
|
query.edit_message_text = AsyncMock()
|
||||||
|
|
||||||
|
update = MagicMock()
|
||||||
|
update.callback_query = query
|
||||||
|
context = MagicMock()
|
||||||
|
|
||||||
|
with patch("tools.approval.resolve_gateway_approval") as mock_resolve:
|
||||||
|
with patch("hermes_constants.get_hermes_home", return_value=Path("/tmp/test")):
|
||||||
|
try:
|
||||||
|
await adapter._handle_callback_query(update, context)
|
||||||
|
except Exception:
|
||||||
|
pass # May fail on file write, that's fine
|
||||||
|
|
||||||
|
# Should NOT have triggered approval resolution
|
||||||
|
mock_resolve.assert_not_called()
|
||||||
Loading…
x
Reference in New Issue
Block a user