diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index ec8d2a84b379..2ea4fb4693e1 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -101,6 +101,9 @@ def _build_discord(adapter) -> List[Dict[str, str]]: "name": ch.name, "guild": guild.name, "type": "channel", + "qualified_name": f"{guild.name}/{ch.name}", + "mention": f"<#{ch.id}>", + "target": f"channel:{ch.id}", }) # Also include DM-capable users we've interacted with is not # feasible via guild enumeration; those come from sessions. @@ -190,7 +193,18 @@ def resolve_channel_name(platform_name: str, name: str) -> Optional[str]: if not channels: return None - query = name.lstrip("#").lower() + raw_query = name.strip() + if platform_name == "discord": + if raw_query.startswith("<#") and raw_query.endswith(">"): + mention_id = raw_query[2:-1].strip() + if mention_id.isdigit(): + return mention_id + if raw_query.lower().startswith("channel:"): + channel_id = raw_query.split(":", 1)[1].strip() + if channel_id.isdigit(): + return channel_id + + query = raw_query.lstrip("#").lower() # 1. Exact name match for ch in channels: diff --git a/gateway/command_catalog.py b/gateway/command_catalog.py new file mode 100644 index 000000000000..5b6f6eee87e0 --- /dev/null +++ b/gateway/command_catalog.py @@ -0,0 +1,281 @@ +"""Helpers for long-tail gateway command surfaces. + +This module keeps config/env editing, session export, and value formatting +out of ``gateway.run`` so the command handlers stay readable. +""" + +from __future__ import annotations + +import html +import json +import os +import re +from pathlib import Path +from typing import Any + +import yaml + +from hermes_cli.config import ( + get_config_path, + get_env_path, + get_env_value, + load_config, + save_config, + save_env_value, +) + + +_ENV_LIKE_EXPLICIT_KEYS = { + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "VOICE_TOOLS_OPENAI_KEY", + "PARALLEL_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "TAVILY_API_KEY", + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "BROWSER_USE_API_KEY", + "FAL_KEY", + "TELEGRAM_BOT_TOKEN", + "DISCORD_BOT_TOKEN", + "TERMINAL_SSH_HOST", + "TERMINAL_SSH_USER", + "TERMINAL_SSH_KEY", + "SUDO_PASSWORD", + "SLACK_BOT_TOKEN", + "SLACK_APP_TOKEN", + "GITHUB_TOKEN", + "HONCHO_API_KEY", + "WANDB_API_KEY", + "TINKER_API_KEY", +} +_ENV_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") + + +def is_env_like_key(key: str) -> bool: + """Return True when ``key`` should be treated as an env var path.""" + normalized = str(key or "").strip().upper() + if not normalized: + return False + if normalized in _ENV_LIKE_EXPLICIT_KEYS: + return True + if normalized.endswith("_API_KEY") or normalized.endswith("_TOKEN"): + return True + if normalized.startswith("TERMINAL_SSH"): + return True + return bool(_ENV_NAME_RE.match(normalized)) + + +def parse_value_text(raw: str) -> Any: + """Parse a user value using YAML semantics, falling back to a string.""" + text = str(raw or "").strip() + if not text: + return "" + try: + value = yaml.safe_load(text) + except Exception: + return text + return text if value is None else value + + +def load_raw_user_config() -> dict[str, Any]: + """Load the on-disk user config without default merging.""" + config_path = get_config_path() + if not config_path.exists(): + return {} + try: + with open(config_path, encoding="utf-8") as handle: + return yaml.safe_load(handle) or {} + except Exception: + return {} + + +def save_raw_user_config(config: dict[str, Any]) -> Path: + """Persist the raw user config to the canonical config path.""" + save_config(config) + return get_config_path() + + +def get_nested_value(mapping: dict[str, Any], path: str) -> Any: + """Resolve a dotted path from a nested dict.""" + current: Any = mapping + for part in str(path or "").split("."): + if not part: + continue + if not isinstance(current, dict) or part not in current: + raise KeyError(path) + current = current[part] + return current + + +def set_nested_value(mapping: dict[str, Any], path: str, value: Any) -> Any: + """Set a dotted path in a nested dict, creating parent dicts as needed.""" + parts = [part for part in str(path or "").split(".") if part] + if not parts: + raise KeyError(path) + + current = mapping + for part in parts[:-1]: + next_value = current.get(part) + if not isinstance(next_value, dict): + next_value = {} + current[part] = next_value + current = next_value + current[parts[-1]] = value + return value + + +def unset_nested_value(mapping: dict[str, Any], path: str) -> bool: + """Delete a dotted path from a nested dict. Returns True if removed.""" + parts = [part for part in str(path or "").split(".") if part] + if not parts: + return False + current = mapping + parents: list[tuple[dict[str, Any], str]] = [] + for part in parts[:-1]: + if not isinstance(current, dict) or part not in current or not isinstance(current[part], dict): + return False + parents.append((current, part)) + current = current[part] + if not isinstance(current, dict) or parts[-1] not in current: + return False + current.pop(parts[-1], None) + for parent, key in reversed(parents): + child = parent.get(key) + if isinstance(child, dict) and not child: + parent.pop(key, None) + else: + break + return True + + +def read_config_or_env_value(key: str) -> tuple[str, Any]: + """Return (kind, value) where kind is ``env`` or ``config``.""" + if is_env_like_key(key): + return "env", get_env_value(key.upper()) + return "config", get_nested_value(load_raw_user_config(), key) + + +def write_config_or_env_value(key: str, raw_value: str) -> tuple[str, Any, Path]: + """Write a config or env value and return (kind, stored_value, path).""" + if is_env_like_key(key): + normalized = key.upper() + save_env_value(normalized, str(raw_value)) + return "env", str(raw_value), get_env_path() + + config = load_raw_user_config() + value = parse_value_text(raw_value) + set_nested_value(config, key, value) + path = save_raw_user_config(config) + return "config", value, path + + +def unset_config_or_env_value(key: str) -> tuple[str, bool, Path]: + """Unset a config or env value and return (kind, removed, path).""" + if is_env_like_key(key): + return "env", unset_env_key(key.upper()), get_env_path() + + config = load_raw_user_config() + removed = unset_nested_value(config, key) + if removed: + save_raw_user_config(config) + return "config", removed, get_config_path() + + +def unset_env_key(key: str) -> bool: + """Remove an environment variable from ``~/.hermes/.env`` and ``os.environ``.""" + env_path = get_env_path() + if not env_path.exists(): + os.environ.pop(key, None) + return False + + with open(env_path, encoding="utf-8") as handle: + lines = handle.readlines() + kept = [ + line + for line in lines + if not line.strip().startswith(f"{key}=") + ] + if kept == lines: + os.environ.pop(key, None) + return False + + with open(env_path, "w", encoding="utf-8") as handle: + handle.writelines(kept) + os.environ.pop(key, None) + return True + + +def format_yaml_block(value: Any) -> str: + """Render ``value`` as a YAML code block.""" + rendered = yaml.safe_dump(value, sort_keys=False, allow_unicode=True).strip() + return f"```yaml\n{rendered or 'null'}\n```" + + +def default_export_path(session_id: str, cwd: str | Path | None, suffix: str = ".html") -> Path: + """Build the default export path inside ``cwd``.""" + base_dir = Path(cwd or os.getcwd()) + export_dir = base_dir / ".hermes-exports" + export_dir.mkdir(parents=True, exist_ok=True) + safe_session_id = re.sub(r"[^A-Za-z0-9_.-]+", "-", session_id or "session").strip("-") or "session" + return export_dir / f"{safe_session_id}{suffix}" + + +def resolve_export_path(raw_path: str, cwd: str | Path | None, *, session_id: str) -> Path: + """Resolve a user path relative to ``cwd`` or fall back to a default export path.""" + candidate = str(raw_path or "").strip() + if not candidate: + return default_export_path(session_id, cwd, ".html") + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = Path(cwd or os.getcwd()) / path + path.parent.mkdir(parents=True, exist_ok=True) + return path + + +def build_session_export_html(snapshot: dict[str, Any]) -> str: + """Render an HTML session export with metadata, prompt, and transcript.""" + meta = snapshot.get("session") or {} + prompt = snapshot.get("context_prompt") or "" + transcript = snapshot.get("messages") or [] + + message_rows: list[str] = [] + for message in transcript: + role = html.escape(str(message.get("role") or "unknown")) + content = html.escape(str(message.get("content") or "")) + message_rows.append( + "
" + f"

{role}

" + f"
{content}
" + "
" + ) + + meta_json = html.escape(json.dumps(meta, ensure_ascii=False, indent=2)) + prompt_html = html.escape(prompt) + return f""" + + + + Hermes Session Export + + + +

Hermes Session Export

+

Session Metadata

+
{meta_json}
+

Current Session Context Prompt

+
{prompt_html}
+

Transcript

+ {''.join(message_rows) or '

No transcript messages available.

'} + + +""" + diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index a7a809bbcfb7..6b1ba343ade9 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -298,6 +298,7 @@ class MessageEvent: # Timestamps timestamp: datetime = field(default_factory=datetime.now) + metadata: Dict[str, Any] = field(default_factory=dict) def is_command(self) -> bool: """Check if this is a command message (e.g., /new, /reset).""" @@ -1160,6 +1161,7 @@ def build_source( chat_topic: Optional[str] = None, user_id_alt: Optional[str] = None, chat_id_alt: Optional[str] = None, + session_namespace: Optional[str] = None, ) -> SessionSource: """Helper to build a SessionSource for this platform.""" # Normalize empty topic to None @@ -1176,6 +1178,7 @@ def build_source( chat_topic=chat_topic.strip() if chat_topic else None, user_id_alt=user_id_alt, chat_id_alt=chat_id_alt, + session_namespace=session_namespace, ) @abstractmethod diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index af36d568241b..b95825c0372c 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -19,7 +19,8 @@ import threading import time from collections import defaultdict -from pathlib import Path +from dataclasses import asdict +from datetime import datetime from typing import Callable, Dict, List, Optional, Any logger = logging.getLogger(__name__) @@ -51,23 +52,17 @@ cache_image_from_url, cache_audio_from_url, ) - - -def _clean_discord_id(entry: str) -> str: - """Strip common prefixes from a Discord user ID or username entry. - - Users sometimes paste IDs with prefixes like ``user:123``, ``<@123>``, - or ``<@!123>`` from Discord's UI or other tools. This normalises the - entry to just the bare ID or username. - """ - entry = entry.strip() - # Strip Discord mention syntax: <@123> or <@!123> - if entry.startswith("<@") and entry.endswith(">"): - entry = entry.lstrip("<@!").rstrip(">") - # Strip "user:" prefix (seen in some Discord tools / onboarding pastes) - if entry.lower().startswith("user:"): - entry = entry[5:] - return entry.strip() +from gateway.platforms.discord_impl import config as discord_config +from gateway.platforms.discord_impl import delivery as discord_delivery +from gateway.platforms.discord_impl import history as discord_history +from gateway.platforms.discord_impl import intake as discord_intake +from gateway.platforms.discord_impl import interactions as discord_interactions +from gateway.platforms.discord_impl import messaging as discord_messaging +from gateway.platforms.discord_impl import native_commands as discord_native_commands +from gateway.platforms.discord_impl import permissions as discord_permissions +from gateway.platforms.discord_impl import runtime_state as discord_runtime_state +from gateway.platforms.discord_impl import state as discord_state +from gateway.platforms.discord_impl import threads as discord_threads def check_discord_requirements() -> bool: @@ -425,7 +420,11 @@ def __init__(self, config: PlatformConfig): super().__init__(config, Platform.DISCORD) self._client: Optional[commands.Bot] = None self._ready_event = asyncio.Event() + self._discord_policy: Optional[discord_config.DiscordPolicyConfig] = None + self._discord_policy_overrides: Dict[str, Any] = {} + self._component_runtime = discord_interactions.create_component_runtime() self._allowed_user_ids: set = set() # For button approval authorization + self._resolve_exec_approval: Optional[Callable[..., Any]] = None # Voice channel state (per-guild) self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id @@ -439,8 +438,29 @@ def __init__(self, config: PlatformConfig): # in those threads don't require @mention. Persisted to disk so the # set survives gateway restarts. self._bot_participated_threads: set = self._load_participated_threads() + self._thread_bindings, self._activation_overrides = self._load_runtime_state() # Cap to prevent unbounded growth (Discord threads get archived). self._MAX_TRACKED_THREADS = 500 + + def _get_discord_policy(self) -> discord_config.DiscordPolicyConfig: + """Return the adapter's cached Discord policy snapshot.""" + if self._discord_policy is None: + self._discord_policy = discord_config.load_policy_config( + self.config, + overrides=self._discord_policy_overrides, + ) + return self._discord_policy + + def apply_runtime_policy_overrides( + self, + overrides: Optional[Dict[str, Any]] = None, + ) -> discord_config.DiscordPolicyConfig: + """Apply runtime-only policy overrides for the active Discord connection.""" + self._discord_policy_overrides = dict(overrides or {}) + self._discord_policy = None + policy = self._get_discord_policy() + self._allowed_user_ids = set(policy.allowed_users) + return policy async def connect(self) -> bool: """Connect to Discord and start receiving events.""" @@ -493,12 +513,7 @@ async def connect(self) -> bool: ) # Parse allowed user entries (may contain usernames or IDs) - allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "") - if allowed_env: - self._allowed_user_ids = { - _clean_discord_id(uid) for uid in allowed_env.split(",") - if uid.strip() - } + self._allowed_user_ids = set(self._get_discord_policy().allowed_users) adapter_self = self # capture for closure @@ -528,14 +543,14 @@ async def on_message(message: DiscordMessage): # "none" — ignore all other bots (default) # "mentions" — accept bot messages only when they @mention us # "all" — accept all bot messages - if getattr(message.author, "bot", False): - allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip() - if allow_bots == "none": - return - elif allow_bots == "mentions": - if not self._client.user or self._client.user not in message.mentions: - return - # "all" falls through to handle_message + is_mentioned = bool(self._client.user and self._client.user in message.mentions) + policy = adapter_self._get_discord_policy() + if discord_intake.should_filter_bot_message( + is_bot=getattr(message.author, "bot", False), + policy=policy.bot_filter_policy, + is_mentioned=is_mentioned, + ): + return await self._handle_message(message) @@ -623,61 +638,37 @@ async def send( return SendResult(success=False, error="Not connected") try: - # Get the channel - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - + channel = await discord_delivery.resolve_channel(self._client, chat_id) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - - # Format and split message if needed + formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) - message_ids = [] reference = None - + if reply_to: try: ref_msg = await channel.fetch_message(int(reply_to)) reference = ref_msg except Exception as e: logger.debug("Could not fetch reply-to message: %s", e) - + for i, chunk in enumerate(chunks): chunk_reference = reference if i == 0 else None - try: - msg = await channel.send( - content=chunk, - reference=chunk_reference, - ) - except Exception as e: - err_text = str(e) - if ( - chunk_reference is not None - and "error code: 50035" in err_text - and "Cannot reply to a system message" in err_text - ): - logger.warning( - "[%s] Reply target %s is a Discord system message; retrying send without reply reference", - self.name, - reply_to, - ) - msg = await channel.send( - content=chunk, - reference=None, - ) - else: - raise + msg = await discord_delivery.send_text_message( + channel, + chunk, + reference=chunk_reference, + ) message_ids.append(str(msg.id)) - + return SendResult( success=True, message_id=message_ids[0] if message_ids else None, raw_response={"message_ids": message_ids} ) - + except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to send Discord message: %s", self.name, e, exc_info=True) return SendResult(success=False, error=str(e)) @@ -689,21 +680,86 @@ async def edit_message( content: str, ) -> SendResult: """Edit a previously sent Discord message.""" - if not self._client: - return SendResult(success=False, error="Not connected") - try: - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - msg = await channel.fetch_message(int(message_id)) - formatted = self.format_message(content) - if len(formatted) > self.MAX_MESSAGE_LENGTH: - formatted = formatted[:self.MAX_MESSAGE_LENGTH - 3] + "..." - await msg.edit(content=formatted) - return SendResult(success=True, message_id=message_id) - except Exception as e: # pragma: no cover - defensive logging - logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True) - return SendResult(success=False, error=str(e)) + result = await discord_messaging.edit_message( + self._client, + chat_id, + message_id, + content, + format_message=self.format_message, + max_message_length=self.MAX_MESSAGE_LENGTH, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to edit Discord message %s: %s", + self.name, + message_id, + result.error, + ) + return result + + async def delete_message( + self, + chat_id: str, + message_id: str, + ) -> SendResult: + """Delete a Discord message.""" + result = await discord_messaging.delete_message( + self._client, + chat_id, + message_id, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to delete Discord message %s: %s", + self.name, + message_id, + result.error, + ) + return result + + async def add_reaction( + self, + chat_id: str, + message_id: str, + emoji: Any, + ) -> SendResult: + """Add a reaction to a Discord message.""" + result = await discord_messaging.add_reaction( + self._client, + chat_id, + message_id, + emoji, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to add reaction to Discord message %s: %s", + self.name, + message_id, + result.error, + ) + return result + + async def remove_reaction( + self, + chat_id: str, + message_id: str, + emoji: Any, + ) -> SendResult: + """Remove the connected bot's reaction from a Discord message.""" + result = await discord_messaging.remove_reaction( + self._client, + chat_id, + message_id, + emoji, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to remove reaction from Discord message %s: %s", + self.name, + message_id, + result.error, + ) + return result async def _send_file_attachment( self, @@ -716,15 +772,17 @@ async def _send_file_attachment( if not self._client: return SendResult(success=False, error="Not connected") - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + channel = await discord_delivery.resolve_channel(self._client, chat_id) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") + discord_mod = sys.modules.get("discord", discord) + if discord_mod is None: # pragma: no cover - import guard + return SendResult(success=False, error="discord.py not installed") + filename = file_name or os.path.basename(file_path) with open(file_path, "rb") as fh: - file = discord.File(fh, filename=filename) + file = discord_mod.File(fh, filename=filename) msg = await channel.send(content=caption if caption else None, file=file) return SendResult(success=True, message_id=str(msg.id)) @@ -1152,10 +1210,8 @@ async def send_image( try: import aiohttp - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + + channel = await discord_delivery.resolve_channel(self._client, chat_id) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") @@ -1242,7 +1298,7 @@ async def send_typing(self, chat_id: str, metadata=None) -> None: """Send typing indicator.""" if self._client: try: - channel = self._client.get_channel(int(chat_id)) + channel = await discord_delivery.resolve_channel(self._client, chat_id) if channel: await channel.typing() except Exception: @@ -1286,6 +1342,184 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to get chat info for %s: %s", self.name, chat_id, e, exc_info=True) return {"name": str(chat_id), "type": "dm", "error": str(e)} + + async def fetch_channel_history( + self, + channel_id: str, + limit: int = 100, + before: Optional[str] = None, + after: Optional[str] = None, + ) -> list: + """Fetch bounded message history.""" + if not self._client: + return [] + messages = await discord_history.fetch_history( + self._client, + channel_id, + limit=limit, + before=before, + after=after, + ) + return [asdict(message) for message in messages] + + async def list_threads( + self, + channel_id: str, + include_archived: bool = False, + limit: int = 100, + before: Optional[Any] = None, + private: bool = False, + joined: bool = False, + ) -> list: + """List active and optionally archived threads for a channel.""" + if not self._client: + return [] + return await discord_messaging.list_threads( + self._client, + channel_id, + include_archived=include_archived, + limit=limit, + before=before, + private=private, + joined=joined, + ) + + async def reply_in_thread( + self, + thread_id: str, + content: str, + reply_to: Optional[str] = None, + ) -> SendResult: + """Send a message to a Discord thread.""" + result = await discord_messaging.reply_in_thread( + self._client, + thread_id, + content, + reply_to=reply_to, + format_message=self.format_message, + truncate_message=self.truncate_message, + max_message_length=self.MAX_MESSAGE_LENGTH, + send_text_message=discord_delivery.send_text_message, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to reply in Discord thread %s: %s", + self.name, + thread_id, + result.error, + ) + return result + + async def search_channel_history( + self, + channel_id: str, + query: str, + limit: int = 50, + author_id: Optional[str] = None, + ) -> list: + """Search channel history.""" + if not self._client: + return [] + messages = await discord_history.search_history( + self._client, + channel_id, + query, + limit=limit, + author_id=author_id, + ) + return [asdict(message) for message in messages] + + async def get_channel_permissions(self, channel_id: str) -> Optional[dict]: + """Get bot permissions for a channel.""" + if not self._client: + return None + permissions = await discord_permissions.check_channel_permissions(self._client, channel_id) + return asdict(permissions) if permissions else None + + async def list_pins( + self, + channel_id: str, + limit: int = 50, + before: Optional[Any] = None, + oldest_first: bool = False, + ) -> list: + """List pinned messages for a Discord channel or thread.""" + if not self._client: + return [] + return await discord_messaging.list_pins( + self._client, + channel_id, + limit=limit, + before=before, + oldest_first=oldest_first, + ) + + async def list_reactions( + self, + chat_id: str, + message_id: str, + limit: int = 100, + ) -> list: + """List reactions for a Discord message.""" + if not self._client: + return [] + return await discord_messaging.list_reactions( + self._client, + chat_id, + message_id, + limit=limit, + ) + + async def pin_message( + self, + chat_id: str, + message_id: str, + reason: Optional[str] = None, + ) -> SendResult: + """Pin a Discord message.""" + result = await discord_messaging.pin_message( + self._client, + chat_id, + message_id, + reason=reason, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to pin Discord message %s: %s", + self.name, + message_id, + result.error, + ) + return result + + async def unpin_message( + self, + chat_id: str, + message_id: str, + reason: Optional[str] = None, + ) -> SendResult: + """Unpin a Discord message.""" + result = await discord_messaging.unpin_message( + self._client, + chat_id, + message_id, + reason=reason, + ) + if not result.success and result.error: + logger.error( + "[%s] Failed to unpin Discord message %s: %s", + self.name, + message_id, + result.error, + ) + return result + + async def get_accessible_channels(self, guild_id: Optional[str] = None) -> list: + """List accessible channels.""" + if not self._client: + return [] + channels = await discord_permissions.list_accessible_channels(self._client, guild_id=guild_id) + return [asdict(channel) for channel in channels] async def _resolve_allowed_usernames(self) -> None: """ @@ -1368,164 +1602,63 @@ async def _run_simple_slash( ) -> None: """Common handler for simple slash commands that dispatch a command string.""" await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, command_text) - await self.handle_message(event) + response = await self._invoke_native_slash_command(interaction, command_text) + if response: + await self._send_native_slash_content(interaction, response) + return if followup_msg: try: await interaction.followup.send(followup_msg, ephemeral=True) except Exception as e: logger.debug("Discord followup failed: %s", e) + async def _invoke_native_slash_command( + self, + interaction: discord.Interaction, + command_text: str, + ) -> str | None: + """Run a native slash command directly through the gateway handler.""" + if not self._message_handler: + return None + event = self._build_slash_event(interaction, command_text) + return await self._message_handler(event) + + async def _send_native_slash_content( + self, + interaction: discord.Interaction, + content: str, + ) -> None: + """Send a native slash response back through the Discord interaction.""" + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + response = getattr(interaction, "response", None) + followup = getattr(interaction, "followup", None) + + first_chunk_sent = False + if response is not None and hasattr(response, "send_message"): + is_done = getattr(response, "is_done", None) + if not callable(is_done) or not is_done(): + await response.send_message(chunks[0], ephemeral=True) + first_chunk_sent = True + + start_index = 1 if first_chunk_sent else 0 + if followup is not None and hasattr(followup, "send"): + for chunk in chunks[start_index:]: + await followup.send(chunk, ephemeral=True) + return + + if not first_chunk_sent and response is not None and hasattr(response, "send_message"): + await response.send_message(chunks[0], ephemeral=True) + def _register_slash_commands(self) -> None: """Register Discord slash commands on the command tree.""" if not self._client: return - - tree = self._client.tree - - @tree.command(name="new", description="Start a new conversation") - async def slash_new(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/reset", "New conversation started~") - - @tree.command(name="reset", description="Reset your Hermes session") - async def slash_reset(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/reset", "Session reset~") - - @tree.command(name="model", description="Show or change the model") - @discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.") - async def slash_model(interaction: discord.Interaction, name: str = ""): - await self._run_simple_slash(interaction, f"/model {name}".strip()) - - @tree.command(name="reasoning", description="Show or change reasoning effort") - @discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.") - async def slash_reasoning(interaction: discord.Interaction, effort: str = ""): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/reasoning {effort}".strip()) - await self.handle_message(event) - - @tree.command(name="personality", description="Set a personality") - @discord.app_commands.describe(name="Personality name. Leave empty to list available.") - async def slash_personality(interaction: discord.Interaction, name: str = ""): - await self._run_simple_slash(interaction, f"/personality {name}".strip()) - - @tree.command(name="retry", description="Retry your last message") - async def slash_retry(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/retry", "Retrying~") - - @tree.command(name="undo", description="Remove the last exchange") - async def slash_undo(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/undo") - - @tree.command(name="status", description="Show Hermes session status") - async def slash_status(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/status", "Status sent~") - - @tree.command(name="sethome", description="Set this chat as the home channel") - async def slash_sethome(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/sethome") - - @tree.command(name="stop", description="Stop the running Hermes agent") - async def slash_stop(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/stop", "Stop requested~") - - @tree.command(name="compress", description="Compress conversation context") - async def slash_compress(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/compress") - - @tree.command(name="title", description="Set or show the session title") - @discord.app_commands.describe(name="Session title. Leave empty to show current.") - async def slash_title(interaction: discord.Interaction, name: str = ""): - await self._run_simple_slash(interaction, f"/title {name}".strip()) - - @tree.command(name="resume", description="Resume a previously-named session") - @discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.") - async def slash_resume(interaction: discord.Interaction, name: str = ""): - await self._run_simple_slash(interaction, f"/resume {name}".strip()) - - @tree.command(name="usage", description="Show token usage for this session") - async def slash_usage(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/usage") - - @tree.command(name="provider", description="Show available providers") - async def slash_provider(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/provider") - - @tree.command(name="help", description="Show available commands") - async def slash_help(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/help") - - @tree.command(name="insights", description="Show usage insights and analytics") - @discord.app_commands.describe(days="Number of days to analyze (default: 7)") - async def slash_insights(interaction: discord.Interaction, days: int = 7): - await self._run_simple_slash(interaction, f"/insights {days}") - - @tree.command(name="reload-mcp", description="Reload MCP servers from config") - async def slash_reload_mcp(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/reload-mcp") - - @tree.command(name="voice", description="Toggle voice reply mode") - @discord.app_commands.describe(mode="Voice mode: on, off, tts, channel, leave, or status") - @discord.app_commands.choices(mode=[ - discord.app_commands.Choice(name="channel — join your voice channel", value="channel"), - discord.app_commands.Choice(name="leave — leave voice channel", value="leave"), - discord.app_commands.Choice(name="on — voice reply to voice messages", value="on"), - discord.app_commands.Choice(name="tts — voice reply to all messages", value="tts"), - discord.app_commands.Choice(name="off — text only", value="off"), - discord.app_commands.Choice(name="status — show current mode", value="status"), - ]) - async def slash_voice(interaction: discord.Interaction, mode: str = ""): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/voice {mode}".strip()) - await self.handle_message(event) - - @tree.command(name="update", description="Update Hermes Agent to the latest version") - async def slash_update(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/update", "Update initiated~") - - @tree.command(name="thread", description="Create a new thread and start a Hermes session in it") - @discord.app_commands.describe( - name="Thread name", - message="Optional first message to send to Hermes in the thread", - auto_archive_duration="Auto-archive in minutes (60, 1440, 4320, 10080)", - ) - async def slash_thread( - interaction: discord.Interaction, - name: str, - message: str = "", - auto_archive_duration: int = 1440, - ): - await interaction.response.defer(ephemeral=True) - await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration) + discord_interactions.register_slash_commands(self._client.tree, self) def _build_slash_event(self, interaction: discord.Interaction, text: str) -> MessageEvent: """Build a MessageEvent from a Discord slash command interaction.""" - is_dm = isinstance(interaction.channel, discord.DMChannel) - chat_type = "dm" if is_dm else "group" - chat_name = "" - if not is_dm and hasattr(interaction.channel, "name"): - chat_name = interaction.channel.name - if hasattr(interaction.channel, "guild") and interaction.channel.guild: - chat_name = f"{interaction.channel.guild.name} / #{chat_name}" - - # Get channel topic (if available) - chat_topic = getattr(interaction.channel, "topic", None) - - source = self.build_source( - chat_id=str(interaction.channel_id), - chat_name=chat_name, - chat_type=chat_type, - user_id=str(interaction.user.id), - user_name=interaction.user.display_name, - chat_topic=chat_topic, - ) - - msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT - return MessageEvent( - text=text, - message_type=msg_type, - source=source, - raw_message=interaction, - ) + return discord_interactions.build_slash_event(self, interaction, text) # ------------------------------------------------------------------ # Thread creation helpers @@ -1539,34 +1672,14 @@ async def _handle_thread_create_slash( auto_archive_duration: int = 1440, ) -> None: """Create a Discord thread from a slash command and start a session in it.""" - result = await self._create_thread( + await discord_threads.handle_thread_create_slash( + self, interaction, - name=name, - message=message, - auto_archive_duration=auto_archive_duration, + name, + message, + auto_archive_duration, ) - if not result.get("success"): - error = result.get("error", "unknown error") - await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True) - return - - thread_id = result.get("thread_id") - thread_name = result.get("thread_name") or name - - # Tell the user where the thread is - link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**" - await interaction.followup.send(f"Created thread {link}", ephemeral=True) - - # Track thread participation so follow-ups don't require @mention - if thread_id: - self._track_thread(thread_id) - - # If a message was provided, kick off a new Hermes session in the thread - starter = (message or "").strip() - if starter and thread_id: - await self._dispatch_thread_session(interaction, thread_id, thread_name, starter) - async def _dispatch_thread_session( self, interaction: discord.Interaction, @@ -1575,50 +1688,21 @@ async def _dispatch_thread_session( text: str, ) -> None: """Build a MessageEvent pointing at a thread and send it through handle_message.""" - guild_name = "" - if hasattr(interaction, "guild") and interaction.guild: - guild_name = interaction.guild.name - - chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name - - source = self.build_source( - chat_id=thread_id, - chat_name=chat_name, - chat_type="thread", - user_id=str(interaction.user.id), - user_name=interaction.user.display_name, - thread_id=thread_id, - ) - - event = MessageEvent( - text=text, - message_type=MessageType.TEXT, - source=source, - raw_message=interaction, + await discord_threads.dispatch_thread_session( + self, + interaction, + thread_id, + thread_name, + text, ) - await self.handle_message(event) def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" - return getattr(channel, "parent", None) or channel + return discord_threads.thread_parent_channel(channel) async def _resolve_interaction_channel(self, interaction: discord.Interaction) -> Optional[Any]: """Return the interaction channel, fetching it if the payload is partial.""" - channel = getattr(interaction, "channel", None) - if channel is not None: - return channel - if not self._client: - return None - channel_id = getattr(interaction, "channel_id", None) - if channel_id is None: - return None - channel = self._client.get_channel(int(channel_id)) - if channel is not None: - return channel - try: - return await self._client.fetch_channel(int(channel_id)) - except Exception: - return None + return await discord_threads.resolve_interaction_channel(self._client, interaction) async def _create_thread( self, @@ -1634,62 +1718,14 @@ async def _create_thread( that (e.g. permission issues), falls back to sending a seed message and creating the thread from it. """ - name = (name or "").strip() - if not name: - return {"error": "Thread name is required."} - - if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES: - allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES)) - return {"error": f"auto_archive_duration must be one of: {allowed}."} - - channel = await self._resolve_interaction_channel(interaction) - if channel is None: - return {"error": "Could not resolve the current Discord channel."} - if isinstance(channel, discord.DMChannel): - return {"error": "Discord threads can only be created inside server text channels, not DMs."} - - parent_channel = self._thread_parent_channel(channel) - if parent_channel is None: - return {"error": "Could not determine a parent text channel for the new thread."} - - display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user" - reason = f"Requested by {display_name} via /thread" - starter_message = (message or "").strip() - - try: - thread = await parent_channel.create_thread( - name=name, - auto_archive_duration=auto_archive_duration, - reason=reason, - ) - if starter_message: - await thread.send(starter_message) - return { - "success": True, - "thread_id": str(thread.id), - "thread_name": getattr(thread, "name", None) or name, - } - except Exception as direct_error: - try: - seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**" - seed_msg = await parent_channel.send(seed_content) - thread = await seed_msg.create_thread( - name=name, - auto_archive_duration=auto_archive_duration, - reason=reason, - ) - return { - "success": True, - "thread_id": str(thread.id), - "thread_name": getattr(thread, "name", None) or name, - } - except Exception as fallback_error: - return { - "error": ( - "Discord rejected direct thread creation and the fallback also failed. " - f"Direct error: {direct_error}. Fallback error: {fallback_error}" - ) - } + return await discord_threads.create_thread( + self._client, + interaction, + name, + message, + auto_archive_duration, + discord_threads.resolve_interaction_channel, + ) # ------------------------------------------------------------------ # Auto-thread helpers @@ -1700,18 +1736,7 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: Returns the created thread object, or ``None`` on failure. """ - # Build a short thread name from the message - content = (message.content or "").strip() - thread_name = content[:80] if content else "Hermes" - if len(content) > 80: - thread_name = thread_name[:77] + "..." - - try: - thread = await message.create_thread(name=thread_name, auto_archive_duration=1440) - return thread - except Exception as e: - logger.warning("[%s] Auto-thread creation failed: %s", self.name, e) - return None + return await discord_threads.auto_create_thread(message) async def send_exec_approval( self, chat_id: str, command: str, approval_id: str @@ -1725,9 +1750,9 @@ async def send_exec_approval( return SendResult(success=False, error="Not connected") try: - channel = self._client.get_channel(int(chat_id)) + channel = await discord_delivery.resolve_channel(self._client, chat_id) if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + return SendResult(success=False, error=f"Channel {chat_id} not found") # Discord embed description limit is 4096; show full command up to that max_desc = 4088 @@ -1739,12 +1764,16 @@ async def send_exec_approval( ) embed.set_footer(text=f"Approval ID: {approval_id}") - view = ExecApprovalView( + view = discord_interactions.create_exec_approval_view( + self, approval_id=approval_id, allowed_user_ids=self._allowed_user_ids, + runtime=self._component_runtime, ) msg = await channel.send(embed=embed, view=view) + if hasattr(view, "bind_message"): + view.bind_message(str(msg.id)) return SendResult(success=True, message_id=str(msg.id)) except Exception as e: @@ -1752,86 +1781,208 @@ async def send_exec_approval( def _get_parent_channel_id(self, channel: Any) -> Optional[str]: """Return the parent channel ID for a Discord thread-like channel, if present.""" - parent = getattr(channel, "parent", None) - if parent is not None and getattr(parent, "id", None) is not None: - return str(parent.id) - parent_id = getattr(channel, "parent_id", None) - if parent_id is not None: - return str(parent_id) - return None + return discord_intake.get_parent_channel_id(channel) + + @staticmethod + def _runtime_state_path(): + """Path to the persisted Discord runtime controls.""" + return discord_runtime_state.runtime_state_path() + + @classmethod + def _load_runtime_state(cls) -> tuple[dict[str, discord_runtime_state.DiscordThreadBinding], dict[str, str]]: + """Load persisted Discord runtime controls.""" + return discord_runtime_state.load_runtime_state() + + def _save_runtime_state(self) -> None: + """Persist Discord thread bindings and activation overrides.""" + discord_runtime_state.save_runtime_state( + self._thread_bindings, + self._activation_overrides, + ) + + def get_thread_binding(self, thread_id: str) -> Optional[discord_runtime_state.DiscordThreadBinding]: + """Return the persisted focus binding for a thread, if any.""" + return self._thread_bindings.get(str(thread_id)) + + def list_thread_bindings(self, parent_chat_id: Optional[str] = None) -> list[discord_runtime_state.DiscordThreadBinding]: + """Return all thread bindings, optionally scoped to one parent channel.""" + bindings = list(self._thread_bindings.values()) + if parent_chat_id is None: + return sorted(bindings, key=lambda binding: (binding.parent_chat_id or "", binding.chat_name, binding.thread_id)) + target_parent = str(parent_chat_id) + return sorted( + [binding for binding in bindings if str(binding.parent_chat_id or "") == target_parent], + key=lambda binding: (binding.chat_name, binding.thread_id), + ) + + def focus_thread_binding( + self, + *, + thread_id: str, + session_key: str, + chat_name: str, + parent_chat_id: Optional[str], + bound_by: str, + idle_timeout_minutes: Optional[int] = None, + max_age_minutes: Optional[int] = None, + ) -> discord_runtime_state.DiscordThreadBinding: + """Create or update a persisted focus binding for a Discord thread.""" + now = datetime.now().isoformat() + existing = self._thread_bindings.get(str(thread_id)) + binding = discord_runtime_state.DiscordThreadBinding( + thread_id=str(thread_id), + session_key=session_key, + chat_id=str(thread_id), + parent_chat_id=str(parent_chat_id) if parent_chat_id else None, + chat_name=chat_name, + bound_by=bound_by, + bound_at=existing.bound_at if existing and existing.bound_at else now, + last_activity_at=now, + idle_timeout_minutes=( + int(idle_timeout_minutes) + if idle_timeout_minutes is not None + else (existing.idle_timeout_minutes if existing else discord_runtime_state.DEFAULT_THREAD_BINDING_IDLE_MINUTES) + ), + max_age_minutes=( + int(max_age_minutes) + if max_age_minutes is not None + else (existing.max_age_minutes if existing else discord_runtime_state.DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES) + ), + ) + self._thread_bindings[binding.thread_id] = binding + self._track_thread(binding.thread_id) + self._save_runtime_state() + return binding + + def unfocus_thread_binding(self, thread_id: str) -> Optional[discord_runtime_state.DiscordThreadBinding]: + """Remove a persisted focus binding for a thread.""" + binding = self._thread_bindings.pop(str(thread_id), None) + if binding: + self._bot_participated_threads.discard(str(thread_id)) + self._save_participated_threads() + self._save_runtime_state() + return binding + + def update_thread_binding_limits( + self, + thread_id: str, + *, + idle_timeout_minutes: Optional[int] = None, + max_age_minutes: Optional[int] = None, + ) -> Optional[discord_runtime_state.DiscordThreadBinding]: + """Update idle/max-age settings for an existing thread binding.""" + existing = self._thread_bindings.get(str(thread_id)) + if existing is None: + return None + updated = discord_runtime_state.DiscordThreadBinding( + thread_id=existing.thread_id, + session_key=existing.session_key, + chat_id=existing.chat_id, + parent_chat_id=existing.parent_chat_id, + chat_name=existing.chat_name, + bound_by=existing.bound_by, + bound_at=existing.bound_at, + last_activity_at=existing.last_activity_at, + idle_timeout_minutes=( + int(idle_timeout_minutes) + if idle_timeout_minutes is not None + else existing.idle_timeout_minutes + ), + max_age_minutes=( + int(max_age_minutes) + if max_age_minutes is not None + else existing.max_age_minutes + ), + ) + self._thread_bindings[updated.thread_id] = updated + self._save_runtime_state() + return updated + + def touch_thread_binding( + self, + thread_id: str, + *, + now: Optional[datetime] = None, + ) -> Optional[discord_runtime_state.DiscordThreadBinding]: + """Refresh last-activity time for a thread binding.""" + binding = self._thread_bindings.get(str(thread_id)) + if binding is None: + return None + updated = discord_runtime_state.touch_binding(binding, now=now) + self._thread_bindings[updated.thread_id] = updated + self._save_runtime_state() + return updated + + def expire_thread_binding_if_needed( + self, + thread_id: str, + *, + now: Optional[datetime] = None, + ) -> Optional[str]: + """Expire a focused thread binding when idle/max-age thresholds are reached.""" + binding = self._thread_bindings.get(str(thread_id)) + if binding is None: + return None + reason = discord_runtime_state.binding_expiration_reason(binding, now=now) + if reason is None: + return None + self.unfocus_thread_binding(thread_id) + return reason + + def get_activation_mode(self, chat_id: str) -> Optional[str]: + """Return the Discord activation override for a chat, if any.""" + return self._activation_overrides.get(str(chat_id)) + + def set_activation_mode(self, chat_id: str, mode: str) -> Optional[str]: + """Set or clear the Discord activation override for a chat.""" + normalized = str(mode or "").strip().lower() + key = str(chat_id) + if normalized in {"", "inherit", "default", "reset"}: + previous = self._activation_overrides.pop(key, None) + self._save_runtime_state() + return previous + if normalized not in {"mention", "always"}: + raise ValueError(f"Unsupported activation mode: {mode}") + self._activation_overrides[key] = normalized + self._save_runtime_state() + return normalized def _is_forum_parent(self, channel: Any) -> bool: """Best-effort check for whether a Discord channel is a forum channel.""" - if channel is None: - return False - forum_cls = getattr(discord, "ForumChannel", None) - if forum_cls and isinstance(channel, forum_cls): - return True - channel_type = getattr(channel, "type", None) - if channel_type is not None: - type_value = getattr(channel_type, "value", channel_type) - if type_value == 15: - return True - return False + return discord_intake.is_forum_parent(channel) def _format_thread_chat_name(self, thread: Any) -> str: """Build a readable chat name for thread-like Discord channels, including forum context when available.""" - thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread")) - parent = getattr(thread, "parent", None) - guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) - guild_name = getattr(guild, "name", None) - parent_name = getattr(parent, "name", None) - - if self._is_forum_parent(parent) and guild_name and parent_name: - return f"{guild_name} / {parent_name} / {thread_name}" - if parent_name and guild_name: - return f"{guild_name} / #{parent_name} / {thread_name}" - if parent_name: - return f"{parent_name} / {thread_name}" - return thread_name + return discord_intake.format_thread_chat_name(thread, self._is_forum_parent) # ------------------------------------------------------------------ # Thread participation persistence # ------------------------------------------------------------------ @staticmethod - def _thread_state_path() -> Path: + def _thread_state_path(): """Path to the persisted thread participation set.""" - from hermes_cli.config import get_hermes_home - return get_hermes_home() / "discord_threads.json" + return discord_state.thread_state_path() @classmethod def _load_participated_threads(cls) -> set: """Load persisted thread IDs from disk.""" - path = cls._thread_state_path() - try: - if path.exists(): - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, list): - return set(data) - except Exception as e: - logger.debug("Could not load discord thread state: %s", e) - return set() + return discord_state.load_participated_threads() def _save_participated_threads(self) -> None: """Persist the current thread set to disk (best-effort).""" - path = self._thread_state_path() - try: - # Trim to most recent entries if over cap - thread_list = list(self._bot_participated_threads) - if len(thread_list) > self._MAX_TRACKED_THREADS: - thread_list = thread_list[-self._MAX_TRACKED_THREADS:] - self._bot_participated_threads = set(thread_list) - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(thread_list), encoding="utf-8") - except Exception as e: - logger.debug("Could not save discord thread state: %s", e) + self._bot_participated_threads = discord_state.save_participated_threads( + self._bot_participated_threads, + max_threads=self._MAX_TRACKED_THREADS, + ) def _track_thread(self, thread_id: str) -> None: """Add a thread to the participation set and persist.""" - if thread_id not in self._bot_participated_threads: - self._bot_participated_threads.add(thread_id) - self._save_participated_threads() + self._bot_participated_threads = discord_state.track_thread( + self._bot_participated_threads, + thread_id, + max_threads=self._MAX_TRACKED_THREADS, + ) async def _handle_message(self, message: DiscordMessage) -> None: """Handle incoming Discord messages.""" @@ -1846,40 +1997,66 @@ async def _handle_message(self, message: DiscordMessage) -> None: thread_id = None parent_channel_id = None - is_thread = isinstance(message.channel, discord.Thread) + is_thread = isinstance(message.channel, discord.Thread) or getattr(message.channel, "parent", None) is not None + policy = self._get_discord_policy() + active_binding = None if is_thread: thread_id = str(message.channel.id) parent_channel_id = self._get_parent_channel_id(message.channel) + self.expire_thread_binding_if_needed(thread_id) + active_binding = self.get_thread_binding(thread_id) if not isinstance(message.channel, discord.DMChannel): - free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") - free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} + free_channels = policy.free_response_channels channel_ids = {str(message.channel.id)} if parent_channel_id: channel_ids.add(parent_channel_id) - require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") + require_mention = policy.require_mention is_free_channel = bool(channel_ids & free_channels) + for channel_id in channel_ids: + activation_mode = self.get_activation_mode(channel_id) + if activation_mode == "always": + require_mention = False + is_free_channel = True + break + if activation_mode == "mention": + require_mention = True + is_free_channel = False # Skip the mention check if the message is in a thread where # the bot has previously participated (auto-created or replied in). - in_bot_thread = is_thread and thread_id in self._bot_participated_threads + in_bot_thread = bool( + is_thread + and thread_id + and ( + thread_id in self._bot_participated_threads + or active_binding is not None + ) + ) + is_mentioned = bool(self._client.user and self._client.user in message.mentions) + + if discord_intake.should_skip_for_mention( + require_mention=require_mention, + is_free_channel=is_free_channel, + in_bot_thread=in_bot_thread, + is_mentioned=is_mentioned, + ): + return - if require_mention and not is_free_channel and not in_bot_thread: - if self._client.user not in message.mentions: - return + if is_mentioned and self._client.user: + message.content = discord_intake.strip_mention(message.content, self._client.user.id) - if self._client.user and self._client.user in message.mentions: - message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip() - message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip() + inline_command, remaining_text = discord_native_commands.extract_inline_shortcut( + message.content + ) # Auto-thread: when enabled, automatically create a thread for every # @mention in a text channel so each conversation is isolated (like Slack). # Messages already inside threads or DMs are unaffected. auto_threaded_channel = None if not is_thread and not isinstance(message.channel, discord.DMChannel): - auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes") - if auto_thread: + if policy.auto_thread: thread = await self._auto_create_thread(message) if thread: is_thread = True @@ -1888,22 +2065,9 @@ async def _handle_message(self, message: DiscordMessage) -> None: self._track_thread(thread_id) # Determine message type - msg_type = MessageType.TEXT - if message.content.startswith("/"): - msg_type = MessageType.COMMAND - elif message.attachments: - # Check attachment types - for att in message.attachments: - if att.content_type: - if att.content_type.startswith("image/"): - msg_type = MessageType.PHOTO - elif att.content_type.startswith("video/"): - msg_type = MessageType.VIDEO - elif att.content_type.startswith("audio/"): - msg_type = MessageType.AUDIO - else: - msg_type = MessageType.DOCUMENT - break + msg_type = MessageType( + discord_intake.classify_message_type(message.content, message.attachments) + ) # When auto-threading kicked in, route responses to the new thread effective_channel = auto_threaded_channel or message.channel @@ -1986,100 +2150,28 @@ async def _handle_message(self, message: DiscordMessage) -> None: timestamp=message.created_at, ) + if inline_command: + inline_event = MessageEvent( + text=f"/{inline_command}", + message_type=MessageType.COMMAND, + source=source, + raw_message=message, + message_id=str(message.id), + reply_to_message_id=event.reply_to_message_id, + timestamp=message.created_at, + metadata={"is_inline_shortcut": True}, + ) + await self.handle_message(inline_event) + if not remaining_text and not media_urls: + return + event.text = remaining_text + event.message_type = MessageType.TEXT + # Track thread participation so the bot won't require @mention for # follow-up messages in threads it has already engaged in. if thread_id: self._track_thread(thread_id) + if active_binding is not None: + self.touch_thread_binding(thread_id) await self.handle_message(event) - - -# --------------------------------------------------------------------------- -# Discord UI Components (outside the adapter class) -# --------------------------------------------------------------------------- - -if DISCORD_AVAILABLE: - - class ExecApprovalView(discord.ui.View): - """ - Interactive button view for exec approval of dangerous commands. - - Shows three buttons: Allow Once (green), Always Allow (blue), Deny (red). - Only users in the allowed list can click. The view times out after 5 minutes. - """ - - def __init__(self, approval_id: str, allowed_user_ids: set): - super().__init__(timeout=300) # 5-minute timeout - self.approval_id = approval_id - self.allowed_user_ids = allowed_user_ids - self.resolved = False - - def _check_auth(self, interaction: discord.Interaction) -> bool: - """Verify the user clicking is authorized.""" - if not self.allowed_user_ids: - return True # No allowlist = anyone can approve - return str(interaction.user.id) in self.allowed_user_ids - - async def _resolve( - self, interaction: discord.Interaction, action: str, color: discord.Color - ): - """Resolve the approval and update the message.""" - if self.resolved: - await interaction.response.send_message( - "This approval has already been resolved~", ephemeral=True - ) - return - - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized to approve commands~", ephemeral=True - ) - return - - self.resolved = True - - # Update the embed with the decision - embed = interaction.message.embeds[0] if interaction.message.embeds else None - if embed: - embed.color = color - embed.set_footer(text=f"{action} by {interaction.user.display_name}") - - # Disable all buttons - for child in self.children: - child.disabled = True - - await interaction.response.edit_message(embed=embed, view=self) - - # Store the approval decision - try: - from tools.approval import approve_permanent - if action == "allow_once": - pass # One-time approval handled by gateway - elif action == "allow_always": - approve_permanent(self.approval_id) - except ImportError: - pass - - @discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green) - async def allow_once( - self, interaction: discord.Interaction, button: discord.ui.Button - ): - await self._resolve(interaction, "allow_once", discord.Color.green()) - - @discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple) - async def allow_always( - self, interaction: discord.Interaction, button: discord.ui.Button - ): - await self._resolve(interaction, "allow_always", discord.Color.blue()) - - @discord.ui.button(label="Deny", style=discord.ButtonStyle.red) - async def deny( - self, interaction: discord.Interaction, button: discord.ui.Button - ): - await self._resolve(interaction, "deny", discord.Color.red()) - - async def on_timeout(self): - """Handle view timeout -- disable buttons and mark as expired.""" - self.resolved = True - for child in self.children: - child.disabled = True diff --git a/gateway/platforms/discord_impl/__init__.py b/gateway/platforms/discord_impl/__init__.py new file mode 100644 index 000000000000..59ad48e4c8ea --- /dev/null +++ b/gateway/platforms/discord_impl/__init__.py @@ -0,0 +1,10 @@ +# Internal implementation package for Discord v2. +# The public import surface remains gateway.platforms.discord (discord.py). +# External consumers should NEVER import from this package directly. +# Imports flow: discord.py -> discord_impl.*, not the reverse. +"""Internal implementation package for Discord v2. + +This package exists only to house Discord-specific implementation modules +behind the stable public adapter surface in ``gateway.platforms.discord``. +It is not a public API. +""" diff --git a/gateway/platforms/discord_impl/command_sessions.py b/gateway/platforms/discord_impl/command_sessions.py new file mode 100644 index 000000000000..213a7d7ee515 --- /dev/null +++ b/gateway/platforms/discord_impl/command_sessions.py @@ -0,0 +1,102 @@ +"""Discord command-session helpers.""" + +from __future__ import annotations + +from dataclasses import asdict, is_dataclass, replace +import re +from typing import Any, Optional, Tuple + +from gateway.platforms.base import MessageEvent, MessageType + +try: + import discord +except ImportError: # pragma: no cover - import guard + discord = None + + +INLINE_SHORTCUT_COMMANDS = ("help", "commands", "status", "whoami", "id") +_INLINE_SHORTCUT_RE = re.compile( + r"(? Tuple[Optional[str], str]: + """Return the first supported inline shortcut and the stripped remaining text.""" + match = _INLINE_SHORTCUT_RE.search(text) + if not match: + return None, text + + command = match.group(1).lstrip("/").lower() + remaining = (text[:match.start()] + " " + text[match.end():]).strip() + remaining = re.sub(r"\s{2,}", " ", remaining) + return command, remaining + + +def _resolve_target_chat(adapter: Any, interaction: Any) -> tuple[str, str, Optional[str]]: + dm_channel_cls = getattr(discord, "DMChannel", None) if discord else None + thread_cls = getattr(discord, "Thread", None) if discord else None + + channel = interaction.channel + channel_id = str(getattr(channel, "id", getattr(interaction, "channel_id", "")) or "") + is_dm = isinstance(channel, dm_channel_cls) if dm_channel_cls else False + is_thread = isinstance(channel, thread_cls) if thread_cls else False + thread_id = channel_id if is_thread else None + + if is_dm: + chat_type = "dm" + chat_name = getattr(interaction.user, "display_name", None) or str(interaction.user.id) + return chat_type, chat_name, thread_id + + if is_thread: + chat_type = "thread" + formatter = getattr(adapter, "_format_thread_chat_name", None) + if callable(formatter): + return chat_type, formatter(channel), thread_id + return chat_type, getattr(channel, "name", channel_id or "unknown"), thread_id + + chat_type = "group" + chat_name = getattr(channel, "name", channel_id or "unknown") + guild = getattr(channel, "guild", None) + if guild: + chat_name = f"{guild.name} / #{chat_name}" + return chat_type, chat_name, thread_id + + +def build_slash_event(adapter: Any, interaction: Any, text: str) -> MessageEvent: + """Build a slash MessageEvent with isolated command-session metadata.""" + chat_type, chat_name, thread_id = _resolve_target_chat(adapter, interaction) + chat_topic = getattr(interaction.channel, "topic", None) + user_id = str(interaction.user.id) + + target_source = adapter.build_source( + chat_id=str(interaction.channel_id), + chat_name=chat_name, + chat_type=chat_type, + user_id=user_id, + user_name=interaction.user.display_name, + thread_id=thread_id, + chat_topic=chat_topic, + ) + session_namespace = f"slash:{user_id}" + if is_dataclass(target_source): + session_source = replace(target_source, session_namespace=session_namespace) + else: + source_kwargs = dict(asdict(target_source)) if hasattr(target_source, "__dataclass_fields__") else dict( + getattr(target_source, "__dict__", {}) + ) + source_kwargs["session_namespace"] = session_namespace + session_source = adapter.build_source(**source_kwargs) + + msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + return MessageEvent( + text=text, + message_type=msg_type, + source=target_source, + raw_message=interaction, + metadata={ + "session_source": session_source, + "command_target_source": target_source, + "is_native_slash": True, + }, + ) diff --git a/gateway/platforms/discord_impl/components.py b/gateway/platforms/discord_impl/components.py new file mode 100644 index 000000000000..af02f331281d --- /dev/null +++ b/gateway/platforms/discord_impl/components.py @@ -0,0 +1,891 @@ +"""Generic Discord component runtime for buttons, selects, and modals.""" + +from __future__ import annotations + +import secrets +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Optional, Sequence + +try: + import discord + + DISCORD_AVAILABLE = True +except ImportError: # pragma: no cover - import guard + discord = None + DISCORD_AVAILABLE = False + + +COMPONENT_CUSTOM_ID_PREFIX = "hermes:cmp" +MODAL_CUSTOM_ID_PREFIX = "hermes:mdl" +DEFAULT_UNAUTHORIZED_MESSAGE = "You're not authorized to use this interaction~" +DEFAULT_MISSING_MESSAGE = "This interaction is no longer available~" +DEFAULT_EXPIRED_MESSAGE = "This interaction has expired~" +DEFAULT_USED_MESSAGE = "This interaction has already been used~" + +ButtonHandler = Callable[["DiscordComponentInvocation"], Awaitable[bool | None] | bool | None] +ModalHandler = Callable[["DiscordModalInvocation"], Awaitable[bool | None] | bool | None] + + +def _now() -> float: + return time.time() + + +def _new_entry_id(prefix: str) -> str: + return f"{prefix}{secrets.token_urlsafe(6)}" + + +def encode_component_custom_id(entry_id: str) -> str: + return f"{COMPONENT_CUSTOM_ID_PREFIX}:{entry_id}" + + +def decode_component_custom_id(custom_id: str) -> Optional[str]: + prefix = f"{COMPONENT_CUSTOM_ID_PREFIX}:" + if not custom_id.startswith(prefix): + return None + return custom_id[len(prefix):] or None + + +def encode_modal_custom_id(entry_id: str) -> str: + return f"{MODAL_CUSTOM_ID_PREFIX}:{entry_id}" + + +def decode_modal_custom_id(custom_id: str) -> Optional[str]: + prefix = f"{MODAL_CUSTOM_ID_PREFIX}:" + if not custom_id.startswith(prefix): + return None + return custom_id[len(prefix):] or None + + +def _button_style(style: str) -> Any: + button_style = getattr(discord, "ButtonStyle", None) + if button_style is None: + return style + mapping = { + "primary": getattr(button_style, "primary", getattr(button_style, "blurple", 1)), + "secondary": getattr(button_style, "secondary", 2), + "success": getattr(button_style, "success", getattr(button_style, "green", 3)), + "danger": getattr(button_style, "danger", getattr(button_style, "red", 4)), + "link": getattr(button_style, "link", 5), + } + return mapping.get(style, mapping["secondary"]) + + +def _text_style(style: str) -> Any: + text_style = getattr(discord, "TextStyle", None) + if text_style is None: + return style + if style == "paragraph": + return getattr(text_style, "paragraph", getattr(text_style, "long", 2)) + return getattr(text_style, "short", 1) + + +def _select_option(**kwargs: Any) -> Any: + option_cls = getattr(discord, "SelectOption", None) + if option_cls is None: + return kwargs + return option_cls(**kwargs) + + +def _coerce_choice_values(values: Sequence[Any] | None) -> tuple[str, ...]: + if not values: + return () + return tuple(str(value) for value in values) + + +async def _await_maybe(result: Any) -> Any: + if hasattr(result, "__await__"): + return await result + return result + + +async def send_ephemeral_message(interaction: Any, content: str) -> None: + """Send an ephemeral response, preferring the initial interaction response.""" + response = getattr(interaction, "response", None) + if response is not None and hasattr(response, "send_message"): + is_done = getattr(response, "is_done", None) + if not callable(is_done) or not is_done(): + await response.send_message(content, ephemeral=True) + return + + followup = getattr(interaction, "followup", None) + if followup is not None and hasattr(followup, "send"): + await followup.send(content, ephemeral=True) + + +@dataclass(frozen=True) +class DiscordSelectOptionSpec: + label: str + value: str + description: Optional[str] = None + default: bool = False + + +@dataclass(frozen=True) +class DiscordButtonSpec: + label: str + handler: Optional[ButtonHandler] = None + style: str = "secondary" + row: Optional[int] = None + disabled: bool = False + emoji: Optional[Any] = None + url: Optional[str] = None + allowed_user_ids: tuple[str, ...] = () + reusable: bool = False + timeout_seconds: Optional[float] = 300.0 + state: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class DiscordSelectSpec: + select_type: str + handler: ButtonHandler + placeholder: Optional[str] = None + min_values: int = 1 + max_values: int = 1 + options: tuple[DiscordSelectOptionSpec, ...] = () + row: Optional[int] = None + disabled: bool = False + allowed_user_ids: tuple[str, ...] = () + reusable: bool = False + timeout_seconds: Optional[float] = 300.0 + state: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class DiscordModalFieldSpec: + field_id: str + label: str + style: str = "short" + placeholder: Optional[str] = None + default: Optional[str] = None + required: bool = True + min_length: Optional[int] = None + max_length: Optional[int] = None + + +@dataclass(frozen=True) +class DiscordModalSpec: + title: str + fields: tuple[DiscordModalFieldSpec, ...] + handler: ModalHandler + allowed_user_ids: tuple[str, ...] = () + reusable: bool = False + timeout_seconds: Optional[float] = 300.0 + state: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class DiscordModalTriggerSpec: + label: str + modal: DiscordModalSpec + style: str = "primary" + row: Optional[int] = None + disabled: bool = False + emoji: Optional[Any] = None + allowed_user_ids: tuple[str, ...] = () + reusable: bool = False + timeout_seconds: Optional[float] = 300.0 + state: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class DiscordComponentEntry: + entry_id: str + kind: str + handler: Optional[ButtonHandler] + allowed_user_ids: tuple[str, ...] + reusable: bool + created_at: float + expires_at: Optional[float] + message_id: Optional[str] = None + state: dict[str, Any] = field(default_factory=dict) + consumed: bool = False + + +@dataclass +class DiscordModalEntry: + entry_id: str + fields: tuple[DiscordModalFieldSpec, ...] + handler: ModalHandler + title: str + allowed_user_ids: tuple[str, ...] + reusable: bool + created_at: float + expires_at: Optional[float] + message_id: Optional[str] = None + state: dict[str, Any] = field(default_factory=dict) + consumed: bool = False + + +@dataclass +class DiscordComponentInvocation: + interaction: Any + runtime: "DiscordComponentRuntime" + entry: DiscordComponentEntry + view: Any = None + component: Any = None + values: tuple[str, ...] = () + + async def deny(self, content: str = DEFAULT_UNAUTHORIZED_MESSAGE) -> None: + await send_ephemeral_message(self.interaction, content) + + def consume(self) -> None: + self.runtime.consume_entry(self.entry.entry_id) + + def disable_all(self) -> None: + if self.view is None: + return + for child in getattr(self.view, "children", []): + child.disabled = True + + +@dataclass +class DiscordModalInvocation: + interaction: Any + runtime: "DiscordComponentRuntime" + entry: DiscordModalEntry + modal: Any = None + values: dict[str, str] = field(default_factory=dict) + + async def deny(self, content: str = DEFAULT_UNAUTHORIZED_MESSAGE) -> None: + await send_ephemeral_message(self.interaction, content) + + def consume(self) -> None: + self.runtime.consume_modal(self.entry.entry_id) + + +class DiscordComponentRuntime: + """Registry-backed runtime for Discord buttons, selects, and modals.""" + + def __init__(self): + self._component_entries: dict[str, DiscordComponentEntry] = {} + self._modal_entries: dict[str, DiscordModalEntry] = {} + + def register_button(self, spec: DiscordButtonSpec) -> Optional[DiscordComponentEntry]: + if spec.url: + return None + return self._register_component( + kind="button", + handler=spec.handler, + allowed_user_ids=spec.allowed_user_ids, + reusable=spec.reusable, + timeout_seconds=spec.timeout_seconds, + state=spec.state, + ) + + def register_select(self, spec: DiscordSelectSpec) -> DiscordComponentEntry: + return self._register_component( + kind=f"{spec.select_type}_select", + handler=spec.handler, + allowed_user_ids=spec.allowed_user_ids, + reusable=spec.reusable, + timeout_seconds=spec.timeout_seconds, + state=spec.state, + ) + + def register_modal(self, spec: DiscordModalSpec) -> DiscordModalEntry: + entry_id = _new_entry_id("mdl_") + now = _now() + expires_at = now + spec.timeout_seconds if spec.timeout_seconds else None + entry = DiscordModalEntry( + entry_id=entry_id, + fields=spec.fields, + handler=spec.handler, + title=spec.title, + allowed_user_ids=tuple(str(user_id) for user_id in spec.allowed_user_ids), + reusable=spec.reusable, + created_at=now, + expires_at=expires_at, + state=dict(spec.state), + ) + self._modal_entries[entry.entry_id] = entry + return entry + + def _register_component( + self, + *, + kind: str, + handler: Optional[ButtonHandler], + allowed_user_ids: Sequence[str], + reusable: bool, + timeout_seconds: Optional[float], + state: dict[str, Any], + ) -> DiscordComponentEntry: + entry_id = _new_entry_id("cmp_") + now = _now() + expires_at = now + timeout_seconds if timeout_seconds else None + entry = DiscordComponentEntry( + entry_id=entry_id, + kind=kind, + handler=handler, + allowed_user_ids=tuple(str(user_id) for user_id in allowed_user_ids), + reusable=reusable, + created_at=now, + expires_at=expires_at, + state=dict(state), + ) + self._component_entries[entry.entry_id] = entry + return entry + + def get_entry(self, entry_id: str) -> Optional[DiscordComponentEntry]: + return self._component_entries.get(entry_id) + + def get_modal(self, entry_id: str) -> Optional[DiscordModalEntry]: + return self._modal_entries.get(entry_id) + + def bind_message(self, entry_ids: Sequence[str], message_id: str) -> None: + for entry_id in entry_ids: + if entry_id in self._component_entries: + self._component_entries[entry_id].message_id = message_id + if entry_id in self._modal_entries: + self._modal_entries[entry_id].message_id = message_id + + def consume_entry(self, entry_id: str) -> None: + entry = self._component_entries.get(entry_id) + if entry is not None: + entry.consumed = True + + def consume_modal(self, entry_id: str) -> None: + entry = self._modal_entries.get(entry_id) + if entry is not None: + entry.consumed = True + + async def dispatch_component( + self, + interaction: Any, + entry_id: str, + *, + view: Any = None, + component: Any = None, + values: Sequence[Any] | None = None, + ) -> None: + entry = self._component_entries.get(entry_id) + failure = self._validate_component_entry(entry, interaction) + if failure: + await send_ephemeral_message(interaction, failure) + return + + invocation = DiscordComponentInvocation( + interaction=interaction, + runtime=self, + entry=entry, + view=view, + component=component, + values=_coerce_choice_values(values), + ) + result = await _await_maybe(entry.handler(invocation) if entry and entry.handler else None) + if entry and not entry.reusable and result is not False: + self.consume_entry(entry.entry_id) + + async def dispatch_modal( + self, + interaction: Any, + entry_id: str, + values: dict[str, str], + *, + modal: Any = None, + ) -> None: + entry = self._modal_entries.get(entry_id) + failure = self._validate_modal_entry(entry, interaction) + if failure: + await send_ephemeral_message(interaction, failure) + return + + invocation = DiscordModalInvocation( + interaction=interaction, + runtime=self, + entry=entry, + modal=modal, + values=dict(values), + ) + result = await _await_maybe(entry.handler(invocation)) + if entry and not entry.reusable and result is not False: + self.consume_modal(entry.entry_id) + + def _validate_component_entry( + self, + entry: Optional[DiscordComponentEntry], + interaction: Any, + ) -> Optional[str]: + if entry is None: + return DEFAULT_MISSING_MESSAGE + if entry.expires_at is not None and _now() > entry.expires_at: + return DEFAULT_EXPIRED_MESSAGE + if entry.consumed: + return DEFAULT_USED_MESSAGE + if entry.allowed_user_ids and str(getattr(getattr(interaction, "user", None), "id", "")) not in entry.allowed_user_ids: + return DEFAULT_UNAUTHORIZED_MESSAGE + return None + + def _validate_modal_entry( + self, + entry: Optional[DiscordModalEntry], + interaction: Any, + ) -> Optional[str]: + if entry is None: + return DEFAULT_MISSING_MESSAGE + if entry.expires_at is not None and _now() > entry.expires_at: + return DEFAULT_EXPIRED_MESSAGE + if entry.consumed: + return DEFAULT_USED_MESSAGE + if entry.allowed_user_ids and str(getattr(getattr(interaction, "user", None), "id", "")) not in entry.allowed_user_ids: + return DEFAULT_UNAUTHORIZED_MESSAGE + return None + + +if DISCORD_AVAILABLE: + + class ManagedButton(discord.ui.Button): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + *, + label: str, + style: str, + row: Optional[int] = None, + disabled: bool = False, + emoji: Optional[Any] = None, + ): + super().__init__( + label=label, + style=_button_style(style), + custom_id=encode_component_custom_id(entry_id), + row=row, + disabled=disabled, + emoji=emoji, + ) + self._runtime = runtime + self._entry_id = entry_id + + async def callback(self, interaction: discord.Interaction): + await self._runtime.dispatch_component( + interaction, + self._entry_id, + view=self.view, + component=self, + ) + + + class StaticLinkButton(discord.ui.Button): + def __init__( + self, + *, + label: str, + style: str, + url: str, + row: Optional[int] = None, + disabled: bool = False, + emoji: Optional[Any] = None, + ): + super().__init__( + label=label, + style=_button_style(style), + url=url, + row=row, + disabled=disabled, + emoji=emoji, + ) + + + _string_select_base = getattr(discord.ui, "Select", None) + _user_select_base = getattr(discord.ui, "UserSelect", None) + _role_select_base = getattr(discord.ui, "RoleSelect", None) + _mentionable_select_base = getattr(discord.ui, "MentionableSelect", None) + _channel_select_base = getattr(discord.ui, "ChannelSelect", None) + _modal_base = getattr(discord.ui, "Modal", None) + _text_input_cls = getattr(discord.ui, "TextInput", None) + + if _string_select_base is not None: + + class ManagedStringSelect(_string_select_base): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + *, + placeholder: Optional[str], + min_values: int, + max_values: int, + options: Sequence[DiscordSelectOptionSpec], + row: Optional[int] = None, + disabled: bool = False, + ): + super().__init__( + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + options=[ + _select_option( + label=option.label, + value=option.value, + description=option.description, + default=option.default, + ) + for option in options + ], + custom_id=encode_component_custom_id(entry_id), + row=row, + disabled=disabled, + ) + self._runtime = runtime + self._entry_id = entry_id + + async def callback(self, interaction: discord.Interaction): + await self._runtime.dispatch_component( + interaction, + self._entry_id, + view=self.view, + component=self, + values=getattr(self, "values", ()), + ) + + else: # pragma: no cover - import guard + ManagedStringSelect = None + + if _user_select_base is not None: + + class ManagedUserSelect(_user_select_base): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + *, + placeholder: Optional[str], + min_values: int, + max_values: int, + row: Optional[int] = None, + disabled: bool = False, + ): + super().__init__( + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + custom_id=encode_component_custom_id(entry_id), + row=row, + disabled=disabled, + ) + self._runtime = runtime + self._entry_id = entry_id + + async def callback(self, interaction: discord.Interaction): + await self._runtime.dispatch_component( + interaction, + self._entry_id, + view=self.view, + component=self, + values=getattr(self, "values", ()), + ) + + else: # pragma: no cover - import guard + ManagedUserSelect = None + + if _role_select_base is not None: + + class ManagedRoleSelect(_role_select_base): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + *, + placeholder: Optional[str], + min_values: int, + max_values: int, + row: Optional[int] = None, + disabled: bool = False, + ): + super().__init__( + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + custom_id=encode_component_custom_id(entry_id), + row=row, + disabled=disabled, + ) + self._runtime = runtime + self._entry_id = entry_id + + async def callback(self, interaction: discord.Interaction): + await self._runtime.dispatch_component( + interaction, + self._entry_id, + view=self.view, + component=self, + values=getattr(self, "values", ()), + ) + + else: # pragma: no cover - import guard + ManagedRoleSelect = None + + if _mentionable_select_base is not None: + + class ManagedMentionableSelect(_mentionable_select_base): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + *, + placeholder: Optional[str], + min_values: int, + max_values: int, + row: Optional[int] = None, + disabled: bool = False, + ): + super().__init__( + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + custom_id=encode_component_custom_id(entry_id), + row=row, + disabled=disabled, + ) + self._runtime = runtime + self._entry_id = entry_id + + async def callback(self, interaction: discord.Interaction): + await self._runtime.dispatch_component( + interaction, + self._entry_id, + view=self.view, + component=self, + values=getattr(self, "values", ()), + ) + + else: # pragma: no cover - import guard + ManagedMentionableSelect = None + + if _channel_select_base is not None: + + class ManagedChannelSelect(_channel_select_base): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + *, + placeholder: Optional[str], + min_values: int, + max_values: int, + row: Optional[int] = None, + disabled: bool = False, + ): + super().__init__( + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + custom_id=encode_component_custom_id(entry_id), + row=row, + disabled=disabled, + ) + self._runtime = runtime + self._entry_id = entry_id + + async def callback(self, interaction: discord.Interaction): + await self._runtime.dispatch_component( + interaction, + self._entry_id, + view=self.view, + component=self, + values=getattr(self, "values", ()), + ) + + else: # pragma: no cover - import guard + ManagedChannelSelect = None + + if _modal_base is not None and _text_input_cls is not None: + + class ManagedModal(_modal_base): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry: DiscordModalEntry, + ): + super().__init__( + title=entry.title, + custom_id=encode_modal_custom_id(entry.entry_id), + timeout=max((entry.expires_at or _now()) - _now(), 1.0) if entry.expires_at else None, + ) + self._runtime = runtime + self._entry = entry + self._inputs: dict[str, Any] = {} + for field_spec in entry.fields: + text_input = _text_input_cls( + label=field_spec.label, + placeholder=field_spec.placeholder, + default=field_spec.default, + required=field_spec.required, + min_length=field_spec.min_length, + max_length=field_spec.max_length, + style=_text_style(field_spec.style), + ) + self._inputs[field_spec.field_id] = text_input + self.add_item(text_input) + + async def on_submit(self, interaction: discord.Interaction): + values = { + field_id: getattr(text_input, "value", "") + for field_id, text_input in self._inputs.items() + } + await self._runtime.dispatch_modal( + interaction, + self._entry.entry_id, + values, + modal=self, + ) + + else: # pragma: no cover - import guard + ManagedModal = None + + + class ManagedModalTriggerButton(ManagedButton): + def __init__( + self, + runtime: DiscordComponentRuntime, + entry_id: str, + modal_entry: DiscordModalEntry, + *, + label: str, + style: str, + row: Optional[int] = None, + disabled: bool = False, + emoji: Optional[Any] = None, + ): + super().__init__( + runtime, + entry_id, + label=label, + style=style, + row=row, + disabled=disabled, + emoji=emoji, + ) + self._modal_entry = modal_entry + + async def callback(self, interaction: discord.Interaction): + failure = self._runtime._validate_component_entry( + self._runtime.get_entry(self._entry_id), + interaction, + ) + if failure: + await send_ephemeral_message(interaction, failure) + return + modal_failure = self._runtime._validate_modal_entry(self._modal_entry, interaction) + if modal_failure: + await send_ephemeral_message(interaction, modal_failure) + return + if ManagedModal is None: + await send_ephemeral_message( + interaction, + "Discord modal support is unavailable in this runtime~", + ) + return + modal = ManagedModal(self._runtime, self._modal_entry) + await interaction.response.send_modal(modal) + entry = self._runtime.get_entry(self._entry_id) + if entry is not None and not entry.reusable: + self._runtime.consume_entry(entry.entry_id) + + + class ManagedComponentView(discord.ui.View): + def __init__(self, runtime: DiscordComponentRuntime, *, timeout: Optional[float] = 300.0): + super().__init__(timeout=timeout) + self.runtime = runtime + self._entry_ids: list[str] = [] + + @property + def entry_ids(self) -> tuple[str, ...]: + return tuple(self._entry_ids) + + def bind_message(self, message_id: str) -> None: + self.runtime.bind_message(self._entry_ids, message_id) + + def add_button(self, spec: DiscordButtonSpec) -> Any: + if spec.url: + button = StaticLinkButton( + label=spec.label, + style=spec.style, + url=spec.url, + row=spec.row, + disabled=spec.disabled, + emoji=spec.emoji, + ) + self.add_item(button) + return button + + entry = self.runtime.register_button(spec) + button = ManagedButton( + self.runtime, + entry.entry_id, + label=spec.label, + style=spec.style, + row=spec.row, + disabled=spec.disabled, + emoji=spec.emoji, + ) + self._entry_ids.append(entry.entry_id) + self.add_item(button) + return button + + def add_select(self, spec: DiscordSelectSpec) -> Any: + entry = self.runtime.register_select(spec) + builder_map = { + "string": ManagedStringSelect, + "user": ManagedUserSelect, + "role": ManagedRoleSelect, + "mentionable": ManagedMentionableSelect, + "channel": ManagedChannelSelect, + } + builder = builder_map.get(spec.select_type) + if builder is None: + raise RuntimeError( + f"Discord select type '{spec.select_type}' is unavailable in this runtime" + ) + kwargs = dict( + placeholder=spec.placeholder, + min_values=spec.min_values, + max_values=spec.max_values, + row=spec.row, + disabled=spec.disabled, + ) + if spec.select_type == "string": + kwargs["options"] = spec.options + select = builder(self.runtime, entry.entry_id, **kwargs) + self._entry_ids.append(entry.entry_id) + self.add_item(select) + return select + + def add_modal_trigger(self, spec: DiscordModalTriggerSpec) -> Any: + modal_entry = self.runtime.register_modal(spec.modal) + trigger_entry = self.runtime.register_button( + DiscordButtonSpec( + label=spec.label, + style=spec.style, + row=spec.row, + disabled=spec.disabled, + emoji=spec.emoji, + allowed_user_ids=spec.allowed_user_ids, + reusable=spec.reusable, + timeout_seconds=spec.timeout_seconds, + state=spec.state, + ) + ) + button = ManagedModalTriggerButton( + self.runtime, + trigger_entry.entry_id, + modal_entry, + label=spec.label, + style=spec.style, + row=spec.row, + disabled=spec.disabled, + emoji=spec.emoji, + ) + self._entry_ids.extend((trigger_entry.entry_id, modal_entry.entry_id)) + self.add_item(button) + return button + + async def on_timeout(self): + for child in self.children: + child.disabled = True + +else: # pragma: no cover - import guard + ManagedComponentView = None diff --git a/gateway/platforms/discord_impl/config.py b/gateway/platforms/discord_impl/config.py new file mode 100644 index 000000000000..79f759e4eac2 --- /dev/null +++ b/gateway/platforms/discord_impl/config.py @@ -0,0 +1,158 @@ +"""Discord configuration and policy helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import os +from typing import Any, Iterable, Mapping + + +@dataclass(frozen=True) +class DiscordPolicyConfig: + """Typed Discord policy snapshot for a single adapter instance.""" + allowed_users: set[str] = field(default_factory=set) + bot_filter_policy: str = "none" + free_response_channels: set[str] = field(default_factory=set) + require_mention: bool = True + auto_thread: bool = True + + +def _coerce_bool(value: Any, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in ("true", "1", "yes", "on"): + return True + if normalized in ("false", "0", "no", "off"): + return False + return bool(value) + + +def _normalize_bot_filter_policy(value: Any, *, default: str = "none") -> str: + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"none", "mentions", "all"}: + return normalized + return default + + +def _split_entries(value: Any) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [entry.strip() for entry in value.split(",") if entry.strip()] + if isinstance(value, Iterable): + entries: list[str] = [] + for entry in value: + if entry is None: + continue + text = str(entry).strip() + if text: + entries.append(text) + return entries + text = str(value).strip() + return [text] if text else [] + + +def clean_discord_id(entry: str) -> str: + """Normalize a Discord user ID or username entry.""" + entry = entry.strip() + if entry.startswith("<@") and entry.endswith(">"): + entry = entry.lstrip("<@!").rstrip(">") + if entry.lower().startswith("user:"): + entry = entry[5:] + return entry.strip() + + +def parse_allowed_users(value: Any) -> set[str]: + """Parse Discord allowed-user config into cleaned entries.""" + return { + clean_discord_id(entry) + for entry in _split_entries(value) + if clean_discord_id(entry) + } + + +def parse_free_response_channels(value: Any) -> set[str]: + """Parse Discord free-response channel config into channel IDs.""" + return {entry for entry in _split_entries(value) if entry} + + +def load_policy_config( + config: Any | None = None, + *, + env: Mapping[str, str] | None = None, + overrides: Mapping[str, Any] | None = None, +) -> DiscordPolicyConfig: + """Load a typed Discord policy snapshot from config.extra with env fallback.""" + env_map = env or os.environ + override_map = overrides or {} + extra = getattr(config, "extra", None) + if not isinstance(extra, dict): + extra = {} + + allowed_users = extra.get("allowed_users") + if allowed_users is None: + allowed_users = env_map.get("DISCORD_ALLOWED_USERS", "") + + bot_filter_policy = extra.get("allow_bots") + if bot_filter_policy is None: + bot_filter_policy = env_map.get("DISCORD_ALLOW_BOTS", "none") + + free_response_channels = extra.get("free_response_channels") + if free_response_channels is None: + free_response_channels = env_map.get("DISCORD_FREE_RESPONSE_CHANNELS", "") + + require_mention = extra.get("require_mention") + if require_mention is None: + require_mention = env_map.get("DISCORD_REQUIRE_MENTION") + + auto_thread = extra.get("auto_thread") + if auto_thread is None: + auto_thread = env_map.get("DISCORD_AUTO_THREAD") + + if "allowed_users" in override_map: + allowed_users = override_map["allowed_users"] + if "allow_bots" in override_map: + bot_filter_policy = override_map["allow_bots"] + if "free_response_channels" in override_map: + free_response_channels = override_map["free_response_channels"] + if "require_mention" in override_map: + require_mention = override_map["require_mention"] + if "auto_thread" in override_map: + auto_thread = override_map["auto_thread"] + + return DiscordPolicyConfig( + allowed_users=parse_allowed_users(allowed_users), + bot_filter_policy=_normalize_bot_filter_policy(bot_filter_policy, default="none"), + free_response_channels=parse_free_response_channels(free_response_channels), + require_mention=_coerce_bool(require_mention, default=True), + auto_thread=_coerce_bool(auto_thread, default=True), + ) + + +def get_bot_filter_policy(config: Any | None = None, *, env: Mapping[str, str] | None = None) -> str: + """Return the effective bot-message filtering policy.""" + return load_policy_config(config, env=env).bot_filter_policy + + +def get_free_response_channels( + config: Any | None = None, + *, + env: Mapping[str, str] | None = None, +) -> set[str]: + """Return the effective free-response channel ID set.""" + return load_policy_config(config, env=env).free_response_channels + + +def is_mention_required(config: Any | None = None, *, env: Mapping[str, str] | None = None) -> bool: + """Return whether Discord server messages require an explicit mention.""" + return load_policy_config(config, env=env).require_mention + + +def is_auto_thread_enabled(config: Any | None = None, *, env: Mapping[str, str] | None = None) -> bool: + """Return whether Discord auto-threading is enabled.""" + return load_policy_config(config, env=env).auto_thread diff --git a/gateway/platforms/discord_impl/delivery.py b/gateway/platforms/discord_impl/delivery.py new file mode 100644 index 000000000000..0944c5f35c26 --- /dev/null +++ b/gateway/platforms/discord_impl/delivery.py @@ -0,0 +1,80 @@ +"""Discord message delivery helpers.""" + +from __future__ import annotations + +import logging +import os +from typing import Any, Optional + +from gateway.platforms.base import SendResult + + +logger = logging.getLogger(__name__) + +try: + import discord +except ImportError: # pragma: no cover - import guard + discord = None + + +async def resolve_channel(client: Any, chat_id: str) -> Optional[Any]: + """Get or fetch a Discord channel by ID.""" + if not client: + return None + + try: + channel_id = int(chat_id) + except (TypeError, ValueError): + return None + + channel = client.get_channel(channel_id) + if channel is not None: + return channel + + try: + return await client.fetch_channel(channel_id) + except Exception: + return None + + +async def send_text_message(channel: Any, content: str, reference: Any = None) -> Any: + """Send a single Discord text message chunk with reply fallback.""" + try: + return await channel.send(content=content, reference=reference) + except Exception as exc: + err_text = str(exc) + if ( + reference is not None + and "error code: 50035" in err_text + and "Cannot reply to a system message" in err_text + ): + logger.warning( + "Reply target is a Discord system message; retrying send without reply reference" + ) + return await channel.send(content=content, reference=None) + raise + + +async def send_file_attachment( + client: Any, + chat_id: str, + file_path: str, + caption: str | None = None, + file_name: str | None = None, +) -> SendResult: + """Send a local file as a Discord attachment.""" + if not client: + return SendResult(success=False, error="Not connected") + + channel = await resolve_channel(client, chat_id) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + if discord is None: # pragma: no cover - import guard + return SendResult(success=False, error="discord.py not installed") + + filename = file_name or os.path.basename(file_path) + with open(file_path, "rb") as fh: + file = discord.File(fh, filename=filename) + msg = await channel.send(content=caption if caption else None, file=file) + return SendResult(success=True, message_id=str(msg.id)) diff --git a/gateway/platforms/discord_impl/history.py b/gateway/platforms/discord_impl/history.py new file mode 100644 index 000000000000..28dce8262a32 --- /dev/null +++ b/gateway/platforms/discord_impl/history.py @@ -0,0 +1,193 @@ +"""Discord history fetch and search. + +Bounded read-only channel/thread history retrieval with permission checks. +""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from datetime import datetime +from types import SimpleNamespace +from typing import Any, Optional + +from gateway.platforms.discord_impl.delivery import resolve_channel +from gateway.platforms.discord_impl import permissions as discord_permissions + +SEARCH_SCAN_LIMIT = 500 +SEARCH_PAGE_SIZE = 100 + + +@dataclass +class HistoryMessage: + """Lightweight representation of a Discord message from history.""" + + id: str + author_id: str + author_name: str + content: str + timestamp: str + is_bot: bool + attachments: list[str] + reply_to: Optional[str] = None + + +def _clamp_limit(limit: int) -> int: + try: + value = int(limit) + except (TypeError, ValueError): + value = 100 + return max(1, min(500, value)) + + +def _history_anchor(message_id: Optional[str]) -> Optional[Any]: + if message_id is None: + return None + try: + anchor_id: Any = int(message_id) + except (TypeError, ValueError): + anchor_id = str(message_id) + return SimpleNamespace(id=anchor_id) + + +async def _collect_history(history_iter: Any) -> list[Any]: + if history_iter is None: + return [] + + if inspect.isawaitable(history_iter): + history_iter = await history_iter + + if hasattr(history_iter, "__aiter__"): + return [message async for message in history_iter] + + return list(history_iter) + + +def _timestamp_to_iso(value: Any) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.isoformat() + isoformat = getattr(value, "isoformat", None) + if callable(isoformat): + return isoformat() + return str(value) + + +def _author_name(message: Any) -> str: + author = getattr(message, "author", None) + if author is None: + return "unknown" + return ( + getattr(author, "display_name", None) + or getattr(author, "name", None) + or str(getattr(author, "id", "unknown")) + ) + + +def _attachments(message: Any) -> list[str]: + urls: list[str] = [] + for attachment in getattr(message, "attachments", []) or []: + url = getattr(attachment, "url", None) + if url: + urls.append(str(url)) + return urls + + +def _reply_to(message: Any) -> Optional[str]: + reference = getattr(message, "reference", None) + if reference is None: + return None + + reference_id = getattr(reference, "message_id", None) + if reference_id is None: + resolved = getattr(reference, "resolved", None) + reference_id = getattr(resolved, "id", None) + if reference_id is None: + return None + return str(reference_id) + + +def _to_history_message(message: Any) -> HistoryMessage: + author = getattr(message, "author", None) + return HistoryMessage( + id=str(getattr(message, "id", "")), + author_id=str(getattr(author, "id", "")), + author_name=_author_name(message), + content=str(getattr(message, "content", "") or ""), + timestamp=_timestamp_to_iso(getattr(message, "created_at", None)), + is_bot=bool(getattr(author, "bot", False)), + attachments=_attachments(message), + reply_to=_reply_to(message), + ) + + +async def fetch_history( + client: Any, + channel_id: str, + limit: int = 100, + before: Optional[str] = None, + after: Optional[str] = None, +) -> list[HistoryMessage]: + """Fetch bounded message history from a Discord channel.""" + channel = await resolve_channel(client, channel_id) + if channel is None: + return [] + + channel_permissions = discord_permissions._build_channel_permissions(client, channel) + if not (channel_permissions.can_read and channel_permissions.can_read_history): + return [] + + history_iter = channel.history( + limit=_clamp_limit(limit), + before=_history_anchor(before), + after=_history_anchor(after), + ) + return [_to_history_message(message) for message in await _collect_history(history_iter)] + + +async def search_history( + client: Any, + channel_id: str, + query: str, + limit: int = 50, + author_id: Optional[str] = None, +) -> list[HistoryMessage]: + """Search recent channel history with bounded text matching.""" + normalized_query = (query or "").strip().lower() + if not normalized_query: + return [] + + clamped_limit = _clamp_limit(limit) + filtered_messages: list[HistoryMessage] = [] + author_filter = str(author_id) if author_id is not None else None + remaining_scan = SEARCH_SCAN_LIMIT + before: Optional[str] = None + + while remaining_scan > 0 and len(filtered_messages) < clamped_limit: + page_size = min(SEARCH_PAGE_SIZE, remaining_scan) + messages = await fetch_history( + client, + channel_id, + limit=page_size, + before=before, + ) + if not messages: + break + + remaining_scan -= len(messages) + + for message in messages: + if author_filter is not None and message.author_id != author_filter: + continue + if normalized_query not in message.content.lower(): + continue + filtered_messages.append(message) + if len(filtered_messages) >= clamped_limit: + break + + if len(messages) < page_size: + break + before = messages[-1].id + + return filtered_messages diff --git a/gateway/platforms/discord_impl/intake.py b/gateway/platforms/discord_impl/intake.py new file mode 100644 index 000000000000..3240297e5bbc --- /dev/null +++ b/gateway/platforms/discord_impl/intake.py @@ -0,0 +1,104 @@ +"""Discord message intake and preflight helpers.""" + +from __future__ import annotations + +from typing import Any, Callable, Iterable, Optional + +try: + import discord +except ImportError: # pragma: no cover - import guard + discord = None + + +def should_filter_bot_message(is_bot: bool, policy: str, is_mentioned: bool) -> bool: + """Return whether a bot-authored message should be filtered.""" + if not is_bot: + return False + if policy == "none": + return True + if policy == "mentions" and not is_mentioned: + return True + return False + + +def should_skip_for_mention( + require_mention: bool, + is_free_channel: bool, + in_bot_thread: bool, + is_mentioned: bool, +) -> bool: + """Return whether a guild message should be skipped for mention gating.""" + return require_mention and not is_free_channel and not in_bot_thread and not is_mentioned + + +def strip_mention(content: str, bot_user_id: int) -> str: + """Strip direct bot mention syntax from the message content.""" + content = content.replace(f"<@{bot_user_id}>", "").strip() + content = content.replace(f"<@!{bot_user_id}>", "").strip() + return content + + +def classify_message_type(content: str, attachments: Iterable[Any]) -> str: + """Return the normalized Discord message type string.""" + if content.startswith("/"): + return "command" + + for attachment in attachments: + content_type = getattr(attachment, "content_type", None) + if not content_type: + continue + if content_type.startswith("image/"): + return "photo" + if content_type.startswith("video/"): + return "video" + if content_type.startswith("audio/"): + return "audio" + return "document" + + return "text" + + +def get_parent_channel_id(channel: Any) -> Optional[str]: + """Return the parent channel ID for a Discord thread-like channel, if present.""" + parent = getattr(channel, "parent", None) + if parent is not None and getattr(parent, "id", None) is not None: + return str(parent.id) + parent_id = getattr(channel, "parent_id", None) + if parent_id is not None: + return str(parent_id) + return None + + +def is_forum_parent(channel: Any) -> bool: + """Best-effort check for whether a Discord channel is a forum channel.""" + if channel is None: + return False + + forum_cls = getattr(discord, "ForumChannel", None) if discord else None + if forum_cls and isinstance(channel, forum_cls): + return True + + channel_type = getattr(channel, "type", None) + if channel_type is not None: + type_value = getattr(channel_type, "value", channel_type) + if type_value == 15: + return True + + return False + + +def format_thread_chat_name(thread: Any, is_forum_fn: Callable[[Any], bool]) -> str: + """Build a readable chat name for thread-like Discord channels.""" + thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread")) + parent = getattr(thread, "parent", None) + guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) + guild_name = getattr(guild, "name", None) + parent_name = getattr(parent, "name", None) + + if is_forum_fn(parent) and guild_name and parent_name: + return f"{guild_name} / {parent_name} / {thread_name}" + if parent_name and guild_name: + return f"{guild_name} / #{parent_name} / {thread_name}" + if parent_name: + return f"{parent_name} / {thread_name}" + return thread_name diff --git a/gateway/platforms/discord_impl/interactions.py b/gateway/platforms/discord_impl/interactions.py new file mode 100644 index 000000000000..00aa36b9cc7b --- /dev/null +++ b/gateway/platforms/discord_impl/interactions.py @@ -0,0 +1,108 @@ +"""Discord slash command wiring and interaction helpers.""" + +from __future__ import annotations + +from typing import Any + +from gateway.platforms.discord_impl import components as discord_components +from gateway.platforms.discord_impl import native_commands + +try: + import discord + DISCORD_AVAILABLE = True +except ImportError: # pragma: no cover - import guard + discord = None + DISCORD_AVAILABLE = False + + +def register_slash_commands(tree: Any, adapter: Any) -> None: + """Register Discord slash commands on the command tree.""" + native_commands.register_slash_commands(tree, adapter) + + +def build_slash_event(adapter: Any, interaction: discord.Interaction, text: str): + """Build a MessageEvent from a Discord slash command interaction.""" + return native_commands.build_slash_event(adapter, interaction, text) + + +def create_component_runtime() -> discord_components.DiscordComponentRuntime: + """Create a generic Discord component runtime.""" + return discord_components.DiscordComponentRuntime() + + +if DISCORD_AVAILABLE: + + def create_exec_approval_view( + adapter: Any, + approval_id: str, + allowed_user_ids: set, + runtime: discord_components.DiscordComponentRuntime | None = None, + ): + """Build a generic component-runtime approval view.""" + runtime = runtime or create_component_runtime() + view = discord_components.ManagedComponentView(runtime, timeout=300) + allowed = tuple(str(user_id) for user_id in allowed_user_ids) + + async def _resolve(invocation: discord_components.DiscordComponentInvocation, decision: str, color: Any) -> bool: + resolver = getattr(adapter, "_resolve_exec_approval", None) + if not callable(resolver): + await invocation.deny("Approval resolver is unavailable~") + return False + + result = resolver(decision=decision, approval_id=approval_id) + if hasattr(result, "__await__"): + result = await result + + embed = invocation.interaction.message.embeds[0] if invocation.interaction.message.embeds else None + if embed: + embed.color = color + embed.set_footer(text=f"{decision} by {invocation.interaction.user.display_name}") + + invocation.disable_all() + await invocation.interaction.response.edit_message(embed=embed, view=view) + + followup = getattr(invocation.interaction, "followup", None) + if followup is not None and hasattr(followup, "send"): + await followup.send(str(result), ephemeral=True) + return True + + view.add_button( + discord_components.DiscordButtonSpec( + label="Allow Once", + style="success", + allowed_user_ids=allowed, + handler=lambda invocation: _resolve( + invocation, + "allow-once", + discord.Color.green(), + ), + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Always Allow", + style="primary", + allowed_user_ids=allowed, + handler=lambda invocation: _resolve( + invocation, + "allow-always", + discord.Color.blue(), + ), + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Deny", + style="danger", + allowed_user_ids=allowed, + handler=lambda invocation: _resolve( + invocation, + "deny", + discord.Color.red(), + ), + ) + ) + return view + +else: # pragma: no cover - import guard + create_exec_approval_view = None diff --git a/gateway/platforms/discord_impl/messaging.py b/gateway/platforms/discord_impl/messaging.py new file mode 100644 index 000000000000..c1d7bca71e2c --- /dev/null +++ b/gateway/platforms/discord_impl/messaging.py @@ -0,0 +1,458 @@ +"""Discord message-operation helpers.""" + +from __future__ import annotations + +import inspect +from datetime import datetime +from typing import Any, Callable, Optional + +from gateway.platforms.base import SendResult +from gateway.platforms.discord_impl.delivery import resolve_channel + + +def normalize_edit_content( + content: str, + *, + format_message: Optional[Callable[[str], str]] = None, + max_message_length: int = 2000, +) -> str: + """Format and truncate edited message content to Discord limits.""" + formatter = format_message or (lambda value: value) + formatted = formatter(content) + if len(formatted) > max_message_length: + return formatted[: max_message_length - 3] + "..." + return formatted + + +def _timestamp_to_iso(value: Any) -> str: + if value is None: + return "" + if isinstance(value, datetime): + return value.isoformat() + isoformat = getattr(value, "isoformat", None) + if callable(isoformat): + return isoformat() + return str(value) + + +def _author_name(message: Any) -> str: + author = getattr(message, "author", None) + if author is None: + return "unknown" + return ( + getattr(author, "display_name", None) + or getattr(author, "name", None) + or str(getattr(author, "id", "unknown")) + ) + + +def _serialize_message(message: Any) -> dict[str, Any]: + author = getattr(message, "author", None) + attachments = [ + str(getattr(attachment, "url")) + for attachment in (getattr(message, "attachments", []) or []) + if getattr(attachment, "url", None) + ] + reference = getattr(message, "reference", None) + reply_to = getattr(reference, "message_id", None) if reference is not None else None + return { + "id": str(getattr(message, "id", "")), + "author_id": str(getattr(author, "id", "")), + "author_name": _author_name(message), + "content": str(getattr(message, "content", "") or ""), + "timestamp": _timestamp_to_iso(getattr(message, "created_at", None)), + "is_bot": bool(getattr(author, "bot", False)), + "attachments": attachments, + "reply_to": str(reply_to) if reply_to is not None else None, + } + + +async def _collect_items(iterable: Any) -> list[Any]: + if iterable is None: + return [] + if inspect.isawaitable(iterable): + iterable = await iterable + if hasattr(iterable, "__aiter__"): + return [item async for item in iterable] + return list(iterable) + + +def _clamp_limit(limit: int, *, default: int, minimum: int, maximum: int) -> int: + try: + value = int(limit) + except (TypeError, ValueError): + value = default + return max(minimum, min(maximum, value)) + + +def _thread_dict(thread: Any) -> dict[str, Any]: + parent = getattr(thread, "parent", None) + guild = getattr(thread, "guild", None) or getattr(parent, "guild", None) + return { + "id": str(getattr(thread, "id", "")), + "name": getattr(thread, "name", "") or "", + "parent_id": str(getattr(parent, "id", "")) if getattr(parent, "id", None) is not None else None, + "parent_name": getattr(parent, "name", None), + "guild_id": str(getattr(guild, "id", "")) if getattr(guild, "id", None) is not None else None, + "guild_name": getattr(guild, "name", None), + "archived": bool(getattr(thread, "archived", False)), + "locked": bool(getattr(thread, "locked", False)), + "message_count": getattr(thread, "message_count", None), + "member_count": getattr(thread, "member_count", None), + } + + +def _emoji_dict(emoji: Any) -> dict[str, Any]: + emoji_id = getattr(emoji, "id", None) + emoji_name = getattr(emoji, "name", None) + if emoji_name is None and isinstance(emoji, str): + emoji_name = emoji + return { + "id": str(emoji_id) if emoji_id is not None else None, + "name": emoji_name, + "raw": str(emoji) if emoji is not None else "", + } + + +def _user_dict(user: Any) -> dict[str, Any]: + username = getattr(user, "username", None) or getattr(user, "name", None) + discriminator = getattr(user, "discriminator", None) + if username and discriminator and str(discriminator) != "0": + tag = f"{username}#{discriminator}" + else: + tag = username + return { + "id": str(getattr(user, "id", "")), + "username": username, + "tag": tag, + } + + +def _normalize_emoji(emoji: Any) -> Any: + if isinstance(emoji, str): + return emoji.strip() + return emoji + + +async def fetch_channel_message( + client: Any, + chat_id: str, + message_id: str, +) -> tuple[Any | None, Any | None, SendResult | None]: + """Resolve a Discord channel and fetch a specific message from it.""" + channel = await resolve_channel(client, chat_id) + if not channel: + return None, None, SendResult(success=False, error=f"Channel {chat_id} not found") + + try: + message = await channel.fetch_message(int(message_id)) + except Exception as exc: + return channel, None, SendResult(success=False, error=str(exc)) + + return channel, message, None + + +async def edit_message( + client: Any, + chat_id: str, + message_id: str, + content: str, + *, + format_message: Optional[Callable[[str], str]] = None, + max_message_length: int = 2000, +) -> SendResult: + """Edit a previously sent Discord message.""" + if not client: + return SendResult(success=False, error="Not connected") + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None: + return error + + formatted = normalize_edit_content( + content, + format_message=format_message, + max_message_length=max_message_length, + ) + + try: + await message.edit(content=formatted) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=message_id) + + +async def delete_message( + client: Any, + chat_id: str, + message_id: str, +) -> SendResult: + """Delete a Discord message.""" + if not client: + return SendResult(success=False, error="Not connected") + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None: + return error + + try: + await message.delete() + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=message_id) + + +async def add_reaction( + client: Any, + chat_id: str, + message_id: str, + emoji: Any, +) -> SendResult: + """Add a reaction to a Discord message.""" + if not client: + return SendResult(success=False, error="Not connected") + + normalized_emoji = _normalize_emoji(emoji) + if not normalized_emoji: + return SendResult(success=False, error="Emoji is required") + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None: + return error + + try: + await message.add_reaction(normalized_emoji) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=message_id) + + +async def remove_reaction( + client: Any, + chat_id: str, + message_id: str, + emoji: Any, +) -> SendResult: + """Remove the connected bot's reaction from a Discord message.""" + if not client: + return SendResult(success=False, error="Not connected") + + normalized_emoji = _normalize_emoji(emoji) + if not normalized_emoji: + return SendResult(success=False, error="Emoji is required") + + member = getattr(client, "user", None) + if member is None: + return SendResult(success=False, error="Client user unavailable") + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None: + return error + + try: + await message.remove_reaction(normalized_emoji, member) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=message_id) + + +async def list_reactions( + client: Any, + chat_id: str, + message_id: str, + *, + limit: int = 100, +) -> list[dict[str, Any]]: + """List reactions on a Discord message with bounded user summaries.""" + if not client: + return [] + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None or message is None: + return [] + + user_limit = _clamp_limit(limit, default=100, minimum=1, maximum=100) + summaries: list[dict[str, Any]] = [] + for reaction in (getattr(message, "reactions", []) or []): + users_fn = getattr(reaction, "users", None) + try: + users = await _collect_items(users_fn(limit=user_limit)) if callable(users_fn) else [] + except Exception: + users = [] + summaries.append( + { + "emoji": _emoji_dict(getattr(reaction, "emoji", None)), + "count": int(getattr(reaction, "count", 0) or 0), + "users": [_user_dict(user) for user in users], + } + ) + return summaries + + +async def list_threads( + client: Any, + channel_id: str, + *, + include_archived: bool = False, + limit: int = 100, + before: Any = None, + private: bool = False, + joined: bool = False, +) -> list[dict[str, Any]]: + """List active and optionally archived threads for a channel.""" + channel = await resolve_channel(client, channel_id) + if channel is None: + return [] + + active_threads = [_thread_dict(thread) for thread in (getattr(channel, "threads", []) or [])] + if not include_archived: + return active_threads + + archived_threads_fn = getattr(channel, "archived_threads", None) + if not callable(archived_threads_fn): + return active_threads + + archived_threads = await _collect_items( + archived_threads_fn( + private=private, + joined=joined, + limit=_clamp_limit(limit, default=100, minimum=1, maximum=100), + before=before, + ) + ) + return active_threads + [_thread_dict(thread) for thread in archived_threads] + + +async def reply_in_thread( + client: Any, + thread_id: str, + content: str, + *, + reply_to: Optional[str] = None, + format_message: Optional[Callable[[str], str]] = None, + truncate_message: Optional[Callable[[str, int], list[str]]] = None, + max_message_length: int = 2000, + send_text_message: Optional[Callable[..., Any]] = None, +) -> SendResult: + """Send a message to a Discord thread after validating the target.""" + if not client: + return SendResult(success=False, error="Not connected") + + channel = await resolve_channel(client, thread_id) + if channel is None: + return SendResult(success=False, error=f"Channel {thread_id} not found") + + if getattr(channel, "parent", None) is None: + return SendResult(success=False, error=f"Channel {thread_id} is not a thread") + + formatter = format_message or (lambda value: value) + truncater = truncate_message or (lambda value, _max_len: [value]) + sender = send_text_message + if sender is None: + from gateway.platforms.discord_impl.delivery import send_text_message as default_send_text_message + sender = default_send_text_message + + formatted = formatter(content) + chunks = truncater(formatted, max_message_length) + message_ids: list[str] = [] + reference = None + + if reply_to: + try: + reference = await channel.fetch_message(int(reply_to)) + except Exception: + reference = None + + try: + for index, chunk in enumerate(chunks): + message = await sender( + channel, + chunk, + reference=reference if index == 0 else None, + ) + message_ids.append(str(message.id)) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult( + success=True, + message_id=message_ids[0] if message_ids else None, + raw_response={"message_ids": message_ids}, + ) + + +async def list_pins( + client: Any, + channel_id: str, + *, + limit: int = 50, + before: Any = None, + oldest_first: bool = False, +) -> list[dict[str, Any]]: + """List pinned messages for a channel or thread.""" + channel = await resolve_channel(client, channel_id) + if channel is None: + return [] + + pins_fn = getattr(channel, "pins", None) + if not callable(pins_fn): + return [] + + pins = await _collect_items( + pins_fn( + limit=_clamp_limit(limit, default=50, minimum=1, maximum=50), + before=before, + oldest_first=oldest_first, + ) + ) + return [_serialize_message(message) for message in pins] + + +async def pin_message( + client: Any, + chat_id: str, + message_id: str, + *, + reason: Optional[str] = None, +) -> SendResult: + """Pin a Discord message.""" + if not client: + return SendResult(success=False, error="Not connected") + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None: + return error + + try: + await message.pin(reason=reason) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=message_id) + + +async def unpin_message( + client: Any, + chat_id: str, + message_id: str, + *, + reason: Optional[str] = None, +) -> SendResult: + """Unpin a Discord message.""" + if not client: + return SendResult(success=False, error="Not connected") + + _channel, message, error = await fetch_channel_message(client, chat_id, message_id) + if error is not None: + return error + + try: + await message.unpin(reason=reason) + except Exception as exc: + return SendResult(success=False, error=str(exc)) + + return SendResult(success=True, message_id=message_id) diff --git a/gateway/platforms/discord_impl/model_picker.py b/gateway/platforms/discord_impl/model_picker.py new file mode 100644 index 000000000000..351a1632da60 --- /dev/null +++ b/gateway/platforms/discord_impl/model_picker.py @@ -0,0 +1,556 @@ +"""Discord-native model picker built on the generic component runtime.""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Awaitable, Callable, Optional, TypeVar + +from gateway.platforms.discord_impl import components as discord_components +from hermes_cli.models import curated_models_for_provider, list_available_providers, provider_label + + +RECENTS_FILE = "discord_model_recents.json" +RECENTS_LIMIT = 5 +RECENT_BUTTONS = 3 +PROVIDER_PAGE_SIZE = 25 +MODEL_PAGE_SIZE = 25 + +ApplySelectionFn = Callable[[str, str, Optional[str]], Awaitable[str]] +T = TypeVar("T") + + +@dataclass(frozen=True) +class RecentModel: + provider: str + model: str + + +@dataclass(frozen=True) +class ProviderItem: + provider_id: str + label: str + authenticated: bool + + +@dataclass +class ModelPickerState: + command_name: str + user_id: str + current_provider: str + current_model: str + pending_provider: str + pending_model: Optional[str] = None + provider_page: int = 1 + model_page: int = 1 + + def reset(self) -> None: + self.pending_provider = self.current_provider + self.pending_model = self.current_model + self.provider_page = 1 + self.model_page = 1 + + @property + def has_pending_change(self) -> bool: + return ( + self.pending_provider != self.current_provider + or (self.pending_model or "") != self.current_model + ) + + +def _hermes_home() -> Path: + return Path(os.getenv("HERMES_HOME", str(Path.home() / ".hermes"))) + + +def _recents_path(hermes_home: Optional[Path] = None) -> Path: + return (hermes_home or _hermes_home()) / RECENTS_FILE + + +def _read_recents(hermes_home: Optional[Path] = None) -> dict[str, list[dict[str, str]]]: + path = _recents_path(hermes_home) + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + + +def _write_recents(payload: dict[str, list[dict[str, str]]], hermes_home: Optional[Path] = None) -> None: + path = _recents_path(hermes_home) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + +def record_recent_model( + user_id: Optional[str], + provider: str, + model: str, + *, + hermes_home: Optional[Path] = None, +) -> None: + user_key = str(user_id or "").strip() + provider_key = str(provider or "").strip() + model_key = str(model or "").strip() + if not user_key or not provider_key or not model_key: + return + + payload = _read_recents(hermes_home) + entries = payload.get(user_key, []) + filtered = [ + entry + for entry in entries + if not ( + str(entry.get("provider", "")).strip() == provider_key + and str(entry.get("model", "")).strip() == model_key + ) + ] + filtered.insert(0, {"provider": provider_key, "model": model_key}) + payload[user_key] = filtered[:RECENTS_LIMIT] + _write_recents(payload, hermes_home) + + +def load_recent_models( + user_id: Optional[str], + *, + hermes_home: Optional[Path] = None, + providers: Optional[list[ProviderItem]] = None, +) -> list[RecentModel]: + user_key = str(user_id or "").strip() + if not user_key: + return [] + + provider_items = providers or get_provider_items() + provider_ids = {item.provider_id for item in provider_items} + recents = _read_recents(hermes_home).get(user_key, []) + filtered: list[RecentModel] = [] + + for entry in recents: + provider_id = str(entry.get("provider", "")).strip() + model_id = str(entry.get("model", "")).strip() + if not provider_id or not model_id or provider_id not in provider_ids: + continue + catalog = {model for model, _desc in curated_models_for_provider(provider_id)} + if catalog and model_id not in catalog: + continue + filtered.append(RecentModel(provider=provider_id, model=model_id)) + + return filtered[:RECENTS_LIMIT] + + +def get_provider_items() -> list[ProviderItem]: + items: list[ProviderItem] = [] + for provider in list_available_providers(): + provider_id = str(provider.get("id", "")).strip() + if not provider_id: + continue + items.append( + ProviderItem( + provider_id=provider_id, + label=str(provider.get("label") or provider_id), + authenticated=bool(provider.get("authenticated")), + ) + ) + return items + + +def _recent_button_label(recent: RecentModel) -> str: + label = recent.model.split("/")[-1] if "/" in recent.model else recent.model + if len(label) > 20: + return label[:17] + "..." + return label + + +def _paginate(items: list[T], page: int, page_size: int) -> tuple[list[T], int, int]: + if page_size < 1: + page_size = 1 + total_pages = max(1, (len(items) + page_size - 1) // page_size) + current_page = max(1, min(page, total_pages)) + start = (current_page - 1) * page_size + end = start + page_size + return items[start:end], current_page, total_pages + + +async def _send_initial_response(interaction: Any, content: str, view: Any) -> None: + response = getattr(interaction, "response", None) + if response is not None: + is_done = getattr(response, "is_done", None) + if callable(is_done) and not is_done(): + await response.send_message(content, ephemeral=True, view=view) + return + followup = getattr(interaction, "followup", None) + if followup is not None and hasattr(followup, "send"): + message = await followup.send(content, ephemeral=True, view=view) + if message is not None and hasattr(view, "bind_message") and getattr(message, "id", None) is not None: + view.bind_message(str(message.id)) + + +async def _edit_response(interaction: Any, content: str, view: Any | None) -> None: + message = getattr(interaction, "message", None) + if message is not None and hasattr(view, "bind_message") and getattr(message, "id", None) is not None: + view.bind_message(str(message.id)) + await interaction.response.edit_message(content=content, view=view) + + +class DiscordModelPickerController: + """Ephemeral provider/model picker scoped to one invoking Discord user.""" + + def __init__( + self, + *, + runtime: discord_components.DiscordComponentRuntime, + command_name: str, + user_id: str, + current_provider: str, + current_model: str, + apply_selection: ApplySelectionFn, + hermes_home: Optional[Path] = None, + ): + self.runtime = runtime + self.state = ModelPickerState( + command_name=command_name, + user_id=str(user_id), + current_provider=current_provider, + current_model=current_model, + pending_provider=current_provider, + pending_model=current_model, + ) + self._apply_selection = apply_selection + self._hermes_home = hermes_home + self._providers = get_provider_items() + + @property + def allowed_user_ids(self) -> tuple[str, ...]: + return (self.state.user_id,) + + def _recent_models(self) -> list[RecentModel]: + return load_recent_models( + self.state.user_id, + hermes_home=self._hermes_home, + providers=self._providers, + ) + + async def open(self, interaction: Any) -> None: + content, view = self._build_provider_view() + await _send_initial_response(interaction, content, view) + + def _provider_line(self, provider_id: str) -> str: + return f"`{provider_label(provider_id)}` (`{provider_id}`)" + + def _model_line(self, provider_id: str, model_id: str) -> str: + return f"`{model_id}` via {provider_label(provider_id)}" + + def _provider_options( + self, + page_items: list[ProviderItem], + ) -> tuple[discord_components.DiscordSelectOptionSpec, ...]: + return tuple( + discord_components.DiscordSelectOptionSpec( + label=item.label[:100], + value=item.provider_id, + description="configured" if item.authenticated else "auth required", + default=item.provider_id == self.state.pending_provider, + ) + for item in page_items + ) + + def _model_options( + self, + models: list[tuple[str, str]], + page: int, + ) -> tuple[discord_components.DiscordSelectOptionSpec, ...]: + page_items, current_page, _total_pages = _paginate(models, page, MODEL_PAGE_SIZE) + self.state.model_page = current_page + return tuple( + discord_components.DiscordSelectOptionSpec( + label=model_id[:100], + value=model_id, + description=(desc or provider_label(self.state.pending_provider))[:100], + default=model_id == self.state.pending_model, + ) + for model_id, desc in page_items + ) + + def _provider_header(self, page: int, total_pages: int) -> str: + lines = [ + "🧠 **Discord Model Picker**", + "", + f"**Current:** {self._model_line(self.state.current_provider, self.state.current_model)}", + f"**Pending:** {self._model_line(self.state.pending_provider, self.state.pending_model or self.state.current_model)}", + "", + f"Choose a provider for `/{self.state.command_name}`.", + f"**Providers:** page {page}/{total_pages}", + ] + recents = self._recent_models() + if recents: + lines.extend( + [ + "", + "**Recent models:**", + *[ + f"• {self._model_line(recent.provider, recent.model)}" + for recent in recents[:RECENT_BUTTONS] + ], + ] + ) + return "\n".join(lines) + + def _model_header(self, page: int, total_pages: int) -> str: + selected = self.state.pending_model or self.state.current_model + return "\n".join( + [ + "🧠 **Discord Model Picker**", + "", + f"**Current:** {self._model_line(self.state.current_provider, self.state.current_model)}", + f"**Pending:** {self._model_line(self.state.pending_provider, selected)}", + "", + f"Choose a model from {self._provider_line(self.state.pending_provider)}.", + f"**Models:** page {page}/{total_pages}", + "Submit applies the pending selection on the next message.", + ] + ) + + def _build_provider_view(self) -> tuple[str, Any]: + view = discord_components.ManagedComponentView(self.runtime, timeout=300) + page_items, current_page, total_pages = _paginate( + self._providers, + self.state.provider_page, + PROVIDER_PAGE_SIZE, + ) + self.state.provider_page = current_page + view.add_select( + discord_components.DiscordSelectSpec( + select_type="string", + placeholder="Choose a provider", + options=self._provider_options(page_items), + allowed_user_ids=self.allowed_user_ids, + reusable=True, + handler=self._handle_provider_select, + row=0, + ) + ) + + recents = self._recent_models()[:RECENT_BUTTONS] + for index, recent in enumerate(recents): + view.add_button( + discord_components.DiscordButtonSpec( + label=_recent_button_label(recent), + style="secondary", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + handler=self._make_recent_handler(recent), + row=1, + ) + ) + + view.add_button( + discord_components.DiscordButtonSpec( + label="Reset", + style="secondary", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + disabled=not self.state.has_pending_change, + handler=self._handle_reset, + row=2, + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Cancel", + style="danger", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + handler=self._handle_cancel, + row=2, + ) + ) + return self._provider_header(current_page, total_pages), view + + def _build_model_view(self) -> tuple[str, Any]: + view = discord_components.ManagedComponentView(self.runtime, timeout=300) + models = curated_models_for_provider(self.state.pending_provider) + _page_items, current_page, total_pages = _paginate(models, self.state.model_page, MODEL_PAGE_SIZE) + self.state.model_page = current_page + if models: + view.add_select( + discord_components.DiscordSelectSpec( + select_type="string", + placeholder="Choose a model", + options=self._model_options(models, current_page), + allowed_user_ids=self.allowed_user_ids, + reusable=True, + handler=self._handle_model_select, + row=0, + ) + ) + else: + self.state.pending_model = None + if total_pages > 1: + view.add_button( + discord_components.DiscordButtonSpec( + label="Prev", + style="secondary", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + disabled=current_page <= 1, + handler=self._handle_prev_models, + row=1, + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Next", + style="secondary", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + disabled=current_page >= total_pages, + handler=self._handle_next_models, + row=1, + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Back", + style="secondary", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + handler=self._handle_back, + row=2, + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Reset", + style="secondary", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + disabled=not self.state.has_pending_change, + handler=self._handle_reset, + row=2, + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Cancel", + style="danger", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + handler=self._handle_cancel, + row=2, + ) + ) + view.add_button( + discord_components.DiscordButtonSpec( + label="Submit", + style="success", + allowed_user_ids=self.allowed_user_ids, + reusable=True, + disabled=not self.state.has_pending_change, + handler=self._handle_submit, + row=2, + ) + ) + content = self._model_header(current_page, total_pages) + if not models: + content += "\n\nNo models are currently available for this provider." + return content, view + + def _make_recent_handler(self, recent: RecentModel) -> Callable[[discord_components.DiscordComponentInvocation], Awaitable[bool | None]]: + async def handler(invocation: discord_components.DiscordComponentInvocation) -> bool | None: + self.state.pending_provider = recent.provider + self.state.pending_model = recent.model + self.state.model_page = 1 + content, view = self._build_model_view() + await _edit_response(invocation.interaction, content, view) + return False + + return handler + + async def _handle_provider_select(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + selected = invocation.values[0] if invocation.values else "" + if not selected: + return False + self.state.pending_provider = selected + catalog = curated_models_for_provider(selected) + self.state.pending_model = catalog[0][0] if catalog else None + self.state.model_page = 1 + content, view = self._build_model_view() + await _edit_response(invocation.interaction, content, view) + return False + + async def _handle_model_select(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + selected = invocation.values[0] if invocation.values else "" + if not selected: + return False + self.state.pending_model = selected + content, view = self._build_model_view() + await _edit_response(invocation.interaction, content, view) + return False + + async def _handle_prev_models(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + self.state.model_page = max(1, self.state.model_page - 1) + content, view = self._build_model_view() + await _edit_response(invocation.interaction, content, view) + return False + + async def _handle_next_models(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + self.state.model_page += 1 + content, view = self._build_model_view() + await _edit_response(invocation.interaction, content, view) + return False + + async def _handle_back(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + content, view = self._build_provider_view() + await _edit_response(invocation.interaction, content, view) + return False + + async def _handle_reset(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + self.state.reset() + content, view = self._build_provider_view() + await _edit_response(invocation.interaction, content, view) + return False + + async def _handle_cancel(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + await _edit_response( + invocation.interaction, + "🧠 **Discord Model Picker**\n\nCancelled. Current model unchanged.", + None, + ) + return None + + async def _handle_submit(self, invocation: discord_components.DiscordComponentInvocation) -> bool | None: + model_name = self.state.pending_model or self.state.current_model + result = await self._apply_selection( + self.state.pending_provider, + model_name, + self.state.user_id, + ) + await _edit_response(invocation.interaction, result, None) + return None + + +async def open_model_picker( + *, + adapter: Any, + interaction: Any, + command_name: str, + user_id: str, + current_provider: str, + current_model: str, + apply_selection: ApplySelectionFn, +) -> None: + """Open the interactive model picker for a Discord slash interaction.""" + runtime = getattr(adapter, "_component_runtime", None) + if runtime is None: + raise RuntimeError("Discord component runtime is unavailable") + controller = DiscordModelPickerController( + runtime=runtime, + command_name=command_name, + user_id=user_id, + current_provider=current_provider, + current_model=current_model, + apply_selection=apply_selection, + ) + await controller.open(interaction) diff --git a/gateway/platforms/discord_impl/native_commands.py b/gateway/platforms/discord_impl/native_commands.py new file mode 100644 index 000000000000..56f311c20980 --- /dev/null +++ b/gateway/platforms/discord_impl/native_commands.py @@ -0,0 +1,1347 @@ +"""Structured Discord native command registration and command UX helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Optional, Sequence + +from gateway.platforms.discord_impl import components as discord_components +from gateway.platforms.discord_impl import command_sessions + +try: + import discord + + DISCORD_AVAILABLE = True +except ImportError: # pragma: no cover - import guard + discord = None + DISCORD_AVAILABLE = False + + +@dataclass(frozen=True) +class DiscordChoiceSpec: + name: str + value: str + description: Optional[str] = None + + +@dataclass(frozen=True) +class DiscordArgSpec: + name: str + description: str + kind: str = "str" # "str" or "int" + default: Any = "" + required: bool = False + choices: tuple[DiscordChoiceSpec, ...] = () + prefer_autocomplete: bool = False + allow_fallback_menu: bool = False + + +@dataclass(frozen=True) +class DiscordNativeCommandSpec: + name: str + description: str + route: str # "simple", "dispatch", "thread" + command_factory: Callable[..., str] | None = None + args: tuple[DiscordArgSpec, ...] = () + followup_msg: Optional[str] = None + defer_ephemeral: bool = True + + +def _join_command(base: str, *parts: Any) -> str: + suffix = " ".join(str(part).strip() for part in parts if str(part).strip()) + return f"{base} {suffix}".strip() + + +def get_command_specs() -> tuple[DiscordNativeCommandSpec, ...]: + """Return the structured Discord-native command spec set.""" + voice_choices = ( + DiscordChoiceSpec("channel — join your voice channel", "channel"), + DiscordChoiceSpec("leave — leave voice channel", "leave"), + DiscordChoiceSpec("on — voice reply to voice messages", "on"), + DiscordChoiceSpec("tts — voice reply to all messages", "tts"), + DiscordChoiceSpec("off — text only", "off"), + DiscordChoiceSpec("status — show current mode", "status"), + ) + vc_choices = ( + DiscordChoiceSpec("join — join your current voice channel", "join"), + DiscordChoiceSpec("leave — leave the active voice channel", "leave"), + DiscordChoiceSpec("status — show current voice channel status", "status"), + ) + think_choices = ( + DiscordChoiceSpec("off — disable reasoning effort", "off"), + DiscordChoiceSpec("minimal — minimum reasoning", "minimal"), + DiscordChoiceSpec("low — lighter reasoning", "low"), + DiscordChoiceSpec("medium — balanced reasoning", "medium"), + DiscordChoiceSpec("high — deeper reasoning", "high"), + DiscordChoiceSpec("xhigh — maximum reasoning", "xhigh"), + ) + reasoning_choices = ( + DiscordChoiceSpec("off — hide reasoning blocks", "off"), + DiscordChoiceSpec("on — show reasoning blocks", "on"), + DiscordChoiceSpec("hide — hide reasoning blocks", "hide"), + DiscordChoiceSpec("show — show reasoning blocks", "show"), + DiscordChoiceSpec("none — disable reasoning effort", "none"), + DiscordChoiceSpec("minimal — minimum reasoning", "minimal"), + DiscordChoiceSpec("low — lighter reasoning", "low"), + DiscordChoiceSpec("medium — balanced reasoning", "medium"), + DiscordChoiceSpec("high — deeper reasoning", "high"), + DiscordChoiceSpec("xhigh — maximum reasoning", "xhigh"), + ) + approval_choices = ( + DiscordChoiceSpec("allow-once — run this command once", "allow-once"), + DiscordChoiceSpec("allow-always — permanently allow this pattern", "allow-always"), + DiscordChoiceSpec("deny — reject this command", "deny"), + ) + send_choices = ( + DiscordChoiceSpec("on — allow send_message for this session", "on"), + DiscordChoiceSpec("off — block send_message for this session", "off"), + DiscordChoiceSpec("inherit — use the default behavior", "inherit"), + ) + activation_choices = ( + DiscordChoiceSpec("mention — require an explicit mention", "mention"), + DiscordChoiceSpec("always — respond without a mention", "always"), + ) + session_choices = ( + DiscordChoiceSpec("idle — inactivity auto-unfocus window", "idle"), + DiscordChoiceSpec("max-age — hard focus lifetime", "max-age"), + DiscordChoiceSpec("status — show current focus binding", "status"), + ) + context_choices = ( + DiscordChoiceSpec("list — concise context summary", "list"), + DiscordChoiceSpec("detail — full context breakdown", "detail"), + DiscordChoiceSpec("json — machine-readable snapshot", "json"), + ) + allowlist_choices = ( + DiscordChoiceSpec("list — show approved command patterns", "list"), + DiscordChoiceSpec("add — permanently approve a pattern", "add"), + DiscordChoiceSpec("remove — remove a stored pattern", "remove"), + ) + config_choices = ( + DiscordChoiceSpec("show — show the current on-disk config", "show"), + DiscordChoiceSpec("get — read one config or env key", "get"), + DiscordChoiceSpec("set — write one config or env key", "set"), + DiscordChoiceSpec("unset — remove one config or env key", "unset"), + ) + debug_choices = ( + DiscordChoiceSpec("show — show runtime override status", "show"), + DiscordChoiceSpec("set — set a runtime override", "set"), + DiscordChoiceSpec("unset — remove a runtime override", "unset"), + DiscordChoiceSpec("reset — clear all runtime overrides", "reset"), + ) + subagent_choices = ( + DiscordChoiceSpec("list — list sub-agent runs", "list"), + DiscordChoiceSpec("kill — kill a sub-agent run", "kill"), + DiscordChoiceSpec("log — show sub-agent logs", "log"), + DiscordChoiceSpec("info — inspect a sub-agent run", "info"), + DiscordChoiceSpec("send — send input to a sub-agent", "send"), + DiscordChoiceSpec("steer — steer a sub-agent", "steer"), + DiscordChoiceSpec("spawn — spawn a sub-agent", "spawn"), + ) + acp_choices = ( + DiscordChoiceSpec("spawn — create an ACP session", "spawn"), + DiscordChoiceSpec("cancel — cancel ACP work", "cancel"), + DiscordChoiceSpec("steer — steer ACP work", "steer"), + DiscordChoiceSpec("close — close an ACP session", "close"), + DiscordChoiceSpec("status — show ACP status", "status"), + DiscordChoiceSpec("set-mode — update ACP mode", "set-mode"), + DiscordChoiceSpec("set — set ACP runtime options", "set"), + DiscordChoiceSpec("cwd — change ACP cwd", "cwd"), + DiscordChoiceSpec("permissions — change ACP permissions", "permissions"), + DiscordChoiceSpec("timeout — change ACP timeout", "timeout"), + DiscordChoiceSpec("model — change ACP model", "model"), + DiscordChoiceSpec("reset-options — clear ACP options", "reset-options"), + DiscordChoiceSpec("doctor — inspect ACP health", "doctor"), + DiscordChoiceSpec("install — install ACP tooling", "install"), + DiscordChoiceSpec("sessions — list ACP sessions", "sessions"), + ) + + return ( + DiscordNativeCommandSpec( + "new", + "Start a new conversation", + "simple", + lambda: "/reset", + followup_msg="New conversation started~", + ), + DiscordNativeCommandSpec( + "reset", + "Reset your Hermes session", + "simple", + lambda: "/reset", + followup_msg="Session reset~", + ), + DiscordNativeCommandSpec("help", "Show available commands", "simple", lambda: "/help"), + DiscordNativeCommandSpec( + "commands", + "Show the full command catalog", + "simple", + lambda: "/commands", + ), + DiscordNativeCommandSpec( + "context", + "Show context summary, detail, or JSON", + "simple", + lambda mode="": _join_command("/context", mode), + args=( + DiscordArgSpec( + "mode", + "Context mode: list, detail, or json", + choices=context_choices, + default="list", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "export-session", + "Export the current session snapshot", + "simple", + lambda path="": _join_command("/export-session", path), + args=( + DiscordArgSpec( + "path", + "Optional export path (.html or .json). Leave empty for the default HTML export.", + ), + ), + ), + DiscordNativeCommandSpec( + "export", + "Alias for /export-session", + "simple", + lambda path="": _join_command("/export", path), + args=( + DiscordArgSpec( + "path", + "Optional export path (.html or .json). Leave empty for the default HTML export.", + ), + ), + ), + DiscordNativeCommandSpec( + "whoami", + "Show the Discord identity Hermes sees", + "simple", + lambda: "/whoami", + ), + DiscordNativeCommandSpec( + "focus", + "Bind the current Discord thread to this Hermes session", + "dispatch", + lambda name="": _join_command("/focus", name), + args=( + DiscordArgSpec( + "name", + "Optional label for the current thread binding", + ), + ), + ), + DiscordNativeCommandSpec( + "unfocus", + "Remove the current Discord thread binding", + "simple", + lambda: "/unfocus", + ), + DiscordNativeCommandSpec( + "agents", + "Show Discord thread bindings for this session", + "simple", + lambda: "/agents", + ), + DiscordNativeCommandSpec( + "session", + "Manage thread binding idle and max-age controls", + "dispatch", + lambda mode="", value="": _join_command("/session", mode, value), + args=( + DiscordArgSpec( + "mode", + "Thread binding control: idle, max-age, or status", + choices=session_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "value", + "Duration like 30m, 2h, 1d, or off", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "id", + "Alias for /whoami", + "simple", + lambda: "/id", + ), + DiscordNativeCommandSpec( + "status", + "Show Hermes session status", + "simple", + lambda: "/status", + followup_msg="Status sent~", + ), + DiscordNativeCommandSpec( + "model", + "Show or change the model", + "simple", + lambda name="": _join_command("/model", name), + args=( + DiscordArgSpec( + "name", + "Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.", + ), + ), + ), + DiscordNativeCommandSpec( + "models", + "Open the interactive model picker", + "simple", + lambda: "/models", + ), + DiscordNativeCommandSpec( + "reasoning", + "Show or change reasoning effort", + "dispatch", + lambda effort="": _join_command("/reasoning", effort), + args=( + DiscordArgSpec( + "effort", + "Reasoning effort: xhigh, high, medium, low, minimal, or none.", + choices=reasoning_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "think", + "Set reasoning effort quickly", + "dispatch", + lambda effort="": _join_command("/think", effort), + args=( + DiscordArgSpec( + "effort", + "Thinking effort: off, minimal, low, medium, high, or xhigh.", + choices=think_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "personality", + "Set a personality", + "simple", + lambda name="": _join_command("/personality", name), + args=( + DiscordArgSpec( + "name", + "Personality name. Leave empty to list available.", + ), + ), + ), + DiscordNativeCommandSpec( + "retry", + "Retry your last message", + "simple", + lambda: "/retry", + followup_msg="Retrying~", + ), + DiscordNativeCommandSpec("undo", "Remove the last exchange", "simple", lambda: "/undo"), + DiscordNativeCommandSpec( + "sethome", + "Set this chat as the home channel", + "simple", + lambda: "/sethome", + ), + DiscordNativeCommandSpec( + "stop", + "Stop the running Hermes agent", + "simple", + lambda: "/stop", + followup_msg="Stop requested~", + ), + DiscordNativeCommandSpec( + "compact", + "Compress conversation context with optional instructions", + "simple", + lambda instructions="": _join_command("/compact", instructions), + args=( + DiscordArgSpec( + "instructions", + "Optional compression guidance for what to preserve.", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "compress", + "Compress conversation context", + "simple", + lambda: "/compress", + ), + DiscordNativeCommandSpec( + "title", + "Set or show the session title", + "simple", + lambda name="": _join_command("/title", name), + args=( + DiscordArgSpec( + "name", + "Session title. Leave empty to show current.", + ), + ), + ), + DiscordNativeCommandSpec( + "resume", + "Resume a previously-named session", + "simple", + lambda name="": _join_command("/resume", name), + args=( + DiscordArgSpec( + "name", + "Session name to resume. Leave empty to list sessions.", + ), + ), + ), + DiscordNativeCommandSpec( + "usage", + "Show token usage for this session", + "simple", + lambda: "/usage", + ), + DiscordNativeCommandSpec( + "provider", + "Show available providers", + "simple", + lambda: "/provider", + ), + DiscordNativeCommandSpec( + "insights", + "Show usage insights and analytics", + "simple", + lambda days=7: _join_command("/insights", days), + args=( + DiscordArgSpec( + "days", + "Number of days to analyze (default: 7)", + kind="int", + default=7, + ), + ), + ), + DiscordNativeCommandSpec( + "reload-mcp", + "Reload MCP servers from config", + "simple", + lambda: "/reload-mcp", + ), + DiscordNativeCommandSpec( + "skill", + "Run a skill by name", + "dispatch", + lambda name="", input="": _join_command("/skill", name, input), + args=( + DiscordArgSpec( + "name", + "Skill name to invoke", + default="", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "input", + "Optional user input for the skill", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "subagents", + "Inspect or control sub-agent runs", + "simple", + lambda mode="", payload="": _join_command("/subagents", mode, payload), + args=( + DiscordArgSpec( + "mode", + "Sub-agent action: list, kill, log, info, send, steer, or spawn", + choices=subagent_choices, + default="list", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "payload", + "Optional sub-agent command arguments", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "kill", + "Abort a running sub-agent", + "simple", + lambda target="": _join_command("/kill", target), + args=( + DiscordArgSpec( + "target", + "Target sub-agent id, ordinal, or all", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "steer", + "Steer a running sub-agent", + "simple", + lambda target="", message="": _join_command("/steer", target, message), + args=( + DiscordArgSpec( + "target", + "Target sub-agent id or ordinal", + default="", + ), + DiscordArgSpec( + "message", + "Steer message", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "tell", + "Alias for /steer", + "simple", + lambda target="", message="": _join_command("/tell", target, message), + args=( + DiscordArgSpec( + "target", + "Target sub-agent id or ordinal", + default="", + ), + DiscordArgSpec( + "message", + "Steer message", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "acp", + "Inspect or control ACP runtime sessions", + "simple", + lambda mode="", payload="": _join_command("/acp", mode, payload), + args=( + DiscordArgSpec( + "mode", + "ACP action to run", + choices=acp_choices, + default="status", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "payload", + "Optional ACP command arguments", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "bash", + "Run a host shell command through gateway control", + "simple", + lambda command="": _join_command("/bash", command), + args=( + DiscordArgSpec( + "command", + "Host shell command to run", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "voice", + "Toggle voice reply mode", + "dispatch", + lambda mode="": _join_command("/voice", mode), + args=( + DiscordArgSpec( + "mode", + "Voice mode: on, off, tts, channel, leave, or status", + choices=voice_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "vc", + "Join, leave, or inspect Discord voice channel state", + "dispatch", + lambda mode="": _join_command("/vc", mode), + args=( + DiscordArgSpec( + "mode", + "Voice channel command: join, leave, or status", + choices=vc_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "approve", + "Resolve a pending command approval", + "dispatch", + lambda decision="", approval_id="": _join_command("/approve", approval_id, decision), + args=( + DiscordArgSpec( + "decision", + "Approval decision: allow-once, allow-always, or deny", + choices=approval_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "approval_id", + "Approval ID shown in the approval prompt footer. Optional for the current chat.", + ), + ), + ), + DiscordNativeCommandSpec( + "allowlist", + "Inspect or edit the command allowlist", + "simple", + lambda mode="", entry="": _join_command("/allowlist", mode, entry), + args=( + DiscordArgSpec( + "mode", + "Allowlist action: list, add, or remove", + choices=allowlist_choices, + default="list", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "entry", + "Pattern key to add or remove. Leave empty for list.", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "config", + "Show or update Hermes configuration", + "simple", + lambda mode="", key="", value="": _join_command("/config", mode, key, value), + args=( + DiscordArgSpec( + "mode", + "Config action: show, get, set, or unset", + choices=config_choices, + default="show", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "key", + "Dotted config path or env var name", + default="", + ), + DiscordArgSpec( + "value", + "Value to set. YAML scalars and JSON-style lists work.", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "debug", + "Inspect runtime override parity status", + "simple", + lambda mode="", key="", value="": _join_command("/debug", mode, key, value), + args=( + DiscordArgSpec( + "mode", + "Debug action: show, set, unset, or reset", + choices=debug_choices, + default="show", + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + DiscordArgSpec( + "key", + "Runtime override key", + default="", + ), + DiscordArgSpec( + "value", + "Runtime override value", + default="", + ), + ), + ), + DiscordNativeCommandSpec( + "send", + "Control whether this session may use send_message", + "dispatch", + lambda mode="": _join_command("/send", mode), + args=( + DiscordArgSpec( + "mode", + "Send policy: on, off, or inherit", + choices=send_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "activation", + "Control mention-vs-always activation for this Discord chat", + "dispatch", + lambda mode="": _join_command("/activation", mode), + args=( + DiscordArgSpec( + "mode", + "Activation mode: mention or always", + choices=activation_choices, + prefer_autocomplete=True, + allow_fallback_menu=True, + ), + ), + ), + DiscordNativeCommandSpec( + "update", + "Update Hermes Agent to the latest version", + "simple", + lambda: "/update", + followup_msg="Update initiated~", + ), + DiscordNativeCommandSpec( + "restart", + "Restart the Hermes gateway", + "simple", + lambda: "/restart", + followup_msg="Restart scheduled~", + ), + DiscordNativeCommandSpec( + "dock-telegram", + "Dock replies for this session to the Telegram home channel", + "simple", + lambda: "/dock-telegram", + ), + DiscordNativeCommandSpec( + "dock-discord", + "Dock replies for this session to the Discord home channel", + "simple", + lambda: "/dock-discord", + ), + DiscordNativeCommandSpec( + "dock-slack", + "Dock replies for this session to the Slack home channel", + "simple", + lambda: "/dock-slack", + ), + DiscordNativeCommandSpec( + "thread", + "Create a new thread and start a Hermes session in it", + "thread", + args=( + DiscordArgSpec("name", "Thread name"), + DiscordArgSpec( + "message", + "Optional first message to send to Hermes in the thread", + default="", + ), + DiscordArgSpec( + "auto_archive_duration", + "Auto-archive in minutes (60, 1440, 4320, 10080)", + kind="int", + default=1440, + ), + ), + ), + ) + + +def extract_inline_shortcut(text: str) -> tuple[str | None, str]: + """Return the first supported inline shortcut and remaining text.""" + return command_sessions.extract_inline_shortcut(text) + + +def _resolve_arg_choices( + adapter: Any, + spec: DiscordNativeCommandSpec, + arg: DiscordArgSpec, + *, + interaction: Any | None = None, + current_kwargs: Optional[dict[str, Any]] = None, +) -> tuple[DiscordChoiceSpec, ...]: + del adapter, interaction, current_kwargs + if spec.name == "skill" and arg.name == "name": + try: + from agent.skill_commands import get_skill_commands + + choices: list[DiscordChoiceSpec] = [] + for command_name, payload in sorted(get_skill_commands().items()): + value = command_name.lstrip("/") + description = str(payload.get("description") or "Skill command") + choices.append(DiscordChoiceSpec(value, value, description[:100] or None)) + return tuple(choices) + except Exception: + return () + return arg.choices + + +def _format_choice_label(label: str, *, limit: int = 80) -> str: + if len(label) <= limit: + return label + return label[: limit - 3] + "..." + + +async def _send_or_edit_interaction_view(interaction: Any, content: str, view: Any) -> None: + response = getattr(interaction, "response", None) + if response is not None and hasattr(response, "edit_message") and getattr(interaction, "message", None) is not None: + try: + await response.edit_message(content=content, view=view) + return + except Exception: + pass + if response is not None and hasattr(response, "send_message"): + is_done = getattr(response, "is_done", None) + if not callable(is_done) or not is_done(): + await response.send_message(content, ephemeral=True, view=view) + return + followup = getattr(interaction, "followup", None) + if followup is not None and hasattr(followup, "send"): + await followup.send(content, ephemeral=True, view=view) + + +async def _open_arg_fallback( + adapter: Any, + interaction: Any, + spec: DiscordNativeCommandSpec, + arg: DiscordArgSpec, + current_kwargs: dict[str, Any], +) -> bool: + choices = _resolve_arg_choices(adapter, spec, arg, interaction=interaction, current_kwargs=current_kwargs) + if not choices or not arg.allow_fallback_menu: + return False + + runtime = getattr(adapter, "_component_runtime", None) + if runtime is None or getattr(discord_components, "ManagedComponentView", None) is None: + return False + + view = discord_components.ManagedComponentView(runtime, timeout=300) + allowed_user_id = str(getattr(getattr(interaction, "user", None), "id", "") or "") + prompt = f"Choose `{arg.name}` for `/{spec.name}`." + + async def _choose(invocation: discord_components.DiscordComponentInvocation, value: str) -> bool: + next_kwargs = dict(current_kwargs) + next_kwargs[arg.name] = value + await _dispatch(adapter, invocation.interaction, spec, **next_kwargs) + return True + + if len(choices) <= 5: + for choice in choices: + view.add_button( + discord_components.DiscordButtonSpec( + label=_format_choice_label(choice.name, limit=40), + style="primary", + allowed_user_ids=(allowed_user_id,), + handler=lambda invocation, value=choice.value: _choose(invocation, value), + ) + ) + else: + view.add_select( + discord_components.DiscordSelectSpec( + select_type="string", + placeholder=arg.description, + options=tuple( + discord_components.DiscordSelectOptionSpec( + label=_format_choice_label(choice.name, limit=100), + value=choice.value, + description=choice.description, + ) + for choice in choices[:25] + ), + allowed_user_ids=(allowed_user_id,), + handler=lambda invocation: _choose(invocation, invocation.values[0]), + ) + ) + + await _send_or_edit_interaction_view(interaction, prompt, view) + return True + + +async def _autocomplete_choices( + adapter: Any, + spec: DiscordNativeCommandSpec, + arg: DiscordArgSpec, + interaction: Any, + current: str, +) -> list[Any]: + choices = _resolve_arg_choices(adapter, spec, arg, interaction=interaction) + query = str(current or "").strip().lower() + filtered = [ + choice + for choice in choices + if not query + or query in choice.name.lower() + or query in choice.value.lower() + ] + return [ + discord.app_commands.Choice(name=choice.name, value=choice.value) + for choice in filtered[:25] + ] + + +def _build_choices_decorator( + adapter: Any, + spec: DiscordNativeCommandSpec, + arg: DiscordArgSpec, +): + choices = _resolve_arg_choices(adapter, spec, arg) + use_autocomplete = bool(arg.prefer_autocomplete and choices) + if use_autocomplete: + autocomplete_factory = getattr(discord.app_commands, "autocomplete", None) + if autocomplete_factory is None: + return (lambda fn: fn), None + + async def autocomplete_callback(interaction, current): + return await _autocomplete_choices( + adapter, + spec, + arg, + interaction, + current, + ) + + return None, discord.app_commands.autocomplete( + **{arg.name: autocomplete_callback} + ) + + if choices: + return discord.app_commands.choices( + **{ + arg.name: [ + discord.app_commands.Choice(name=choice.name, value=choice.value) + for choice in choices[:25] + ] + } + ), None + + identity = lambda fn: fn + return identity, None + + +def _register_zero_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None: + @tree.command(name=spec.name, description=spec.description) + async def callback(interaction: Any): + await _dispatch(adapter, interaction, spec) + + +def _register_single_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None: + arg = spec.args[0] + choices_decorator, autocomplete_decorator = _build_choices_decorator(adapter, spec, arg) + if choices_decorator is None: + choices_decorator = lambda fn: fn + if autocomplete_decorator is None: + autocomplete_decorator = lambda fn: fn + + if arg.name == "name": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(name=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + name: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, name=name) + return + + if arg.name == "effort": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(effort=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + effort: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, effort=effort) + return + + if arg.name == "days": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(days=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + days: int = arg.default, + ): + await _dispatch(adapter, interaction, spec, days=days) + return + + if arg.name == "mode": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(mode=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + mode: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, mode=mode) + return + + if arg.name == "path": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(path=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + path: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, path=path) + return + + if arg.name == "instructions": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(instructions=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + instructions: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, instructions=instructions) + return + + if arg.name == "target": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(target=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + target: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, target=target) + return + + if arg.name == "command": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(command=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + command: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, command=command) + return + + if arg.name == "decision": + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe(decision=arg.description) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + decision: str = arg.default, + ): + await _dispatch(adapter, interaction, spec, decision=decision) + return + + raise ValueError(f"Unsupported Discord arg registration for {spec.name}:{arg.name}") + + +def _register_double_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None: + first, second = spec.args + choices_decorator, autocomplete_decorator = _build_choices_decorator(adapter, spec, first) + if choices_decorator is None: + choices_decorator = lambda fn: fn + if autocomplete_decorator is None: + autocomplete_decorator = lambda fn: fn + + if (first.name, second.name) == ("decision", "approval_id"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + decision=first.description, + approval_id=second.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + decision: str = first.default, + approval_id: str = second.default, + ): + await _dispatch( + adapter, + interaction, + spec, + decision=decision, + approval_id=approval_id, + ) + return + + if (first.name, second.name) == ("mode", "value"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + mode=first.description, + value=second.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + mode: str = first.default, + value: str = second.default, + ): + await _dispatch( + adapter, + interaction, + spec, + mode=mode, + value=value, + ) + return + + if (first.name, second.name) == ("mode", "entry"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + mode=first.description, + entry=second.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + mode: str = first.default, + entry: str = second.default, + ): + await _dispatch( + adapter, + interaction, + spec, + mode=mode, + entry=entry, + ) + return + + if (first.name, second.name) == ("name", "input"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + name=first.description, + input=second.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + name: str = first.default, + input: str = second.default, + ): + await _dispatch( + adapter, + interaction, + spec, + name=name, + input=input, + ) + return + + if (first.name, second.name) == ("mode", "payload"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + mode=first.description, + payload=second.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + mode: str = first.default, + payload: str = second.default, + ): + await _dispatch( + adapter, + interaction, + spec, + mode=mode, + payload=payload, + ) + return + + if (first.name, second.name) == ("target", "message"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + target=first.description, + message=second.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + target: str = first.default, + message: str = second.default, + ): + await _dispatch( + adapter, + interaction, + spec, + target=target, + message=message, + ) + return + + raise ValueError( + f"Unsupported Discord command spec shape for {spec.name}:{first.name},{second.name}" + ) + + +def _register_triple_arg_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None: + first, second, third = spec.args + choices_decorator, autocomplete_decorator = _build_choices_decorator(adapter, spec, first) + if choices_decorator is None: + choices_decorator = lambda fn: fn + if autocomplete_decorator is None: + autocomplete_decorator = lambda fn: fn + + if (first.name, second.name, third.name) == ("mode", "key", "value"): + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + mode=first.description, + key=second.description, + value=third.description, + ) + @autocomplete_decorator + @choices_decorator + async def callback( + interaction: Any, + mode: str = first.default, + key: str = second.default, + value: str = third.default, + ): + await _dispatch( + adapter, + interaction, + spec, + mode=mode, + key=key, + value=value, + ) + return + + raise ValueError( + f"Unsupported Discord command spec shape for {spec.name}:{first.name},{second.name},{third.name}" + ) + + +def _register_thread_command(tree: Any, adapter: Any, spec: DiscordNativeCommandSpec) -> None: + @tree.command(name=spec.name, description=spec.description) + @discord.app_commands.describe( + name=spec.args[0].description, + message=spec.args[1].description, + auto_archive_duration=spec.args[2].description, + ) + async def callback( + interaction: Any, + name: str, + message: str = "", + auto_archive_duration: int = 1440, + ): + await _dispatch( + adapter, + interaction, + spec, + name=name, + message=message, + auto_archive_duration=auto_archive_duration, + ) + + +def register_slash_commands(tree: Any, adapter: Any) -> None: + """Register Discord slash commands from structured specs.""" + if tree is None or not DISCORD_AVAILABLE: + return + + for spec in get_command_specs(): + if spec.route == "thread": + _register_thread_command(tree, adapter, spec) + continue + if len(spec.args) == 0: + _register_zero_arg_command(tree, adapter, spec) + continue + if len(spec.args) == 1: + _register_single_arg_command(tree, adapter, spec) + continue + if len(spec.args) == 2: + _register_double_arg_command(tree, adapter, spec) + continue + if len(spec.args) == 3: + _register_triple_arg_command(tree, adapter, spec) + continue + raise ValueError(f"Unsupported Discord command spec shape for {spec.name}") + + +def build_slash_event(adapter: Any, interaction: Any, text: str): + """Build a slash event via the command-session helper.""" + return command_sessions.build_slash_event(adapter, interaction, text) + + +async def _dispatch(adapter: Any, interaction: Any, spec: DiscordNativeCommandSpec, **kwargs: Any) -> None: + missing_choice_arg = next( + ( + arg + for arg in spec.args + if arg.allow_fallback_menu + and not str(kwargs.get(arg.name, "") or "").strip() + and _resolve_arg_choices(adapter, spec, arg, interaction=interaction, current_kwargs=kwargs) + ), + None, + ) + if missing_choice_arg is not None: + opened = await _open_arg_fallback( + adapter, + interaction, + spec, + missing_choice_arg, + kwargs, + ) + if opened: + return + + if spec.route == "simple": + command_text = spec.command_factory(**kwargs) if spec.command_factory else f"/{spec.name}" + await adapter._run_simple_slash(interaction, command_text, spec.followup_msg) + return + + if spec.route == "dispatch": + if spec.defer_ephemeral: + await interaction.response.defer(ephemeral=True) + command_text = spec.command_factory(**kwargs) if spec.command_factory else f"/{spec.name}" + response = await adapter._invoke_native_slash_command(interaction, command_text) + if response: + await adapter._send_native_slash_content(interaction, response) + return + + if spec.route == "thread": + if spec.defer_ephemeral: + await interaction.response.defer(ephemeral=True) + await adapter._handle_thread_create_slash( + interaction, + kwargs["name"], + kwargs.get("message", ""), + kwargs.get("auto_archive_duration", 1440), + ) + return + + raise ValueError(f"Unsupported Discord command route: {spec.route}") diff --git a/gateway/platforms/discord_impl/permissions.py b/gateway/platforms/discord_impl/permissions.py new file mode 100644 index 000000000000..8fa7bd91db54 --- /dev/null +++ b/gateway/platforms/discord_impl/permissions.py @@ -0,0 +1,202 @@ +"""Discord permission introspection. + +Channel/thread access checks, bot permission queries, and visibility helpers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional + +from gateway.platforms.discord_impl.delivery import resolve_channel + +try: + import discord +except ImportError: # pragma: no cover - import guard + discord = None + + +@dataclass +class ChannelPermissions: + """Bot's effective permissions in a Discord channel.""" + + channel_id: str + channel_name: str + can_read: bool + can_send: bool + can_read_history: bool + can_attach_files: bool + can_embed_links: bool + can_add_reactions: bool + can_manage_threads: bool + can_create_threads: bool + + +@dataclass +class AccessibleChannel: + """Readable Discord channel entry with normalized target metadata.""" + + channel_id: str + channel_name: str + guild_id: Optional[str] + guild_name: Optional[str] + channel_kind: str + qualified_name: str + mention: str + can_read: bool + can_send: bool + can_read_history: bool + can_attach_files: bool + can_embed_links: bool + can_add_reactions: bool + can_manage_threads: bool + can_create_threads: bool + + +def _channel_name(channel: Any) -> str: + recipient = getattr(channel, "recipient", None) + recipient_name = getattr(recipient, "name", None) + return ( + getattr(channel, "name", None) + or recipient_name + or str(getattr(channel, "id", "unknown")) + ) + + +def _permission_flag(perms: Any, *names: str) -> bool: + for name in names: + value = getattr(perms, name, None) + if value is not None: + return bool(value) + return False + + +def _is_dm_channel(channel: Any) -> bool: + dm_cls = getattr(discord, "DMChannel", None) if discord else None + if dm_cls and isinstance(channel, dm_cls): + return True + + channel_type = getattr(channel, "type", None) + type_value = getattr(channel_type, "value", channel_type) + if type_value in {"dm", "private"}: + return True + + return getattr(channel, "guild", None) is None and getattr(channel, "recipient", None) is not None + + +def _build_channel_permissions(client: Any, channel: Any) -> ChannelPermissions: + if _is_dm_channel(channel): + return ChannelPermissions( + channel_id=str(getattr(channel, "id", "")), + channel_name=_channel_name(channel), + can_read=True, + can_send=True, + can_read_history=True, + can_attach_files=True, + can_embed_links=True, + can_add_reactions=True, + can_manage_threads=False, + can_create_threads=False, + ) + + guild = getattr(channel, "guild", None) + member = getattr(guild, "me", None) or getattr(client, "user", None) + permissions_for = getattr(channel, "permissions_for", None) + + if guild is None or member is None or not callable(permissions_for): + return ChannelPermissions( + channel_id=str(getattr(channel, "id", "")), + channel_name=_channel_name(channel), + can_read=False, + can_send=False, + can_read_history=False, + can_attach_files=False, + can_embed_links=False, + can_add_reactions=False, + can_manage_threads=False, + can_create_threads=False, + ) + + perms = permissions_for(member) + return ChannelPermissions( + channel_id=str(getattr(channel, "id", "")), + channel_name=_channel_name(channel), + can_read=_permission_flag(perms, "view_channel", "read_messages"), + can_send=_permission_flag(perms, "send_messages"), + can_read_history=_permission_flag(perms, "read_message_history"), + can_attach_files=_permission_flag(perms, "attach_files"), + can_embed_links=_permission_flag(perms, "embed_links"), + can_add_reactions=_permission_flag(perms, "add_reactions"), + can_manage_threads=_permission_flag(perms, "manage_threads"), + can_create_threads=_permission_flag( + perms, + "create_public_threads", + "create_private_threads", + ), + ) + + +def _build_accessible_channel(client: Any, channel: Any) -> AccessibleChannel: + perms = _build_channel_permissions(client, channel) + guild = getattr(channel, "guild", None) + guild_id = str(getattr(guild, "id", "")) if getattr(guild, "id", None) is not None else None + guild_name = getattr(guild, "name", None) + channel_name = _channel_name(channel) + qualified_name = f"{guild_name}/{channel_name}" if guild_name else channel_name + return AccessibleChannel( + channel_id=perms.channel_id, + channel_name=channel_name, + guild_id=guild_id, + guild_name=guild_name, + channel_kind="channel", + qualified_name=qualified_name, + mention=f"<#{perms.channel_id}>", + can_read=perms.can_read, + can_send=perms.can_send, + can_read_history=perms.can_read_history, + can_attach_files=perms.can_attach_files, + can_embed_links=perms.can_embed_links, + can_add_reactions=perms.can_add_reactions, + can_manage_threads=perms.can_manage_threads, + can_create_threads=perms.can_create_threads, + ) + + +async def check_channel_permissions( + client: Any, + channel_id: str, +) -> Optional[ChannelPermissions]: + """Check bot's effective permissions in a channel.""" + channel = await resolve_channel(client, channel_id) + if channel is None: + return None + return _build_channel_permissions(client, channel) + + +async def list_accessible_channels( + client: Any, + guild_id: Optional[str] = None, +) -> list[AccessibleChannel]: + """List channels the bot can access, optionally filtered by guild.""" + if not client: + return [] + + accessible_channels: list[AccessibleChannel] = [] + for guild in getattr(client, "guilds", []) or []: + if guild_id is not None and str(getattr(guild, "id", "")) != str(guild_id): + continue + + for channel in getattr(guild, "text_channels", []) or []: + accessible_channel = _build_accessible_channel(client, channel) + if accessible_channel.can_read: + accessible_channels.append(accessible_channel) + + return accessible_channels + + +async def can_read_channel(client: Any, channel_id: str) -> bool: + """Quick check: can the bot read messages in this channel?""" + channel_permissions = await check_channel_permissions(client, channel_id) + if channel_permissions is None: + return False + return channel_permissions.can_read and channel_permissions.can_read_history diff --git a/gateway/platforms/discord_impl/runtime_state.py b/gateway/platforms/discord_impl/runtime_state.py new file mode 100644 index 000000000000..46804044827f --- /dev/null +++ b/gateway/platforms/discord_impl/runtime_state.py @@ -0,0 +1,154 @@ +"""Persisted Discord runtime state for thread bindings and activation overrides.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass +from datetime import datetime, timedelta +from typing import Any, Optional + +from hermes_cli.config import get_hermes_home + +logger = logging.getLogger(__name__) + +RUNTIME_STATE_VERSION = 1 +DEFAULT_THREAD_BINDING_IDLE_MINUTES = 24 * 60 +DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES = 0 + + +@dataclass +class DiscordThreadBinding: + """Persisted thread-focus binding for Discord thread UX controls.""" + + thread_id: str + session_key: str + chat_id: str + parent_chat_id: Optional[str] = None + chat_name: str = "" + bound_by: str = "" + bound_at: str = "" + last_activity_at: str = "" + idle_timeout_minutes: int = DEFAULT_THREAD_BINDING_IDLE_MINUTES + max_age_minutes: int = DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES + + +def runtime_state_path(): + """Return the persisted Discord runtime state path.""" + return get_hermes_home() / "discord_runtime_state.json" + + +def _parse_datetime(raw: str) -> Optional[datetime]: + text = str(raw or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text) + except Exception: + return None + + +def load_runtime_state() -> tuple[dict[str, DiscordThreadBinding], dict[str, str]]: + """Load persisted Discord thread bindings and activation overrides.""" + path = runtime_state_path() + if not path.exists(): + return {}, {} + + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + logger.debug("Could not load Discord runtime state: %s", exc) + return {}, {} + + bindings_payload = payload.get("thread_bindings") or {} + bindings: dict[str, DiscordThreadBinding] = {} + for thread_id, raw in bindings_payload.items(): + if not isinstance(raw, dict): + continue + try: + binding = DiscordThreadBinding( + thread_id=str(raw.get("thread_id") or thread_id), + session_key=str(raw.get("session_key") or ""), + chat_id=str(raw.get("chat_id") or raw.get("thread_id") or thread_id), + parent_chat_id=str(raw.get("parent_chat_id") or "").strip() or None, + chat_name=str(raw.get("chat_name") or ""), + bound_by=str(raw.get("bound_by") or ""), + bound_at=str(raw.get("bound_at") or ""), + last_activity_at=str(raw.get("last_activity_at") or raw.get("bound_at") or ""), + idle_timeout_minutes=int(raw.get("idle_timeout_minutes") or DEFAULT_THREAD_BINDING_IDLE_MINUTES), + max_age_minutes=int(raw.get("max_age_minutes") or DEFAULT_THREAD_BINDING_MAX_AGE_MINUTES), + ) + except Exception: + continue + if binding.thread_id and binding.session_key: + bindings[binding.thread_id] = binding + + activation_payload = payload.get("activation_overrides") or {} + activation_overrides = { + str(chat_id): str(mode).strip().lower() + for chat_id, mode in activation_payload.items() + if str(chat_id).strip() and str(mode).strip().lower() in {"mention", "always"} + } + return bindings, activation_overrides + + +def save_runtime_state( + bindings: dict[str, DiscordThreadBinding], + activation_overrides: dict[str, str], +) -> None: + """Persist Discord thread bindings and activation overrides.""" + path = runtime_state_path() + payload = { + "version": RUNTIME_STATE_VERSION, + "thread_bindings": { + thread_id: asdict(binding) + for thread_id, binding in sorted(bindings.items()) + }, + "activation_overrides": { + str(chat_id): str(mode) + for chat_id, mode in sorted(activation_overrides.items()) + if str(mode).strip().lower() in {"mention", "always"} + }, + } + try: + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + except Exception as exc: + logger.debug("Could not save Discord runtime state: %s", exc) + + +def binding_expiration_reason( + binding: DiscordThreadBinding, + *, + now: Optional[datetime] = None, +) -> Optional[str]: + """Return ``idle`` or ``max-age`` when the binding has expired.""" + now = now or datetime.now() + last_activity_at = _parse_datetime(binding.last_activity_at) or _parse_datetime(binding.bound_at) or now + bound_at = _parse_datetime(binding.bound_at) or last_activity_at + + if binding.max_age_minutes and now >= bound_at + timedelta(minutes=binding.max_age_minutes): + return "max-age" + if binding.idle_timeout_minutes and now >= last_activity_at + timedelta(minutes=binding.idle_timeout_minutes): + return "idle" + return None + + +def touch_binding( + binding: DiscordThreadBinding, + *, + now: Optional[datetime] = None, +) -> DiscordThreadBinding: + """Return a copy of ``binding`` with refreshed activity time.""" + now = now or datetime.now() + return DiscordThreadBinding( + thread_id=binding.thread_id, + session_key=binding.session_key, + chat_id=binding.chat_id, + parent_chat_id=binding.parent_chat_id, + chat_name=binding.chat_name, + bound_by=binding.bound_by, + bound_at=binding.bound_at, + last_activity_at=now.isoformat(), + idle_timeout_minutes=binding.idle_timeout_minutes, + max_age_minutes=binding.max_age_minutes, + ) diff --git a/gateway/platforms/discord_impl/runtime_views.py b/gateway/platforms/discord_impl/runtime_views.py new file mode 100644 index 000000000000..856a7e8c4f83 --- /dev/null +++ b/gateway/platforms/discord_impl/runtime_views.py @@ -0,0 +1,630 @@ +"""Discord-native runtime status, help, command, and identity renderers.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import json +import os +from typing import Any, Optional + +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource, build_session_context, build_session_context_prompt +from hermes_cli.commands import ( + CommandDef, + format_gateway_command_signature, + gateway_command_defs, + gateway_commands_by_category, +) +from hermes_cli.models import provider_label + + +@dataclass(frozen=True) +class DiscordStatusSnapshot: + session_id: str + session_key: str + created_at: datetime + updated_at: datetime + source: SessionSource + target_source: SessionSource + configured_model: str + configured_provider: str + active_model: str + active_provider: str + is_fallback: bool + runtime_provider: str + api_mode: str + base_url: str + credentials_configured: bool + credential_source: Optional[str] + transport_command: Optional[str] + runtime_error: Optional[str] + context_length: Optional[int] + input_tokens: int + output_tokens: int + cache_read_tokens: int + cache_write_tokens: int + total_tokens: int + last_prompt_tokens: int + estimated_cost_usd: float + cost_status: str + is_running: bool + has_pending_message: bool + has_pending_approval: bool + approval_command_preview: Optional[str] + has_background_process: bool + voice_mode: str + send_policy: str + dock_target: Optional[str] + activation_mode: Optional[str] + focused_thread_summary: Optional[str] + connected_platforms: tuple[str, ...] + + +@dataclass(frozen=True) +class DiscordWhoamiSnapshot: + source: SessionSource + target_source: SessionSource + session_key: str + + +@dataclass(frozen=True) +class DiscordContextSnapshot: + session_id: str + session_key: str + source: SessionSource + target_source: SessionSource + message_count: int + role_counts: dict[str, int] + transcript_tokens: int + context_prompt_tokens: int + context_prompt_chars: int + current_model: str + current_provider: str + connected_platforms: tuple[str, ...] + context_files: tuple[str, ...] + skill_commands: tuple[str, ...] + has_soul: bool + context_prompt: str + + +def _format_timestamp(value: datetime) -> str: + return value.strftime("%Y-%m-%d %H:%M") + + +def _short_id(value: str, limit: int = 12) -> str: + if len(value) <= limit: + return value + return value[:limit] + "..." + + +def _yes_no(value: bool, yes: str = "Yes", no: str = "No") -> str: + return yes if value else no + + +def _alias_note(cmd: CommandDef) -> str: + visible_aliases = [ + f"`/{alias}`" + for alias in cmd.aliases + if alias.replace("-", "_") != cmd.name.replace("-", "_") + ] + if not visible_aliases: + return "" + return f" (alias: {', '.join(visible_aliases)})" + + +def _command_lookup() -> dict[str, CommandDef]: + return {cmd.name: cmd for cmd in gateway_command_defs()} + + +def _load_skill_commands() -> dict[str, str]: + try: + from agent.skill_commands import get_skill_commands + + skill_cmds = get_skill_commands() + except Exception: + return {} + + result: dict[str, str] = {} + for name, payload in sorted(skill_cmds.items()): + result[name] = str(payload.get("description") or "Skill command") + return result + + +def _resolve_target_source(event: MessageEvent, session_source: SessionSource) -> SessionSource: + metadata = getattr(event, "metadata", None) + if isinstance(metadata, dict): + target = metadata.get("command_target_source") + if isinstance(target, SessionSource): + return target + return event.source if isinstance(event.source, SessionSource) else session_source + + +def _resolve_context_length(active_model: str, runtime: dict[str, Any]) -> Optional[int]: + if not active_model: + return None + try: + from agent.model_metadata import get_model_context_length + + return get_model_context_length( + active_model, + base_url=str(runtime.get("base_url") or ""), + api_key=str(runtime.get("api_key") or ""), + ) + except Exception: + return None + + +def collect_discord_status_snapshot(runner: Any, event: MessageEvent) -> DiscordStatusSnapshot: + """Collect Discord-native status data without rendering concerns.""" + session_source = runner._session_source_for_event(event) + target_source = _resolve_target_source(event, session_source) + session_entry = runner.session_store.get_or_create_session(session_source) + session_key = runner._session_key_for_source(session_source) + state = runner._load_current_model_selection() + configured_model = str(state.get("current_model") or "") + configured_provider = str(state.get("current_provider") or "openrouter") + active_model = str(getattr(runner, "_effective_model", None) or configured_model) + active_provider = str(getattr(runner, "_effective_provider", None) or configured_provider) + is_fallback = bool(getattr(runner, "_effective_model", None)) + + runtime_requested = active_provider or configured_provider + runtime, runtime_error = runner._resolve_model_runtime_details(runtime_requested) + runtime_provider = str(runtime.get("provider") or runtime_requested) + api_mode = str(runtime.get("api_mode") or "unknown") + base_url = str(runtime.get("base_url") or "unknown") + transport_command = str(runtime.get("command") or "").strip() or None + credential_source = str(runtime.get("source") or "").strip() or None + credentials_configured = bool(str(runtime.get("api_key") or "").strip() or transport_command) + context_length = _resolve_context_length(active_model, runtime) + + try: + from tools.process_registry import process_registry + + has_background_process = process_registry.has_active_for_session(session_key) + except Exception: + has_background_process = False + + pending_approval = getattr(runner, "_pending_approvals", {}).get(session_key) + approval_command_preview = None + if isinstance(pending_approval, dict): + approval_command = str(pending_approval.get("command") or "").strip() + if approval_command: + approval_command_preview = approval_command[:80] + if len(approval_command) > 80: + approval_command_preview += "..." + + connected_platforms = tuple(sorted(platform.value for platform in getattr(runner, "adapters", {}).keys())) + send_policy = "inherit" + if hasattr(runner, "_session_send_policy"): + try: + send_policy = str(runner._session_send_policy(session_key) or "inherit") + except Exception: + send_policy = "inherit" + + dock_target = None + if hasattr(runner, "_dock_target_for_session"): + try: + dock_target_state = runner._dock_target_for_session(session_key) + except Exception: + dock_target_state = None + if dock_target_state and hasattr(runner, "_format_dock_target"): + try: + dock_target = runner._format_dock_target(dock_target_state) + except Exception: + dock_target = None + + activation_mode = None + focused_thread_summary = None + if target_source.platform.value == "discord": + adapter = getattr(runner, "adapters", {}).get(target_source.platform) + if adapter is not None and target_source.chat_type != "dm": + if hasattr(adapter, "get_activation_mode"): + try: + activation_mode = adapter.get_activation_mode(target_source.chat_id) + except Exception: + activation_mode = None + if activation_mode is None and hasattr(adapter, "_get_discord_policy"): + try: + policy = adapter._get_discord_policy() + activation_mode = "mention" if getattr(policy, "require_mention", True) else "always" + except Exception: + activation_mode = None + if adapter is not None and target_source.thread_id and hasattr(adapter, "get_thread_binding"): + try: + binding = adapter.get_thread_binding(target_source.thread_id) + except Exception: + binding = None + if binding is not None: + idle = getattr(binding, "idle_timeout_minutes", 0) or 0 + max_age = getattr(binding, "max_age_minutes", 0) or 0 + focused_thread_summary = ( + f"yes ({getattr(binding, 'chat_name', '') or target_source.thread_id}; " + f"idle {idle}m; max-age {max_age}m)" + ) + + return DiscordStatusSnapshot( + session_id=session_entry.session_id, + session_key=session_key, + created_at=session_entry.created_at, + updated_at=session_entry.updated_at, + source=session_source, + target_source=target_source, + configured_model=configured_model, + configured_provider=configured_provider, + active_model=active_model, + active_provider=active_provider, + is_fallback=is_fallback, + runtime_provider=runtime_provider, + api_mode=api_mode, + base_url=base_url, + credentials_configured=credentials_configured, + credential_source=credential_source, + transport_command=transport_command, + runtime_error=runtime_error, + context_length=context_length, + input_tokens=session_entry.input_tokens, + output_tokens=session_entry.output_tokens, + cache_read_tokens=session_entry.cache_read_tokens, + cache_write_tokens=session_entry.cache_write_tokens, + total_tokens=session_entry.total_tokens, + last_prompt_tokens=session_entry.last_prompt_tokens, + estimated_cost_usd=session_entry.estimated_cost_usd, + cost_status=session_entry.cost_status, + is_running=session_key in getattr(runner, "_running_agents", {}), + has_pending_message=bool(getattr(runner, "_pending_messages", {}).get(session_key)), + has_pending_approval=bool(pending_approval), + approval_command_preview=approval_command_preview, + has_background_process=has_background_process, + voice_mode=getattr(runner, "_voice_mode", {}).get(target_source.chat_id, "off"), + send_policy=send_policy, + dock_target=dock_target, + activation_mode=activation_mode, + focused_thread_summary=focused_thread_summary, + connected_platforms=connected_platforms, + ) + + +def _estimate_tokens(messages: list[dict[str, str]]) -> int: + try: + from agent.model_metadata import estimate_messages_tokens_rough + + return int(estimate_messages_tokens_rough(messages) or 0) + except Exception: + return 0 + + +def _extract_context_file_names(context_prompt: str) -> tuple[str, ...]: + names: list[str] = [] + for line in context_prompt.splitlines(): + if line.startswith("## "): + names.append(line[3:].strip()) + return tuple(names) + + +def collect_discord_context_snapshot(runner: Any, event: MessageEvent) -> DiscordContextSnapshot: + """Collect a Discord-native snapshot of the current transcript and prompt context.""" + target_source = runner._command_target_source_for_event(event) + session_entry = runner.session_store.get_or_create_session(target_source) + history = runner.session_store.load_transcript(session_entry.session_id) + role_counts: dict[str, int] = {} + transcript_messages: list[dict[str, str]] = [] + for item in history: + role = str(item.get("role") or "unknown") + role_counts[role] = role_counts.get(role, 0) + 1 + content = str(item.get("content") or "") + if content: + transcript_messages.append({"role": role, "content": content}) + + current_state = runner._load_current_model_selection() + current_model = str(current_state.get("current_model") or "") + current_provider = str(current_state.get("current_provider") or "") + connected_platforms = tuple(sorted(platform.value for platform in getattr(runner, "adapters", {}).keys())) + + cwd = os.environ.get("TERMINAL_CWD") or os.environ.get("MESSAGING_CWD") or os.getcwd() + try: + from agent.prompt_builder import ( + build_context_files_prompt, + build_skills_system_prompt, + load_soul_md, + ) + + soul = load_soul_md() or "" + context_files_prompt = build_context_files_prompt(cwd, skip_soul=bool(soul)) + skills_prompt = build_skills_system_prompt() + except Exception: + soul = "" + context_files_prompt = "" + skills_prompt = "" + + session_context = build_session_context(target_source, runner.config, session_entry) + session_context_prompt = build_session_context_prompt(session_context) + prompt_parts = [part for part in (soul, skills_prompt, context_files_prompt, session_context_prompt) if part] + context_prompt = "\n\n".join(prompt_parts) + skill_commands = tuple(sorted(_load_skill_commands().keys())) + + return DiscordContextSnapshot( + session_id=session_entry.session_id, + session_key=session_entry.session_key, + source=runner._session_source_for_event(event), + target_source=target_source, + message_count=len(history), + role_counts=role_counts, + transcript_tokens=_estimate_tokens(transcript_messages), + context_prompt_tokens=_estimate_tokens([{"role": "system", "content": context_prompt}]) if context_prompt else 0, + context_prompt_chars=len(context_prompt), + current_model=current_model, + current_provider=current_provider, + connected_platforms=connected_platforms, + context_files=_extract_context_file_names(context_files_prompt), + skill_commands=skill_commands, + has_soul=bool(soul), + context_prompt=context_prompt, + ) + + +def render_discord_status(snapshot: DiscordStatusSnapshot) -> str: + """Render Discord status output in a multi-section, chat-readable format.""" + lines = [ + "📊 **Hermes Status**", + "", + "**Session**", + f"• Session ID: `{_short_id(snapshot.session_id)}`", + f"• Session Key: `{_short_id(snapshot.session_key, limit=18)}`", + f"• Created: {_format_timestamp(snapshot.created_at)}", + f"• Last Activity: {_format_timestamp(snapshot.updated_at)}", + f"• Chat: {snapshot.target_source.chat_name or snapshot.target_source.chat_id}", + f"• Chat Type: `{snapshot.target_source.chat_type}`", + ] + if snapshot.target_source.thread_id: + lines.append(f"• Thread ID: `{snapshot.target_source.thread_id}`") + if snapshot.target_source.chat_topic: + lines.append(f"• Chat Topic: {snapshot.target_source.chat_topic}") + if snapshot.source.session_namespace: + lines.append(f"• Session Namespace: `{snapshot.source.session_namespace}`") + + lines.extend( + [ + "", + "**Model**", + f"• Configured Model: `{snapshot.configured_model}`", + f"• Configured Provider: {provider_label(snapshot.configured_provider)} (`{snapshot.configured_provider}`)", + f"• Active Model: `{snapshot.active_model}`{' (fallback)' if snapshot.is_fallback else ''}", + f"• Active Provider: {provider_label(snapshot.active_provider)} (`{snapshot.active_provider}`)", + ] + ) + if snapshot.runtime_error: + lines.append(f"• Runtime Resolution: failed ({snapshot.runtime_error})") + else: + lines.extend( + [ + f"• Runtime Provider: {provider_label(snapshot.runtime_provider)} (`{snapshot.runtime_provider}`)", + f"• API Mode: `{snapshot.api_mode}`", + f"• Base URL: `{snapshot.base_url}`", + f"• Credentials: {'configured ✓' if snapshot.credentials_configured else 'missing ⚠️'}", + ] + ) + if snapshot.credential_source: + lines.append(f"• Credential Source: `{snapshot.credential_source}`") + if snapshot.transport_command: + lines.append(f"• Transport Command: `{snapshot.transport_command}`") + + lines.extend( + [ + "", + "**Usage & Context**", + f"• Total Tokens: {snapshot.total_tokens:,}", + f"• Last Prompt Tokens: {snapshot.last_prompt_tokens:,}", + f"• Input / Output: {snapshot.input_tokens:,} / {snapshot.output_tokens:,}", + f"• Cache Read / Write: {snapshot.cache_read_tokens:,} / {snapshot.cache_write_tokens:,}", + ] + ) + if snapshot.context_length is not None: + lines.append(f"• Context Window: {snapshot.context_length:,} tokens") + if snapshot.estimated_cost_usd: + lines.append( + f"• Estimated Cost: ${snapshot.estimated_cost_usd:.4f} ({snapshot.cost_status or 'unknown'})" + ) + elif snapshot.cost_status and snapshot.cost_status != "unknown": + lines.append(f"• Cost Status: `{snapshot.cost_status}`") + + lines.extend( + [ + "", + "**Runtime**", + f"• Agent Running: {_yes_no(snapshot.is_running, 'Yes ⚡', 'No')}", + f"• Pending Interrupt: {_yes_no(snapshot.has_pending_message)}", + f"• Pending Approval: {_yes_no(snapshot.has_pending_approval)}", + f"• Background Processes: {_yes_no(snapshot.has_background_process)}", + f"• Voice Mode: `{snapshot.voice_mode}`", + f"• Send Policy: `{snapshot.send_policy}`", + ] + ) + if snapshot.approval_command_preview: + lines.append(f"• Approval Command: `{snapshot.approval_command_preview}`") + if snapshot.dock_target: + lines.append(f"• Dock Target: {snapshot.dock_target}") + if snapshot.activation_mode: + lines.append(f"• Activation: `{snapshot.activation_mode}`") + if snapshot.focused_thread_summary: + lines.append(f"• Focused Thread: {snapshot.focused_thread_summary}") + + lines.extend( + [ + "", + "**Platforms**", + f"• Connected: {', '.join(snapshot.connected_platforms) if snapshot.connected_platforms else 'none'}", + ] + ) + return "\n".join(lines) + + +def render_discord_context(snapshot: DiscordContextSnapshot, mode: str = "list") -> str: + """Render Discord context output in list, detail, or JSON form.""" + mode = str(mode or "list").strip().lower() + if mode == "json": + payload = { + "session_id": snapshot.session_id, + "session_key": snapshot.session_key, + "source": snapshot.source.to_dict(), + "target_source": snapshot.target_source.to_dict(), + "message_count": snapshot.message_count, + "role_counts": snapshot.role_counts, + "transcript_tokens": snapshot.transcript_tokens, + "context_prompt_tokens": snapshot.context_prompt_tokens, + "context_prompt_chars": snapshot.context_prompt_chars, + "current_model": snapshot.current_model, + "current_provider": snapshot.current_provider, + "connected_platforms": list(snapshot.connected_platforms), + "context_files": list(snapshot.context_files), + "skill_commands": list(snapshot.skill_commands), + "has_soul": snapshot.has_soul, + } + return f"```json\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n```" + + lines = [ + "🧠 **Hermes Context**", + "", + f"• Session ID: `{_short_id(snapshot.session_id)}`", + f"• Session Key: `{_short_id(snapshot.session_key, limit=18)}`", + f"• Chat: {snapshot.target_source.chat_name or snapshot.target_source.chat_id}", + f"• Messages: {snapshot.message_count}", + f"• Transcript Tokens: ~{snapshot.transcript_tokens:,}", + f"• Context Prompt: ~{snapshot.context_prompt_tokens:,} tokens / {snapshot.context_prompt_chars:,} chars", + f"• Model: `{snapshot.current_model or 'unknown'}`", + ] + if snapshot.current_provider: + lines.append(f"• Provider: `{snapshot.current_provider}`") + if snapshot.connected_platforms: + lines.append(f"• Connected Platforms: {', '.join(snapshot.connected_platforms)}") + if snapshot.role_counts: + role_summary = ", ".join(f"{role}={count}" for role, count in sorted(snapshot.role_counts.items())) + lines.append(f"• Roles: {role_summary}") + + if mode != "detail": + return "\n".join(lines) + + lines.extend( + [ + "", + "**Prompt Sources**", + f"• SOUL.md Loaded: {_yes_no(snapshot.has_soul)}", + f"• Context Files: {', '.join(snapshot.context_files) if snapshot.context_files else 'none'}", + f"• Skill Commands Indexed: {', '.join(snapshot.skill_commands) if snapshot.skill_commands else 'none'}", + "", + "**Prompt Preview**", + "```text", + snapshot.context_prompt[:3500] + ("..." if len(snapshot.context_prompt) > 3500 else ""), + "```", + ] + ) + return "\n".join(lines) + + +def _render_command_lines(command_names: tuple[str, ...]) -> list[str]: + lookup = _command_lookup() + lines: list[str] = [] + for name in command_names: + cmd = lookup.get(name) + if cmd is None: + continue + lines.append( + f"`{format_gateway_command_signature(cmd)}` — {cmd.description}{_alias_note(cmd)}" + ) + return lines + + +def render_discord_help() -> str: + """Render concise Discord help with clear separation from /commands.""" + skill_cmds = _load_skill_commands() + lines = [ + "📖 **Hermes Help**", + "", + "Use `/commands` for the full command catalog.", + "", + "**Quick Start**", + *( + _render_command_lines( + ("help", "commands", "status", "whoami", "model", "models") + ) + ), + "", + "**Session**", + *(_render_command_lines(("new", "retry", "undo", "thread", "resume", "title", "compact", "context", "stop"))), + "", + "**Configuration**", + *(_render_command_lines(("model", "models", "provider", "reasoning", "personality", "voice", "allowlist", "config"))), + "", + "**Info & Maintenance**", + *(_render_command_lines(("usage", "insights", "export-session", "reload-mcp", "update"))), + ] + if skill_cmds: + lines.extend( + [ + "", + f"**Skill Commands** ({len(skill_cmds)} installed)", + ] + ) + for name, description in skill_cmds.items(): + lines.append(f"`{name}` — {description}") + return "\n".join(lines) + + +def render_discord_commands() -> str: + """Render the full grouped Discord command catalog.""" + skill_cmds = _load_skill_commands() + lines = [ + "🧭 **Hermes Command Catalog**", + "", + "Use `/help` for the guided overview.", + ] + for category, commands in gateway_commands_by_category(): + lines.extend(["", f"**{category}**"]) + for cmd in commands: + lines.append( + f"`{format_gateway_command_signature(cmd)}` — {cmd.description}{_alias_note(cmd)}" + ) + if skill_cmds: + lines.extend(["", "**Skill Commands**"]) + for name, description in skill_cmds.items(): + lines.append(f"`{name}` — {description}") + return "\n".join(lines) + + +def collect_discord_whoami_snapshot(runner: Any, event: MessageEvent) -> DiscordWhoamiSnapshot: + """Collect Discord identity and routing details.""" + session_source = runner._session_source_for_event(event) + target_source = _resolve_target_source(event, session_source) + session_key = runner._session_key_for_source(session_source) + return DiscordWhoamiSnapshot( + source=session_source, + target_source=target_source, + session_key=session_key, + ) + + +def render_discord_whoami(snapshot: DiscordWhoamiSnapshot) -> str: + """Render Discord sender and routing identity.""" + source = snapshot.source + target = snapshot.target_source + lines = [ + "👤 **Hermes Sees You As**", + "", + "**Identity**", + f"• Platform: {source.platform.value if source.platform else 'unknown'}", + f"• User ID: `{source.user_id or 'unknown'}`", + f"• User Name: {source.user_name or 'unknown'}", + "", + "**Routing**", + f"• Chat ID: `{target.chat_id}`", + f"• Chat Type: `{target.chat_type}`", + f"• Chat Name: {target.chat_name or 'unknown'}", + ] + if target.thread_id: + lines.append(f"• Thread ID: `{target.thread_id}`") + if target.chat_topic: + lines.append(f"• Chat Topic: {target.chat_topic}") + if source.session_namespace: + lines.append(f"• Session Namespace: `{source.session_namespace}`") + lines.append(f"• Session Key: `{_short_id(snapshot.session_key, limit=18)}`") + return "\n".join(lines) diff --git a/gateway/platforms/discord_impl/state.py b/gateway/platforms/discord_impl/state.py new file mode 100644 index 000000000000..f9d67298f40a --- /dev/null +++ b/gateway/platforms/discord_impl/state.py @@ -0,0 +1,54 @@ +"""Discord thread participation persistence helpers.""" + +from __future__ import annotations + +import json +import logging + +from hermes_cli.config import get_hermes_home + + +logger = logging.getLogger(__name__) + + +def thread_state_path(): + """Return the persisted thread participation state path.""" + return get_hermes_home() / "discord_threads.json" + + +def load_participated_threads() -> set[str]: + """Load persisted thread IDs from disk.""" + path = thread_state_path() + try: + if path.exists(): + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, list): + return set(data) + except Exception as exc: + logger.debug("Could not load discord thread state: %s", exc) + return set() + + +def save_participated_threads(threads: set[str], max_threads: int = 500) -> set[str]: + """Persist the current thread set to disk and return the trimmed set.""" + path = thread_state_path() + try: + thread_list = list(threads) + if len(thread_list) > max_threads: + thread_list = thread_list[-max_threads:] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(thread_list), encoding="utf-8") + return set(thread_list) + except Exception as exc: + logger.debug("Could not save discord thread state: %s", exc) + return set(threads) + + +def track_thread(threads: set[str], thread_id: str, max_threads: int = 500) -> set[str]: + """Add a thread to the participation set, persist it, and return the updated set.""" + if thread_id in threads: + return set(threads) + + updated_threads = set(threads) + updated_threads.add(thread_id) + return save_participated_threads(updated_threads, max_threads=max_threads) diff --git a/gateway/platforms/discord_impl/threads.py b/gateway/platforms/discord_impl/threads.py new file mode 100644 index 000000000000..3685a8b3add6 --- /dev/null +++ b/gateway/platforms/discord_impl/threads.py @@ -0,0 +1,193 @@ +"""Discord thread creation and routing helpers.""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from gateway.platforms.base import MessageEvent, MessageType + + +logger = logging.getLogger(__name__) + +VALID_THREAD_AUTO_ARCHIVE_MINUTES = {60, 1440, 4320, 10080} + +try: + import discord +except ImportError: # pragma: no cover - import guard + discord = None + + +async def auto_create_thread(message: Any, max_name_len: int = 80) -> Optional[Any]: + """Create a thread from a Discord message for auto-threading.""" + content = (message.content or "").strip() + thread_name = content[:max_name_len] if content else "Hermes" + if len(content) > max_name_len: + thread_name = thread_name[: max_name_len - 3] + "..." + + try: + return await message.create_thread(name=thread_name, auto_archive_duration=1440) + except Exception as exc: + logger.warning("Auto-thread creation failed: %s", exc) + return None + + +async def create_thread( + client: Any, + interaction: Any, + name: str, + message: str, + auto_archive_duration: int, + resolve_channel_fn, +) -> dict[str, Any]: + """Create a Discord thread in the interaction's current channel.""" + name = (name or "").strip() + if not name: + return {"error": "Thread name is required."} + + if auto_archive_duration not in VALID_THREAD_AUTO_ARCHIVE_MINUTES: + allowed = ", ".join(str(v) for v in sorted(VALID_THREAD_AUTO_ARCHIVE_MINUTES)) + return {"error": f"auto_archive_duration must be one of: {allowed}."} + + channel = await resolve_channel_fn(client, interaction) + if channel is None: + return {"error": "Could not resolve the current Discord channel."} + + dm_channel_cls = getattr(discord, "DMChannel", None) if discord else None + if dm_channel_cls and isinstance(channel, dm_channel_cls): + return {"error": "Discord threads can only be created inside server text channels, not DMs."} + + parent_channel = thread_parent_channel(channel) + if parent_channel is None: + return {"error": "Could not determine a parent text channel for the new thread."} + + display_name = getattr(getattr(interaction, "user", None), "display_name", None) or "unknown user" + reason = f"Requested by {display_name} via /thread" + starter_message = (message or "").strip() + + try: + thread = await parent_channel.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + if starter_message: + await thread.send(starter_message) + return { + "success": True, + "thread_id": str(thread.id), + "thread_name": getattr(thread, "name", None) or name, + } + except Exception as direct_error: + try: + seed_content = starter_message or f"\U0001f9f5 Thread created by Hermes: **{name}**" + seed_msg = await parent_channel.send(seed_content) + thread = await seed_msg.create_thread( + name=name, + auto_archive_duration=auto_archive_duration, + reason=reason, + ) + return { + "success": True, + "thread_id": str(thread.id), + "thread_name": getattr(thread, "name", None) or name, + } + except Exception as fallback_error: + return { + "error": ( + "Discord rejected direct thread creation and the fallback also failed. " + f"Direct error: {direct_error}. Fallback error: {fallback_error}" + ) + } + + +async def handle_thread_create_slash( + adapter: Any, + interaction: Any, + name: str, + message: str = "", + auto_archive_duration: int = 1440, +) -> None: + """Create a Discord thread from a slash command and start a session in it.""" + result = await adapter._create_thread( + interaction, + name=name, + message=message, + auto_archive_duration=auto_archive_duration, + ) + + if not result.get("success"): + error = result.get("error", "unknown error") + await interaction.followup.send(f"Failed to create thread: {error}", ephemeral=True) + return + + thread_id = result.get("thread_id") + thread_name = result.get("thread_name") or name + + link = f"<#{thread_id}>" if thread_id else f"**{thread_name}**" + await interaction.followup.send(f"Created thread {link}", ephemeral=True) + + if thread_id: + adapter._track_thread(thread_id) + + starter = (message or "").strip() + if starter and thread_id: + await adapter._dispatch_thread_session(interaction, thread_id, thread_name, starter) + + +async def dispatch_thread_session( + adapter: Any, + interaction: Any, + thread_id: str, + thread_name: str, + text: str, +) -> None: + """Build a thread MessageEvent and send it through the adapter handler.""" + guild_name = "" + if hasattr(interaction, "guild") and interaction.guild: + guild_name = interaction.guild.name + + chat_name = f"{guild_name} / {thread_name}" if guild_name else thread_name + source = adapter.build_source( + chat_id=thread_id, + chat_name=chat_name, + chat_type="thread", + user_id=str(interaction.user.id), + user_name=interaction.user.display_name, + thread_id=thread_id, + ) + + event = MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=source, + raw_message=interaction, + ) + await adapter.handle_message(event) + + +def thread_parent_channel(channel: Any) -> Any: + """Return the parent text channel when invoked from a thread.""" + return getattr(channel, "parent", None) or channel + + +async def resolve_interaction_channel(client: Any, interaction: Any) -> Optional[Any]: + """Return the interaction channel, fetching it if the payload is partial.""" + channel = getattr(interaction, "channel", None) + if channel is not None: + return channel + if not client: + return None + + channel_id = getattr(interaction, "channel_id", None) + if channel_id is None: + return None + + channel = client.get_channel(int(channel_id)) + if channel is not None: + return channel + + try: + return await client.fetch_channel(int(channel_id)) + except Exception: + return None diff --git a/gateway/run.py b/gateway/run.py index 9547387480e9..2247d673bf6e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -24,6 +24,7 @@ import tempfile import threading import time +import uuid from logging.handlers import RotatingFileHandler from pathlib import Path from datetime import datetime @@ -219,6 +220,15 @@ def _ensure_ssl_certs() -> None: ) from gateway.delivery import DeliveryRouter, DeliveryTarget from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType +from gateway.command_catalog import ( + build_session_export_html, + format_yaml_block, + load_raw_user_config, + read_config_or_env_value, + resolve_export_path, + unset_config_or_env_value, + write_config_or_env_value, +) logger = logging.getLogger(__name__) @@ -315,6 +325,7 @@ class GatewayRunner: def __init__(self, config: Optional[GatewayConfig] = None): self.config = config or load_gateway_config() self.adapters: Dict[Platform, BasePlatformAdapter] = {} + self._runtime_debug_overrides: Dict[str, Any] = {} # Load ephemeral config from config.yaml / env vars. # Both are injected at API-call time only and never persisted. @@ -342,6 +353,12 @@ def __init__(self, config: Optional[GatewayConfig] = None): # Key: session_key, Value: AIAgent instance self._running_agents: Dict[str, Any] = {} self._pending_messages: Dict[str, str] = {} # Queued messages during interrupt + self._bash_jobs: Dict[str, str] = {} + self._subagent_runtime: Dict[str, Dict[str, Any]] = {} + self._acp_session_bindings: Dict[str, str] = {} + self._acp_session_meta: Dict[str, Dict[str, Any]] = {} + self._acp_running_tasks: Dict[str, asyncio.Task] = {} + self._acp_session_manager = None # Track active fallback model/provider when primary is rate-limited. # Set after an agent run where fallback was activated; cleared when @@ -384,6 +401,322 @@ def __init__(self, config: Optional[GatewayConfig] = None): # Per-chat voice reply mode: "off" | "voice_only" | "all" self._voice_mode: Dict[str, str] = self._load_voice_modes() + runtime_controls = self._load_runtime_controls() + self._session_send_policies: Dict[str, str] = runtime_controls["send_policies"] + self._session_docks: Dict[str, Dict[str, Any]] = runtime_controls["dock_targets"] + + _DEBUG_REASONING_LEVELS = {"none", "minimal", "low", "medium", "high", "xhigh"} + _DEBUG_BACKGROUND_NOTIFICATION_MODES = {"all", "result", "error", "off"} + _DEBUG_PROVIDER_DATA_COLLECTION_MODES = {"allow", "deny"} + _DEBUG_DISCORD_BOT_FILTER_POLICIES = {"none", "mentions", "all"} + _DEBUG_SUPPORTED_KEYS: Dict[str, str] = { + "agent.system_prompt": "Ephemeral system prompt for new turns in the running gateway process.", + "agent.reasoning_effort": "Runtime reasoning effort (none|minimal|low|medium|high|xhigh).", + "display.show_reasoning": "Show or hide model reasoning in responses.", + "display.background_process_notifications": "Background process watcher notifications (all|result|error|off).", + "provider_routing.only": "Preferred provider allowlist for OpenRouter routing.", + "provider_routing.ignore": "Providers to skip for OpenRouter routing.", + "provider_routing.order": "Explicit provider preference order for OpenRouter routing.", + "provider_routing.sort": "Provider routing sort strategy.", + "provider_routing.require_parameters": "Require providers that support all request parameters.", + "provider_routing.data_collection": "Provider data collection policy (allow|deny).", + "fallback_model.provider": "Fallback provider used when the primary route fails.", + "fallback_model.model": "Fallback model used when the primary route fails.", + "smart_model_routing.enabled": "Enable or disable cheap-vs-strong smart routing.", + "smart_model_routing.max_simple_chars": "Maximum message characters for cheap-route eligibility.", + "smart_model_routing.max_simple_words": "Maximum word count for cheap-route eligibility.", + "smart_model_routing.cheap_model.provider": "Cheap-route provider.", + "smart_model_routing.cheap_model.model": "Cheap-route model.", + "discord.allow_bots": "Discord bot-message policy (none|mentions|all).", + "discord.free_response_channels": "Discord channel IDs that bypass mention gating.", + "discord.require_mention": "Require @mention in Discord server channels.", + "discord.auto_thread": "Auto-create Discord threads on @mention.", + } + + def _get_runtime_debug_overrides(self) -> Dict[str, Any]: + overrides = getattr(self, "_runtime_debug_overrides", None) + if overrides is None: + overrides = {} + self._runtime_debug_overrides = overrides + return overrides + + @staticmethod + def _parse_debug_scalar(raw_value: str) -> Any: + import yaml + + text = (raw_value or "").strip() + if not text: + return "" + try: + return yaml.safe_load(text) + except Exception: + return text + + @classmethod + def _parse_debug_bool(cls, raw_value: str) -> bool: + parsed = cls._parse_debug_scalar(raw_value) + if isinstance(parsed, bool): + return parsed + if isinstance(parsed, str): + normalized = parsed.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError("expected a boolean value") + + @classmethod + def _parse_debug_string_list(cls, raw_value: str) -> List[str]: + parsed = cls._parse_debug_scalar(raw_value) + if parsed is None: + return [] + if isinstance(parsed, (list, tuple, set)): + items = list(parsed) + elif isinstance(parsed, str): + if "," in parsed: + items = [item.strip() for item in parsed.split(",")] + else: + items = [parsed.strip()] + else: + items = [str(parsed).strip()] + return [str(item).strip() for item in items if str(item).strip()] + + @classmethod + def _parse_debug_optional_string(cls, raw_value: str) -> Optional[str]: + parsed = cls._parse_debug_scalar(raw_value) + if parsed is None: + return None + text = str(parsed).strip() + if not text: + return None + return text + + @classmethod + def _parse_debug_int(cls, raw_value: str) -> int: + parsed = cls._parse_debug_scalar(raw_value) + try: + return int(parsed) + except (TypeError, ValueError) as exc: + raise ValueError("expected an integer value") from exc + + @classmethod + def _parse_debug_value(cls, key: str, raw_value: str) -> Any: + if key == "agent.system_prompt": + return str(raw_value) + + if key == "agent.reasoning_effort": + normalized = str(raw_value).strip().lower() + if normalized == "off": + normalized = "none" + if normalized not in cls._DEBUG_REASONING_LEVELS: + raise ValueError( + "expected one of: none, minimal, low, medium, high, xhigh" + ) + return normalized + + if key == "display.show_reasoning": + return cls._parse_debug_bool(raw_value) + + if key == "display.background_process_notifications": + normalized = str(raw_value).strip().lower() + if normalized not in cls._DEBUG_BACKGROUND_NOTIFICATION_MODES: + raise ValueError("expected one of: all, result, error, off") + return normalized + + if key in { + "provider_routing.only", + "provider_routing.ignore", + "provider_routing.order", + "discord.free_response_channels", + }: + return cls._parse_debug_string_list(raw_value) + + if key == "provider_routing.sort": + return cls._parse_debug_optional_string(raw_value) + + if key == "provider_routing.require_parameters": + return cls._parse_debug_bool(raw_value) + + if key == "provider_routing.data_collection": + value = cls._parse_debug_optional_string(raw_value) + if value is None: + return None + normalized = value.lower() + if normalized not in cls._DEBUG_PROVIDER_DATA_COLLECTION_MODES: + raise ValueError("expected one of: allow, deny") + return normalized + + if key in {"fallback_model.provider", "fallback_model.model"}: + return cls._parse_debug_optional_string(raw_value) + + if key == "smart_model_routing.enabled": + return cls._parse_debug_bool(raw_value) + + if key in {"smart_model_routing.max_simple_chars", "smart_model_routing.max_simple_words"}: + return cls._parse_debug_int(raw_value) + + if key in {"smart_model_routing.cheap_model.provider", "smart_model_routing.cheap_model.model"}: + return cls._parse_debug_optional_string(raw_value) + + if key == "discord.allow_bots": + normalized = str(raw_value).strip().lower() + if normalized not in cls._DEBUG_DISCORD_BOT_FILTER_POLICIES: + raise ValueError("expected one of: none, mentions, all") + return normalized + + if key in {"discord.require_mention", "discord.auto_thread"}: + return cls._parse_debug_bool(raw_value) + + raise KeyError(key) + + @staticmethod + def _format_runtime_debug_value(value: Any) -> str: + return format_yaml_block(value) + + @staticmethod + def _reasoning_config_to_level(reasoning_config: dict | None) -> str: + if reasoning_config is None: + return "medium" + if reasoning_config.get("enabled") is False: + return "none" + return str(reasoning_config.get("effort") or "medium").strip().lower() + + @classmethod + def _reasoning_level_to_config(cls, level: str) -> dict | None: + normalized = (level or "").strip().lower() + if not normalized or normalized == "medium": + return {"enabled": True, "effort": "medium"} + if normalized == "none": + return {"enabled": False} + if normalized in {"minimal", "low", "high", "xhigh"}: + return {"enabled": True, "effort": normalized} + return None + + def _get_effective_reasoning_config(self) -> dict | None: + overrides = self._get_runtime_debug_overrides() + if "agent.reasoning_effort" not in overrides: + return self._load_reasoning_config() + return self._reasoning_level_to_config(str(overrides["agent.reasoning_effort"])) + + def _get_effective_show_reasoning(self) -> bool: + overrides = self._get_runtime_debug_overrides() + if "display.show_reasoning" in overrides: + return bool(overrides["display.show_reasoning"]) + return self._load_show_reasoning() + + def _get_effective_background_notifications_mode(self) -> str: + overrides = self._get_runtime_debug_overrides() + if "display.background_process_notifications" in overrides: + return str(overrides["display.background_process_notifications"]) + return self._load_background_notifications_mode() + + def _build_effective_provider_routing(self) -> dict: + merged = dict(self._load_provider_routing() or {}) + overrides = self._get_runtime_debug_overrides() + for suffix in ("only", "ignore", "order", "sort", "require_parameters", "data_collection"): + key = f"provider_routing.{suffix}" + if key in overrides: + merged[suffix] = overrides[key] + return merged + + def _build_effective_fallback_model(self) -> dict | None: + merged = dict(self._load_fallback_model() or {}) + overrides = self._get_runtime_debug_overrides() + for suffix in ("provider", "model"): + key = f"fallback_model.{suffix}" + if key in overrides: + merged[suffix] = overrides[key] + provider = str(merged.get("provider") or "").strip() + model = str(merged.get("model") or "").strip() + if not provider or not model: + return None + return {"provider": provider, "model": model} + + def _build_effective_smart_model_routing(self) -> dict: + merged = dict(self._load_smart_model_routing() or {}) + cheap_model = dict(merged.get("cheap_model") or {}) + merged["cheap_model"] = cheap_model + overrides = self._get_runtime_debug_overrides() + + for suffix in ("enabled", "max_simple_chars", "max_simple_words"): + key = f"smart_model_routing.{suffix}" + if key in overrides: + merged[suffix] = overrides[key] + + for suffix in ("provider", "model"): + key = f"smart_model_routing.cheap_model.{suffix}" + if key in overrides: + cheap_model[suffix] = overrides[key] + + return merged + + def _build_effective_discord_policy_overrides(self) -> dict: + overrides = self._get_runtime_debug_overrides() + policy_overrides: Dict[str, Any] = {} + for source_key, target_key in ( + ("discord.allow_bots", "allow_bots"), + ("discord.free_response_channels", "free_response_channels"), + ("discord.require_mention", "require_mention"), + ("discord.auto_thread", "auto_thread"), + ): + if source_key in overrides: + policy_overrides[target_key] = overrides[source_key] + return policy_overrides + + def _get_effective_runtime_debug_value(self, key: str) -> Any: + overrides = self._get_runtime_debug_overrides() + if key == "agent.system_prompt": + return overrides[key] if key in overrides else self._load_ephemeral_system_prompt() + if key == "agent.reasoning_effort": + if key in overrides: + return overrides[key] + return self._reasoning_config_to_level(self._load_reasoning_config()) + if key == "display.show_reasoning": + return self._get_effective_show_reasoning() + if key == "display.background_process_notifications": + return self._get_effective_background_notifications_mode() + if key.startswith("provider_routing."): + return self._build_effective_provider_routing().get(key.split(".", 1)[1]) + if key.startswith("fallback_model."): + current = self._build_effective_fallback_model() or {} + return current.get(key.split(".", 1)[1]) + if key.startswith("smart_model_routing.cheap_model."): + current = self._build_effective_smart_model_routing().get("cheap_model") or {} + return current.get(key.rsplit(".", 1)[1]) + if key.startswith("smart_model_routing."): + current = self._build_effective_smart_model_routing() + return current.get(key.split(".", 1)[1]) + if key.startswith("discord."): + from gateway.platforms.discord_impl import config as discord_config + + policy = discord_config.load_policy_config( + getattr(self, "config", None).platforms.get(Platform.DISCORD) + if getattr(self, "config", None) and getattr(self.config, "platforms", None) + else None, + overrides=self._build_effective_discord_policy_overrides(), + ) + mapping = { + "discord.allow_bots": policy.bot_filter_policy, + "discord.free_response_channels": sorted(policy.free_response_channels), + "discord.require_mention": policy.require_mention, + "discord.auto_thread": policy.auto_thread, + } + return mapping[key] + raise KeyError(key) + + def _apply_runtime_debug_overrides(self) -> None: + self._ephemeral_system_prompt = str(self._get_effective_runtime_debug_value("agent.system_prompt") or "") + self._reasoning_config = self._get_effective_reasoning_config() + self._show_reasoning = self._get_effective_show_reasoning() + self._provider_routing = self._build_effective_provider_routing() + self._fallback_model = self._build_effective_fallback_model() + self._smart_model_routing = self._build_effective_smart_model_routing() + + discord_adapter = getattr(self, "adapters", {}).get(Platform.DISCORD) + if discord_adapter and hasattr(discord_adapter, "apply_runtime_policy_overrides"): + discord_adapter.apply_runtime_policy_overrides( + self._build_effective_discord_policy_overrides() + ) def _get_or_create_gateway_honcho(self, session_key: str): """Return a persistent Honcho manager/config pair for this gateway session.""" @@ -453,6 +786,7 @@ def _has_setup_skill(self) -> bool: # -- Voice mode persistence ------------------------------------------ _VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json" + _RUNTIME_CONTROLS_PATH = _hermes_home / "gateway_runtime_controls.json" def _load_voice_modes(self) -> Dict[str, str]: try: @@ -499,6 +833,56 @@ def _sync_voice_mode_state_to_adapter(self, adapter) -> None: chat_id for chat_id, mode in self._voice_mode.items() if mode == "off" ) + def _load_runtime_controls(self) -> Dict[str, Dict[str, Any]]: + """Load persisted per-session send-policy and dock-routing controls.""" + try: + payload = json.loads(self._RUNTIME_CONTROLS_PATH.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {"send_policies": {}, "dock_targets": {}} + + if not isinstance(payload, dict): + return {"send_policies": {}, "dock_targets": {}} + + raw_send_policies = payload.get("send_policies") or {} + send_policies = { + str(session_key): str(mode) + for session_key, mode in raw_send_policies.items() + if str(mode) in {"on", "off", "inherit"} + } + + raw_dock_targets = payload.get("dock_targets") or {} + dock_targets: Dict[str, Dict[str, Any]] = {} + for session_key, target in raw_dock_targets.items(): + if not isinstance(target, dict): + continue + platform = str(target.get("platform") or "").strip().lower() + chat_id = str(target.get("chat_id") or "").strip() + if not platform or not chat_id: + continue + dock_targets[str(session_key)] = { + "platform": platform, + "chat_id": chat_id, + "thread_id": str(target.get("thread_id") or "").strip() or None, + "name": str(target.get("name") or "").strip(), + } + + return { + "send_policies": send_policies, + "dock_targets": dock_targets, + } + + def _save_runtime_controls(self) -> None: + """Persist per-session send-policy and dock-routing controls.""" + payload = { + "send_policies": getattr(self, "_session_send_policies", {}) or {}, + "dock_targets": getattr(self, "_session_docks", {}) or {}, + } + try: + self._RUNTIME_CONTROLS_PATH.parent.mkdir(parents=True, exist_ok=True) + self._RUNTIME_CONTROLS_PATH.write_text(json.dumps(payload, indent=2, sort_keys=True)) + except OSError as e: + logger.warning("Failed to save gateway runtime controls: %s", e) + # ----------------------------------------------------------------- def _flush_memories_for_session( @@ -610,6 +994,970 @@ def _session_key_for_source(self, source: SessionSource) -> str: group_sessions_per_user=getattr(config, "group_sessions_per_user", True), ) + def _session_source_for_event(self, event: MessageEvent) -> SessionSource: + """Resolve the session source for an event, honoring command-session overrides.""" + metadata = getattr(event, "metadata", None) + if isinstance(metadata, dict): + session_source = metadata.get("session_source") + if isinstance(session_source, SessionSource): + return session_source + return event.source + + def _command_target_source_for_event(self, event: MessageEvent) -> SessionSource: + """Resolve the command target source, falling back to the event source.""" + metadata = getattr(event, "metadata", None) + if isinstance(metadata, dict): + target_source = metadata.get("command_target_source") + if isinstance(target_source, SessionSource): + return target_source + return event.source + + @staticmethod + def _new_approval_id() -> str: + """Generate a short human-readable approval identifier.""" + return f"appr-{uuid.uuid4().hex[:8]}" + + @staticmethod + def _normalize_approval_decision(raw: str) -> Optional[str]: + value = str(raw or "").strip().lower() + mapping = { + "allow-once": "allow-once", + "once": "allow-once", + "allow_once": "allow-once", + "yes": "allow-once", + "approve": "allow-once", + "allow-session": "allow-session", + "allow_session": "allow-session", + "session": "allow-session", + "ses": "allow-session", + "allow-always": "allow-always", + "always": "allow-always", + "allow_always": "allow-always", + "deny": "deny", + "no": "deny", + "reject": "deny", + "cancel": "deny", + "show": "show", + "view": "show", + "full": "show", + } + return mapping.get(value) + + def _find_pending_approval( + self, + *, + approval_id: Optional[str] = None, + source: Optional[SessionSource] = None, + ) -> tuple[Optional[str], Optional[dict[str, Any]]]: + """Find a pending approval by explicit approval ID or source session.""" + if approval_id: + for session_key, pending in self._pending_approvals.items(): + if str(pending.get("approval_id") or "") == approval_id: + return session_key, pending + return None, None + + if source is None: + return None, None + + session_key = self._session_key_for_source(source) + return session_key, self._pending_approvals.get(session_key) + + def _approval_usage_text(self) -> str: + return "`/approve`, `/approve session`, `/approve always`, or `/approve [id] `" + + def _resolve_pending_approval( + self, + *, + decision: str, + approval_id: Optional[str] = None, + source: Optional[SessionSource] = None, + ) -> str: + """Resolve a pending approval by ID or source session.""" + normalized = self._normalize_approval_decision(decision) + if normalized is None: + return ( + f"⚠️ Unknown approval decision: `{decision}`\n\n" + f"Use {self._approval_usage_text()}" + ) + + session_key, pending = self._find_pending_approval(approval_id=approval_id, source=source) + if not session_key or not pending: + if normalized == "deny": + return "No pending command to deny." + return "No pending command to approve." + + pending_id = str(pending.get("approval_id") or "") + command = str(pending.get("command") or "").strip() + pattern_keys = list(pending.get("pattern_keys") or []) + if not pattern_keys: + pattern_key = str(pending.get("pattern_key") or "").strip() + if pattern_key: + pattern_keys = [pattern_key] + + timestamp = pending.get("timestamp") + if isinstance(timestamp, (int, float)) and time.time() - float(timestamp) > 300: + self._pending_approvals.pop(session_key, None) + return "⚠️ Approval expired (timed out after 5 minutes). Ask the agent to try again." + + if normalized == "show": + return ( + f"⚠️ Pending approval `{pending_id}`\n\n" + f"```\n{command}\n```\n\n" + f"Use {self._approval_usage_text()}" + ) + + if normalized == "deny": + self._pending_approvals.pop(session_key, None) + return "❌ Command denied." + + import tools.approval as approval_mod + from tools.terminal_tool import terminal_tool + + if normalized == "allow-session": + for pattern_key in pattern_keys: + approval_mod.approve_session(session_key, pattern_key) + elif normalized == "allow-always": + for pattern_key in pattern_keys: + approval_mod.approve_session(session_key, pattern_key) + approval_mod.approve_permanent(pattern_key) + approval_mod.save_permanent_allowlist(approval_mod._permanent_approved) + else: + for pattern_key in pattern_keys: + approval_mod.approve_session(session_key, pattern_key) + + self._pending_approvals.pop(session_key, None) + on_approve = pending.get("on_approve") + if callable(on_approve): + try: + return on_approve(normalized) + except Exception as e: + logger.exception("Pending approval callback failed") + return f"❌ Approved command failed: {e}" + + result = terminal_tool(command=command, force=True) + if normalized == "allow-session": + prefix = ( + "✅ Command approved and executed " + "(pattern approved for this session). Decision: approved (allow-session)." + ) + elif normalized == "allow-always": + prefix = ( + "✅ Command approved and executed " + "(pattern approved permanently). Decision: approved (allow-always)." + ) + else: + prefix = "✅ Command approved and executed. Decision: approved (allow-once)." + if pending_id: + prefix = f"{prefix} (`{pending_id}`)" + return f"{prefix}\n\n```\n{result[:3500]}\n```" + + @staticmethod + def _format_minutes_label(minutes: int) -> str: + if minutes <= 0: + return "off" + if minutes % 1440 == 0: + days = minutes // 1440 + return f"{days}d" + if minutes % 60 == 0: + hours = minutes // 60 + return f"{hours}h" + return f"{minutes}m" + + @staticmethod + def _parse_duration_minutes(raw: str) -> Optional[int]: + value = str(raw or "").strip().lower() + if not value: + return None + if value in {"off", "none", "disable", "disabled"}: + return 0 + + from cron.jobs import parse_duration + + return parse_duration(value) + + def _get_discord_adapter(self): + adapter = self.adapters.get(Platform.DISCORD) + if adapter is None: + return None + return adapter + + def _session_send_policy(self, session_key: str) -> str: + policies = getattr(self, "_session_send_policies", None) + if not isinstance(policies, dict): + return "inherit" + return policies.get(session_key, "inherit") + + def _dock_target_for_session(self, session_key: str) -> Optional[Dict[str, Any]]: + dock_targets = getattr(self, "_session_docks", None) + if not isinstance(dock_targets, dict): + return None + target = dock_targets.get(session_key) + if not isinstance(target, dict): + return None + return target + + def _format_dock_target(self, target: Dict[str, Any]) -> str: + platform = str(target.get("platform") or "unknown") + name = str(target.get("name") or "").strip() + chat_id = str(target.get("chat_id") or "") + if name: + return f"{platform}:{name} (`{chat_id}`)" + return f"{platform} home channel (`{chat_id}`)" + + def _get_bash_jobs(self) -> Dict[str, str]: + jobs = getattr(self, "_bash_jobs", None) + if jobs is None: + jobs = {} + self._bash_jobs = jobs + return jobs + + @staticmethod + def _rewrite_bash_shortcut(raw_text: str) -> Optional[str]: + text = str(raw_text or "") + stripped = text.lstrip() + if not stripped.startswith("!"): + return None + payload = stripped[1:].strip() + if not payload: + return None + if payload.startswith("poll"): + args = payload[4:].strip() + return f"/bash poll {args}".strip() + if payload.startswith("stop"): + args = payload[4:].strip() + return f"/bash stop {args}".strip() + return f"/bash {payload}" + + @staticmethod + def _split_command_args(raw: str, *, max_parts: int | None = None) -> list[str]: + text = str(raw or "").strip() + if not text: + return [] + try: + parts = shlex.split(text) + except ValueError: + parts = text.split() + if max_parts is None or len(parts) <= max_parts: + return parts + head = parts[: max_parts - 1] + tail = " ".join(parts[max_parts - 1 :]).strip() + return [*head, tail] + + @staticmethod + def _normalize_skill_name(raw: str) -> str: + return str(raw or "").strip().lower().replace(" ", "-").replace("_", "-").lstrip("/") + + @staticmethod + def _parity_blocker_text(command: str, plan_id: str, detail: str) -> str: + return ( + f"⚠️ `/{command}` is still blocked in Hermes parity work.\n\n" + f"**Blocked by:** `{plan_id}`\n" + f"**Reason:** {detail}" + ) + + def _get_bash_foreground_seconds(self) -> int: + raw_ms = os.getenv("HERMES_BASH_FOREGROUND_MS", "").strip() + if not raw_ms: + try: + cfg = load_raw_user_config() or {} + commands_cfg = cfg.get("commands", {}) if isinstance(cfg, dict) else {} + raw_ms = str(commands_cfg.get("bashForegroundMs", "") or "").strip() + except Exception: + raw_ms = "" + try: + ms = int(raw_ms or "2000") + except ValueError: + ms = 2000 + if ms <= 0: + return 0 + return max(1, (ms + 999) // 1000) + + @staticmethod + def _format_bash_output(output: str, *, limit: int = 3500) -> str: + text = str(output or "").rstrip() + if not text: + return "_(no output)_" + if len(text) > limit: + text = text[-limit:] + text = f"...\n{text}" + return f"```\n{text}\n```" + + def _resolve_bash_target_session_id(self, session_key: str, raw_target: str) -> Optional[str]: + from tools.process_registry import process_registry + + target = str(raw_target or "").strip() + jobs = self._get_bash_jobs() + if target: + return target + session_id = jobs.get(session_key) + if not session_id: + return None + current = process_registry.get(session_id) + if current is None or current.exited: + jobs.pop(session_key, None) + return None + return session_id + + def _store_pending_bash_approval( + self, + *, + session_key: str, + command: str, + approval_payload: dict[str, Any], + on_approve, + ) -> str: + approval_id = self._new_approval_id() + pending = { + "approval_id": approval_id, + "session_key": session_key, + "created_at": datetime.now().isoformat(), + "command": command, + "description": approval_payload.get("description", "command flagged"), + "pattern_key": approval_payload.get("pattern_key", ""), + "on_approve": on_approve, + } + self._pending_approvals[session_key] = pending + return approval_id + + def _run_bash_process( + self, + *, + source: SessionSource, + session_key: str, + command: str, + force: bool = False, + ) -> dict[str, Any]: + from tools.terminal_tool import terminal_tool + + context = build_session_context(source, self.config) + tracked_vars = [ + "HERMES_SESSION_PLATFORM", + "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_CHAT_NAME", + "HERMES_SESSION_THREAD_ID", + "HERMES_SESSION_KEY", + "HERMES_SESSION_SEND_POLICY", + ] + previous_env = {name: os.environ.get(name) for name in tracked_vars} + try: + self._set_session_env(context) + os.environ["HERMES_SESSION_KEY"] = session_key + os.environ["HERMES_SESSION_SEND_POLICY"] = self._session_send_policy(session_key) + raw = terminal_tool(command=command, background=True, force=force) + finally: + self._clear_session_env() + for name in ("HERMES_SESSION_KEY", "HERMES_SESSION_SEND_POLICY"): + previous = previous_env.get(name) + if previous is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous + for name in ("HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"): + previous = previous_env.get(name) + if previous is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous + + if isinstance(raw, str): + try: + parsed = json.loads(raw) + except Exception: + return {"status": "error", "error": raw} + if isinstance(parsed, dict): + return parsed + return {"status": "error", "error": "Unexpected terminal result"} + + def _resume_bash_after_approval( + self, + *, + source: SessionSource, + session_key: str, + command: str, + ) -> str: + result = self._run_bash_process( + source=source, + session_key=session_key, + command=command, + force=True, + ) + return self._format_bash_result( + session_key=session_key, + command=command, + result=result, + ) + + def _format_bash_result( + self, + *, + session_key: str, + command: str, + result: dict[str, Any], + ) -> str: + from tools.process_registry import process_registry + + status = str(result.get("status") or "").strip() + if status == "approval_required": + return str(result.get("error") or "Approval required.") + if status == "blocked": + return f"❌ {result.get('error') or 'Command blocked.'}" + if result.get("error") and not result.get("session_id"): + return f"❌ Bash command failed: {result.get('error')}" + + proc_id = str(result.get("session_id") or "").strip() + if not proc_id: + return f"❌ Bash command failed: {result.get('error') or 'No process session ID returned.'}" + + self._get_bash_jobs()[session_key] = proc_id + wait_seconds = self._get_bash_foreground_seconds() + wait_result = process_registry.wait(proc_id, timeout=wait_seconds) if wait_seconds > 0 else {"status": "timeout"} + + if wait_result.get("status") == "exited": + self._get_bash_jobs().pop(session_key, None) + exit_code = wait_result.get("exit_code") + output = wait_result.get("output", "") + return "\n".join( + [ + "💻 **Bash Complete**", + "", + f"**Command:** `{command}`", + f"**Exit Code:** `{exit_code}`", + "", + self._format_bash_output(output), + ] + ) + + if wait_result.get("status") == "interrupted": + self._get_bash_jobs().pop(session_key, None) + return "\n".join( + [ + "⚡ **Bash Interrupted**", + "", + f"**Command:** `{command}`", + "", + self._format_bash_output(wait_result.get("output", "")), + ] + ) + + process_info = process_registry.poll(proc_id) + pid = process_info.get("pid") + preview = process_info.get("output_preview", "") + return "\n".join( + [ + "💻 **Bash Running**", + "", + f"**Command:** `{command}`", + f"**Session ID:** `{proc_id}`", + f"**PID:** `{pid}`" if pid else "**PID:** `unknown`", + "", + "Use `/bash poll` or `!poll` to check progress.", + "Use `/bash stop` or `!stop` to terminate it.", + "", + self._format_bash_output(preview, limit=1200), + ] + ) + + @staticmethod + def _control_commands_during_run() -> set[str]: + return {"status", "stop", "approve", "bash", "subagents", "kill", "steer", "tell", "acp"} + + def _get_subagent_runtime(self) -> dict[str, dict[str, Any]]: + runtime = getattr(self, "_subagent_runtime", None) + if not isinstance(runtime, dict): + runtime = {} + self._subagent_runtime = runtime + return runtime + + def _get_subagent_session_state(self, session_key: str) -> dict[str, Any]: + runtime = self._get_subagent_runtime() + state = runtime.get(session_key) + if not isinstance(state, dict): + state = {"next_ordinal": 1, "entries": {}} + runtime[session_key] = state + state.setdefault("next_ordinal", 1) + state.setdefault("entries", {}) + return state + + @staticmethod + def _subagent_now() -> str: + return datetime.now().isoformat(timespec="seconds") + + @staticmethod + def _truncate_line(text: str, *, limit: int = 120) -> str: + value = str(text or "").strip() + if len(value) <= limit: + return value + return value[: limit - 3] + "..." + + def _append_subagent_log(self, entry: dict[str, Any], message: str) -> None: + logs = entry.setdefault("logs", []) + logs.append(f"[{self._subagent_now()}] {message}") + if len(logs) > 60: + del logs[:-60] + entry["updated_at"] = self._subagent_now() + + def _create_subagent_entry( + self, + *, + session_key: str, + child: Any, + task_index: int, + goal: str, + context: Optional[str], + toolsets: Optional[list[str]], + model: Optional[str], + source_kind: str, + ) -> dict[str, Any]: + state = self._get_subagent_session_state(session_key) + ordinal = int(state.get("next_ordinal", 1)) + state["next_ordinal"] = ordinal + 1 + subagent_id = f"sa-{ordinal}" + entry = { + "id": subagent_id, + "ordinal": ordinal, + "task_index": task_index, + "goal": str(goal or "").strip(), + "context": str(context or "").strip(), + "toolsets": list(toolsets or []), + "model": model or "", + "status": "pending", + "source_kind": source_kind, + "created_at": self._subagent_now(), + "updated_at": self._subagent_now(), + "started_at": None, + "ended_at": None, + "summary": "", + "error": "", + "logs": [], + "restarts": 0, + "child": child, + "pending_steer": None, + } + state["entries"][subagent_id] = entry + self._append_subagent_log(entry, f"Registered subagent for goal: {self._truncate_line(goal)}") + return entry + + def _find_subagent_entry(self, session_key: str, child: Any) -> Optional[dict[str, Any]]: + if child is None: + return None + state = self._get_subagent_session_state(session_key) + for entry in state["entries"].values(): + if entry.get("child") is child: + return entry + child_id = str(getattr(child, "_gateway_subagent_id", "") or "").strip() + if child_id: + return state["entries"].get(child_id) + return None + + def _resolve_subagent_targets( + self, + session_key: str, + raw_target: str, + *, + active_only: bool = False, + allow_all: bool = False, + ) -> tuple[list[dict[str, Any]], Optional[str]]: + state = self._get_subagent_session_state(session_key) + entries = list(state["entries"].values()) + if active_only: + entries = [e for e in entries if e.get("status") in {"pending", "running"}] + target = str(raw_target or "").strip() + if not target: + if len(entries) == 1: + return [entries[0]], None + if not entries: + return [], "No matching subagents found for this session." + return [], "Multiple subagents match. Use an explicit `sa-#` or `#` target." + if allow_all and target.lower() == "all": + return entries, None + if target.startswith("#"): + target = target[1:].strip() + if target.isdigit(): + ordinal = int(target) + for entry in entries: + if int(entry.get("ordinal", 0)) == ordinal: + return [entry], None + return [], f"No subagent found for `#{ordinal}`." + match = state["entries"].get(target) + if match and (not active_only or match.get("status") in {"pending", "running"}): + return [match], None + return [], f"No subagent found for `{target}`." + + def _format_subagent_list(self, session_key: str) -> str: + state = self._get_subagent_session_state(session_key) + entries = list(state["entries"].values()) + if not entries: + return "No subagents recorded for this session." + lines = ["🤖 **Subagents**", ""] + for entry in sorted(entries, key=lambda item: int(item.get("ordinal", 0))): + status = str(entry.get("status") or "unknown") + goal = self._truncate_line(entry.get("goal") or "(no goal)") + lines.append(f"- `#{entry['ordinal']}` / `{entry['id']}` — {status} — {goal}") + summary = str(entry.get("summary") or "").strip() + if summary: + lines.append(f" summary: {self._truncate_line(summary, limit=140)}") + return "\n".join(lines) + + def _format_subagent_info(self, entry: dict[str, Any]) -> str: + lines = [ + "🤖 **Subagent Info**", + "", + f"**ID:** `{entry['id']}`", + f"**Ordinal:** `#{entry['ordinal']}`", + f"**Status:** `{entry.get('status')}`", + f"**Source:** `{entry.get('source_kind')}`", + f"**Model:** `{entry.get('model') or 'default'}`", + f"**Toolsets:** `{', '.join(entry.get('toolsets') or []) or 'default'}`", + f"**Goal:** {entry.get('goal') or '_(empty)_'}", + ] + if entry.get("error"): + lines.append(f"**Error:** `{entry['error']}`") + if entry.get("summary"): + lines.append(f"**Summary:** {entry['summary']}") + if entry.get("started_at"): + lines.append(f"**Started:** `{entry['started_at']}`") + if entry.get("ended_at"): + lines.append(f"**Ended:** `{entry['ended_at']}`") + return "\n".join(lines) + + def _format_subagent_log(self, entry: dict[str, Any]) -> str: + lines = ["🤖 **Subagent Log**", "", f"**ID:** `{entry['id']}`", ""] + logs = entry.get("logs") or [] + if not logs: + lines.append("_(no log events yet)_") + else: + lines.append("```") + lines.extend(logs[-25:]) + lines.append("```") + return "\n".join(lines) + + def _attach_subagent_runtime_hooks(self, agent: Any, session_key: str) -> None: + if agent is None: + return + agent._register_delegate_child = ( + lambda **payload: self._register_delegate_child(session_key=session_key, **payload) + ) + agent._start_delegate_child = ( + lambda **payload: self._start_delegate_child(session_key=session_key, **payload) + ) + agent._record_delegate_child_progress = ( + lambda **payload: self._record_delegate_child_progress(session_key=session_key, **payload) + ) + agent._complete_delegate_child = ( + lambda **payload: self._complete_delegate_child(session_key=session_key, **payload) + ) + + def _register_delegate_child( + self, + *, + session_key: str, + child: Any, + task_index: int, + goal: str, + context: Optional[str], + toolsets: Optional[list[str]], + model: Optional[str], + ) -> str: + entry = self._create_subagent_entry( + session_key=session_key, + child=child, + task_index=task_index, + goal=goal, + context=context, + toolsets=toolsets, + model=model, + source_kind="delegate", + ) + return entry["id"] + + def _start_delegate_child( + self, + *, + session_key: str, + child: Any, + goal: str, + restarted: bool = False, + ) -> None: + entry = self._find_subagent_entry(session_key, child) + if not entry: + return + entry["status"] = "running" + entry["started_at"] = self._subagent_now() + entry["updated_at"] = self._subagent_now() + entry["goal"] = str(goal or "").strip() + if restarted: + entry["restarts"] = int(entry.get("restarts") or 0) + 1 + self._append_subagent_log(entry, f"Restarted on steer: {self._truncate_line(goal)}") + else: + self._append_subagent_log(entry, f"Started: {self._truncate_line(goal)}") + + def _record_delegate_child_progress( + self, + *, + session_key: str, + child: Any, + task_index: int, + tool_name: str, + preview: Optional[str], + args: Optional[dict[str, Any]], + summary: Optional[str], + ) -> None: + entry = self._find_subagent_entry(session_key, child) + if not entry: + return + text = summary or preview or tool_name + entry["last_progress"] = str(text or "").strip() + self._append_subagent_log(entry, f"{tool_name}: {self._truncate_line(text, limit=160)}") + + def _complete_delegate_child( + self, + *, + session_key: str, + child: Any, + entry: dict[str, Any], + goal: str, + ) -> None: + stored = self._find_subagent_entry(session_key, child) + if not stored: + return + stored["status"] = entry.get("status", "completed") + stored["summary"] = str(entry.get("summary") or "").strip() + stored["error"] = str(entry.get("error") or "").strip() + stored["goal"] = str(goal or stored.get("goal") or "").strip() + stored["ended_at"] = self._subagent_now() + stored["updated_at"] = self._subagent_now() + stored["child"] = None + if stored["status"] == "completed": + self._append_subagent_log(stored, f"Completed: {self._truncate_line(stored.get('summary') or 'done', limit=160)}") + elif stored["status"] == "interrupted": + self._append_subagent_log(stored, "Interrupted.") + else: + detail = stored.get("error") or stored.get("summary") or "failed" + self._append_subagent_log(stored, f"Finished with {stored['status']}: {self._truncate_line(detail, limit=160)}") + + def _build_subagent_progress_callback(self, session_key: str, child: Any): + def _callback(tool_name: str, preview: str = None, args: dict = None): + self._record_delegate_child_progress( + session_key=session_key, + child=child, + task_index=int(getattr(child, "_gateway_subagent_task_index", 0) or 0), + tool_name=tool_name, + preview=preview, + args=args, + summary=preview or tool_name, + ) + + return _callback + + def _get_acp_session_manager(self): + manager = getattr(self, "_acp_session_manager", None) + if manager is None: + from acp_adapter.session import SessionManager + + manager = SessionManager() + self._acp_session_manager = manager + return manager + + def _get_acp_bindings(self) -> dict[str, str]: + bindings = getattr(self, "_acp_session_bindings", None) + if not isinstance(bindings, dict): + bindings = {} + self._acp_session_bindings = bindings + return bindings + + def _get_acp_meta(self) -> dict[str, dict[str, Any]]: + meta = getattr(self, "_acp_session_meta", None) + if not isinstance(meta, dict): + meta = {} + self._acp_session_meta = meta + return meta + + def _get_acp_tasks(self) -> dict[str, asyncio.Task]: + tasks = getattr(self, "_acp_running_tasks", None) + if not isinstance(tasks, dict): + tasks = {} + self._acp_running_tasks = tasks + return tasks + + def _get_acp_session_meta_entry(self, session_id: str) -> dict[str, Any]: + meta = self._get_acp_meta() + entry = meta.get(session_id) + if not isinstance(entry, dict): + entry = {"options": {}, "last_result": None, "last_prompt": "", "updated_at": self._subagent_now()} + meta[session_id] = entry + entry.setdefault("options", {}) + entry.setdefault("updated_at", self._subagent_now()) + return entry + + def _resolve_acp_target(self, session_key: str, payload: str) -> tuple[Optional[str], str]: + manager = self._get_acp_session_manager() + text = str(payload or "").strip() + parts = self._split_command_args(text, max_parts=2) + if parts and manager.get_session(parts[0]): + return parts[0], parts[1] if len(parts) > 1 else "" + bound = self._get_acp_bindings().get(session_key) + return bound, text + + def _format_acp_sessions(self, session_key: str) -> str: + manager = self._get_acp_session_manager() + listing = manager.list_sessions() + if not listing: + return "No ACP sessions are active." + bound = self._get_acp_bindings().get(session_key) + tasks = self._get_acp_tasks() + lines = ["🧩 **ACP Sessions**", ""] + for item in listing: + session_id = item.get("session_id", "") + busy = session_id in tasks and not tasks[session_id].done() + label = " (bound)" if session_id == bound else "" + lines.append( + f"- `{session_id}`{label} — cwd `{item.get('cwd')}` — model `{item.get('model') or 'default'}` — busy `{str(busy).lower()}`" + ) + return "\n".join(lines) + + def _format_acp_status(self, session_id: str) -> str: + manager = self._get_acp_session_manager() + state = manager.get_session(session_id) + if state is None: + return f"No ACP session found for `{session_id}`." + meta = self._get_acp_session_meta_entry(session_id) + task = self._get_acp_tasks().get(session_id) + busy = bool(task and not task.done()) + lines = [ + "🧩 **ACP Status**", + "", + f"**Session:** `{session_id}`", + f"**Busy:** `{str(busy).lower()}`", + f"**CWD:** `{state.cwd}`", + f"**Model:** `{state.model or getattr(state.agent, 'model', '') or 'default'}`", + f"**History Messages:** `{len(state.history)}`", + ] + options = meta.get("options") or {} + if options: + lines.append(f"**Options:** `{json.dumps(options, default=str, sort_keys=True)}`") + if meta.get("last_prompt"): + lines.append(f"**Last Prompt:** {self._truncate_line(meta['last_prompt'], limit=180)}") + last_result = meta.get("last_result") or {} + if isinstance(last_result, dict) and last_result.get("final_response"): + lines.append(f"**Last Result:** {self._truncate_line(last_result['final_response'], limit=180)}") + return "\n".join(lines) + + def _prepare_skill_command( + self, + event: MessageEvent, + *, + task_id: str, + ) -> tuple[Optional[str], Optional[str]]: + """Rewrite `/skill` into the underlying skill invocation message.""" + args_text = event.get_command_args().strip() + parts = self._split_command_args(args_text, max_parts=2) + if not parts: + from agent.skill_commands import get_skill_commands + + skill_cmds = get_skill_commands() + if not skill_cmds: + return None, "No skill commands are installed." + lines = ["⚡ **Installed Skills**", ""] + for name, payload in sorted(skill_cmds.items()): + lines.append(f"`{name}` — {payload.get('description', 'Skill command')}") + lines.append("") + lines.append("Usage: `/skill [input]`") + return None, "\n".join(lines) + + from agent.skill_commands import build_skill_invocation_message + + skill_name = self._normalize_skill_name(parts[0]) + user_instruction = parts[1] if len(parts) > 1 else "" + cmd_key = f"/{skill_name}" + prepared = build_skill_invocation_message( + cmd_key, + user_instruction, + task_id=task_id, + ) + if not prepared: + return None, f"Unknown skill `{skill_name}`. Use `/skill` to list installed skills." + event.text = prepared + return prepared, None + + def _schedule_gateway_restart(self, delay_seconds: float = 1.5) -> None: + async def _restart_later(): + await asyncio.sleep(delay_seconds) + hermes_cmd = _resolve_hermes_bin() + if not hermes_cmd: + logger.error("Could not locate hermes executable for gateway restart") + return + cmd = hermes_cmd + ["gateway", "restart"] + try: + import shutil + import subprocess + + systemd_run = shutil.which("systemd-run") + if systemd_run: + subprocess.Popen( + [systemd_run, "--user", "--scope", "--unit=hermes-discord-restart", "--", *cmd], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + else: + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except Exception: + logger.exception("Gateway restart scheduling failed") + + asyncio.create_task(_restart_later()) + + async def _deliver_docked_response( + self, + *, + session_key: str, + response: str, + source: SessionSource, + ) -> Optional[str]: + target = self._dock_target_for_session(session_key) + if not target or not response.strip(): + return response + + target_platform = str(target.get("platform") or "").strip().lower() + if not target_platform: + return response + + try: + platform = Platform(target_platform) + except ValueError: + return response + + same_target = ( + platform == source.platform + and str(target.get("chat_id") or "") == str(source.chat_id) + and str(target.get("thread_id") or "") == str(source.thread_id or "") + ) + if same_target: + return response + + dock_target = DeliveryTarget( + platform=platform, + chat_id=str(target.get("chat_id") or ""), + thread_id=str(target.get("thread_id") or "").strip() or None, + ) + results = await self.delivery_router.deliver( + response, + [dock_target], + metadata={ + "source_platform": source.platform.value if source.platform else "unknown", + "source_chat_id": source.chat_id, + }, + ) + result = results.get(dock_target.to_string()) or {} + if result.get("success"): + return None + error = result.get("error") or "unknown dock delivery failure" + return f"⚠️ Reply docking failed: {error}\n\n{response}" + def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwargs: dict) -> dict: from agent.smart_model_routing import resolve_turn_route @@ -901,6 +2249,8 @@ async def start(self) -> bool: # Set up message + fatal error handlers adapter.set_message_handler(self._handle_message) adapter.set_fatal_error_handler(self._handle_adapter_fatal_error) + if platform == Platform.DISCORD and hasattr(adapter, "_resolve_exec_approval"): + adapter._resolve_exec_approval = self._resolve_pending_approval # Try to connect logger.info("Connecting to %s...", platform.value) @@ -1064,6 +2414,26 @@ async def stop(self) -> None: except Exception as e: logger.debug("Failed interrupting agent during shutdown: %s", e) + for runtime in self._get_subagent_runtime().values(): + for entry in runtime.get("entries", {}).values(): + child = entry.get("child") + if child is not None and hasattr(child, "interrupt"): + try: + child.interrupt("Gateway shutting down") + except Exception as e: + logger.debug("Failed interrupting subagent during shutdown: %s", e) + + for session_id, task in list(self._get_acp_tasks().items()): + state = self._get_acp_session_manager().get_session(session_id) + if state and state.cancel_event: + state.cancel_event.set() + try: + state.agent.interrupt("Gateway shutting down") + except Exception: + logger.debug("Failed interrupting ACP session during shutdown", exc_info=True) + if task and not task.done(): + task.cancel() + for platform, adapter in list(self.adapters.items()): try: await adapter.cancel_background_tasks() @@ -1302,6 +2672,7 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: 7. Return response """ source = event.source + session_source = self._session_source_for_event(event) # Check if user is authorized if not self._is_user_authorized(source): @@ -1332,6 +2703,46 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: ) return None + # User-defined quick commands (bypass agent loop, no LLM call) + command = event.get_command() + if command: + if isinstance(self.config, dict): + quick_commands = self.config.get("quick_commands", {}) or {} + else: + quick_commands = getattr(self.config, "quick_commands", {}) or {} + if not isinstance(quick_commands, dict): + quick_commands = {} + if command in quick_commands: + qcmd = quick_commands[command] + if qcmd.get("type") == "exec": + exec_cmd = qcmd.get("command", "") + if exec_cmd: + try: + proc = await asyncio.create_subprocess_shell( + exec_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) + output = (stdout or stderr).decode().strip() + return output if output else "Command returned no output." + except asyncio.TimeoutError: + return "Quick command timed out (30s)." + except Exception as e: + return f"Quick command error: {e}" + else: + return f"Quick command '/{command}' has no command defined." + elif qcmd.get("type") == "alias": + target = qcmd.get("target", "").strip() + if target: + target = target if target.startswith("/") else f"/{target}" + user_args = event.get_command_args().strip() + event.text = f"{target} {user_args}".strip() + else: + return f"Quick command '/{command}' has no target defined." + else: + return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')." + # PRIORITY handling when an agent is already running for this session. # Default behavior is to interrupt immediately so user text/stop messages # are handled with minimal latency. @@ -1339,44 +2750,44 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # Special case: Telegram/photo bursts often arrive as multiple near- # simultaneous updates. Do NOT interrupt for photo-only follow-ups here; # let the adapter-level batching/queueing logic absorb them. - _quick_key = self._session_key_for_source(source) + _quick_key = self._session_key_for_source(session_source) if _quick_key in self._running_agents: - if event.get_command() == "status": + command_name = (event.get_command() or "").strip().lower() + running_agent = self._running_agents.get(_quick_key) + if command_name == "status": return await self._handle_status_command(event) - + if command_name == "stop" and running_agent is _AGENT_PENDING_SENTINEL: + return "⏳ The agent is still starting up — nothing to stop yet." # /reset and /new must bypass the running-agent guard so they # actually dispatch as commands instead of being queued as user # text (which would be fed back to the agent with the same - # broken history — #2170). Interrupt the agent first, then + # broken history — #2170). Interrupt the agent first, then # clear the adapter's pending queue so the stale "/reset" text # doesn't get re-processed as a user message after the # interrupt completes. from hermes_cli.commands import resolve_command as _resolve_cmd_inner + _evt_cmd = event.get_command() _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None if _cmd_def_inner and _cmd_def_inner.name == "new": - running_agent = self._running_agents.get(_quick_key) if running_agent and running_agent is not _AGENT_PENDING_SENTINEL: running_agent.interrupt("Session reset requested") - # Clear any pending messages so the old text doesn't replay adapter = self.adapters.get(source.platform) - if adapter and hasattr(adapter, 'get_pending_message'): - adapter.get_pending_message(_quick_key) # consume and discard + if adapter and hasattr(adapter, "get_pending_message"): + adapter.get_pending_message(_quick_key) self._pending_messages.pop(_quick_key, None) - # Clean up the running agent entry so the reset handler - # doesn't think an agent is still active. if _quick_key in self._running_agents: del self._running_agents[_quick_key] return await self._handle_reset_command(event) - # /queue — queue without interrupting - if event.get_command() in ("queue", "q"): + if _cmd_def_inner and _cmd_def_inner.name == "queue": queued_text = event.get_command_args().strip() if not queued_text: return "Usage: /queue " adapter = self.adapters.get(source.platform) if adapter: from gateway.platforms.base import MessageEvent as _ME, MessageType as _MT + queued_event = _ME( text=queued_text, message_type=_MT.TEXT, @@ -1386,7 +2797,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: adapter._pending_messages[_quick_key] = queued_event return "Queued for the next turn." - if event.message_type == MessageType.PHOTO: + if command_name in self._control_commands_during_run(): + pass + elif event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) adapter = self.adapters.get(source.platform) if adapter: @@ -1406,26 +2819,25 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: else: adapter._pending_messages[_quick_key] = event return None - - running_agent = self._running_agents.get(_quick_key) - if running_agent is _AGENT_PENDING_SENTINEL: - # Agent is being set up but not ready yet. - if event.get_command() == "stop": - # Nothing to interrupt — agent hasn't started yet. - return "⏳ The agent is still starting up — nothing to stop yet." - # Queue the message so it will be picked up after the - # agent starts. - adapter = self.adapters.get(source.platform) - if adapter: - adapter._pending_messages[_quick_key] = event - return None - logger.debug("PRIORITY interrupt for session %s", _quick_key[:20]) - running_agent.interrupt(event.text) - if _quick_key in self._pending_messages: - self._pending_messages[_quick_key] += "\n" + event.text else: - self._pending_messages[_quick_key] = event.text - return None + if running_agent is _AGENT_PENDING_SENTINEL: + if command_name == "stop": + return "⏳ The agent is still starting up — nothing to stop yet." + adapter = self.adapters.get(source.platform) + if adapter: + adapter._pending_messages[_quick_key] = event + return None + logger.debug("PRIORITY interrupt for session %s", _quick_key[:20]) + running_agent.interrupt(event.text) + if _quick_key in self._pending_messages: + self._pending_messages[_quick_key] += "\n" + event.text + else: + self._pending_messages[_quick_key] = event.text + return None + + rewritten_bash = self._rewrite_bash_shortcut(event.text) + if rewritten_bash: + event.text = rewritten_bash # Check for commands command = event.get_command() @@ -1434,8 +2846,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: # GATEWAY_KNOWN_COMMANDS is derived from the central COMMAND_REGISTRY # in hermes_cli/commands.py — no hardcoded set to maintain here. from hermes_cli.commands import GATEWAY_KNOWN_COMMANDS, resolve_command as _resolve_cmd - if command and command in GATEWAY_KNOWN_COMMANDS: - await self.hooks.emit(f"command:{command}", { + hooks = getattr(self, "hooks", None) + if command and command in GATEWAY_KNOWN_COMMANDS and hooks is not None: + await hooks.emit(f"command:{command}", { "platform": source.platform.value if source.platform else "", "user_id": source.user_id, "command": command, @@ -1451,19 +2864,64 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "help": return await self._handle_help_command(event) + + if canonical == "commands": + return await self._handle_commands_command(event) + + if canonical == "context": + return await self._handle_context_command(event) + + if canonical == "export-session": + return await self._handle_export_session_command(event) + + if canonical == "whoami": + return await self._handle_whoami_command(event) + + if canonical == "focus": + return await self._handle_focus_command(event) + + if canonical == "unfocus": + return await self._handle_unfocus_command(event) + + if canonical == "agents": + return await self._handle_agents_command(event) + + if canonical == "session": + return await self._handle_session_command(event) if canonical == "status": return await self._handle_status_command(event) - + + if canonical == "approve": + return await self._handle_approve_command(event) + + if canonical == "allowlist": + return await self._handle_allowlist_command(event) + + if canonical == "config": + return await self._handle_config_command(event) + + if canonical == "debug": + return await self._handle_debug_command(event) + if canonical == "stop": return await self._handle_stop_command(event) - - if canonical == "model": + + if canonical in {"model", "models"}: return await self._handle_model_command(event) if canonical == "reasoning": return await self._handle_reasoning_command(event) + if canonical == "think": + return await self._handle_think_command(event) + + if canonical == "send": + return await self._handle_send_command(event) + + if canonical == "activation": + return await self._handle_activation_command(event) + if canonical == "provider": return await self._handle_provider_command(event) @@ -1501,6 +2959,9 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "sethome": return await self._handle_set_home_command(event) + if canonical == "compact": + return await self._handle_compress_command(event) + if canonical == "compress": return await self._handle_compress_command(event) @@ -1519,9 +2980,31 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "deny": return await self._handle_deny_command(event) + if canonical == "skill": + try: + _prepared, error = self._prepare_skill_command(event, task_id=_quick_key) + if error: + return error + canonical = None + except Exception as e: + logger.exception("Failed to prepare /skill command") + return f"Failed to invoke skill: {e}" + if canonical == "update": return await self._handle_update_command(event) + if canonical == "restart": + return await self._handle_restart_command(event) + + if canonical == "dock-telegram": + return await self._handle_dock_command(event, Platform.TELEGRAM) + + if canonical == "dock-discord": + return await self._handle_dock_command(event, Platform.DISCORD) + + if canonical == "dock-slack": + return await self._handle_dock_command(event, Platform.SLACK) + if canonical == "title": return await self._handle_title_command(event) @@ -1537,47 +3020,20 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: if canonical == "voice": return await self._handle_voice_command(event) - # User-defined quick commands (bypass agent loop, no LLM call) - if command: - if isinstance(self.config, dict): - quick_commands = self.config.get("quick_commands", {}) or {} - else: - quick_commands = getattr(self.config, "quick_commands", {}) or {} - if not isinstance(quick_commands, dict): - quick_commands = {} - if command in quick_commands: - qcmd = quick_commands[command] - if qcmd.get("type") == "exec": - exec_cmd = qcmd.get("command", "") - if exec_cmd: - try: - proc = await asyncio.create_subprocess_shell( - exec_cmd, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30) - output = (stdout or stderr).decode().strip() - return output if output else "Command returned no output." - except asyncio.TimeoutError: - return "Quick command timed out (30s)." - except Exception as e: - return f"Quick command error: {e}" - else: - return f"Quick command '/{command}' has no command defined." - elif qcmd.get("type") == "alias": - target = qcmd.get("target", "").strip() - if target: - target = target if target.startswith("/") else f"/{target}" - target_command = target.lstrip("/") - user_args = event.get_command_args().strip() - event.text = f"{target} {user_args}".strip() - command = target_command - # Fall through to normal command dispatch below - else: - return f"Quick command '/{command}' has no target defined." - else: - return f"Quick command '/{command}' has unsupported type (supported: 'exec', 'alias')." + if canonical == "subagents": + return await self._handle_subagents_command(event) + + if canonical == "kill": + return await self._handle_kill_command(event) + + if canonical == "steer": + return await self._handle_steer_command(event) + + if canonical == "acp": + return await self._handle_acp_command(event) + + if canonical == "bash": + return await self._handle_bash_command(event) # Skill slash commands: /skill-name loads the skill and sends to agent if command: @@ -1631,8 +3087,9 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): session_entry.created_at == session_entry.updated_at or getattr(session_entry, "was_auto_reset", False) ) - if _is_new_session: - await self.hooks.emit("session:start", { + hooks = getattr(self, "hooks", None) + if _is_new_session and hooks is not None: + await hooks.emit("session:start", { "platform": source.platform.value if source.platform else "", "user_id": source.user_id, "session_id": session_entry.session_id, @@ -2139,19 +3596,28 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): import time as _time pending = pop_pending(session_key) if pending: - pending["timestamp"] = _time.time() + approval_id = str(pending.get("approval_id") or self._new_approval_id()) + pending["approval_id"] = approval_id + pending["session_key"] = session_key + pending.setdefault("created_at", datetime.now().isoformat()) self._pending_approvals[session_key] = pending - # Append structured instructions so the user knows how to respond - cmd_preview = pending.get("command", "") - if len(cmd_preview) > 200: - cmd_preview = cmd_preview[:200] + "..." - approval_hint = ( - f"\n\n⚠️ **Dangerous command requires approval:**\n" - f"```\n{cmd_preview}\n```\n" - f"Reply `/approve` to execute, `/approve session` to approve this pattern " - f"for the session, or `/deny` to cancel." + approval_note = ( + f"\n\nApproval ID: `{approval_id}`\n" + f"Use {self._approval_usage_text()} or the Discord approval buttons." ) - response = (response or "") + approval_hint + response = f"{response}{approval_note}" if response else approval_note.strip() + + if source.platform == Platform.DISCORD: + approval_adapter = self.adapters.get(Platform.DISCORD) + if approval_adapter and hasattr(approval_adapter, "send_exec_approval"): + try: + await approval_adapter.send_exec_approval( + source.chat_id, + pending.get("command", ""), + approval_id, + ) + except Exception as approval_error: + logger.debug("Discord exec approval UI failed: %s", approval_error) except Exception as e: logger.debug("Failed to check pending approvals: %s", e) @@ -2247,8 +3713,11 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): base_url=agent_result.get("base_url"), ) - # Auto voice reply: send TTS audio before the text response - if self._should_send_voice_reply(event, response, agent_messages): + dock_target = self._dock_target_for_session(session_key) + + # Auto voice reply: send TTS audio before the text response. + # Skip when replies for this session are docked elsewhere. + if dock_target is None and self._should_send_voice_reply(event, response, agent_messages): await self._send_voice_reply(event, response) # If streaming already delivered the response, return None so @@ -2256,6 +3725,13 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str): if agent_result.get("already_sent"): return None + if response: + response = await self._deliver_docked_response( + session_key=session_key, + response=response, + source=source, + ) + return response except Exception as e: @@ -2356,6 +3832,12 @@ async def _handle_reset_command(self, event: MessageEvent) -> str: async def _handle_status_command(self, event: MessageEvent) -> str: """Handle /status command.""" + if self._session_source_for_event(event).platform == Platform.DISCORD: + from gateway.platforms.discord_impl import runtime_views + + snapshot = runtime_views.collect_discord_status_snapshot(self, event) + return runtime_views.render_discord_status(snapshot) + source = event.source session_entry = self.session_store.get_or_create_session(source) @@ -2378,6 +3860,865 @@ async def _handle_status_command(self, event: MessageEvent) -> str: ] return "\n".join(lines) + + async def _handle_approve_command(self, event: MessageEvent) -> str: + """Handle /approve command for pending exec approvals.""" + raw_args = event.get_command_args().strip() + target_source = self._command_target_source_for_event(event) + if not raw_args: + return self._resolve_pending_approval(decision="allow-once", source=target_source) + + tokens = raw_args.split() + approval_id: Optional[str] = None + decision: Optional[str] = None + + if len(tokens) == 1: + maybe_decision = self._normalize_approval_decision(tokens[0]) + if maybe_decision: + decision = maybe_decision + else: + approval_id = tokens[0] + else: + first_decision = self._normalize_approval_decision(tokens[0]) + second_decision = self._normalize_approval_decision(tokens[1]) + if first_decision and not second_decision: + decision = first_decision + approval_id = tokens[1] + else: + approval_id = tokens[0] + decision = second_decision or self._normalize_approval_decision(tokens[1]) + + if approval_id and decision is None: + return self._resolve_pending_approval(decision="show", approval_id=approval_id) + if decision is None: + return f"Use {self._approval_usage_text()}" + + return self._resolve_pending_approval( + decision=decision, + approval_id=approval_id, + source=target_source, + ) + + async def _handle_allowlist_command(self, event: MessageEvent) -> str: + """Handle /allowlist command - inspect or edit command approval patterns.""" + import tools.approval as approval_mod + + raw_args = event.get_command_args().strip() + parts = self._split_command_args(raw_args, max_parts=2) + mode = (parts[0] if parts else "list").strip().lower() + entry = parts[1].strip() if len(parts) > 1 else "" + + if mode not in {"list", "add", "remove"}: + return ( + f"⚠️ Unknown allowlist action: `{mode}`\n\n" + "Use `/allowlist [list|add|remove] [entry]`" + ) + + patterns = set(approval_mod.load_permanent_allowlist()) + if mode == "list": + if not patterns: + return "No permanently approved command patterns are stored." + lines = ["🛡️ **Command Allowlist**", ""] + for pattern in sorted(patterns): + lines.append(f"• `{pattern}`") + return "\n".join(lines) + + if not entry: + return f"Usage: `/allowlist {mode} `" + + if mode == "add": + patterns.add(entry) + approval_mod.load_permanent(patterns) + approval_mod.save_permanent_allowlist(patterns) + return f"✅ Added `{entry}` to the command allowlist." + + if entry not in patterns: + return f"`{entry}` is not in the command allowlist." + patterns.remove(entry) + approval_mod._permanent_approved = set(patterns) + approval_mod.save_permanent_allowlist(patterns) + return f"🗑️ Removed `{entry}` from the command allowlist." + + async def _handle_config_command(self, event: MessageEvent) -> str: + """Handle /config command - inspect or update on-disk Hermes config.""" + raw_args = event.get_command_args().strip() + parts = self._split_command_args(raw_args, max_parts=3) + mode = (parts[0] if parts else "show").strip().lower() + key = parts[1].strip() if len(parts) > 1 else "" + value = parts[2].strip() if len(parts) > 2 else "" + + if mode == "show": + if key: + try: + kind, resolved = read_config_or_env_value(key) + except KeyError: + return f"`{key}` is not set." + return f"📄 **Config Show** (`{kind}`)\n\n`{key}`\n{format_yaml_block(resolved)}" + raw_config = load_raw_user_config() + return ( + "📄 **Hermes Config (raw user file)**\n\n" + f"{format_yaml_block(raw_config or {})}" + ) + + if mode == "get": + if not key: + return "Usage: `/config get `" + try: + kind, resolved = read_config_or_env_value(key) + except KeyError: + return f"`{key}` is not set." + return f"📄 **Config Value** (`{kind}`)\n\n`{key}`\n{format_yaml_block(resolved)}" + + if mode == "set": + if not key or not value: + return "Usage: `/config set `" + kind, stored_value, path = write_config_or_env_value(key, value) + return ( + f"✅ Updated `{key}` in `{path}` (`{kind}`)\n\n" + f"{format_yaml_block(stored_value)}" + ) + + if mode == "unset": + if not key: + return "Usage: `/config unset `" + kind, removed, path = unset_config_or_env_value(key) + if not removed: + return f"`{key}` is not set." + return f"🗑️ Removed `{key}` from `{path}` (`{kind}`)." + + return ( + f"⚠️ Unknown config action: `{mode}`\n\n" + "Use `/config [show|get|set|unset] [key] [value]`" + ) + + async def _handle_debug_command(self, event: MessageEvent) -> str: + """Handle /debug command - live runtime overrides for the current gateway process.""" + args = event.get_command_args().strip() + try: + parts = shlex.split(args) if args else [] + except ValueError as exc: + return f"⚠️ Invalid debug arguments: {exc}" + + if not parts or parts[0] == "show": + if len(parts) > 2: + return "Usage: `/debug show [key]`" + if len(parts) == 2: + key = parts[1].strip() + if key not in self._DEBUG_SUPPORTED_KEYS: + supported = "\n".join(f"- `{name}`" for name in sorted(self._DEBUG_SUPPORTED_KEYS)) + return ( + f"⚠️ Unsupported debug key: `{key}`\n\n" + f"**Supported keys**\n{supported}" + ) + overrides = self._get_runtime_debug_overrides() + value = self._get_effective_runtime_debug_value(key) + override_state = "yes" if key in overrides else "no" + return ( + "🛠️ **Runtime Debug Key**\n\n" + f"**Key:** `{key}`\n" + f"**Override active:** {override_state}\n\n" + f"{self._format_runtime_debug_value(value)}" + ) + + overrides = self._get_runtime_debug_overrides() + active = { + key: self._get_effective_runtime_debug_value(key) + for key in sorted(overrides) + } + supported = "\n".join( + f"- `{name}` — {description}" + for name, description in self._DEBUG_SUPPORTED_KEYS.items() + ) + active_block = "None" if not active else self._format_runtime_debug_value(active) + return ( + "🛠️ **Runtime Debug Overrides**\n\n" + "Applies only to the running Hermes gateway process and the current Discord connection.\n" + "Config files are unchanged.\n\n" + "**Active overrides**\n" + f"{active_block}\n\n" + "**Usage**\n" + "`/debug show [key]`\n" + "`/debug set `\n" + "`/debug unset `\n" + "`/debug reset`\n\n" + f"**Supported keys**\n{supported}" + ) + + action = parts[0].lower() + if action == "reset": + if len(parts) != 1: + return "Usage: `/debug reset`" + cleared = len(self._get_runtime_debug_overrides()) + self._get_runtime_debug_overrides().clear() + self._apply_runtime_debug_overrides() + return ( + "🛠️ ✓ Runtime debug overrides cleared.\n" + f"Removed `{cleared}` override(s) from the running gateway process." + ) + + if action == "unset": + if len(parts) != 2: + return "Usage: `/debug unset `" + key = parts[1].strip() + if key not in self._DEBUG_SUPPORTED_KEYS: + return f"⚠️ Unsupported debug key: `{key}`" + removed = self._get_runtime_debug_overrides().pop(key, None) + self._apply_runtime_debug_overrides() + if removed is None: + return f"`{key}` did not have a runtime override." + return ( + f"🛠️ ✓ Removed runtime override for `{key}`.\n\n" + f"{self._format_runtime_debug_value(self._get_effective_runtime_debug_value(key))}" + ) + + if action == "set": + if len(parts) < 3: + return "Usage: `/debug set `" + key = parts[1].strip() + if key not in self._DEBUG_SUPPORTED_KEYS: + return f"⚠️ Unsupported debug key: `{key}`" + value_text = args.split(None, 2)[2] + try: + parsed = self._parse_debug_value(key, value_text) + except ValueError as exc: + return f"⚠️ Invalid value for `{key}`: {exc}" + self._get_runtime_debug_overrides()[key] = parsed + self._apply_runtime_debug_overrides() + return ( + f"🛠️ ✓ Runtime override set for `{key}`.\n" + "Applies immediately to the running gateway process only.\n\n" + f"{self._format_runtime_debug_value(self._get_effective_runtime_debug_value(key))}" + ) + + return ( + f"⚠️ Unknown debug action: `{action}`\n\n" + "Use `/debug show [key]`, `/debug set `, `/debug unset `, or `/debug reset`." + ) + + def _build_manual_subagent_agent( + self, + *, + source: SessionSource, + session_key: str, + entry: dict[str, Any], + ): + from run_agent import AIAgent + from tools.delegate_tool import DEFAULT_TOOLSETS + + model = _resolve_gateway_model() + runtime_kwargs = _resolve_runtime_agent_kwargs() + self._apply_runtime_debug_overrides() + turn_route = self._resolve_turn_agent_config(entry.get("goal", ""), model, runtime_kwargs) + platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value + agent = AIAgent( + model=turn_route["model"], + **turn_route["runtime"], + max_iterations=int(os.getenv("HERMES_MAX_ITERATIONS", "90")), + quiet_mode=True, + verbose_logging=False, + enabled_toolsets=DEFAULT_TOOLSETS, + reasoning_config=self._get_effective_reasoning_config(), + skip_context_files=True, + skip_memory=True, + session_id=f"{session_key}:{entry['id']}", + platform=platform_key, + ) + agent._gateway_subagent_id = entry["id"] + agent._gateway_subagent_task_index = entry["ordinal"] + agent.tool_progress_callback = self._build_subagent_progress_callback(session_key, agent) + self._attach_subagent_runtime_hooks(agent, session_key) + entry["child"] = agent + entry["toolsets"] = list(DEFAULT_TOOLSETS) + entry["model"] = turn_route["model"] + return agent + + async def _notify_manual_subagent_completion( + self, + *, + source: SessionSource, + entry: dict[str, Any], + ) -> None: + adapter = self.adapters.get(source.platform) + if not adapter: + return + metadata = {"thread_id": source.thread_id} if source.thread_id else None + status = str(entry.get("status") or "") + if status == "completed": + content = ( + f"🤖 **Subagent Complete**\n\n" + f"**ID:** `{entry['id']}`\n" + f"**Goal:** {self._truncate_line(entry.get('goal') or '', limit=160)}\n\n" + f"{entry.get('summary') or '_(no summary)_'}" + ) + else: + detail = entry.get("error") or entry.get("summary") or "Subagent stopped." + content = ( + f"🤖 **Subagent {status.title() or 'Update'}**\n\n" + f"**ID:** `{entry['id']}`\n" + f"{self._truncate_line(detail, limit=500)}" + ) + try: + await adapter.send(source.chat_id, content, metadata=metadata) + except Exception as e: + logger.debug("Manual subagent completion send failed: %s", e) + + async def _start_manual_subagent( + self, + *, + source: SessionSource, + session_key: str, + goal: str, + ) -> dict[str, Any]: + entry = self._create_subagent_entry( + session_key=session_key, + child=None, + task_index=0, + goal=goal, + context=None, + toolsets=[], + model=None, + source_kind="manual", + ) + try: + agent = self._build_manual_subagent_agent(source=source, session_key=session_key, entry=entry) + except Exception as exc: + entry["status"] = "error" + entry["error"] = str(exc) + self._append_subagent_log(entry, f"Failed to start: {exc}") + return entry + + self._start_delegate_child(session_key=session_key, child=agent, goal=goal) + loop = asyncio.get_running_loop() + + async def _runner(): + current_goal = goal + + def _run_sync(): + nonlocal current_goal + while True: + result = agent.run_conversation( + user_message=current_goal, + task_id=f"{session_key}:{entry['id']}", + ) + pending_steer = str(entry.get("pending_steer") or "").strip() + if result.get("interrupted") and pending_steer: + entry["pending_steer"] = None + current_goal = pending_steer + self._start_delegate_child( + session_key=session_key, + child=agent, + goal=current_goal, + restarted=True, + ) + continue + return result, current_goal + + result, final_goal = await loop.run_in_executor(None, _run_sync) + summary = str(result.get("final_response") or "").strip() + completed = bool(result.get("completed")) and bool(summary) + status = "interrupted" if result.get("interrupted") else "completed" if completed else "failed" + self._complete_delegate_child( + session_key=session_key, + child=agent, + entry={ + "status": status, + "summary": summary, + "error": result.get("error") or "", + }, + goal=final_goal, + ) + await self._notify_manual_subagent_completion(source=source, entry=entry) + + entry["async_task"] = asyncio.create_task(_runner()) + return entry + + async def _handle_subagents_command(self, event: MessageEvent) -> str: + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + session_key = session_entry.session_key + + raw_args = event.get_command_args().strip() + parts = self._split_command_args(raw_args, max_parts=2) + action = (parts[0].strip().lower() if parts else "list") or "list" + payload = parts[1] if len(parts) > 1 else "" + + if action == "list": + return self._format_subagent_list(session_key) + + if action == "info": + targets, error = self._resolve_subagent_targets(session_key, payload, active_only=False) + if error: + return error + return self._format_subagent_info(targets[0]) + + if action == "log": + targets, error = self._resolve_subagent_targets(session_key, payload, active_only=False) + if error: + return error + return self._format_subagent_log(targets[0]) + + if action == "spawn": + goal = str(payload or "").strip() + if not goal: + return "Usage: `/subagents spawn `" + entry = await self._start_manual_subagent(source=target_source, session_key=session_key, goal=goal) + if entry.get("status") == "error": + return f"❌ Failed to spawn subagent: {entry.get('error')}" + return ( + f"🤖 Spawned subagent `#{entry['ordinal']}` / `{entry['id']}`.\n\n" + f"**Goal:** {goal}\n" + "Use `/subagents list`, `/subagents info `, or `/subagents log ` to inspect it." + ) + + if action == "kill": + target = payload.strip() + return await self._handle_kill_command( + MessageEvent( + text=f"/kill {target}".strip(), + message_type=event.message_type, + source=event.source, + raw_message=event.raw_message, + message_id=event.message_id, + media_urls=event.media_urls, + media_types=event.media_types, + reply_to_message_id=event.reply_to_message_id, + reply_to_text=event.reply_to_text, + timestamp=event.timestamp, + metadata=dict(event.metadata or {}), + ) + ) + + if action in {"steer", "send"}: + if not payload.strip(): + return f"Usage: `/subagents {action} `" + steer_parts = self._split_command_args(payload, max_parts=2) + if len(steer_parts) < 2: + return f"Usage: `/subagents {action} `" + target, message = steer_parts + steer_text = f"/steer {target} {message}" + return await self._handle_steer_command( + MessageEvent( + text=steer_text, + message_type=event.message_type, + source=event.source, + raw_message=event.raw_message, + message_id=event.message_id, + media_urls=event.media_urls, + media_types=event.media_types, + reply_to_message_id=event.reply_to_message_id, + reply_to_text=event.reply_to_text, + timestamp=event.timestamp, + metadata=dict(event.metadata or {}), + ) + ) + + return ( + "Usage: `/subagents list|info|log|spawn|kill|steer|send`\n" + "Examples:\n" + "`/subagents list`\n" + "`/subagents spawn research the failing tests`\n" + "`/subagents steer #1 focus on the Discord adapter only`" + ) + + async def _handle_kill_command(self, event: MessageEvent) -> str: + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + session_key = session_entry.session_key + raw_target = event.get_command_args().strip() + targets, error = self._resolve_subagent_targets( + session_key, + raw_target, + active_only=True, + allow_all=True, + ) + if error: + return error + if not targets: + return "No active subagents found for this session." + for entry in targets: + entry["pending_steer"] = None + child = entry.get("child") + if child is not None and hasattr(child, "interrupt"): + try: + child.interrupt("Killed from /kill") + except Exception as e: + logger.debug("Subagent kill failed for %s: %s", entry.get("id"), e) + self._append_subagent_log(entry, "Kill requested.") + if raw_target.strip().lower() == "all": + return f"🛑 Requested stop for `{len(targets)}` active subagent(s)." + return f"🛑 Requested stop for `{targets[0]['id']}`." + + async def _handle_steer_command(self, event: MessageEvent) -> str: + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + session_key = session_entry.session_key + parts = self._split_command_args(event.get_command_args().strip(), max_parts=2) + if len(parts) < 2: + return "Usage: `/steer `" + target, message = parts + targets, error = self._resolve_subagent_targets(session_key, target, active_only=True) + if error: + return error + entry = targets[0] + child = entry.get("child") + if child is None or not hasattr(child, "interrupt"): + return f"Subagent `{entry['id']}` is not running." + entry["pending_steer"] = message + self._append_subagent_log(entry, f"Steer requested: {self._truncate_line(message, limit=160)}") + child.interrupt(message) + return ( + f"↪ Steering subagent `{entry['id']}`.\n\n" + f"**Next goal:** {message}\n" + "The current run will stop and restart on the steer message." + ) + + async def _run_acp_prompt( + self, + *, + source: SessionSource, + session_id: str, + prompt: str, + ) -> str: + manager = self._get_acp_session_manager() + state = manager.get_session(session_id) + if state is None: + return f"No ACP session found for `{session_id}`." + tasks = self._get_acp_tasks() + meta = self._get_acp_session_meta_entry(session_id) + if session_id in tasks and not tasks[session_id].done(): + meta["pending_steer"] = prompt + meta["updated_at"] = self._subagent_now() + try: + state.cancel_event.set() + state.agent.interrupt(prompt) + except Exception: + logger.debug("ACP steer interrupt failed", exc_info=True) + return f"↪ Steering ACP session `{session_id}`." + + loop = asyncio.get_running_loop() + meta["last_prompt"] = prompt + meta["status"] = "running" + meta["updated_at"] = self._subagent_now() + + async def _runner(): + current_prompt = prompt + while True: + if state.cancel_event: + state.cancel_event.clear() + + def _sync_run(): + return state.agent.run_conversation( + user_message=current_prompt, + conversation_history=state.history, + task_id=session_id, + ) + + result = await loop.run_in_executor(None, _sync_run) + state.history = result.get("messages") or state.history + pending_steer = str(meta.pop("pending_steer", "") or "").strip() + if result.get("interrupted") and pending_steer: + current_prompt = pending_steer + meta["last_prompt"] = current_prompt + meta["updated_at"] = self._subagent_now() + continue + meta["last_result"] = result + meta["status"] = "interrupted" if result.get("interrupted") else "completed" if result.get("completed") else "failed" + meta["updated_at"] = self._subagent_now() + adapter = self.adapters.get(source.platform) + if adapter and result.get("final_response"): + metadata = {"thread_id": source.thread_id} if source.thread_id else None + try: + await adapter.send( + source.chat_id, + f"🧩 **ACP Session `{session_id}`**\n\n{result.get('final_response')}", + metadata=metadata, + ) + except Exception as e: + logger.debug("ACP completion send failed: %s", e) + break + + tasks[session_id] = asyncio.create_task(_runner()) + return f"🧩 ACP session `{session_id}` is running." + + async def _handle_acp_command(self, event: MessageEvent) -> str: + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + session_key = session_entry.session_key + args = event.get_command_args().strip() + parts = self._split_command_args(args, max_parts=2) + action = (parts[0].strip().lower() if parts else "sessions") or "sessions" + payload = parts[1] if len(parts) > 1 else "" + manager = self._get_acp_session_manager() + bindings = self._get_acp_bindings() + + if action == "sessions": + return self._format_acp_sessions(session_key) + + if action == "spawn": + cwd = str(payload or ".").strip() or "." + state = manager.create_session(cwd=cwd) + bindings[session_key] = state.session_id + meta = self._get_acp_session_meta_entry(state.session_id) + meta["source"] = target_source + meta["updated_at"] = self._subagent_now() + return ( + f"🧩 Created ACP session `{state.session_id}`.\n\n" + f"**CWD:** `{cwd}`\n" + "This session is now the default ACP target for this chat." + ) + + if action == "doctor": + try: + import acp as _acp # noqa: F401 + available = "yes" + except Exception: + available = "no" + bound = bindings.get(session_key) or "none" + running = sum(1 for task in self._get_acp_tasks().values() if not task.done()) + return ( + "🧩 **ACP Doctor**\n\n" + f"**Python ACP package:** `{available}`\n" + f"**Sessions:** `{len(manager.list_sessions())}`\n" + f"**Running:** `{running}`\n" + f"**Bound session for this chat:** `{bound}`" + ) + + if action == "install": + try: + import acp as _acp # noqa: F401 + return "ACP support is already installed in this Hermes environment." + except Exception as exc: + return f"ACP tooling is not importable in this environment: {exc}" + + if action == "status": + target_id, remainder = self._resolve_acp_target(session_key, payload) + if target_id: + return self._format_acp_status(target_id) + return self._format_acp_sessions(session_key) + + target_id, remainder = self._resolve_acp_target(session_key, payload) + if not target_id: + return "No ACP session is bound for this chat. Use `/acp spawn [cwd]` first or pass an explicit session ID." + state = manager.get_session(target_id) + if state is None: + return f"No ACP session found for `{target_id}`." + meta = self._get_acp_session_meta_entry(target_id) + + if action == "close": + task = self._get_acp_tasks().get(target_id) + if task and not task.done(): + return f"ACP session `{target_id}` is still running. Use `/acp cancel {target_id}` first." + removed = manager.remove_session(target_id) + if removed: + for key, value in list(bindings.items()): + if value == target_id: + bindings.pop(key, None) + self._get_acp_meta().pop(target_id, None) + return f"🧩 Closed ACP session `{target_id}`." + return f"No ACP session found for `{target_id}`." + + if action == "cancel": + task = self._get_acp_tasks().get(target_id) + if state.cancel_event: + state.cancel_event.set() + try: + state.agent.interrupt() + except Exception: + logger.debug("ACP cancel interrupt failed", exc_info=True) + if task and not task.done(): + return f"🛑 Cancel requested for ACP session `{target_id}`." + return f"ACP session `{target_id}` was idle; cancel flag set." + + if action == "cwd": + new_cwd = str(remainder or "").strip() + if not new_cwd: + return f"🧩 ACP session `{target_id}` cwd: `{state.cwd}`" + manager.update_cwd(target_id, new_cwd) + return f"🧩 ACP session `{target_id}` cwd set to `{new_cwd}`." + + if action == "model": + new_model = str(remainder or "").strip() + if not new_model: + return f"🧩 ACP session `{target_id}` model: `{state.model or getattr(state.agent, 'model', '') or 'default'}`" + state.model = new_model + try: + state.agent.model = new_model + except Exception: + logger.debug("Failed to update ACP agent model", exc_info=True) + meta["updated_at"] = self._subagent_now() + return f"🧩 ACP session `{target_id}` model set to `{new_model}`." + + if action == "set-mode": + mode = str(remainder or "").strip() + if not mode: + return "Usage: `/acp set-mode [session_id] `" + meta["options"]["mode"] = mode + meta["updated_at"] = self._subagent_now() + return f"🧩 ACP session `{target_id}` mode set to `{mode}`." + + if action == "permissions": + perms = str(remainder or "").strip() + if not perms: + current = meta["options"].get("permissions", "default") + return f"🧩 ACP session `{target_id}` permissions: `{current}`" + meta["options"]["permissions"] = perms + meta["updated_at"] = self._subagent_now() + return f"🧩 ACP session `{target_id}` permissions set to `{perms}`." + + if action == "timeout": + raw_timeout = str(remainder or "").strip() + if not raw_timeout: + current = meta["options"].get("timeout", "default") + return f"🧩 ACP session `{target_id}` timeout: `{current}`" + try: + timeout_value = int(raw_timeout) + except ValueError: + return f"Invalid timeout `{raw_timeout}`. Expected an integer number of seconds." + meta["options"]["timeout"] = timeout_value + meta["updated_at"] = self._subagent_now() + return f"🧩 ACP session `{target_id}` timeout set to `{timeout_value}`s." + + if action == "set": + kv = self._split_command_args(remainder, max_parts=2) + if len(kv) < 2: + return "Usage: `/acp set [session_id] `" + key, value = kv + meta["options"][key] = value + meta["updated_at"] = self._subagent_now() + return f"🧩 ACP session `{target_id}` option `{key}` set to `{value}`." + + if action == "reset-options": + cleared = len(meta["options"]) + meta["options"] = {} + meta["updated_at"] = self._subagent_now() + return f"🧩 ACP session `{target_id}` reset `{cleared}` option(s)." + + if action == "steer": + prompt = str(remainder or "").strip() + if not prompt: + return "Usage: `/acp steer [session_id] `" + return await self._run_acp_prompt(source=target_source, session_id=target_id, prompt=prompt) + + return ( + "Usage: `/acp sessions|spawn|status|cancel|close|steer|cwd|model|set-mode|set|permissions|timeout|reset-options|doctor|install`\n" + "Examples:\n" + "`/acp spawn /home/alan/projects/hermes-agent`\n" + "`/acp steer write a quick status report`\n" + "`/acp status`" + ) + + async def _handle_bash_command(self, event: MessageEvent) -> str: + """Handle /bash command and !-style shortcuts for host shell execution.""" + from tools.process_registry import process_registry + + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + session_key = session_entry.session_key + + raw_args = event.get_command_args().strip() + parts = self._split_command_args(raw_args, max_parts=2) + if not parts: + return ( + "Usage: `/bash `\n" + " `/bash poll [session_id]`\n" + " `/bash stop [session_id]`\n\n" + "Shortcuts: `! `, `!poll`, `!stop`" + ) + + action = parts[0].strip().lower() + if action == "poll": + target_id = self._resolve_bash_target_session_id(session_key, parts[1] if len(parts) > 1 else "") + if not target_id: + return "No active bash job found for this session." + poll = process_registry.poll(target_id) + if poll.get("status") == "not_found": + self._get_bash_jobs().pop(session_key, None) + return f"No bash job found for `{target_id}`." + log_payload = process_registry.read_log(target_id, limit=40) + if poll.get("status") == "exited": + self._get_bash_jobs().pop(session_key, None) + return "\n".join( + [ + "💻 **Bash Complete**", + "", + f"**Session ID:** `{target_id}`", + f"**Exit Code:** `{poll.get('exit_code')}`", + "", + self._format_bash_output(log_payload.get("output", "")), + ] + ) + return "\n".join( + [ + "💻 **Bash Status**", + "", + f"**Session ID:** `{target_id}`", + f"**PID:** `{poll.get('pid')}`" if poll.get("pid") else "**PID:** `unknown`", + f"**Uptime:** `{poll.get('uptime_seconds', 0)}s`", + "", + self._format_bash_output(log_payload.get("output", "")), + ] + ) + + if action == "stop": + target_id = self._resolve_bash_target_session_id(session_key, parts[1] if len(parts) > 1 else "") + if not target_id: + return "No active bash job found for this session." + result = process_registry.kill_process(target_id) + if self._get_bash_jobs().get(session_key) == target_id: + self._get_bash_jobs().pop(session_key, None) + if result.get("status") in {"killed", "already_exited"}: + return f"🛑 Bash job `{target_id}` stopped." + return f"❌ Failed to stop bash job `{target_id}`: {result.get('error') or result.get('status')}" + + current_job_id = self._resolve_bash_target_session_id(session_key, "") + if current_job_id: + return ( + "A bash job is already running for this session.\n\n" + f"**Session ID:** `{current_job_id}`\n" + "Use `/bash poll` to inspect it or `/bash stop` to terminate it first." + ) + + command = raw_args + result = self._run_bash_process( + source=target_source, + session_key=session_key, + command=command, + force=False, + ) + + if str(result.get("status") or "") == "approval_required": + approval_id = self._store_pending_bash_approval( + session_key=session_key, + command=command, + approval_payload=result, + on_approve=lambda _decision: self._resume_bash_after_approval( + source=target_source, + session_key=session_key, + command=command, + ), + ) + if target_source.platform == Platform.DISCORD: + approval_adapter = self.adapters.get(Platform.DISCORD) + if approval_adapter and hasattr(approval_adapter, "send_exec_approval"): + try: + await approval_adapter.send_exec_approval( + target_source.chat_id, + command, + approval_id, + ) + except Exception as approval_error: + logger.debug("Discord exec approval UI failed for /bash: %s", approval_error) + return ( + f"⚠️ Bash command requires approval (`{approval_id}`).\n\n" + f"```\n{command}\n```\n\n" + f"Use {self._approval_usage_text()} or the Discord approval buttons." + ) + + return self._format_bash_result( + session_key=session_key, + command=command, + result=result, + ) async def _handle_stop_command(self, event: MessageEvent) -> str: """Handle /stop command - interrupt a running agent.""" @@ -2396,6 +4737,11 @@ async def _handle_stop_command(self, event: MessageEvent) -> str: async def _handle_help_command(self, event: MessageEvent) -> str: """Handle /help command - list available commands.""" + if self._session_source_for_event(event).platform == Platform.DISCORD: + from gateway.platforms.discord_impl import runtime_views + + return runtime_views.render_discord_help() + from hermes_cli.commands import gateway_help_lines lines = [ "📖 **Hermes Commands**\n", @@ -2411,124 +4757,569 @@ async def _handle_help_command(self, event: MessageEvent) -> str: except Exception: pass return "\n".join(lines) - - async def _handle_model_command(self, event: MessageEvent) -> str: - """Handle /model command - show or change the current model.""" - import yaml - from hermes_cli.models import ( - parse_model_input, - validate_requested_model, - curated_models_for_provider, - normalize_provider, - _PROVIDER_LABELS, + + async def _handle_commands_command(self, event: MessageEvent) -> str: + """Handle /commands command - show the full gateway command catalog.""" + if self._session_source_for_event(event).platform == Platform.DISCORD: + from gateway.platforms.discord_impl import runtime_views + + return runtime_views.render_discord_commands() + + from hermes_cli.commands import gateway_help_lines + + return "\n".join([ + "🧭 **Hermes Command Catalog**", + "", + *gateway_help_lines(), + ]) + + async def _handle_context_command(self, event: MessageEvent) -> str: + """Handle /context command - inspect the current transcript and prompt context.""" + mode = (event.get_command_args().strip() or "list").lower() + if mode not in {"list", "detail", "json"}: + return ( + f"⚠️ Unknown context mode: `{mode}`\n\n" + "Use `/context [list|detail|json]`" + ) + + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + history = self.session_store.load_transcript(session_entry.session_id) + + if self._session_source_for_event(event).platform == Platform.DISCORD: + from gateway.platforms.discord_impl import runtime_views + + snapshot = runtime_views.collect_discord_context_snapshot(self, event) + return runtime_views.render_discord_context(snapshot, mode) + + role_counts: dict[str, int] = {} + for item in history: + role = str(item.get("role") or "unknown") + role_counts[role] = role_counts.get(role, 0) + 1 + lines = [ + "🧠 **Hermes Context**", + "", + f"• Session ID: `{session_entry.session_id}`", + f"• Session Key: `{session_entry.session_key}`", + f"• Messages: {len(history)}", + f"• Roles: {', '.join(f'{k}={v}' for k, v in sorted(role_counts.items())) or 'none'}", + ] + if mode == "detail": + preview = history[-5:] + lines.extend(["", "```json", json.dumps(preview, indent=2, ensure_ascii=False), "```"]) + elif mode == "json": + payload = { + "session_id": session_entry.session_id, + "session_key": session_entry.session_key, + "message_count": len(history), + "role_counts": role_counts, + } + return f"```json\n{json.dumps(payload, indent=2, ensure_ascii=False)}\n```" + return "\n".join(lines) + + async def _handle_export_session_command(self, event: MessageEvent) -> str: + """Handle /export-session command - write a session export to disk.""" + target_source = self._command_target_source_for_event(event) + session_entry = self.session_store.get_or_create_session(target_source) + history = self.session_store.load_transcript(session_entry.session_id) + from gateway.platforms.discord_impl import runtime_views + + snapshot = runtime_views.collect_discord_context_snapshot(self, event) + export_payload = { + "session": { + "session_id": session_entry.session_id, + "session_key": session_entry.session_key, + "source": target_source.to_dict(), + "current_model": snapshot.current_model, + "current_provider": snapshot.current_provider, + "connected_platforms": list(snapshot.connected_platforms), + }, + "context_prompt": snapshot.context_prompt, + "messages": history, + } + cwd = os.environ.get("TERMINAL_CWD") or os.environ.get("MESSAGING_CWD") or os.getcwd() + raw_path = event.get_command_args().strip() + export_path = resolve_export_path(raw_path, cwd, session_id=session_entry.session_id) + if export_path.suffix.lower() == ".json": + export_path.write_text(json.dumps(export_payload, indent=2, ensure_ascii=False), encoding="utf-8") + mode = "JSON" + else: + export_path.write_text(build_session_export_html(export_payload), encoding="utf-8") + mode = "HTML" + return ( + f"📦 Exported the current session to `{export_path}`\n\n" + f"• Format: {mode}\n" + f"• Messages: {len(history)}\n" + f"• Session ID: `{session_entry.session_id}`" ) - args = event.get_command_args().strip() - config_path = _hermes_home / 'config.yaml' + async def _handle_whoami_command(self, event: MessageEvent) -> str: + """Handle /whoami command - show the sender and routing identity Hermes sees.""" + if self._session_source_for_event(event).platform == Platform.DISCORD: + from gateway.platforms.discord_impl import runtime_views + + snapshot = runtime_views.collect_discord_whoami_snapshot(self, event) + return runtime_views.render_discord_whoami(snapshot) + + source = event.source + lines = [ + "👤 **Hermes Sees You As**", + "", + f"**Platform:** {source.platform.value if source.platform else 'unknown'}", + f"**User ID:** `{source.user_id or 'unknown'}`", + f"**User Name:** {source.user_name or 'unknown'}", + f"**Chat ID:** `{source.chat_id}`", + f"**Chat Type:** {source.chat_type}", + ] + if source.chat_name: + lines.append(f"**Chat Name:** {source.chat_name}") + if source.thread_id: + lines.append(f"**Thread ID:** `{source.thread_id}`") + if source.chat_topic: + lines.append(f"**Chat Topic:** {source.chat_topic}") + return "\n".join(lines) + + def _resolve_discord_thread_target( + self, + event: MessageEvent, + ) -> tuple[Optional[Any], Optional[SessionSource], Optional[str], Optional[str]]: + """Return Discord adapter, target source, thread id, and parent channel id.""" + target_source = self._command_target_source_for_event(event) + if target_source.platform != Platform.DISCORD: + return None, None, None, None + + adapter = self._get_discord_adapter() + if adapter is None: + return None, None, None, None + + thread_id = str(target_source.thread_id or "").strip() + if not thread_id: + return adapter, target_source, None, None + + raw = getattr(event, "raw_message", None) + channel = getattr(raw, "channel", None) + parent_chat_id = None + if channel is not None and hasattr(adapter, "_get_parent_channel_id"): + parent_chat_id = adapter._get_parent_channel_id(channel) + return adapter, target_source, thread_id, parent_chat_id + + def _render_thread_binding_status(self, binding: Any) -> str: + idle_label = self._format_minutes_label(int(binding.idle_timeout_minutes or 0)) + max_age_label = self._format_minutes_label(int(binding.max_age_minutes or 0)) + lines = [ + "🧵 **Focused Thread**", + "", + f"• Thread: `{binding.thread_id}`", + f"• Session: `{binding.session_key}`", + f"• Label: {binding.chat_name or binding.thread_id}", + f"• Bound By: {binding.bound_by or 'unknown'}", + f"• Idle Auto-Unfocus: `{idle_label}`", + f"• Max Age: `{max_age_label}`", + ] + if binding.parent_chat_id: + lines.append(f"• Parent Channel: `{binding.parent_chat_id}`") + return "\n".join(lines) + + async def _handle_focus_command(self, event: MessageEvent) -> str: + """Handle /focus for Discord thread binding.""" + adapter, target_source, thread_id, parent_chat_id = self._resolve_discord_thread_target(event) + if adapter is None or target_source is None: + return "This command is only available on Discord." + if not thread_id: + return "Use `/focus` inside a Discord thread." + + session_key = self._session_key_for_source(target_source) + label = event.get_command_args().strip() or (target_source.chat_name or thread_id) + binding = adapter.focus_thread_binding( + thread_id=thread_id, + session_key=session_key, + chat_name=label, + parent_chat_id=parent_chat_id, + bound_by=target_source.user_name or target_source.user_id or "unknown", + ) + return ( + "🧵 Thread focused.\n\n" + f"{self._render_thread_binding_status(binding)}" + ) + + async def _handle_unfocus_command(self, event: MessageEvent) -> str: + """Handle /unfocus for Discord thread binding removal.""" + adapter, _target_source, thread_id, _parent_chat_id = self._resolve_discord_thread_target(event) + if adapter is None: + return "This command is only available on Discord." + if not thread_id: + return "Use `/unfocus` inside a focused Discord thread." + + binding = adapter.unfocus_thread_binding(thread_id) + if binding is None: + return "No focused thread binding exists here." + return "🧵 Thread unfocused. Messages here now require normal Discord activation again." + + async def _handle_agents_command(self, event: MessageEvent) -> str: + """Handle /agents for Discord thread-binding inspection.""" + adapter, target_source, thread_id, parent_chat_id = self._resolve_discord_thread_target(event) + if adapter is None or target_source is None: + return "This command is only available on Discord." + + if thread_id: + binding = adapter.get_thread_binding(thread_id) + if binding is None: + return "No focused thread binding exists for this thread." + is_running = binding.session_key in getattr(self, "_running_agents", {}) + return "\n".join( + [ + "🧵 **Thread-Bound Hermes Sessions**", + "", + f"• Target: `{binding.session_key}`", + f"• Label: {binding.chat_name or binding.thread_id}", + f"• Running: {'yes' if is_running else 'no'}", + f"• Idle Auto-Unfocus: `{self._format_minutes_label(int(binding.idle_timeout_minutes or 0))}`", + f"• Max Age: `{self._format_minutes_label(int(binding.max_age_minutes or 0))}`", + ] + ) + + bindings = adapter.list_thread_bindings(parent_chat_id=parent_chat_id or target_source.chat_id) + if not bindings: + return "No focused Discord thread bindings were found for this chat." + lines = ["🧵 **Focused Discord Threads**", ""] + for binding in bindings: + running = "yes" if binding.session_key in getattr(self, "_running_agents", {}) else "no" + lines.append( + f"• `{binding.thread_id}` — {binding.chat_name or binding.thread_id} " + f"(running: {running}, idle: {self._format_minutes_label(int(binding.idle_timeout_minutes or 0))}, " + f"max-age: {self._format_minutes_label(int(binding.max_age_minutes or 0))})" + ) + return "\n".join(lines) + + async def _handle_session_command(self, event: MessageEvent) -> str: + """Handle /session idle|max-age controls for focused Discord threads.""" + adapter, _target_source, thread_id, _parent_chat_id = self._resolve_discord_thread_target(event) + if adapter is None: + return "This command is only available on Discord." + if not thread_id: + return "Use `/session` inside a focused Discord thread." + + binding = adapter.get_thread_binding(thread_id) + if binding is None: + return "No focused thread binding exists here. Use `/focus` first." + + tokens = [token for token in event.get_command_args().strip().split() if token] + if not tokens or tokens[0].lower() == "status": + return self._render_thread_binding_status(binding) + if len(tokens) < 2: + return "Use `/session idle ` or `/session max-age `." + + mode = tokens[0].strip().lower() + try: + minutes = self._parse_duration_minutes(tokens[1]) + except ValueError as exc: + return f"⚠️ {exc}" + if minutes is None: + return "Use `/session idle ` or `/session max-age `." + + if mode == "idle": + binding = adapter.update_thread_binding_limits(thread_id, idle_timeout_minutes=minutes) + elif mode in {"max-age", "max_age", "maxage"}: + binding = adapter.update_thread_binding_limits(thread_id, max_age_minutes=minutes) + mode = "max-age" + else: + return "Use `/session idle ` or `/session max-age `." + + if binding is None: + return "No focused thread binding exists here. Use `/focus` first." + return ( + f"🧵 Updated thread session `{mode}` to `{self._format_minutes_label(minutes)}`.\n\n" + f"{self._render_thread_binding_status(binding)}" + ) + + async def _handle_send_command(self, event: MessageEvent) -> str: + """Handle /send on|off|inherit for the current session.""" + target_source = self._command_target_source_for_event(event) + session_key = self._session_key_for_source(target_source) + args = event.get_command_args().strip().lower() + current = self._session_send_policy(session_key) + if not args: + return f"📮 Current send policy for this session: `{current}`" + if args not in {"on", "off", "inherit"}: + return "Use `/send on`, `/send off`, or `/send inherit`." + + if not isinstance(getattr(self, "_session_send_policies", None), dict): + self._session_send_policies = {} + self._session_send_policies[session_key] = args + if args == "inherit": + self._session_send_policies.pop(session_key, None) + self._save_runtime_controls() + return f"📮 Send policy for this session is now `{args}`." + + async def _handle_activation_command(self, event: MessageEvent) -> str: + """Handle /activation mention|always for Discord chats.""" + target_source = self._command_target_source_for_event(event) + if target_source.platform != Platform.DISCORD or target_source.chat_type == "dm": + return "This command only works in Discord servers and threads." + + adapter = self._get_discord_adapter() + if adapter is None: + return "Discord is not connected." + + args = event.get_command_args().strip().lower() + current = adapter.get_activation_mode(target_source.chat_id) or ( + "mention" if adapter._get_discord_policy().require_mention else "always" + ) + if not args: + return f"🎛️ Current activation mode for this chat: `{current}`" + if args not in {"mention", "always"}: + return "Use `/activation mention` or `/activation always`." + + adapter.set_activation_mode(target_source.chat_id, args) + return f"🎛️ Activation mode for this chat is now `{args}`." + + async def _handle_restart_command(self, event: MessageEvent) -> str: + """Handle /restart by scheduling a delayed gateway restart.""" + del event + self._schedule_gateway_restart() + return "♻️ Gateway restart scheduled. Hermes will reconnect shortly." + + async def _handle_dock_command(self, event: MessageEvent, platform: Platform) -> str: + """Handle /dock-* by routing future replies for this session to a home channel.""" + target_source = self._command_target_source_for_event(event) + session_key = self._session_key_for_source(target_source) + home = self.config.get_home_channel(platform) + if home is None: + return f"No home channel is configured for `{platform.value}`." + + if not isinstance(getattr(self, "_session_docks", None), dict): + self._session_docks = {} + self._session_docks[session_key] = { + "platform": platform.value, + "chat_id": home.chat_id, + "thread_id": None, + "name": home.name, + } + self._save_runtime_controls() + return f"📮 Replies for this session are now docked to {platform.value} home channel **{home.name}** (`{home.chat_id}`)." + + def _load_current_model_selection(self) -> dict[str, Any]: + """Resolve the configured model/provider from config plus runtime env.""" + import yaml + from hermes_cli.models import normalize_provider - # Resolve current model and provider from config - current = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6" + config_path = _hermes_home / "config.yaml" + current_model = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6" current_provider = "openrouter" + try: if config_path.exists(): with open(config_path, encoding="utf-8") as f: cfg = yaml.safe_load(f) or {} model_cfg = cfg.get("model", {}) if isinstance(model_cfg, str): - current = model_cfg + current_model = model_cfg elif isinstance(model_cfg, dict): - current = model_cfg.get("default", current) + current_model = model_cfg.get("default", current_model) current_provider = model_cfg.get("provider", current_provider) except Exception: pass - # Resolve "auto" to the actual provider using credential detection current_provider = normalize_provider(current_provider) if current_provider == "auto": try: from hermes_cli.auth import resolve_provider as _resolve_provider + current_provider = _resolve_provider(current_provider) except Exception: current_provider = "openrouter" - # Detect custom endpoint: provider resolved to openrouter but a custom - # base URL is configured — the user set up a custom endpoint. if current_provider == "openrouter" and os.getenv("OPENAI_BASE_URL", "").strip(): current_provider = "custom" - if not args: - # If a fallback model is active, show it instead of config - if self._effective_model: - eff_provider = self._effective_provider or 'unknown' - eff_label = _PROVIDER_LABELS.get(eff_provider, eff_provider) - cfg_label = _PROVIDER_LABELS.get(current_provider, current_provider) - lines = [ - f"🤖 **Active model:** `{self._effective_model}` (fallback)", - f"**Provider:** {eff_label}", - f"**Primary model** (`{current}` via {cfg_label}) is rate-limited.", + return { + "config_path": config_path, + "current_model": current_model, + "current_provider": current_provider, + } + + def _resolve_model_runtime_details(self, requested_provider: str) -> tuple[dict[str, Any], Optional[str]]: + """Return runtime provider details for the active model selection.""" + try: + from hermes_cli.runtime_provider import resolve_runtime_provider + + runtime = resolve_runtime_provider(requested=requested_provider) + return runtime, None + except Exception as exc: + return {}, str(exc) + + async def _respond_to_native_slash( + self, + event: MessageEvent, + content: str, + ) -> bool: + """Send an ephemeral response for Discord native slash commands.""" + interaction = getattr(event, "raw_message", None) + if interaction is None: + return False + + response = getattr(interaction, "response", None) + if response is not None and hasattr(response, "send_message"): + is_done = getattr(response, "is_done", None) + if not callable(is_done) or not is_done(): + await response.send_message(content, ephemeral=True) + return True + + followup = getattr(interaction, "followup", None) + if followup is not None and hasattr(followup, "send"): + await followup.send(content, ephemeral=True) + return True + return False + + def _is_native_discord_slash(self, event: MessageEvent) -> bool: + metadata = getattr(event, "metadata", None) or {} + return bool( + event.source.platform == Platform.DISCORD + and isinstance(metadata, dict) + and metadata.get("is_native_slash") + ) + + def _render_model_catalog_response(self) -> str: + """Render the current model plus numbered catalog for the active provider.""" + from hermes_cli.models import curated_models_for_provider, provider_label + + state = self._load_current_model_selection() + current_model = state["current_model"] + current_provider = state["current_provider"] + provider_name = provider_label(current_provider) + lines = [ + "🤖 **Model Catalog**", + "", + f"**Current model:** `{current_model}`", + f"**Provider:** {provider_name} (`{current_provider}`)", + ] + if self._effective_model: + lines.extend( + [ + f"**Active fallback:** `{self._effective_model}`", + f"**Fallback provider:** {provider_label(self._effective_provider or 'unknown')}", + ] + ) + lines.append("") + + curated = curated_models_for_provider(current_provider) + if curated: + lines.append(f"**Available models ({provider_name}):**") + for index, (model_id, desc) in enumerate(curated, start=1): + marker = " ← current" if model_id == current_model else "" + detail = f" — {desc}" if desc else "" + lines.append(f"{index}. `{model_id}`{detail}{marker}") + lines.append("") + else: + lines.extend( + [ + f"No curated model list is available for `{current_provider}` right now.", "", ] - lines.append("To change: `/model model-name`") - lines.append("Switch provider: `/model provider:model-name`") - return "\n".join(lines) + ) - provider_label = _PROVIDER_LABELS.get(current_provider, current_provider) - lines = [ - f"🤖 **Current model:** `{current}`", - f"**Provider:** {provider_label}", + lines.extend( + [ + "Use `/model ` to choose by index.", + "Use `/models` on Discord to open the interactive picker.", + "Use `/model provider:model-name` to switch providers.", + "Use `/model status` for runtime endpoint and auth details.", ] - # Show custom endpoint URL when using a custom provider - if current_provider == "custom": - from hermes_cli.models import _get_custom_base_url - custom_url = _get_custom_base_url() or os.getenv("OPENAI_BASE_URL", "") - if custom_url: - lines.append(f"**Endpoint:** `{custom_url}`") - lines.append("") - curated = curated_models_for_provider(current_provider) - if curated: - lines.append(f"**Available models ({provider_label}):**") - for mid, desc in curated: - marker = " ←" if mid == current else "" - label = f" _{desc}_" if desc else "" - lines.append(f"• `{mid}`{label}{marker}") - lines.append("") - lines.append("To change: `/model model-name`") - lines.append("Switch provider: `/model provider-name` or `/model provider:model-name`") + ) + return "\n".join(lines) + + def _render_model_status_response(self) -> str: + """Render detailed model/provider/runtime status.""" + from hermes_cli.models import provider_label + + state = self._load_current_model_selection() + current_model = state["current_model"] + current_provider = state["current_provider"] + runtime, runtime_error = self._resolve_model_runtime_details(current_provider) + runtime_provider = str(runtime.get("provider") or current_provider) + runtime_label = provider_label(runtime_provider) + api_key = str(runtime.get("api_key") or "").strip() + command = runtime.get("command") + lines = [ + "🤖 **Model Status**", + "", + f"**Configured model:** `{current_model}`", + f"**Configured provider:** {provider_label(current_provider)} (`{current_provider}`)", + ] + if self._effective_model: + effective_provider = self._effective_provider or current_provider + lines.extend( + [ + f"**Active model:** `{self._effective_model}` (fallback)", + f"**Active provider:** {provider_label(effective_provider)} (`{effective_provider}`)", + f"**Primary model:** `{current_model}`", + ] + ) + else: + lines.append(f"**Active model:** `{current_model}`") + + if runtime_error: + lines.extend( + [ + "", + f"⚠️ Runtime provider resolution failed: {runtime_error}", + ] + ) return "\n".join(lines) - # Parse provider:model syntax - target_provider, new_model = parse_model_input(args, current_provider) - # Auto-detect provider when no explicit provider:model syntax was used - if target_provider == current_provider: - from hermes_cli.models import detect_provider_for_model - detected = detect_provider_for_model(new_model, current_provider) - if detected: - target_provider, new_model = detected + lines.extend( + [ + f"**Runtime provider:** {runtime_label} (`{runtime_provider}`)", + f"**API mode:** `{runtime.get('api_mode') or 'unknown'}`", + f"**Base URL:** `{runtime.get('base_url') or 'unknown'}`", + f"**Credentials:** {'configured ✓' if api_key or command else 'missing ⚠️'}", + ] + ) + if command: + lines.append(f"**Transport command:** `{command}`") + source = runtime.get("source") + if source: + lines.append(f"**Credential source:** `{source}`") + return "\n".join(lines) + + def _resolve_numbered_model(self, raw_index: str, current_provider: str) -> Optional[str]: + """Resolve `/model ` against the current provider catalog.""" + from hermes_cli.models import curated_models_for_provider + + try: + index = int(raw_index) + except (TypeError, ValueError): + return None + if index < 1: + return None + curated = curated_models_for_provider(current_provider) + if index > len(curated): + return None + return curated[index - 1][0] + + async def _apply_model_selection( + self, + target_provider: str, + new_model: str, + user_id: Optional[str] = None, + ) -> str: + """Validate, persist, and activate a model/provider choice.""" + import yaml + from hermes_cli.models import validate_requested_model, provider_label + + state = self._load_current_model_selection() + current_provider = state["current_provider"] + config_path = state["config_path"] provider_changed = target_provider != current_provider + runtime, runtime_error = self._resolve_model_runtime_details( + target_provider if provider_changed else current_provider + ) + if provider_changed and runtime_error: + return f"⚠️ Could not resolve credentials for provider '{provider_label(target_provider)}': {runtime_error}" - # Resolve credentials for the target provider (for API probe) - api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or "" - base_url = "https://openrouter.ai/api/v1" - if provider_changed: - try: - from hermes_cli.runtime_provider import resolve_runtime_provider - runtime = resolve_runtime_provider(requested=target_provider) - api_key = runtime.get("api_key", "") - base_url = runtime.get("base_url", "") - except Exception as e: - provider_label = _PROVIDER_LABELS.get(target_provider, target_provider) - return f"⚠️ Could not resolve credentials for provider '{provider_label}': {e}" - else: - # Use current provider's base_url from config or registry - try: - from hermes_cli.runtime_provider import resolve_runtime_provider - runtime = resolve_runtime_provider(requested=current_provider) - api_key = runtime.get("api_key", "") - base_url = runtime.get("base_url", "") - except Exception: - pass + api_key = str(runtime.get("api_key") or os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY") or "") + base_url = str(runtime.get("base_url") or "https://openrouter.ai/api/v1") - # Validate the model against the live API try: validation = validate_requested_model( new_model, @@ -2540,11 +5331,10 @@ async def _handle_model_command(self, event: MessageEvent) -> str: validation = {"accepted": True, "persist": True, "recognized": False, "message": None} if not validation.get("accepted"): - msg = validation.get("message", "Invalid model") - tip = "\n\nUse `/model` to see available models, `/provider` to see providers" if "Did you mean" not in msg else "" - return f"⚠️ {msg}{tip}" + message = validation.get("message", "Invalid model") + tip = "\n\nUse `/model list` to see numbered models, `/provider` to see providers." if "Did you mean" not in message else "" + return f"⚠️ {message}{tip}" - # Persist to config only if validation approves if validation.get("persist"): try: user_config = {} @@ -2554,33 +5344,110 @@ async def _handle_model_command(self, event: MessageEvent) -> str: if "model" not in user_config or not isinstance(user_config["model"], dict): user_config["model"] = {} user_config["model"]["default"] = new_model - if provider_changed: - user_config["model"]["provider"] = target_provider - with open(config_path, 'w', encoding="utf-8") as f: + user_config["model"]["provider"] = target_provider + with open(config_path, "w", encoding="utf-8") as f: yaml.dump(user_config, f, default_flow_style=False, sort_keys=False) - except Exception as e: - return f"⚠️ Failed to save model change: {e}" + except Exception as exc: + return f"⚠️ Failed to save model change: {exc}" - # Set env vars so the next agent run picks up the change os.environ["HERMES_MODEL"] = new_model - if provider_changed: - os.environ["HERMES_INFERENCE_PROVIDER"] = target_provider + os.environ["HERMES_INFERENCE_PROVIDER"] = target_provider - provider_label = _PROVIDER_LABELS.get(target_provider, target_provider) - provider_note = f"\n**Provider:** {provider_label}" if provider_changed else "" + try: + from gateway.platforms.discord_impl import model_picker as discord_model_picker - warning = "" - if validation.get("message"): - warning = f"\n⚠️ {validation['message']}" + discord_model_picker.record_recent_model(user_id, target_provider, new_model) + except Exception: + pass - if validation.get("persist"): - persist_note = "saved to config" - else: - persist_note = "this session only — will revert on restart" - # Clear fallback state since user explicitly chose a model self._effective_model = None self._effective_provider = None - return f"🤖 Model changed to `{new_model}` ({persist_note}){provider_note}{warning}\n_(takes effect on next message)_" + + warning = f"\n⚠️ {validation['message']}" if validation.get("message") else "" + persist_note = "saved to config" if validation.get("persist") else "this session only — will revert on restart" + return ( + f"🤖 Model changed to `{new_model}` ({persist_note})\n" + f"**Provider:** {provider_label(target_provider)} (`{target_provider}`){warning}\n" + "_(takes effect on next message)_" + ) + + async def _open_discord_model_picker(self, event: MessageEvent, command_name: str) -> str | None: + """Open the Discord-native interactive model picker.""" + if not self._is_native_discord_slash(event): + return self._render_model_catalog_response() + + adapter = self.adapters.get(Platform.DISCORD) + interaction = getattr(event, "raw_message", None) + if adapter is None or interaction is None: + return self._render_model_catalog_response() + + state = self._load_current_model_selection() + try: + from gateway.platforms.discord_impl import model_picker as discord_model_picker + + await discord_model_picker.open_model_picker( + adapter=adapter, + interaction=interaction, + command_name=command_name, + user_id=str(event.source.user_id or ""), + current_provider=state["current_provider"], + current_model=state["current_model"], + apply_selection=self._apply_model_selection, + ) + except Exception as exc: + logger.warning("Discord model picker failed; falling back to text response: %s", exc, exc_info=True) + return self._render_model_catalog_response() + return None + + async def _handle_model_command(self, event: MessageEvent) -> str | None: + """Handle /model and /models commands.""" + from hermes_cli.models import parse_model_input, detect_provider_for_model + + command_name = (event.get_command() or "model").lower() + args = event.get_command_args().strip() + is_native_slash = self._is_native_discord_slash(event) + state = self._load_current_model_selection() + current_provider = state["current_provider"] + + response: Optional[str] + + lowered = args.lower() + if command_name == "models": + if is_native_slash: + return await self._open_discord_model_picker(event, command_name="models") + response = self._render_model_catalog_response() + elif not args: + if is_native_slash: + return await self._open_discord_model_picker(event, command_name="model") + response = self._render_model_catalog_response() + elif lowered == "status": + response = self._render_model_status_response() + elif lowered == "list": + response = self._render_model_catalog_response() + else: + numbered_model = self._resolve_numbered_model(args, current_provider) + if numbered_model: + response = await self._apply_model_selection( + current_provider, + numbered_model, + user_id=event.source.user_id, + ) + else: + target_provider, new_model = parse_model_input(args, current_provider) + if target_provider == current_provider: + detected = detect_provider_for_model(new_model, current_provider) + if detected: + target_provider, new_model = detected + response = await self._apply_model_selection( + target_provider, + new_model, + user_id=event.source.user_id, + ) + + if is_native_slash and response: + await self._respond_to_native_slash(event, response) + return None + return response async def _handle_provider_command(self, event: MessageEvent) -> str: """Handle /provider command - show available providers.""" @@ -3242,6 +6109,7 @@ async def _run_background_task( # Read model from config via shared helper model = _resolve_gateway_model() + self._apply_runtime_debug_overrides() # Determine toolset (same logic as _run_agent) default_toolset_map = { @@ -3289,7 +6157,7 @@ async def _run_background_task( pr = self._provider_routing max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) - reasoning_config = self._load_reasoning_config() + reasoning_config = self._get_effective_reasoning_config() self._reasoning_config = reasoning_config turn_route = self._resolve_turn_agent_config(prompt, model, runtime_kwargs) @@ -3399,8 +6267,7 @@ async def _handle_reasoning_command(self, event: MessageEvent) -> str: args = event.get_command_args().strip().lower() config_path = _hermes_home / "config.yaml" - self._reasoning_config = self._load_reasoning_config() - self._show_reasoning = self._load_show_reasoning() + self._apply_runtime_debug_overrides() def _save_config_key(key_path: str, value): """Save a dot-separated key to config.yaml.""" @@ -3444,11 +6311,13 @@ def _save_config_key(key_path: str, value): if args in ("show", "on"): self._show_reasoning = True _save_config_key("display.show_reasoning", True) + self._apply_runtime_debug_overrides() return "🧠 ✓ Reasoning display: **ON**\nModel thinking will be shown before each response." if args in ("hide", "off"): self._show_reasoning = False _save_config_key("display.show_reasoning", False) + self._apply_runtime_debug_overrides() return "🧠 ✓ Reasoning display: **OFF**" # Effort level change @@ -3466,15 +6335,58 @@ def _save_config_key(key_path: str, value): self._reasoning_config = parsed if _save_config_key("agent.reasoning_effort", effort): + self._apply_runtime_debug_overrides() return f"🧠 ✓ Reasoning effort set to `{effort}` (saved to config)\n_(takes effect on next message)_" else: + self._apply_runtime_debug_overrides() return f"🧠 ✓ Reasoning effort set to `{effort}` (this session only)" + async def _handle_think_command(self, event: MessageEvent) -> str: + """Handle /think command — reasoning effort only, without display toggles.""" + args = event.get_command_args().strip().lower() + self._apply_runtime_debug_overrides() + + if not args: + rc = self._reasoning_config + if rc is None: + level = "medium (default)" + elif rc.get("enabled") is False: + level = "off" + else: + level = rc.get("effort", "medium") + return ( + "🧠 **Thinking Effort**\n\n" + f"**Current:** `{level}`\n\n" + "_Usage:_ `/think `" + ) + + effort = "none" if args == "off" else args + if effort not in ("none", "minimal", "low", "medium", "high", "xhigh"): + return ( + f"⚠️ Unknown thinking level: `{args}`\n\n" + "**Valid levels:** off, minimal, low, medium, high, xhigh" + ) + + think_event = MessageEvent( + text=f"/reasoning {effort}", + source=event.source, + message_type=event.message_type, + raw_message=event.raw_message, + message_id=event.message_id, + media_urls=list(event.media_urls), + media_types=list(event.media_types), + reply_to_message_id=event.reply_to_message_id, + reply_to_text=event.reply_to_text, + metadata=event.metadata, + ) + return await self._handle_reasoning_command(think_event) + async def _handle_compress_command(self, event: MessageEvent) -> str: """Handle /compress command -- manually compress conversation context.""" - source = event.source + source = self._command_target_source_for_event(event) session_entry = self.session_store.get_or_create_session(source) history = self.session_store.load_transcript(session_entry.session_id) + instructions = event.get_command_args().strip() if not history or len(history) < 4: return "Not enough conversation to compress (need at least 4 messages)." @@ -3510,7 +6422,7 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: loop = asyncio.get_event_loop() compressed, _ = await loop.run_in_executor( None, - lambda: tmp_agent._compress_context(msgs, "", approx_tokens=approx_tokens), + lambda: tmp_agent._compress_context(msgs, instructions, approx_tokens=approx_tokens), ) self.session_store.rewrite_transcript(session_entry.session_id, compressed) @@ -3521,10 +6433,13 @@ async def _handle_compress_command(self, event: MessageEvent) -> str: new_count = len(compressed) new_tokens = estimate_messages_tokens_rough(compressed) - return ( + result = ( f"🗜️ Compressed: {original_count} → {new_count} messages\n" f"~{approx_tokens:,} → ~{new_tokens:,} tokens" ) + if instructions: + result += f"\nPreserved guidance: `{instructions[:160]}`" + return result except Exception as e: logger.warning("Manual compress failed: %s", e) return f"Compression failed: {e}" @@ -3794,77 +6709,14 @@ async def _handle_reload_mcp_command(self, event: MessageEvent) -> str: logger.warning("MCP reload failed: %s", e) return f"❌ MCP reload failed: {e}" - # ------------------------------------------------------------------ - # /approve & /deny — explicit dangerous-command approval - # ------------------------------------------------------------------ - - _APPROVAL_TIMEOUT_SECONDS = 300 # 5 minutes - - async def _handle_approve_command(self, event: MessageEvent) -> str: - """Handle /approve command — execute a pending dangerous command. - - Usage: - /approve — approve and execute the pending command - /approve session — approve and remember for this session - /approve always — approve this pattern permanently - """ - source = event.source - session_key = self._session_key_for_source(source) - - if session_key not in self._pending_approvals: - return "No pending command to approve." - - import time as _time - approval = self._pending_approvals[session_key] - - # Check for timeout - ts = approval.get("timestamp", 0) - if _time.time() - ts > self._APPROVAL_TIMEOUT_SECONDS: - self._pending_approvals.pop(session_key, None) - return "⚠️ Approval expired (timed out after 5 minutes). Ask the agent to try again." - - self._pending_approvals.pop(session_key) - cmd = approval["command"] - pattern_keys = approval.get("pattern_keys", []) - if not pattern_keys: - pk = approval.get("pattern_key", "") - pattern_keys = [pk] if pk else [] - - # Determine approval scope from args - args = event.get_command_args().strip().lower() - from tools.approval import approve_session, approve_permanent - - if args in ("always", "permanent", "permanently"): - for pk in pattern_keys: - approve_permanent(pk) - scope_msg = " (pattern approved permanently)" - elif args in ("session", "ses"): - for pk in pattern_keys: - approve_session(session_key, pk) - scope_msg = " (pattern approved for this session)" - else: - # One-time approval — just approve for session so the immediate - # replay works, but don't advertise it as session-wide - for pk in pattern_keys: - approve_session(session_key, pk) - scope_msg = "" - - logger.info("User approved dangerous command via /approve: %s...%s", cmd[:60], scope_msg) - from tools.terminal_tool import terminal_tool - result = terminal_tool(command=cmd, force=True) - return f"✅ Command approved and executed{scope_msg}.\n\n```\n{result[:3500]}\n```" - async def _handle_deny_command(self, event: MessageEvent) -> str: - """Handle /deny command — reject a pending dangerous command.""" - source = event.source - session_key = self._session_key_for_source(source) - - if session_key not in self._pending_approvals: - return "No pending command to deny." - - self._pending_approvals.pop(session_key) - logger.info("User denied dangerous command via /deny") - return "❌ Command denied." + """Handle /deny command for pending exec approvals.""" + approval_id = event.get_command_args().strip() or None + return self._resolve_pending_approval( + decision="deny", + approval_id=approval_id, + source=self._command_target_source_for_event(event), + ) async def _handle_update_command(self, event: MessageEvent) -> str: """Handle /update command — update Hermes Agent to the latest version. @@ -4072,7 +6924,13 @@ def _set_session_env(self, context: SessionContext) -> None: def _clear_session_env(self) -> None: """Clear session environment variables.""" - for var in ["HERMES_SESSION_PLATFORM", "HERMES_SESSION_CHAT_ID", "HERMES_SESSION_CHAT_NAME", "HERMES_SESSION_THREAD_ID"]: + for var in [ + "HERMES_SESSION_PLATFORM", + "HERMES_SESSION_CHAT_ID", + "HERMES_SESSION_CHAT_NAME", + "HERMES_SESSION_THREAD_ID", + "HERMES_SESSION_SEND_POLICY", + ]: if var in os.environ: del os.environ[var] @@ -4248,7 +7106,7 @@ async def _run_process_watcher(self, watcher: dict) -> None: platform_name = watcher.get("platform", "") chat_id = watcher.get("chat_id", "") thread_id = watcher.get("thread_id", "") - notify_mode = self._load_background_notifications_mode() + notify_mode = self._get_effective_background_notifications_mode() logger.debug("Process watcher started: %s (every %ss, notify=%s)", session_id, interval, notify_mode) @@ -4605,6 +7463,7 @@ def run_sync(): # Pass session_key to process registry via env var so background # processes can be mapped back to this gateway session os.environ["HERMES_SESSION_KEY"] = session_key or "" + os.environ["HERMES_SESSION_SEND_POLICY"] = self._session_send_policy(session_key) # Read from env var or use default (same as CLI) max_iterations = int(os.getenv("HERMES_MAX_ITERATIONS", "90")) @@ -4639,9 +7498,10 @@ def run_sync(): "tools": [], } + self._apply_runtime_debug_overrides() pr = self._provider_routing honcho_manager, honcho_config = self._get_or_create_gateway_honcho(session_key) - reasoning_config = self._load_reasoning_config() + reasoning_config = self._get_effective_reasoning_config() self._reasoning_config = reasoning_config # Set up streaming consumer if enabled _stream_consumer = None @@ -4701,6 +7561,7 @@ def run_sync(): session_db=self._session_db, fallback_model=self._fallback_model, ) + self._attach_subagent_runtime_hooks(agent, session_key) # Store agent reference for interrupt support agent_holder[0] = agent diff --git a/gateway/session.py b/gateway/session.py index c6fb8582261e..68b21d3f1b42 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -84,6 +84,7 @@ class SessionSource: chat_topic: Optional[str] = None # Channel topic/description (Discord, Slack) user_id_alt: Optional[str] = None # Signal UUID (alternative to phone number) chat_id_alt: Optional[str] = None # Signal group internal ID + session_namespace: Optional[str] = None # Isolated command/session namespace @property def description(self) -> str: @@ -116,6 +117,7 @@ def to_dict(self) -> Dict[str, Any]: "user_name": self.user_name, "thread_id": self.thread_id, "chat_topic": self.chat_topic, + "session_namespace": self.session_namespace, } if self.user_id_alt: d["user_id_alt"] = self.user_id_alt @@ -136,6 +138,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource": chat_topic=data.get("chat_topic"), user_id_alt=data.get("user_id_alt"), chat_id_alt=data.get("chat_id_alt"), + session_namespace=data.get("session_namespace"), ) @classmethod @@ -435,11 +438,23 @@ def build_session_key(source: SessionSource, group_sessions_per_user: bool = Tru if source.chat_type == "dm": if source.chat_id: if source.thread_id: - return f"agent:main:{platform}:dm:{source.chat_id}:{source.thread_id}" - return f"agent:main:{platform}:dm:{source.chat_id}" + parts = ["agent:main", platform, "dm", source.chat_id, source.thread_id] + if source.session_namespace: + parts.append(source.session_namespace) + return ":".join(parts) + parts = ["agent:main", platform, "dm", source.chat_id] + if source.session_namespace: + parts.append(source.session_namespace) + return ":".join(parts) if source.thread_id: - return f"agent:main:{platform}:dm:{source.thread_id}" - return f"agent:main:{platform}:dm" + parts = ["agent:main", platform, "dm", source.thread_id] + if source.session_namespace: + parts.append(source.session_namespace) + return ":".join(parts) + parts = ["agent:main", platform, "dm"] + if source.session_namespace: + parts.append(source.session_namespace) + return ":".join(parts) participant_id = source.user_id_alt or source.user_id key_parts = ["agent:main", platform, source.chat_type] @@ -450,6 +465,8 @@ def build_session_key(source: SessionSource, group_sessions_per_user: bool = Tru key_parts.append(source.thread_id) if group_sessions_per_user and participant_id: key_parts.append(str(participant_id)) + if source.session_namespace: + key_parts.append(source.session_namespace) return ":".join(key_parts) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 1c687f6d384e..a9a7c41f14b7 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -61,26 +61,59 @@ class CommandDef: CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"), CommandDef("stop", "Kill all running background processes", "Session"), - CommandDef("approve", "Approve a pending dangerous command", "Session", - gateway_only=True, args_hint="[session|always]"), CommandDef("deny", "Deny a pending dangerous command", "Session", gateway_only=True), CommandDef("background", "Run a prompt in the background", "Session", aliases=("bg",), args_hint=""), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", aliases=("q",), args_hint=""), + CommandDef("compact", "Compress conversation context with optional instructions", "Session", + gateway_only=True, args_hint="[instructions]"), CommandDef("status", "Show session info", "Session", gateway_only=True), + CommandDef("approve", "Resolve a pending exec approval", "Session", + gateway_only=True, args_hint="[id] [allow-once|allow-always|deny]"), + CommandDef("commands", "Show the full gateway command catalog", "Session", + gateway_only=True), + CommandDef("context", "Show current conversation context details", "Session", + gateway_only=True, args_hint="[list|detail|json]", + subcommands=("list", "detail", "json")), + CommandDef("export-session", "Export the current session snapshot", "Session", + gateway_only=True, aliases=("export",), args_hint="[path]"), + CommandDef("whoami", "Show the sender identity Hermes sees", "Session", + gateway_only=True, aliases=("id",)), + CommandDef("focus", "Bind the current Discord thread to this Hermes session", "Session", + gateway_only=True, args_hint="[label]"), + CommandDef("unfocus", "Remove the current Discord thread binding", "Session", + gateway_only=True), + CommandDef("agents", "Show Discord thread bindings for this session", "Session", + gateway_only=True), + CommandDef("session", "Manage Discord thread binding idle/max-age controls", "Session", + gateway_only=True, args_hint="[idle|max-age] [duration|off]", + subcommands=("idle", "max-age", "status")), CommandDef("sethome", "Set this chat as the home channel", "Session", gateway_only=True, aliases=("set-home",)), CommandDef("resume", "Resume a previously-named session", "Session", args_hint="[name]"), + CommandDef("subagents", "Inspect or control sub-agent runs for this session", "Session", + gateway_only=True, args_hint="[list|kill|log|info|send|steer|spawn] [args...]", + subcommands=("list", "kill", "log", "info", "send", "steer", "spawn")), + CommandDef("kill", "Abort a running sub-agent for this session", "Session", + gateway_only=True, args_hint=""), + CommandDef("steer", "Steer a running sub-agent immediately", "Session", + gateway_only=True, aliases=("tell",), args_hint=" "), # Configuration - CommandDef("config", "Show current configuration", "Configuration", - cli_only=True), + CommandDef("config", "Show or update Hermes configuration", "Configuration", + args_hint="[show|get|set|unset] [key] [value]", + subcommands=("show", "get", "set", "unset")), + CommandDef("allowlist", "Inspect or edit the command allowlist", "Configuration", + gateway_only=True, args_hint="[list|add|remove] [entry]", + subcommands=("list", "add", "remove")), CommandDef("model", "Show or change the current model", "Configuration", args_hint="[name]"), + CommandDef("models", "Open the Discord model picker or list models", "Configuration", + gateway_only=True), CommandDef("provider", "Show available providers and current provider", "Configuration"), CommandDef("prompt", "View/set custom system prompt", "Configuration", @@ -94,10 +127,21 @@ class CommandDef: CommandDef("reasoning", "Manage reasoning effort and display", "Configuration", args_hint="[level|show|hide]", subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), + CommandDef("think", "Set reasoning effort quickly", "Configuration", + gateway_only=True, aliases=("thinking", "t"), + args_hint="[off|minimal|low|medium|high|xhigh]"), + CommandDef("send", "Control whether this session may use send_message", "Configuration", + gateway_only=True, args_hint="[on|off|inherit]"), + CommandDef("activation", "Control Discord mention-vs-always activation for this chat", "Configuration", + gateway_only=True, args_hint="[mention|always]"), + CommandDef("debug", "Manage runtime-only configuration overrides", "Configuration", + gateway_only=True, args_hint="[show|set|unset|reset] [key] [value]", + subcommands=("show", "set", "unset", "reset")), CommandDef("skin", "Show or change the display skin/theme", "Configuration", cli_only=True, args_hint="[name]"), CommandDef("voice", "Toggle voice mode", "Configuration", - args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), + aliases=("vc",), args_hint="[on|off|tts|channel|join|leave|status]", + subcommands=("on", "off", "tts", "channel", "join", "leave", "status")), # Tools & Skills CommandDef("tools", "Manage tools: /tools [list|disable|enable] [name...]", "Tools & Skills", @@ -107,11 +151,17 @@ class CommandDef: CommandDef("skills", "Search, install, inspect, or manage skills", "Tools & Skills", cli_only=True, subcommands=("search", "browse", "inspect", "install")), + CommandDef("skill", "Run a skill by name from messaging platforms", "Tools & Skills", + gateway_only=True, args_hint=" [input]"), CommandDef("cron", "Manage scheduled tasks", "Tools & Skills", cli_only=True, args_hint="[subcommand]", subcommands=("list", "add", "create", "edit", "pause", "resume", "run", "remove")), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", aliases=("reload_mcp",)), + CommandDef("acp", "Inspect or control ACP runtime sessions", "Tools & Skills", + gateway_only=True, args_hint="[spawn|cancel|steer|close|status|set-mode|set|cwd|permissions|timeout|model|reset-options|doctor|install|sessions] [args...]"), + CommandDef("bash", "Run a host shell command through gateway control", "Tools & Skills", + gateway_only=True, args_hint=""), CommandDef("browser", "Connect browser tools to your live Chrome via CDP", "Tools & Skills", cli_only=True, args_hint="[connect|disconnect|status]", subcommands=("connect", "disconnect", "status")), @@ -129,6 +179,14 @@ class CommandDef: cli_only=True), CommandDef("update", "Update Hermes Agent to the latest version", "Info", gateway_only=True), + CommandDef("restart", "Restart the Hermes gateway", "Info", + gateway_only=True), + CommandDef("dock-telegram", "Dock replies for this session to the Telegram home channel", "Info", + gateway_only=True, aliases=("dock_telegram",)), + CommandDef("dock-discord", "Dock replies for this session to the Discord home channel", "Info", + gateway_only=True, aliases=("dock_discord",)), + CommandDef("dock-slack", "Dock replies for this session to the Slack home channel", "Info", + gateway_only=True, aliases=("dock_slack",)), # Exit CommandDef("quit", "Exit the CLI", "Exit", @@ -237,6 +295,29 @@ def gateway_help_lines() -> list[str]: return lines +def gateway_command_defs() -> list[CommandDef]: + """Return gateway-available command definitions in registry order.""" + return [cmd for cmd in COMMAND_REGISTRY if not cmd.cli_only] + + +def gateway_commands_by_category() -> list[tuple[str, tuple[CommandDef, ...]]]: + """Return gateway commands grouped by category in registry order.""" + grouped: dict[str, list[CommandDef]] = {} + category_order: list[str] = [] + for cmd in gateway_command_defs(): + if cmd.category not in grouped: + grouped[cmd.category] = [] + category_order.append(cmd.category) + grouped[cmd.category].append(cmd) + return [(category, tuple(grouped[category])) for category in category_order] + + +def format_gateway_command_signature(cmd: CommandDef) -> str: + """Return a formatted gateway command signature.""" + args = f" {cmd.args_hint}" if cmd.args_hint else "" + return f"/{cmd.name}{args}" + + def telegram_bot_commands() -> list[tuple[str, str]]: """Return (command_name, description) pairs for Telegram setMyCommands. diff --git a/tests/gateway/test_bash_command.py b/tests/gateway/test_bash_command.py new file mode 100644 index 000000000000..727e4507531b --- /dev/null +++ b/tests/gateway/test_bash_command.py @@ -0,0 +1,199 @@ +"""Tests for gateway /bash command parity.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(**overrides) -> SessionSource: + data = { + "platform": Platform.DISCORD, + "user_id": "u1", + "chat_id": "c1", + "user_name": "alan", + "chat_name": "Hermes / #general", + "chat_type": "group", + } + data.update(overrides) + return SessionSource(**data) + + +def _make_event(text: str, *, source: SessionSource | None = None) -> MessageEvent: + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + source=source or _make_source(), + message_id="m1", + ) + + +def _make_runner() -> GatewayRunner: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")} + ) + runner.adapters = { + Platform.DISCORD: SimpleNamespace(send_exec_approval=AsyncMock()) + } + source = _make_source() + session_entry = SessionEntry( + session_key=build_session_key(source), + session_id="sess-1", + created_at=datetime(2026, 3, 19, 12, 0), + updated_at=datetime(2026, 3, 19, 12, 30), + platform=Platform.DISCORD, + chat_type="group", + ) + runner.session_store = MagicMock() + runner.session_store._generate_session_key = lambda session_source: build_session_key(session_source) + runner.session_store.get_or_create_session.return_value = session_entry + runner._bash_jobs = {} + runner._pending_approvals = {} + runner._running_agents = {} + runner._pending_messages = {} + runner._voice_mode = {} + runner._session_send_policies = {} + runner._session_docks = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + return runner + + +def test_rewrite_bash_shortcut_supports_command_poll_and_stop(): + assert GatewayRunner._rewrite_bash_shortcut("! echo hello") == "/bash echo hello" + assert GatewayRunner._rewrite_bash_shortcut("!poll") == "/bash poll" + assert GatewayRunner._rewrite_bash_shortcut("!stop proc-1") == "/bash stop proc-1" + assert GatewayRunner._rewrite_bash_shortcut("hello") is None + + +@pytest.mark.asyncio +async def test_handle_bash_command_completes_fast_process(monkeypatch): + runner = _make_runner() + + monkeypatch.setattr( + runner, + "_run_bash_process", + lambda **_kwargs: {"session_id": "proc-1"}, + ) + monkeypatch.setattr( + "tools.process_registry.process_registry", + SimpleNamespace( + wait=lambda session_id, timeout=None: { + "status": "exited", + "exit_code": 0, + "output": "hello\nworld", + }, + poll=lambda _sid: {"status": "exited"}, + ), + ) + + result = await runner._handle_bash_command(_make_event("/bash echo hello")) + + assert "Bash Complete" in result + assert "`0`" in result + assert "hello" in result + assert runner._bash_jobs == {} + + +@pytest.mark.asyncio +async def test_handle_bash_command_backgrounds_long_process(monkeypatch): + runner = _make_runner() + + monkeypatch.setattr( + runner, + "_run_bash_process", + lambda **_kwargs: {"session_id": "proc-2"}, + ) + monkeypatch.setattr( + "tools.process_registry.process_registry", + SimpleNamespace( + wait=lambda session_id, timeout=None: {"status": "timeout"}, + poll=lambda _sid: { + "status": "running", + "pid": 4242, + "output_preview": "still running", + }, + ), + ) + + result = await runner._handle_bash_command(_make_event("/bash sleep 30")) + + assert "Bash Running" in result + assert "`proc-2`" in result + assert "still running" in result + assert runner._bash_jobs[build_session_key(_make_source())] == "proc-2" + + +@pytest.mark.asyncio +async def test_bash_poll_and_stop_use_current_job(monkeypatch): + runner = _make_runner() + session_key = build_session_key(_make_source()) + runner._bash_jobs[session_key] = "proc-9" + + registry = SimpleNamespace( + get=lambda _sid: SimpleNamespace(exited=False), + poll=lambda _sid: { + "status": "running", + "pid": 999, + "uptime_seconds": 8, + }, + read_log=lambda _sid, limit=40: {"output": "line 1\nline 2"}, + kill_process=lambda _sid: {"status": "killed"}, + ) + monkeypatch.setattr("tools.process_registry.process_registry", registry) + + poll_result = await runner._handle_bash_command(_make_event("/bash poll")) + stop_result = await runner._handle_bash_command(_make_event("/bash stop")) + + assert "Bash Status" in poll_result + assert "line 1" in poll_result + assert "stopped" in stop_result + assert session_key not in runner._bash_jobs + + +@pytest.mark.asyncio +async def test_bash_command_records_pending_approval(monkeypatch): + runner = _make_runner() + monkeypatch.setattr( + runner, + "_run_bash_process", + lambda **_kwargs: { + "status": "approval_required", + "error": "Need approval", + "description": "dangerous command", + "pattern_key": "rm-rf", + }, + ) + + result = await runner._handle_bash_command(_make_event("/bash rm -rf /tmp/demo")) + pending = next(iter(runner._pending_approvals.values())) + + assert "requires approval" in result + assert pending["pattern_key"] == "rm-rf" + assert callable(pending["on_approve"]) + runner.adapters[Platform.DISCORD].send_exec_approval.assert_awaited_once() + + +def test_resolve_pending_approval_uses_callback_when_present(): + runner = _make_runner() + source = _make_source() + session_key = build_session_key(source) + runner._pending_approvals[session_key] = { + "approval_id": "appr-1", + "command": "echo hi", + "pattern_key": "echo", + "on_approve": lambda decision: f"approved:{decision}", + } + + result = runner._resolve_pending_approval(decision="allow-once", source=source) + + assert result == "approved:allow-once" + assert session_key not in runner._pending_approvals diff --git a/tests/gateway/test_channel_directory.py b/tests/gateway/test_channel_directory.py index 2ecacc457db0..3bb9802251ab 100644 --- a/tests/gateway/test_channel_directory.py +++ b/tests/gateway/test_channel_directory.py @@ -80,6 +80,16 @@ def test_guild_qualified_match(self, tmp_path): assert resolve_channel_name("discord", "ServerA/general") == "111" assert resolve_channel_name("discord", "ServerB/general") == "222" + def test_discord_channel_mention_and_channel_prefix_resolve_directly(self, tmp_path): + platforms = { + "discord": [ + {"id": "111", "name": "general", "guild": "ServerA", "type": "channel"}, + ] + } + with self._setup(tmp_path, platforms): + assert resolve_channel_name("discord", "<#111>") == "111" + assert resolve_channel_name("discord", "channel:111") == "111" + def test_prefix_match_unambiguous(self, tmp_path): platforms = { "slack": [ diff --git a/tests/gateway/test_debug_command.py b/tests/gateway/test_debug_command.py new file mode 100644 index 000000000000..79ee43feda4d --- /dev/null +++ b/tests/gateway/test_debug_command.py @@ -0,0 +1,175 @@ +"""Tests for gateway /debug runtime override behavior.""" + +from __future__ import annotations + +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +from gateway.platforms.discord import DiscordAdapter # noqa: E402 +from gateway.run import GatewayRunner # noqa: E402 + + +def _make_event(text="/debug") -> MessageEvent: + source = SessionSource( + platform=Platform.DISCORD, + user_id="u1", + chat_id="c1", + user_name="alan", + chat_type="group", + ) + return MessageEvent(text=text, source=source, message_id="m1") + + +def _make_runner(discord_extra: dict | None = None) -> GatewayRunner: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig( + enabled=True, + token="***", + extra=discord_extra or {}, + ) + } + ) + runner.adapters = { + Platform.DISCORD: DiscordAdapter(runner.config.platforms[Platform.DISCORD]) + } + runner._runtime_debug_overrides = {} + runner._ephemeral_system_prompt = "" + runner._prefill_messages = [] + runner._reasoning_config = None + runner._show_reasoning = False + runner._provider_routing = {} + runner._fallback_model = None + runner._smart_model_routing = {} + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._voice_mode = {} + runner._session_send_policies = {} + runner._session_docks = {} + runner._session_db = None + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + return runner + + +@pytest.mark.asyncio +async def test_debug_show_lists_supported_keys_when_no_overrides(): + runner = _make_runner() + + result = await runner._handle_debug_command(_make_event("/debug")) + + assert "Runtime Debug Overrides" in result + assert "None" in result + assert "`agent.system_prompt`" in result + assert "`discord.auto_thread`" in result + + +@pytest.mark.asyncio +async def test_debug_set_show_reasoning_updates_runtime_cache(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setattr("gateway.run._hermes_home", hermes_home) + runner = _make_runner() + + result = await runner._handle_debug_command( + _make_event("/debug set display.show_reasoning true") + ) + + assert runner._show_reasoning is True + assert "`true`" not in result # format_yaml_block uses yaml-style, not inline code + assert "Runtime override set for `display.show_reasoning`" in result + assert "true" in result.lower() + + +@pytest.mark.asyncio +async def test_debug_set_and_unset_reasoning_effort_restores_base_config(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "agent:\n reasoning_effort: low\n", + encoding="utf-8", + ) + monkeypatch.setattr("gateway.run._hermes_home", hermes_home) + runner = _make_runner() + + await runner._handle_debug_command(_make_event("/debug set agent.reasoning_effort xhigh")) + unset_result = await runner._handle_debug_command(_make_event("/debug unset agent.reasoning_effort")) + + assert runner._reasoning_config == {"enabled": True, "effort": "low"} + assert "Removed runtime override for `agent.reasoning_effort`" in unset_result + assert "low" in unset_result + + +@pytest.mark.asyncio +async def test_debug_set_discord_policy_override_updates_live_adapter(): + runner = _make_runner({"auto_thread": True, "require_mention": True}) + adapter = runner.adapters[Platform.DISCORD] + + result = await runner._handle_debug_command( + _make_event("/debug set discord.auto_thread false") + ) + + assert adapter._get_discord_policy().auto_thread is False + assert adapter._get_discord_policy().require_mention is True + assert runner._runtime_debug_overrides["discord.auto_thread"] is False + assert "Runtime override set for `discord.auto_thread`" in result + + +@pytest.mark.asyncio +async def test_debug_reset_clears_active_overrides(tmp_path, monkeypatch): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + monkeypatch.setattr("gateway.run._hermes_home", hermes_home) + runner = _make_runner() + + await runner._handle_debug_command(_make_event("/debug set display.show_reasoning true")) + await runner._handle_debug_command(_make_event("/debug set discord.require_mention false")) + result = await runner._handle_debug_command(_make_event("/debug reset")) + + assert runner._runtime_debug_overrides == {} + assert runner._show_reasoning is False + assert runner.adapters[Platform.DISCORD]._get_discord_policy().require_mention is True + assert "Removed `2` override(s)" in result diff --git a/tests/gateway/test_discord_command_catalog.py b/tests/gateway/test_discord_command_catalog.py new file mode 100644 index 000000000000..fe98bf5dd635 --- /dev/null +++ b/tests/gateway/test_discord_command_catalog.py @@ -0,0 +1,232 @@ +"""Tests for Discord long-tail command catalog parity work.""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(**overrides) -> SessionSource: + data = { + "platform": Platform.DISCORD, + "user_id": "u1", + "chat_id": "c1", + "user_name": "alan", + "chat_name": "Hermes / #general", + "chat_type": "group", + } + data.update(overrides) + return SessionSource(**data) + + +def _make_event(text: str, *, source: SessionSource | None = None) -> MessageEvent: + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + source=source or _make_source(), + message_id="m1", + ) + + +def _make_runner(history: list[dict] | None = None) -> GatewayRunner: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")} + ) + runner.adapters = {Platform.DISCORD: MagicMock()} + source = _make_source() + session_entry = SessionEntry( + session_key=build_session_key(source), + session_id="sess-1", + created_at=datetime(2026, 3, 19, 12, 0), + updated_at=datetime(2026, 3, 19, 12, 30), + platform=Platform.DISCORD, + chat_type="group", + ) + runner.session_store = MagicMock() + runner.session_store._generate_session_key = lambda session_source: build_session_key(session_source) + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = history or [] + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._voice_mode = {} + runner._session_send_policies = {} + runner._session_docks = {} + runner._effective_model = None + runner._effective_provider = None + runner._reasoning_config = {"enabled": True, "effort": "medium"} + runner._show_reasoning = False + runner._provider_routing = {} + runner._fallback_model = None + runner._session_db = None + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner._load_current_model_selection = lambda: { + "current_model": "anthropic/claude-opus-4.6", + "current_provider": "openrouter", + } + runner._resolve_model_runtime_details = lambda _provider: ({}, None) + runner._is_user_authorized = lambda _source: True + return runner + + +@pytest.mark.asyncio +async def test_allowlist_command_adds_lists_and_removes(monkeypatch): + runner = _make_runner() + store: set[str] = set() + import tools.approval as approval_mod + + monkeypatch.setattr(approval_mod, "load_permanent_allowlist", lambda: set(store)) + monkeypatch.setattr( + approval_mod, + "load_permanent", + lambda patterns: store.update(patterns), + ) + monkeypatch.setattr( + approval_mod, + "save_permanent_allowlist", + lambda patterns: store.clear() or store.update(patterns), + ) + monkeypatch.setattr(approval_mod, "_permanent_approved", set()) + + add_result = await runner._handle_allowlist_command(_make_event("/allowlist add recursive delete")) + list_result = await runner._handle_allowlist_command(_make_event("/allowlist")) + remove_result = await runner._handle_allowlist_command(_make_event("/allowlist remove recursive delete")) + + assert "Added `recursive delete`" in add_result + assert "🛡️ **Command Allowlist**" in list_result + assert "`recursive delete`" in list_result + assert "Removed `recursive delete`" in remove_result + + +@pytest.mark.asyncio +async def test_config_command_sets_gets_and_unsets_values(monkeypatch): + runner = _make_runner() + + set_result = await runner._handle_config_command( + _make_event("/config set terminal.backend docker") + ) + get_result = await runner._handle_config_command( + _make_event("/config get terminal.backend") + ) + unset_result = await runner._handle_config_command( + _make_event("/config unset terminal.backend") + ) + + assert "Updated `terminal.backend`" in set_result + assert "docker" in get_result + assert "Removed `terminal.backend`" in unset_result + + +@pytest.mark.asyncio +async def test_context_command_renders_list_detail_and_json(): + history = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + {"role": "tool", "content": "done"}, + {"role": "assistant", "content": "all set"}, + ] + runner = _make_runner(history) + + list_result = await runner._handle_context_command(_make_event("/context")) + detail_result = await runner._handle_context_command(_make_event("/context detail")) + json_result = await runner._handle_context_command(_make_event("/context json")) + + assert "🧠 **Hermes Context**" in list_result + assert "Messages: 4" in list_result + assert "**Prompt Sources**" in detail_result + payload = json.loads(json_result.removeprefix("```json\n").removesuffix("\n```")) + assert payload["message_count"] == 4 + assert payload["role_counts"]["assistant"] == 2 + + +@pytest.mark.asyncio +async def test_export_session_writes_html_and_json(tmp_path, monkeypatch): + history = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + runner = _make_runner(history) + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + + html_result = await runner._handle_export_session_command(_make_event("/export-session")) + json_path = tmp_path / "session.json" + json_result = await runner._handle_export_session_command( + _make_event(f"/export-session {json_path}") + ) + + html_path = next((tmp_path / ".hermes-exports").glob("*.html")) + assert html_path.exists() + assert "Hermes Session Export" in html_path.read_text(encoding="utf-8") + assert json_path.exists() + payload = json.loads(json_path.read_text(encoding="utf-8")) + assert payload["session"]["session_id"] == "sess-1" + assert len(payload["messages"]) == 2 + assert "Exported the current session" in html_result + assert "Format: JSON" in json_result + + +def test_prepare_skill_command_lists_and_rewrites(monkeypatch): + runner = _make_runner() + event = _make_event("/skill summarize docs") + + monkeypatch.setattr( + "agent.skill_commands.build_skill_invocation_message", + lambda cmd_key, user_instruction, task_id=None: f"prepared:{cmd_key}:{user_instruction}:{task_id}", + ) + prepared, error = runner._prepare_skill_command(event, task_id="sess-123") + + assert error is None + assert prepared == "prepared:/summarize:docs:sess-123" + assert event.text == prepared + + +@pytest.mark.asyncio +async def test_subagents_command_lists_empty_runtime(): + runner = _make_runner() + result = await runner._handle_message(_make_event("/subagents list")) + + assert "No subagents recorded" in result + + +@pytest.mark.asyncio +async def test_compact_command_passes_instructions_to_compressor(monkeypatch): + history = [ + {"role": "user", "content": "message one"}, + {"role": "assistant", "content": "reply one"}, + {"role": "user", "content": "message two"}, + {"role": "assistant", "content": "reply two"}, + ] + runner = _make_runner(history) + captured: dict[str, str] = {} + + class FakeAgent: + def __init__(self, *args, **kwargs): + pass + + def _compress_context(self, msgs, instructions, approx_tokens=None): + captured["instructions"] = instructions + return ([{"role": "system", "content": "summary"}], None) + + monkeypatch.setattr("gateway.run._resolve_runtime_agent_kwargs", lambda: {"api_key": "secret"}) + monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "anthropic/claude-opus-4.6") + monkeypatch.setattr("run_agent.AIAgent", FakeAgent) + monkeypatch.setattr("agent.model_metadata.estimate_messages_tokens_rough", lambda _msgs: 123) + + result = await runner._handle_compress_command(_make_event("/compact keep TODOs and decisions")) + + assert captured["instructions"] == "keep TODOs and decisions" + assert "Preserved guidance" in result + runner.session_store.rewrite_transcript.assert_called_once() diff --git a/tests/gateway/test_discord_command_ux.py b/tests/gateway/test_discord_command_ux.py new file mode 100644 index 000000000000..27fca1d86104 --- /dev/null +++ b/tests/gateway/test_discord_command_ux.py @@ -0,0 +1,180 @@ +"""Tests for Discord command UX additions: /approve and /think.""" + +from datetime import datetime +import importlib +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(**overrides) -> SessionSource: + data = { + "platform": Platform.DISCORD, + "user_id": "u1", + "chat_id": "c1", + "user_name": "alan", + "chat_name": "Hermes / #general", + "chat_type": "group", + } + data.update(overrides) + return SessionSource(**data) + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + adapter.send_exec_approval = AsyncMock() + runner.adapters = {Platform.DISCORD: adapter} + runner.session_store = MagicMock() + runner.session_store._generate_session_key = lambda source: build_session_key(source) + runner._pending_approvals = {} + runner._voice_mode = {} + runner._running_agents = {} + runner._pending_messages = {} + runner._effective_model = None + runner._effective_provider = None + runner._resolve_model_runtime_details = lambda _provider: ({}, None) + runner._load_current_model_selection = lambda: { + "current_model": "anthropic/claude-opus-4.6", + "current_provider": "openrouter", + } + runner._reasoning_config = {"enabled": True, "effort": "medium"} + runner._show_reasoning = False + return runner + + +@pytest.mark.asyncio +async def test_approve_command_resolves_current_pending_from_command_target(monkeypatch): + runner = _make_runner() + target_source = _make_source() + slash_source = _make_source(session_namespace="slash:u1") + session_key = build_session_key(target_source) + runner._pending_approvals[session_key] = { + "approval_id": "appr-1234", + "command": "rm -rf /tmp/test", + "pattern_keys": ["recursive delete"], + "session_key": session_key, + } + + approve_calls: list[tuple[str, str]] = [] + monkeypatch.setattr( + "tools.approval.approve_session", + lambda key, pattern: approve_calls.append((key, pattern)), + ) + terminal_module = importlib.import_module("tools.terminal_tool") + monkeypatch.setattr( + terminal_module, + "terminal_tool", + lambda **kwargs: "command output", + ) + + event = MessageEvent( + text="/approve allow-once", + message_type=MessageType.COMMAND, + source=target_source, + message_id="m1", + metadata={ + "session_source": slash_source, + "command_target_source": target_source, + }, + ) + + result = await runner._handle_approve_command(event) + + assert "approved (allow-once)" in result + assert "command output" in result + assert approve_calls == [(session_key, "recursive delete")] + assert session_key not in runner._pending_approvals + + +@pytest.mark.asyncio +async def test_approve_command_allow_always_uses_id_lookup(monkeypatch): + runner = _make_runner() + target_source = _make_source() + session_key = build_session_key(target_source) + runner._pending_approvals[session_key] = { + "approval_id": "appr-9999", + "command": "rm -rf /tmp/test", + "pattern_keys": ["recursive delete", "tirith:shortened_url"], + "session_key": session_key, + } + + approval_calls = { + "session": [], + "permanent": [], + "saved": [], + } + monkeypatch.setattr( + "tools.approval.approve_session", + lambda key, pattern: approval_calls["session"].append((key, pattern)), + ) + monkeypatch.setattr( + "tools.approval.approve_permanent", + lambda pattern: approval_calls["permanent"].append(pattern), + ) + monkeypatch.setattr( + "tools.approval.save_permanent_allowlist", + lambda patterns: approval_calls["saved"].append(set(patterns)), + ) + monkeypatch.setattr( + "tools.approval._permanent_approved", + {"recursive delete", "tirith:shortened_url"}, + ) + terminal_module = importlib.import_module("tools.terminal_tool") + monkeypatch.setattr( + terminal_module, + "terminal_tool", + lambda **kwargs: "command output", + ) + + event = MessageEvent( + text="/approve appr-9999 allow-always", + message_type=MessageType.COMMAND, + source=_make_source(user_id="u2"), + message_id="m1", + ) + + result = await runner._handle_approve_command(event) + + assert "approved (allow-always)" in result + assert approval_calls["session"] == [ + (session_key, "recursive delete"), + (session_key, "tirith:shortened_url"), + ] + assert approval_calls["permanent"] == ["recursive delete", "tirith:shortened_url"] + assert approval_calls["saved"] == [{"recursive delete", "tirith:shortened_url"}] + + +@pytest.mark.asyncio +async def test_think_command_routes_to_reasoning_effort(monkeypatch): + runner = _make_runner() + + monkeypatch.setattr( + runner, + "_handle_reasoning_command", + AsyncMock(return_value="🧠 reasoning updated"), + ) + event = MessageEvent( + text="/think high", + message_type=MessageType.COMMAND, + source=_make_source(), + message_id="m1", + ) + + result = await runner._handle_think_command(event) + + assert result == "🧠 reasoning updated" + think_event = runner._handle_reasoning_command.await_args.args[0] + assert think_event.text == "/reasoning high" + assert think_event.source.chat_id == "c1" diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index bf8d4a2920b7..fd57f4ffdf17 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -139,6 +139,48 @@ async def test_discord_free_response_in_server_channels(adapter, monkeypatch): assert event.source.chat_type == "group" +@pytest.mark.asyncio +async def test_discord_platform_extra_overrides_env_for_mention_policy(monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + config = PlatformConfig( + enabled=True, + token="fake-token", + extra={"require_mention": False}, + ) + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + adapter.handle_message = AsyncMock() + + message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_discord_platform_extra_overrides_env_for_free_response_channels(monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "999") + + config = PlatformConfig( + enabled=True, + token="fake-token", + extra={"free_response_channels": ["123"]}, + ) + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + adapter.handle_message = AsyncMock() + + message = make_message(channel=FakeTextChannel(channel_id=123), content="allowed without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + + @pytest.mark.asyncio async def test_discord_free_response_in_threads(adapter, monkeypatch): monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") @@ -358,3 +400,68 @@ async def test_discord_thread_participation_tracked_on_dispatch(adapter, monkeyp await adapter._handle_message(message) assert "777" in adapter._bot_participated_threads + + +@pytest.mark.asyncio +async def test_discord_focused_thread_skips_mention_requirement(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + + parent = FakeTextChannel(channel_id=222, name="general") + thread = FakeThread(channel_id=333, name="focused-thread", parent=parent) + adapter.focus_thread_binding( + thread_id="333", + session_key="discord:thread:333", + chat_name="focused-thread", + parent_chat_id="222", + bound_by="alan", + ) + + message = make_message(channel=thread, content="follow-up without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.source.chat_type == "thread" + assert event.text == "follow-up without mention" + + +@pytest.mark.asyncio +async def test_discord_unfocused_thread_requires_mention_again(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + + parent = FakeTextChannel(channel_id=222, name="general") + thread = FakeThread(channel_id=334, name="focused-thread", parent=parent) + adapter.focus_thread_binding( + thread_id="334", + session_key="discord:thread:334", + chat_name="focused-thread", + parent_chat_id="222", + bound_by="alan", + ) + adapter.unfocus_thread_binding("334") + + message = make_message(channel=thread, content="follow-up without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_discord_activation_override_always_allows_channel_without_mention(adapter, monkeypatch): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) + + adapter.set_activation_mode("789", "always") + message = make_message(channel=FakeTextChannel(channel_id=789), content="allowed without mention") + + await adapter._handle_message(message) + + adapter.handle_message.assert_awaited_once() + event = adapter.handle_message.await_args.args[0] + assert event.text == "allowed without mention" diff --git a/tests/gateway/test_discord_impl_components.py b/tests/gateway/test_discord_impl_components.py new file mode 100644 index 000000000000..54b7ac7ddbd6 --- /dev/null +++ b/tests/gateway/test_discord_impl_components.py @@ -0,0 +1,320 @@ +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + + +class FakeView: + def __init__(self, timeout=None): + self.timeout = timeout + self.children = [] + + def add_item(self, item): + item.view = self + self.children.append(item) + + +class FakeButton: + def __init__( + self, + *, + label=None, + style=None, + custom_id=None, + row=None, + disabled=False, + emoji=None, + url=None, + ): + self.label = label + self.style = style + self.custom_id = custom_id + self.row = row + self.disabled = disabled + self.emoji = emoji + self.url = url + self.view = None + + +class FakeSelect: + def __init__( + self, + *, + placeholder=None, + min_values=1, + max_values=1, + options=None, + custom_id=None, + row=None, + disabled=False, + ): + self.placeholder = placeholder + self.min_values = min_values + self.max_values = max_values + self.options = list(options or []) + self.custom_id = custom_id + self.row = row + self.disabled = disabled + self.values = [] + self.view = None + + +class FakeUserSelect(FakeSelect): + pass + + +class FakeRoleSelect(FakeSelect): + pass + + +class FakeMentionableSelect(FakeSelect): + pass + + +class FakeChannelSelect(FakeSelect): + pass + + +class FakeModal: + def __init__(self, *, title=None, custom_id=None, timeout=None): + self.title = title + self.custom_id = custom_id + self.timeout = timeout + self.children = [] + + def add_item(self, item): + self.children.append(item) + + +class FakeTextInput: + def __init__( + self, + *, + label, + placeholder=None, + default=None, + required=True, + min_length=None, + max_length=None, + style=None, + ): + self.label = label + self.placeholder = placeholder + self.default = default + self.required = required + self.min_length = min_length + self.max_length = max_length + self.style = style + self.value = default or "" + + +def _load_components_module(): + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Interaction = object + discord_mod.ui = SimpleNamespace( + View=FakeView, + Button=FakeButton, + Select=FakeSelect, + UserSelect=FakeUserSelect, + RoleSelect=FakeRoleSelect, + MentionableSelect=FakeMentionableSelect, + ChannelSelect=FakeChannelSelect, + Modal=FakeModal, + TextInput=FakeTextInput, + ) + discord_mod.ButtonStyle = SimpleNamespace( + primary=1, + secondary=2, + success=3, + danger=4, + link=5, + green=3, + blurple=1, + red=4, + ) + discord_mod.SelectOption = lambda **kwargs: SimpleNamespace(**kwargs) + discord_mod.TextStyle = SimpleNamespace(short="short", paragraph="paragraph") + + sys.modules["discord"] = discord_mod + return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.components")) + + +components = _load_components_module() + + +def _interaction(user_id="42"): + return SimpleNamespace( + user=SimpleNamespace(id=user_id, display_name=f"user-{user_id}"), + response=SimpleNamespace( + send_message=AsyncMock(), + send_modal=AsyncMock(), + is_done=lambda: False, + ), + followup=SimpleNamespace(send=AsyncMock()), + ) + + +def test_component_custom_id_round_trip(): + custom_id = components.encode_component_custom_id("cmp_abc123") + assert components.decode_component_custom_id(custom_id) == "cmp_abc123" + + +@pytest.mark.asyncio +async def test_single_use_button_is_consumed_after_callback(): + runtime = components.DiscordComponentRuntime() + seen = [] + + async def handler(invocation): + seen.append(invocation.entry.entry_id) + + view = components.ManagedComponentView(runtime, timeout=300) + button = view.add_button( + components.DiscordButtonSpec(label="Run", style="primary", handler=handler) + ) + interaction = _interaction() + + await button.callback(interaction) + await button.callback(interaction) + + assert len(seen) == 1 + interaction.response.send_message.assert_awaited_once_with( + components.DEFAULT_USED_MESSAGE, + ephemeral=True, + ) + + +@pytest.mark.asyncio +async def test_reusable_button_can_be_clicked_multiple_times(): + runtime = components.DiscordComponentRuntime() + seen = [] + + async def handler(invocation): + seen.append(invocation.entry.entry_id) + + view = components.ManagedComponentView(runtime, timeout=300) + button = view.add_button( + components.DiscordButtonSpec( + label="Run Again", + style="secondary", + handler=handler, + reusable=True, + ) + ) + interaction = _interaction() + + await button.callback(interaction) + await button.callback(interaction) + + assert len(seen) == 2 + interaction.response.send_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_unauthorized_component_is_denied_consistently(): + runtime = components.DiscordComponentRuntime() + handler = AsyncMock() + + view = components.ManagedComponentView(runtime, timeout=300) + button = view.add_button( + components.DiscordButtonSpec( + label="Secret", + style="danger", + handler=handler, + allowed_user_ids=("42",), + ) + ) + + interaction = _interaction(user_id="7") + await button.callback(interaction) + + handler.assert_not_awaited() + interaction.response.send_message.assert_awaited_once_with( + components.DEFAULT_UNAUTHORIZED_MESSAGE, + ephemeral=True, + ) + + +def test_select_family_builders_cover_all_supported_types(): + runtime = components.DiscordComponentRuntime() + view = components.ManagedComponentView(runtime, timeout=300) + handler = AsyncMock() + + view.add_select( + components.DiscordSelectSpec( + select_type="string", + handler=handler, + options=( + components.DiscordSelectOptionSpec(label="One", value="one"), + components.DiscordSelectOptionSpec(label="Two", value="two"), + ), + ) + ) + view.add_select(components.DiscordSelectSpec(select_type="user", handler=handler)) + view.add_select(components.DiscordSelectSpec(select_type="role", handler=handler)) + view.add_select(components.DiscordSelectSpec(select_type="mentionable", handler=handler)) + view.add_select(components.DiscordSelectSpec(select_type="channel", handler=handler)) + + assert [type(child).__name__ for child in view.children] == [ + "ManagedStringSelect", + "ManagedUserSelect", + "ManagedRoleSelect", + "ManagedMentionableSelect", + "ManagedChannelSelect", + ] + + +@pytest.mark.asyncio +async def test_modal_trigger_and_submission_round_trip(): + runtime = components.DiscordComponentRuntime() + submitted = [] + + async def modal_handler(invocation): + submitted.append(invocation.values) + + view = components.ManagedComponentView(runtime, timeout=300) + trigger = view.add_modal_trigger( + components.DiscordModalTriggerSpec( + label="Open Form", + reusable=True, + modal=components.DiscordModalSpec( + title="Feedback", + handler=modal_handler, + fields=( + components.DiscordModalFieldSpec( + field_id="summary", + label="Summary", + default="hello", + ), + ), + ), + ) + ) + interaction = _interaction() + + await trigger.callback(interaction) + + interaction.response.send_modal.assert_awaited_once() + modal = interaction.response.send_modal.await_args.args[0] + assert modal.title == "Feedback" + modal.children[0].value = "shipped" + + submit_interaction = _interaction() + await modal.on_submit(submit_interaction) + + assert submitted == [{"summary": "shipped"}] + + +def test_bind_message_records_message_association_for_entries(): + runtime = components.DiscordComponentRuntime() + view = components.ManagedComponentView(runtime, timeout=300) + view.add_button( + components.DiscordButtonSpec(label="Bind Me", style="primary", handler=AsyncMock()) + ) + view.bind_message("555") + + entry = runtime.get_entry(view.entry_ids[0]) + assert entry.message_id == "555" diff --git a/tests/gateway/test_discord_impl_config.py b/tests/gateway/test_discord_impl_config.py new file mode 100644 index 000000000000..47755b146587 --- /dev/null +++ b/tests/gateway/test_discord_impl_config.py @@ -0,0 +1,138 @@ +"""Tests for Discord config and policy helpers.""" + +from gateway.config import PlatformConfig +from gateway.platforms.discord_impl import config as discord_config + + +def test_clean_discord_id_strips_common_prefixes(): + assert discord_config.clean_discord_id(" user:123 ") == "123" + assert discord_config.clean_discord_id("<@123>") == "123" + assert discord_config.clean_discord_id("<@!123>") == "123" + assert discord_config.clean_discord_id("teknium") == "teknium" + + +def test_parse_allowed_users_cleans_and_filters_entries(): + parsed = discord_config.parse_allowed_users(" 123, <@!456>, user:teknium, , <@789> ") + + assert parsed == {"123", "456", "teknium", "789"} + + +def test_parse_free_response_channels_accepts_lists_and_strings(): + parsed = discord_config.parse_free_response_channels(["123", " 456 ", 789, ""]) + + assert parsed == {"123", "456", "789"} + + +def test_get_bot_filter_policy_defaults_to_none(monkeypatch): + monkeypatch.delenv("DISCORD_ALLOW_BOTS", raising=False) + + assert discord_config.get_bot_filter_policy() == "none" + + +def test_get_bot_filter_policy_normalizes_case_and_whitespace(monkeypatch): + monkeypatch.setenv("DISCORD_ALLOW_BOTS", " Mentions ") + + assert discord_config.get_bot_filter_policy() == "mentions" + + +def test_get_free_response_channels_parses_ids(monkeypatch): + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", " 123,456 , ,789 ") + + assert discord_config.get_free_response_channels() == {"123", "456", "789"} + + +def test_is_mention_required_defaults_true(monkeypatch): + monkeypatch.delenv("DISCORD_REQUIRE_MENTION", raising=False) + + assert discord_config.is_mention_required() is True + + +def test_is_mention_required_accepts_falsey_env_values(monkeypatch): + for value in ("false", "0", "no"): + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", value) + assert discord_config.is_mention_required() is False + + +def test_is_auto_thread_enabled_defaults_true(monkeypatch): + monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) + + assert discord_config.is_auto_thread_enabled() is True + + +def test_is_auto_thread_enabled_accepts_truthy_and_falsey_values(monkeypatch): + for value in ("true", "1", "yes"): + monkeypatch.setenv("DISCORD_AUTO_THREAD", value) + assert discord_config.is_auto_thread_enabled() is True + + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + assert discord_config.is_auto_thread_enabled() is False + + +def test_load_policy_config_reads_env_defaults(monkeypatch): + monkeypatch.setenv("DISCORD_ALLOWED_USERS", "123,<@!456>") + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "mentions") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "1,2") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") + + policy = discord_config.load_policy_config() + + assert policy.allowed_users == {"123", "456"} + assert policy.bot_filter_policy == "mentions" + assert policy.free_response_channels == {"1", "2"} + assert policy.require_mention is False + assert policy.auto_thread is False + + +def test_load_policy_config_prefers_platform_extra_over_env(monkeypatch): + monkeypatch.setenv("DISCORD_ALLOWED_USERS", "111") + monkeypatch.setenv("DISCORD_ALLOW_BOTS", "none") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "10") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + + config = PlatformConfig( + enabled=True, + extra={ + "allowed_users": ["<@!222>", "user:teknium"], + "allow_bots": "all", + "free_response_channels": ["20", "30"], + "require_mention": False, + "auto_thread": False, + }, + ) + + policy = discord_config.load_policy_config(config) + + assert policy.allowed_users == {"222", "teknium"} + assert policy.bot_filter_policy == "all" + assert policy.free_response_channels == {"20", "30"} + assert policy.require_mention is False + assert policy.auto_thread is False + + +def test_load_policy_config_applies_runtime_overrides_over_platform_extra(monkeypatch): + config = PlatformConfig( + enabled=True, + extra={ + "allow_bots": "none", + "free_response_channels": ["20"], + "require_mention": True, + "auto_thread": True, + }, + ) + + policy = discord_config.load_policy_config( + config, + overrides={ + "allow_bots": "mentions", + "free_response_channels": ["44", "55"], + "require_mention": False, + "auto_thread": False, + }, + ) + + assert policy.bot_filter_policy == "mentions" + assert policy.free_response_channels == {"44", "55"} + assert policy.require_mention is False + assert policy.auto_thread is False diff --git a/tests/gateway/test_discord_impl_delivery.py b/tests/gateway/test_discord_impl_delivery.py new file mode 100644 index 000000000000..9dd4c0c947f5 --- /dev/null +++ b/tests/gateway/test_discord_impl_delivery.py @@ -0,0 +1,192 @@ +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + + +def _load_delivery_module(): + class FakeIntents: + @staticmethod + def default(): + return SimpleNamespace() + + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = FakeIntents + discord_mod.Client = MagicMock + discord_mod.File = MagicMock() + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=MagicMock, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery")) + + +delivery = _load_delivery_module() + + +@pytest.mark.asyncio +async def test_resolve_channel_returns_cached_channel(): + channel = SimpleNamespace(id=123) + client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + result = await delivery.resolve_channel(client, "123") + + assert result is channel + client.fetch_channel.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_channel_fetches_when_not_cached(): + channel = SimpleNamespace(id=456) + client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(return_value=channel), + ) + + result = await delivery.resolve_channel(client, "456") + + assert result is channel + client.fetch_channel.assert_awaited_once_with(456) + + +@pytest.mark.asyncio +async def test_resolve_channel_returns_none_when_missing(): + client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(return_value=None), + ) + + result = await delivery.resolve_channel(client, "789") + + assert result is None + + +@pytest.mark.asyncio +async def test_send_text_message_sends_normally(): + message = SimpleNamespace(id=1) + channel = SimpleNamespace(send=AsyncMock(return_value=message)) + + result = await delivery.send_text_message(channel, "hello") + + assert result is message + channel.send.assert_awaited_once_with(content="hello", reference=None) + + +@pytest.mark.asyncio +async def test_send_text_message_retries_without_reference_for_system_message(): + message = SimpleNamespace(id=2) + send_calls = [] + + async def fake_send(*, content, reference=None): + send_calls.append({"content": content, "reference": reference}) + if len(send_calls) == 1: + raise RuntimeError( + "400 Bad Request (error code: 50035): Invalid Form Body\n" + "In message_reference: Cannot reply to a system message" + ) + return message + + reference = SimpleNamespace(id=99) + channel = SimpleNamespace(send=AsyncMock(side_effect=fake_send)) + + result = await delivery.send_text_message(channel, "hello", reference=reference) + + assert result is message + assert send_calls == [ + {"content": "hello", "reference": reference}, + {"content": "hello", "reference": None}, + ] + + +@pytest.mark.asyncio +async def test_send_file_attachment_succeeds(tmp_path, monkeypatch): + file_path = tmp_path / "sample.txt" + file_path.write_text("hello", encoding="utf-8") + + sent_message = SimpleNamespace(id=42) + channel = SimpleNamespace(send=AsyncMock(return_value=sent_message)) + client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + file_cls = MagicMock() + monkeypatch.setattr(delivery.discord, "File", file_cls) + + result = await delivery.send_file_attachment(client, "123", str(file_path), caption="cap") + + assert result.success is True + assert result.message_id == "42" + channel.send.assert_awaited_once() + assert file_cls.call_args.kwargs["filename"] == "sample.txt" + + +@pytest.mark.asyncio +async def test_send_file_attachment_returns_not_connected_error(): + result = await delivery.send_file_attachment(None, "123", "/tmp/missing.txt") + + assert result.success is False + assert result.error == "Not connected" + + +@pytest.mark.asyncio +async def test_send_file_attachment_returns_channel_not_found_error(tmp_path): + file_path = tmp_path / "sample.txt" + file_path.write_text("hello", encoding="utf-8") + + client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(return_value=None), + ) + + result = await delivery.send_file_attachment(client, "123", str(file_path)) + + assert result.success is False + assert result.error == "Channel 123 not found" diff --git a/tests/gateway/test_discord_impl_history.py b/tests/gateway/test_discord_impl_history.py new file mode 100644 index 000000000000..8d2d4c9fbc24 --- /dev/null +++ b/tests/gateway/test_discord_impl_history.py @@ -0,0 +1,342 @@ +from datetime import datetime, timezone +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + +def _install_discord_mock(): + class FakeIntents: + @staticmethod + def default(): + return SimpleNamespace() + + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = FakeIntents + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=MagicMock, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + + +def _load_history_module(): + _install_discord_mock() + importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery")) + importlib.reload(importlib.import_module("gateway.platforms.discord_impl.permissions")) + return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.history")) + + +history = _load_history_module() + + +class FakeHistoryIterator: + def __init__(self, messages): + self._messages = list(messages) + self._index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._index >= len(self._messages): + raise StopAsyncIteration + message = self._messages[self._index] + self._index += 1 + return message + + +class FakeHistoryChannel: + def __init__(self, messages, readable=True): + self.id = 123 + self.name = "general" + self.guild = SimpleNamespace(me=SimpleNamespace(id=999), name="Hermes") + self._messages = list(messages) + self.last_history_kwargs = None + self.history_calls = 0 + self.permissions_for = MagicMock( + return_value=SimpleNamespace( + view_channel=readable, + read_message_history=readable, + send_messages=True, + attach_files=True, + embed_links=True, + add_reactions=True, + manage_threads=False, + create_public_threads=False, + create_private_threads=False, + ) + ) + + def history(self, *, limit, before=None, after=None): + self.history_calls += 1 + self.last_history_kwargs = { + "limit": limit, + "before": before, + "after": after, + } + + filtered = list(self._messages) + if before is not None: + filtered = [message for message in filtered if int(message.id) < int(before.id)] + if after is not None: + filtered = [message for message in filtered if int(message.id) > int(after.id)] + return FakeHistoryIterator(filtered[:limit]) + + +def _make_message( + message_id, + *, + content, + author_id="42", + author_name="Jezza", + is_bot=False, + timestamp=None, + attachments=None, + reply_to=None, +): + if timestamp is None: + timestamp = datetime(2026, 3, 18, 12, 0, 0, tzinfo=timezone.utc) + reference = None if reply_to is None else SimpleNamespace(message_id=reply_to) + return SimpleNamespace( + id=message_id, + author=SimpleNamespace(id=author_id, name=author_name, display_name=author_name, bot=is_bot), + content=content, + created_at=timestamp, + attachments=[SimpleNamespace(url=url) for url in (attachments or [])], + reference=reference, + ) + + +def _make_client(channel): + return SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(return_value=None if channel is None else channel), + user=SimpleNamespace(id=999), + ) + + +@pytest.mark.asyncio +async def test_fetch_history_returns_messages_and_clamps_limits(): + messages = [ + _make_message(message_id, content=f"message {message_id}") + for message_id in range(600, 0, -1) + ] + channel = FakeHistoryChannel(messages) + client = _make_client(channel) + + result_min = await history.fetch_history(client, "123", limit=0) + + assert len(result_min) == 1 + assert channel.last_history_kwargs["limit"] == 1 + + result_max = await history.fetch_history(client, "123", limit=999) + + assert len(result_max) == 500 + assert channel.last_history_kwargs["limit"] == 500 + assert result_max[0].id == "600" + assert result_max[-1].id == "101" + + +@pytest.mark.asyncio +async def test_fetch_history_applies_before_and_after_filters(): + messages = [ + _make_message(message_id, content=f"message {message_id}") + for message_id in (105, 104, 103, 102, 101, 100) + ] + channel = FakeHistoryChannel(messages) + client = _make_client(channel) + + result = await history.fetch_history(client, "123", limit=10, before="104", after="101") + + assert [message.id for message in result] == ["103", "102"] + assert channel.last_history_kwargs["before"].id == 104 + assert channel.last_history_kwargs["after"].id == 101 + + +@pytest.mark.asyncio +async def test_fetch_history_returns_empty_for_empty_channel(): + channel = FakeHistoryChannel([]) + client = _make_client(channel) + + result = await history.fetch_history(client, "123") + + assert result == [] + + +@pytest.mark.asyncio +async def test_fetch_history_returns_empty_without_read_access(): + channel = FakeHistoryChannel([_make_message(1, content="hidden")], readable=False) + client = _make_client(channel) + + result = await history.fetch_history(client, "123") + + assert result == [] + assert channel.history_calls == 0 + + +@pytest.mark.asyncio +async def test_fetch_history_returns_empty_for_missing_channel(): + client = _make_client(None) + + result = await history.fetch_history(client, "123") + + assert result == [] + + +def test_history_message_dataclass_fields(): + message = history.HistoryMessage( + id="1", + author_id="42", + author_name="Jezza", + content="hello", + timestamp="2026-03-18T12:00:00+00:00", + is_bot=False, + attachments=["https://example.com/file.png"], + reply_to="0", + ) + + assert message.id == "1" + assert message.author_id == "42" + assert message.author_name == "Jezza" + assert message.content == "hello" + assert message.timestamp == "2026-03-18T12:00:00+00:00" + assert message.is_bot is False + assert message.attachments == ["https://example.com/file.png"] + assert message.reply_to == "0" + + +@pytest.mark.asyncio +async def test_search_history_filters_by_case_insensitive_query(): + channel = FakeHistoryChannel( + [ + _make_message(10, content="Hermes status update"), + _make_message(9, content="unrelated"), + _make_message(8, content="another hermes note"), + ] + ) + client = _make_client(channel) + + result = await history.search_history(client, "123", "HeRmEs", limit=5) + + assert [message.id for message in result] == ["10", "8"] + + +@pytest.mark.asyncio +async def test_search_history_filters_by_author_id(): + channel = FakeHistoryChannel( + [ + _make_message(10, content="deploy update", author_id="1", author_name="Ada"), + _make_message(9, content="deploy update", author_id="2", author_name="Linus"), + _make_message(8, content="deploy update", author_id="1", author_name="Ada"), + ] + ) + client = _make_client(channel) + + result = await history.search_history(client, "123", "deploy", limit=5, author_id="1") + + assert [message.id for message in result] == ["10", "8"] + assert all(message.author_id == "1" for message in result) + + +@pytest.mark.asyncio +async def test_search_history_returns_empty_when_no_matches(): + channel = FakeHistoryChannel( + [ + _make_message(10, content="hello world"), + _make_message(9, content="still nothing"), + ] + ) + client = _make_client(channel) + + result = await history.search_history(client, "123", "needle") + + assert result == [] + + +@pytest.mark.asyncio +async def test_search_history_returns_empty_for_missing_channel(): + client = _make_client(None) + + result = await history.search_history(client, "123", "needle") + + assert result == [] + + +@pytest.mark.asyncio +async def test_search_history_finds_match_beyond_old_limit_multiplier_window(): + channel = FakeHistoryChannel( + [ + _make_message(message_id, content="needle" if message_id == 130 else f"message {message_id}") + for message_id in range(250, 0, -1) + ] + ) + client = _make_client(channel) + + result = await history.search_history(client, "123", "needle", limit=1) + + assert [message.id for message in result] == ["130"] + + +@pytest.mark.asyncio +async def test_search_history_finds_author_filtered_match_beyond_old_limit_multiplier_window(): + messages = [] + for message_id in range(250, 0, -1): + if 250 >= message_id >= 150: + messages.append(_make_message(message_id, content="deploy update", author_id="2")) + elif message_id == 130: + messages.append(_make_message(message_id, content="deploy update", author_id="1")) + else: + messages.append(_make_message(message_id, content=f"message {message_id}", author_id="2")) + + channel = FakeHistoryChannel(messages) + client = _make_client(channel) + + result = await history.search_history(client, "123", "deploy", limit=1, author_id="1") + + assert [message.id for message in result] == ["130"] diff --git a/tests/gateway/test_discord_impl_intake.py b/tests/gateway/test_discord_impl_intake.py new file mode 100644 index 000000000000..6f2d9ce27d02 --- /dev/null +++ b/tests/gateway/test_discord_impl_intake.py @@ -0,0 +1,108 @@ +"""Tests for Discord intake and preflight helpers.""" + +from types import SimpleNamespace + +from gateway.platforms.discord_impl import intake as discord_intake + + +class FakeForumChannel: + def __init__(self, channel_id=1, name="forum", guild_name="Hermes Server"): + self.id = channel_id + self.name = name + self.guild = SimpleNamespace(name=guild_name) + self.type = 15 + + +class FakeTextChannel: + def __init__(self, channel_id=1, name="general", guild_name="Hermes Server"): + self.id = channel_id + self.name = name + self.guild = SimpleNamespace(name=guild_name) + + +class FakeThread: + def __init__(self, channel_id=1, name="thread", parent=None, guild_name="Hermes Server"): + self.id = channel_id + self.name = name + self.parent = parent + self.parent_id = getattr(parent, "id", None) + self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name) + + +def test_should_filter_bot_message_matches_policy(): + assert discord_intake.should_filter_bot_message(False, "none", False) is False + assert discord_intake.should_filter_bot_message(True, "none", False) is True + assert discord_intake.should_filter_bot_message(True, "mentions", False) is True + assert discord_intake.should_filter_bot_message(True, "mentions", True) is False + assert discord_intake.should_filter_bot_message(True, "all", False) is False + assert discord_intake.should_filter_bot_message(True, "weird", False) is False + + +def test_should_skip_for_mention_matches_gate_conditions(): + assert discord_intake.should_skip_for_mention(True, False, False, False) is True + assert discord_intake.should_skip_for_mention(True, True, False, False) is False + assert discord_intake.should_skip_for_mention(True, False, True, False) is False + assert discord_intake.should_skip_for_mention(True, False, False, True) is False + assert discord_intake.should_skip_for_mention(False, False, False, False) is False + + +def test_strip_mention_removes_both_mention_forms(): + assert discord_intake.strip_mention("<@123> hello", 123) == "hello" + assert discord_intake.strip_mention("<@!123> hello", 123) == "hello" + assert discord_intake.strip_mention("before <@123> and <@!123> after", 123) == "before and after" + + +def test_classify_message_type_prefers_commands_and_attachment_types(): + assert discord_intake.classify_message_type("/status", []) == "command" + assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="image/png")]) == "photo" + assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="video/mp4")]) == "video" + assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="audio/ogg")]) == "audio" + assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type="application/pdf")]) == "document" + assert discord_intake.classify_message_type("hello", [SimpleNamespace(content_type=None)]) == "text" + + +def test_get_parent_channel_id_prefers_parent_object(): + parent = SimpleNamespace(id=222) + channel = SimpleNamespace(parent=parent, parent_id=333) + + assert discord_intake.get_parent_channel_id(channel) == "222" + + +def test_get_parent_channel_id_falls_back_to_parent_id(): + channel = SimpleNamespace(parent=None, parent_id=333) + + assert discord_intake.get_parent_channel_id(channel) == "333" + + +def test_is_forum_parent_checks_discord_class_and_type(monkeypatch): + monkeypatch.setattr( + discord_intake, + "discord", + SimpleNamespace(ForumChannel=FakeForumChannel), + raising=False, + ) + + assert discord_intake.is_forum_parent(FakeForumChannel()) is True + assert discord_intake.is_forum_parent(SimpleNamespace(type=15)) is True + assert discord_intake.is_forum_parent(SimpleNamespace(type=0)) is False + assert discord_intake.is_forum_parent(None) is False + + +def test_format_thread_chat_name_includes_forum_context(): + forum = FakeForumChannel(name="support-forum") + thread = FakeThread(name="Forum topic", parent=forum) + + assert ( + discord_intake.format_thread_chat_name(thread, discord_intake.is_forum_parent) + == "Hermes Server / support-forum / Forum topic" + ) + + +def test_format_thread_chat_name_formats_regular_threads(): + parent = FakeTextChannel(name="general") + thread = FakeThread(name="Follow-up", parent=parent) + + assert ( + discord_intake.format_thread_chat_name(thread, discord_intake.is_forum_parent) + == "Hermes Server / #general / Follow-up" + ) diff --git a/tests/gateway/test_discord_impl_interactions.py b/tests/gateway/test_discord_impl_interactions.py new file mode 100644 index 000000000000..df5260c3c087 --- /dev/null +++ b/tests/gateway/test_discord_impl_interactions.py @@ -0,0 +1,470 @@ +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + +from gateway.config import Platform +from gateway.session import SessionSource +from gateway.platforms.base import MessageType + + +class FakeView: + def __init__(self, timeout=None): + self.timeout = timeout + self.children = [] + + def add_item(self, item): + item.view = self + self.children.append(item) + + +class FakeButton: + def __init__( + self, + *, + label=None, + style=None, + custom_id=None, + row=None, + disabled=False, + emoji=None, + url=None, + ): + self.label = label + self.style = style + self.custom_id = custom_id + self.row = row + self.disabled = disabled + self.emoji = emoji + self.url = url + self.view = None + + +class FakeSelect: + def __init__( + self, + *, + placeholder=None, + min_values=1, + max_values=1, + options=None, + custom_id=None, + row=None, + disabled=False, + ): + self.placeholder = placeholder + self.min_values = min_values + self.max_values = max_values + self.options = list(options or []) + self.custom_id = custom_id + self.row = row + self.disabled = disabled + self.values = [] + self.view = None + + +class FakeUserSelect(FakeSelect): + pass + + +class FakeRoleSelect(FakeSelect): + pass + + +class FakeMentionableSelect(FakeSelect): + pass + + +class FakeChannelSelect(FakeSelect): + pass + + +class FakeModal: + def __init__(self, *, title=None, custom_id=None, timeout=None): + self.title = title + self.custom_id = custom_id + self.timeout = timeout + self.children = [] + + def add_item(self, item): + self.children.append(item) + + +class FakeTextInput: + def __init__( + self, + *, + label, + placeholder=None, + default=None, + required=True, + min_length=None, + max_length=None, + style=None, + ): + self.label = label + self.placeholder = placeholder + self.default = default + self.required = required + self.min_length = min_length + self.max_length = max_length + self.style = style + self.value = default or "" + + +class FakeEmbed: + def __init__(self): + self.color = None + self.footer_text = None + + def set_footer(self, *, text): + self.footer_text = text + + +class FakeTree: + def __init__(self): + self.commands = {} + + def command(self, *, name, description): + def decorator(fn): + self.commands[name] = fn + return fn + + return decorator + + +def _load_interactions_module(): + class FakeIntents: + @staticmethod + def default(): + return SimpleNamespace() + + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = FakeIntents + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.ui = SimpleNamespace( + View=FakeView, + Button=FakeButton, + Select=FakeSelect, + UserSelect=FakeUserSelect, + RoleSelect=FakeRoleSelect, + MentionableSelect=FakeMentionableSelect, + ChannelSelect=FakeChannelSelect, + Modal=FakeModal, + TextInput=FakeTextInput, + button=lambda *a, **k: (lambda fn: fn), + ) + discord_mod.ButtonStyle = SimpleNamespace( + primary=1, + secondary=2, + success=3, + danger=4, + link=5, + green=3, + blurple=1, + red=4, + ) + discord_mod.Color = SimpleNamespace( + green=lambda: "green", + blue=lambda: "blue", + red=lambda: "red", + orange=lambda: "orange", + ) + discord_mod.SelectOption = lambda **kwargs: SimpleNamespace(**kwargs) + discord_mod.TextStyle = SimpleNamespace(short="short", paragraph="paragraph") + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + autocomplete=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=MagicMock, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + for module_name in ( + "gateway.platforms.discord_impl.components", + "gateway.platforms.discord_impl.native_commands", + "gateway.platforms.discord_impl.interactions", + ): + if module_name in sys.modules: + importlib.reload(sys.modules[module_name]) + else: + importlib.import_module(module_name) + return sys.modules["gateway.platforms.discord_impl.interactions"] + + +interactions = _load_interactions_module() + + +def _build_adapter(): + return SimpleNamespace( + build_source=lambda **kwargs: SessionSource(platform=Platform.DISCORD, **kwargs), + _run_simple_slash=AsyncMock(), + _invoke_native_slash_command=AsyncMock(), + _send_native_slash_content=AsyncMock(), + _build_slash_event=MagicMock(return_value=SimpleNamespace()), + handle_message=AsyncMock(), + _handle_thread_create_slash=AsyncMock(), + _resolve_exec_approval=AsyncMock(return_value="resolved"), + _component_runtime=interactions.create_component_runtime(), + ) + + +def test_build_slash_event_constructs_message_event(): + adapter = _build_adapter() + interaction = SimpleNamespace( + channel=SimpleNamespace( + name="general", + guild=SimpleNamespace(name="Hermes"), + topic="topic", + ), + channel_id=123, + user=SimpleNamespace(id=42, display_name="Jezza"), + ) + + event = interactions.build_slash_event(adapter, interaction, "/status") + + assert event.text == "/status" + assert event.message_type is MessageType.COMMAND + assert event.source.chat_id == "123" + assert event.source.chat_name == "Hermes / #general" + assert event.source.chat_topic == "topic" + assert isinstance(event.metadata.get("session_source"), SessionSource) + assert event.metadata["session_source"].session_namespace == "slash:42" + assert event.metadata["command_target_source"].chat_id == "123" + + +def test_register_slash_commands_registers_expected_names(): + tree = FakeTree() + adapter = _build_adapter() + + interactions.register_slash_commands(tree, adapter) + + assert set(tree.commands) == { + "new", + "reset", + "help", + "commands", + "context", + "export-session", + "export", + "whoami", + "focus", + "unfocus", + "agents", + "session", + "id", + "approve", + "allowlist", + "config", + "debug", + "model", + "models", + "reasoning", + "think", + "personality", + "retry", + "undo", + "status", + "sethome", + "stop", + "compact", + "compress", + "title", + "resume", + "usage", + "provider", + "help", + "insights", + "reload-mcp", + "skill", + "subagents", + "kill", + "steer", + "tell", + "acp", + "bash", + "voice", + "vc", + "send", + "activation", + "update", + "restart", + "dock-telegram", + "dock-discord", + "dock-slack", + "thread", + } + + +def test_extract_inline_shortcut_finds_first_supported_command(): + command, remaining = interactions.native_commands.extract_inline_shortcut("hey /status please") + + assert command == "status" + assert remaining == "hey please" + + +def test_extract_inline_shortcut_returns_none_for_plain_text(): + command, remaining = interactions.native_commands.extract_inline_shortcut("just chatting here") + + assert command is None + assert remaining == "just chatting here" + + +@pytest.mark.asyncio +async def test_native_dispatch_opens_fallback_menu_for_missing_discrete_arg(): + adapter = _build_adapter() + spec = next(spec for spec in interactions.native_commands.get_command_specs() if spec.name == "think") + interaction = SimpleNamespace( + user=SimpleNamespace(id=42, display_name="Jezza"), + message=None, + response=SimpleNamespace( + edit_message=AsyncMock(), + send_message=AsyncMock(), + is_done=lambda: False, + ), + followup=SimpleNamespace(send=AsyncMock()), + ) + + await interactions.native_commands._dispatch(adapter, interaction, spec, effort="") + + interaction.response.send_message.assert_awaited_once() + args = interaction.response.send_message.await_args.args + kwargs = interaction.response.send_message.await_args.kwargs + assert kwargs["ephemeral"] is True + assert "Choose `effort` for `/think`." == args[0] + assert kwargs["view"] is not None + adapter.handle_message.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_native_dispatch_route_sends_returned_response(): + adapter = _build_adapter() + adapter._invoke_native_slash_command.return_value = "focused" + spec = next(spec for spec in interactions.native_commands.get_command_specs() if spec.name == "focus") + interaction = SimpleNamespace( + response=SimpleNamespace( + defer=AsyncMock(), + send_message=AsyncMock(), + is_done=lambda: True, + ), + followup=SimpleNamespace(send=AsyncMock()), + ) + + await interactions.native_commands._dispatch(adapter, interaction, spec, name="release-room") + + interaction.response.defer.assert_awaited_once_with(ephemeral=True) + adapter._invoke_native_slash_command.assert_awaited_once_with( + interaction, + "/focus release-room", + ) + adapter._send_native_slash_content.assert_awaited_once_with(interaction, "focused") + + +@pytest.mark.asyncio +async def test_native_autocomplete_filters_discrete_choices(): + adapter = _build_adapter() + spec = next(spec for spec in interactions.native_commands.get_command_specs() if spec.name == "reasoning") + arg = spec.args[0] + + result = await interactions.native_commands._autocomplete_choices( + adapter, + spec, + arg, + SimpleNamespace(), + "hi", + ) + + assert [choice.value for choice in result] == ["off", "hide", "high", "xhigh"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method_name", "expected_color", "expected_footer"), + [ + ("allow_once", "green", "allow_once by Jezza"), + ("allow_always", "blue", "allow_always by Jezza"), + ("deny", "red", "deny by Jezza"), + ], +) +async def test_exec_approval_view_button_callbacks_resolve_correctly( + method_name, + expected_color, + expected_footer, +): + adapter = _build_adapter() + view = interactions.create_exec_approval_view(adapter, "approval-1", {"42"}) + embed = FakeEmbed() + interaction = SimpleNamespace( + user=SimpleNamespace(id=42, display_name="Jezza"), + message=SimpleNamespace(embeds=[embed]), + response=SimpleNamespace( + edit_message=AsyncMock(), + send_message=AsyncMock(), + ), + followup=SimpleNamespace(send=AsyncMock()), + ) + + button_labels = { + "allow_once": "Allow Once", + "allow_always": "Always Allow", + "deny": "Deny", + } + button = next(child for child in view.children if child.label == button_labels[method_name]) + await button.callback(interaction) + + assert embed.color == expected_color + assert embed.footer_text == expected_footer.replace("_", "-") + assert all(child.disabled for child in view.children) + interaction.response.edit_message.assert_awaited_once_with(embed=embed, view=view) + interaction.followup.send.assert_awaited_once_with("resolved", ephemeral=True) + assert adapter._resolve_exec_approval.await_count == 1 + decision = adapter._resolve_exec_approval.await_args.kwargs["decision"] + assert decision == expected_footer.split(" by ")[0].replace("_", "-") + assert adapter._resolve_exec_approval.await_args.kwargs["approval_id"] == "approval-1" + + +@pytest.mark.asyncio +async def test_exec_approval_view_rejects_unauthorized_user(): + adapter = _build_adapter() + view = interactions.create_exec_approval_view(adapter, "approval-1", {"42"}) + interaction = SimpleNamespace( + user=SimpleNamespace(id=7, display_name="Mallory"), + message=SimpleNamespace(embeds=[FakeEmbed()]), + response=SimpleNamespace( + edit_message=AsyncMock(), + send_message=AsyncMock(), + ), + ) + + button = next(child for child in view.children if child.label == "Deny") + await button.callback(interaction) + + interaction.response.edit_message.assert_not_awaited() + interaction.response.send_message.assert_awaited_once_with( + "You're not authorized to use this interaction~", + ephemeral=True, + ) diff --git a/tests/gateway/test_discord_impl_messaging.py b/tests/gateway/test_discord_impl_messaging.py new file mode 100644 index 000000000000..6686a01859df --- /dev/null +++ b/tests/gateway/test_discord_impl_messaging.py @@ -0,0 +1,407 @@ +from datetime import datetime +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + + +def _load_messaging_module(): + class FakeIntents: + @staticmethod + def default(): + return SimpleNamespace() + + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = FakeIntents + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=MagicMock, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery")) + return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.messaging")) + + +messaging = _load_messaging_module() + + +def _make_client(channel): + return SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(return_value=None if channel is None else channel), + ) + + +class _AsyncItemsIterator: + def __init__(self, items): + self._items = list(items) + self._index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._index >= len(self._items): + raise StopAsyncIteration + item = self._items[self._index] + self._index += 1 + return item + + +def test_normalize_edit_content_truncates_to_limit(): + result = messaging.normalize_edit_content( + "a" * 25, + format_message=lambda value: value.upper(), + max_message_length=10, + ) + + assert result == "AAAAAAA..." + + +@pytest.mark.asyncio +async def test_edit_message_returns_success(): + message = SimpleNamespace(edit=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + client = _make_client(channel) + + result = await messaging.edit_message( + client, + "123", + "456", + "hello", + format_message=lambda value: f"**{value}**", + max_message_length=2000, + ) + + assert result.success is True + assert result.message_id == "456" + channel.fetch_message.assert_awaited_once_with(456) + message.edit.assert_awaited_once_with(content="**hello**") + + +@pytest.mark.asyncio +async def test_edit_message_returns_missing_channel_error(): + result = await messaging.edit_message( + _make_client(None), + "123", + "456", + "hello", + ) + + assert result.success is False + assert result.error == "Channel 123 not found" + + +@pytest.mark.asyncio +async def test_edit_message_returns_fetch_error(): + channel = SimpleNamespace(fetch_message=AsyncMock(side_effect=RuntimeError("missing message"))) + client = _make_client(channel) + + result = await messaging.edit_message(client, "123", "456", "hello") + + assert result.success is False + assert result.error == "missing message" + + +@pytest.mark.asyncio +async def test_edit_message_truncates_oversized_content(): + message = SimpleNamespace(edit=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + client = _make_client(channel) + + result = await messaging.edit_message( + client, + "123", + "456", + "a" * 20, + max_message_length=10, + ) + + assert result.success is True + message.edit.assert_awaited_once_with(content="aaaaaaa...") + + +@pytest.mark.asyncio +async def test_delete_message_returns_success(): + message = SimpleNamespace(delete=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + + result = await messaging.delete_message(_make_client(channel), "123", "456") + + assert result.success is True + assert result.message_id == "456" + message.delete.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_add_and_remove_reaction_call_message_methods(): + message = SimpleNamespace(add_reaction=AsyncMock(), remove_reaction=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + client = _make_client(channel) + client.user = SimpleNamespace(id=999) + + add_result = await messaging.add_reaction(client, "123", "456", "🔥") + remove_result = await messaging.remove_reaction(client, "123", "456", "🔥") + + assert add_result.success is True + assert remove_result.success is True + message.add_reaction.assert_awaited_once_with("🔥") + message.remove_reaction.assert_awaited_once_with("🔥", client.user) + + +@pytest.mark.asyncio +async def test_remove_reaction_requires_client_user(): + message = SimpleNamespace(remove_reaction=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + + result = await messaging.remove_reaction(_make_client(channel), "123", "456", "🔥") + + assert result.success is False + assert result.error == "Client user unavailable" + message.remove_reaction.assert_not_called() + + +@pytest.mark.asyncio +async def test_list_reactions_serializes_unicode_and_custom_emoji_users(): + class _CustomEmoji: + id = 77 + name = "party" + + def __str__(self): + return "<:party:77>" + + unicode_reaction = SimpleNamespace( + emoji="🔥", + count=2, + users=MagicMock( + return_value=_AsyncItemsIterator( + [SimpleNamespace(id=1, username="alan", discriminator="1234")] + ) + ), + ) + custom_reaction = SimpleNamespace( + emoji=_CustomEmoji(), + count=1, + users=MagicMock( + return_value=_AsyncItemsIterator([SimpleNamespace(id=2, name="bot-user")]) + ), + ) + message = SimpleNamespace(reactions=[unicode_reaction, custom_reaction]) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + + result = await messaging.list_reactions(_make_client(channel), "123", "456", limit=5) + + assert result == [ + { + "emoji": {"id": None, "name": "🔥", "raw": "🔥"}, + "count": 2, + "users": [{"id": "1", "username": "alan", "tag": "alan#1234"}], + }, + { + "emoji": {"id": "77", "name": "party", "raw": "<:party:77>"}, + "count": 1, + "users": [{"id": "2", "username": "bot-user", "tag": "bot-user"}], + }, + ] + unicode_reaction.users.assert_called_once_with(limit=5) + custom_reaction.users.assert_called_once_with(limit=5) + + +@pytest.mark.asyncio +async def test_list_reactions_returns_empty_when_message_missing(): + channel = SimpleNamespace(fetch_message=AsyncMock(side_effect=RuntimeError("missing message"))) + + result = await messaging.list_reactions(_make_client(channel), "123", "456") + + assert result == [] + + +@pytest.mark.asyncio +async def test_list_threads_returns_active_threads(): + active_thread = SimpleNamespace( + id=10, + name="alpha", + parent=SimpleNamespace(id=5, name="general"), + guild=SimpleNamespace(id=1, name="Hermes"), + archived=False, + locked=False, + message_count=3, + member_count=2, + ) + channel = SimpleNamespace(threads=[active_thread]) + + result = await messaging.list_threads(_make_client(channel), "123") + + assert result == [ + { + "id": "10", + "name": "alpha", + "parent_id": "5", + "parent_name": "general", + "guild_id": "1", + "guild_name": "Hermes", + "archived": False, + "locked": False, + "message_count": 3, + "member_count": 2, + } + ] + + +@pytest.mark.asyncio +async def test_list_threads_includes_archived_threads_when_requested(): + archived_thread = SimpleNamespace( + id=11, + name="archive", + parent=SimpleNamespace(id=5, name="general"), + guild=SimpleNamespace(id=1, name="Hermes"), + archived=True, + locked=True, + message_count=9, + member_count=4, + ) + channel = SimpleNamespace( + threads=[], + archived_threads=MagicMock(return_value=_AsyncItemsIterator([archived_thread])), + ) + + result = await messaging.list_threads(_make_client(channel), "123", include_archived=True, limit=25) + + assert result[0]["id"] == "11" + channel.archived_threads.assert_called_once_with( + private=False, + joined=False, + limit=25, + before=None, + ) + + +@pytest.mark.asyncio +async def test_reply_in_thread_rejects_non_thread_channel(): + channel = SimpleNamespace(parent=None) + + result = await messaging.reply_in_thread(_make_client(channel), "123", "hello") + + assert result.success is False + assert result.error == "Channel 123 is not a thread" + + +@pytest.mark.asyncio +async def test_reply_in_thread_sends_chunks(): + sent_message = SimpleNamespace(id=55) + thread = SimpleNamespace( + parent=SimpleNamespace(id=5), + fetch_message=AsyncMock(return_value=SimpleNamespace(id=99)), + ) + send_text_message = AsyncMock(return_value=sent_message) + + result = await messaging.reply_in_thread( + _make_client(thread), + "123", + "hello", + reply_to="99", + format_message=lambda value: value.upper(), + truncate_message=lambda value, _max_len: [value[:3], value[3:]], + send_text_message=send_text_message, + ) + + assert result.success is True + assert result.message_id == "55" + assert send_text_message.await_count == 2 + first_call = send_text_message.await_args_list[0] + second_call = send_text_message.await_args_list[1] + assert first_call.args[0] is thread + assert first_call.args[1] == "HEL" + assert first_call.kwargs["reference"].id == 99 + assert second_call.args[1] == "LO" + assert second_call.kwargs["reference"] is None + + +@pytest.mark.asyncio +async def test_list_pins_serializes_messages(): + author = SimpleNamespace(id=42, name="Jezza", display_name="Jezza", bot=False) + pinned = SimpleNamespace( + id=7, + author=author, + content="important", + created_at=datetime(2026, 3, 18, 12, 0, 0), + attachments=[], + reference=None, + ) + channel = SimpleNamespace(pins=MagicMock(return_value=_AsyncItemsIterator([pinned]))) + + result = await messaging.list_pins(_make_client(channel), "123") + + assert result == [ + { + "id": "7", + "author_id": "42", + "author_name": "Jezza", + "content": "important", + "timestamp": "2026-03-18T12:00:00", + "is_bot": False, + "attachments": [], + "reply_to": None, + } + ] + + +@pytest.mark.asyncio +async def test_pin_and_unpin_message_call_message_methods(): + message = SimpleNamespace(pin=AsyncMock(), unpin=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + client = _make_client(channel) + + pin_result = await messaging.pin_message(client, "123", "456", reason="keep") + unpin_result = await messaging.unpin_message(client, "123", "456", reason="drop") + + assert pin_result.success is True + assert unpin_result.success is True + message.pin.assert_awaited_once_with(reason="keep") + message.unpin.assert_awaited_once_with(reason="drop") diff --git a/tests/gateway/test_discord_impl_permissions.py b/tests/gateway/test_discord_impl_permissions.py new file mode 100644 index 000000000000..6020398a4def --- /dev/null +++ b/tests/gateway/test_discord_impl_permissions.py @@ -0,0 +1,250 @@ +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + +def _install_discord_mock(): + class FakeIntents: + @staticmethod + def default(): + return SimpleNamespace() + + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = FakeIntents + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=MagicMock, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + + +def _load_permissions_module(): + _install_discord_mock() + importlib.reload(importlib.import_module("gateway.platforms.discord_impl.delivery")) + return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.permissions")) + + +permissions = _load_permissions_module() + + +def _make_client(channel=None, guilds=None): + return SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(return_value=None if channel is None else channel), + guilds=list(guilds or []), + user=SimpleNamespace(id=999), + ) + + +def _make_guild_channel(channel_id, name, *, can_read=True, can_read_history=True): + guild_member = SimpleNamespace(id=999) + guild = SimpleNamespace(id=777, me=guild_member, name="Hermes") + permission_bits = SimpleNamespace( + view_channel=can_read, + read_messages=can_read, + send_messages=True, + read_message_history=can_read_history, + attach_files=True, + embed_links=True, + add_reactions=True, + manage_threads=False, + create_public_threads=True, + create_private_threads=False, + ) + return SimpleNamespace( + id=channel_id, + name=name, + guild=guild, + permissions_for=MagicMock(return_value=permission_bits), + ) + + +@pytest.mark.asyncio +async def test_check_channel_permissions_for_guild_text_channel(): + channel = _make_guild_channel(123, "general") + client = _make_client(channel=channel) + + result = await permissions.check_channel_permissions(client, "123") + + assert result == permissions.ChannelPermissions( + channel_id="123", + channel_name="general", + can_read=True, + can_send=True, + can_read_history=True, + can_attach_files=True, + can_embed_links=True, + can_add_reactions=True, + can_manage_threads=False, + can_create_threads=True, + ) + channel.permissions_for.assert_called_once_with(channel.guild.me) + + +@pytest.mark.asyncio +async def test_check_channel_permissions_for_dm_channel_disables_thread_flags(): + dm_channel = permissions.discord.DMChannel() + dm_channel.id = 321 + dm_channel.name = None + dm_channel.guild = None + dm_channel.recipient = SimpleNamespace(name="Alan") + client = _make_client(channel=dm_channel) + + result = await permissions.check_channel_permissions(client, "321") + + assert result == permissions.ChannelPermissions( + channel_id="321", + channel_name="Alan", + can_read=True, + can_send=True, + can_read_history=True, + can_attach_files=True, + can_embed_links=True, + can_add_reactions=True, + can_manage_threads=False, + can_create_threads=False, + ) + + +@pytest.mark.asyncio +async def test_check_channel_permissions_returns_none_for_missing_channel(): + client = _make_client(channel=None) + + result = await permissions.check_channel_permissions(client, "123") + + assert result is None + + +@pytest.mark.asyncio +async def test_list_accessible_channels_filters_to_readable_channels_only(): + readable = _make_guild_channel(1, "general", can_read=True) + hidden = _make_guild_channel(2, "private", can_read=False) + guild = SimpleNamespace(id=777, name="Hermes", text_channels=[readable, hidden]) + readable.guild = guild + hidden.guild = guild + client = _make_client(guilds=[guild]) + + result = await permissions.list_accessible_channels(client) + + assert result == [ + permissions.AccessibleChannel( + channel_id="1", + channel_name="general", + guild_id="777", + guild_name="Hermes", + channel_kind="channel", + qualified_name="Hermes/general", + mention="<#1>", + can_read=True, + can_send=True, + can_read_history=True, + can_attach_files=True, + can_embed_links=True, + can_add_reactions=True, + can_manage_threads=False, + can_create_threads=True, + ) + ] + + +@pytest.mark.asyncio +async def test_list_accessible_channels_respects_guild_id_filter(): + guild_one = SimpleNamespace(id=111, name="One", text_channels=[_make_guild_channel(1, "one")]) + guild_two = SimpleNamespace(id=222, name="Two", text_channels=[_make_guild_channel(2, "two")]) + for guild in (guild_one, guild_two): + for channel in guild.text_channels: + channel.guild = guild + client = _make_client(guilds=[guild_one, guild_two]) + + result = await permissions.list_accessible_channels(client, guild_id="222") + + assert [channel.channel_id for channel in result] == ["2"] + assert [channel.channel_name for channel in result] == ["two"] + + +@pytest.mark.asyncio +async def test_list_accessible_channels_includes_normalized_target_metadata(): + guild = SimpleNamespace(id=777, name="Hermes", text_channels=[_make_guild_channel(1, "general")]) + for channel in guild.text_channels: + channel.guild = guild + client = _make_client(guilds=[guild]) + + result = await permissions.list_accessible_channels(client) + + assert result == [ + permissions.AccessibleChannel( + channel_id="1", + channel_name="general", + guild_id="777", + guild_name="Hermes", + channel_kind="channel", + qualified_name="Hermes/general", + mention="<#1>", + can_read=True, + can_send=True, + can_read_history=True, + can_attach_files=True, + can_embed_links=True, + can_add_reactions=True, + can_manage_threads=False, + can_create_threads=True, + ) + ] + + +@pytest.mark.asyncio +async def test_can_read_channel_returns_true_and_false_correctly(): + readable = _make_guild_channel(123, "general", can_read=True, can_read_history=True) + unreadable = _make_guild_channel(456, "private", can_read=False, can_read_history=False) + + readable_client = _make_client(channel=readable) + unreadable_client = _make_client(channel=unreadable) + + assert await permissions.can_read_channel(readable_client, "123") is True + assert await permissions.can_read_channel(unreadable_client, "456") is False diff --git a/tests/gateway/test_discord_impl_scaffold.py b/tests/gateway/test_discord_impl_scaffold.py new file mode 100644 index 000000000000..fef5c52923b5 --- /dev/null +++ b/tests/gateway/test_discord_impl_scaffold.py @@ -0,0 +1,104 @@ +"""Import tests for the Discord v2 internal implementation scaffold.""" + +import importlib +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + + +def _ensure_discord_mock(): + """Install a lightweight discord mock when discord.py isn't available.""" + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + + +def test_discord_impl_package_is_importable(): + package = importlib.import_module("gateway.platforms.discord_impl") + assert package is not None + + +def test_discord_impl_submodules_are_importable(): + module_names = ( + "config", + "intake", + "delivery", + "interactions", + "threads", + "state", + "history", + "permissions", + ) + + for module_name in module_names: + module = importlib.import_module(f"gateway.platforms.discord_impl.{module_name}") + assert module is not None + + +def test_discord_impl_re_exports_nothing(): + package = importlib.import_module("gateway.platforms.discord_impl") + assert not hasattr(package, "__all__") or not package.__all__ + + +def test_discord_public_import_surface_remains_available(): + from gateway.platforms.discord import ( + DiscordAdapter, + VoiceReceiver, + check_discord_requirements, + ) + + assert DiscordAdapter is not None + assert VoiceReceiver is not None + assert callable(check_discord_requirements) diff --git a/tests/gateway/test_discord_impl_state.py b/tests/gateway/test_discord_impl_state.py new file mode 100644 index 000000000000..cd1438b009f6 --- /dev/null +++ b/tests/gateway/test_discord_impl_state.py @@ -0,0 +1,61 @@ +"""Tests for Discord thread participation persistence helpers.""" + +import json + +from gateway.platforms.discord_impl import state as discord_state + + +def test_thread_state_path_uses_hermes_home(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + assert discord_state.thread_state_path() == tmp_path / "discord_threads.json" + + +def test_load_participated_threads_returns_empty_without_file(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + assert discord_state.load_participated_threads() == set() + + +def test_load_participated_threads_reads_json_list(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "discord_threads.json").write_text(json.dumps(["111", "222"]), encoding="utf-8") + + assert discord_state.load_participated_threads() == {"111", "222"} + + +def test_load_participated_threads_tolerates_corrupt_json(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "discord_threads.json").write_text("not-json", encoding="utf-8") + + assert discord_state.load_participated_threads() == set() + + +def test_save_participated_threads_persists_trimmed_set(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + saved_threads = discord_state.save_participated_threads({"1", "2", "3", "4"}, max_threads=2) + + assert saved_threads <= {"1", "2", "3", "4"} + assert len(saved_threads) == 2 + persisted = set(json.loads((tmp_path / "discord_threads.json").read_text(encoding="utf-8"))) + assert persisted == saved_threads + + +def test_track_thread_adds_and_persists(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + updated_threads = discord_state.track_thread(set(), "111") + + assert updated_threads == {"111"} + assert json.loads((tmp_path / "discord_threads.json").read_text(encoding="utf-8")) == ["111"] + + +def test_track_thread_duplicate_is_noop(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + existing = discord_state.track_thread(set(), "111") + + updated_threads = discord_state.track_thread(existing, "111") + + assert updated_threads == {"111"} + assert json.loads((tmp_path / "discord_threads.json").read_text(encoding="utf-8")) == ["111"] diff --git a/tests/gateway/test_discord_impl_threads.py b/tests/gateway/test_discord_impl_threads.py new file mode 100644 index 000000000000..e4a66fee2d7f --- /dev/null +++ b/tests/gateway/test_discord_impl_threads.py @@ -0,0 +1,271 @@ +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock, MagicMock +import importlib +import sys + +import pytest + + +def _load_threads_module(): + class FakeIntents: + @staticmethod + def default(): + return SimpleNamespace() + + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = FakeIntents + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=MagicMock, + ) + discord_mod.FFmpegPCMAudio = MagicMock + discord_mod.PCMVolumeTransformer = MagicMock + discord_mod.http = SimpleNamespace(Route=MagicMock) + + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + return importlib.reload(importlib.import_module("gateway.platforms.discord_impl.threads")) + + +threads = _load_threads_module() + + +@pytest.mark.asyncio +async def test_auto_create_thread_uses_message_content(): + thread = SimpleNamespace(id=1, name="hello") + message = SimpleNamespace( + content="hello world", + create_thread=AsyncMock(return_value=thread), + ) + + result = await threads.auto_create_thread(message) + + assert result is thread + message.create_thread.assert_awaited_once_with( + name="hello world", + auto_archive_duration=1440, + ) + + +@pytest.mark.asyncio +async def test_auto_create_thread_truncates_long_message_names(): + thread = SimpleNamespace(id=2, name="truncated") + message = SimpleNamespace( + content="a" * 200, + create_thread=AsyncMock(return_value=thread), + ) + + result = await threads.auto_create_thread(message) + + assert result is thread + thread_name = message.create_thread.await_args.kwargs["name"] + assert len(thread_name) <= 80 + assert thread_name.endswith("...") + + +@pytest.mark.asyncio +async def test_create_thread_succeeds_with_direct_creation(): + created_thread = SimpleNamespace(id=555, name="Planning", send=AsyncMock()) + parent_channel = SimpleNamespace( + create_thread=AsyncMock(return_value=created_thread), + send=AsyncMock(), + ) + interaction = SimpleNamespace( + user=SimpleNamespace(display_name="Jezza"), + ) + + async def resolve_channel_fn(_client, _interaction): + return SimpleNamespace(parent=parent_channel) + + result = await threads.create_thread( + client=MagicMock(), + interaction=interaction, + name="Planning", + message="Kickoff", + auto_archive_duration=1440, + resolve_channel_fn=resolve_channel_fn, + ) + + assert result == { + "success": True, + "thread_id": "555", + "thread_name": "Planning", + } + parent_channel.create_thread.assert_awaited_once_with( + name="Planning", + auto_archive_duration=1440, + reason="Requested by Jezza via /thread", + ) + created_thread.send.assert_awaited_once_with("Kickoff") + + +@pytest.mark.asyncio +async def test_create_thread_falls_back_to_seed_message(): + created_thread = SimpleNamespace(id=777, name="Planning") + seed_message = SimpleNamespace(create_thread=AsyncMock(return_value=created_thread)) + parent_channel = SimpleNamespace( + create_thread=AsyncMock(side_effect=RuntimeError("direct failed")), + send=AsyncMock(return_value=seed_message), + ) + interaction = SimpleNamespace( + user=SimpleNamespace(display_name="Jezza"), + ) + + async def resolve_channel_fn(_client, _interaction): + return SimpleNamespace(parent=parent_channel) + + result = await threads.create_thread( + client=MagicMock(), + interaction=interaction, + name="Planning", + message="Kickoff", + auto_archive_duration=1440, + resolve_channel_fn=resolve_channel_fn, + ) + + assert result == { + "success": True, + "thread_id": "777", + "thread_name": "Planning", + } + parent_channel.send.assert_awaited_once_with("Kickoff") + seed_message.create_thread.assert_awaited_once_with( + name="Planning", + auto_archive_duration=1440, + reason="Requested by Jezza via /thread", + ) + + +@pytest.mark.asyncio +async def test_create_thread_rejects_dm_channel(): + dm_channel = threads.discord.DMChannel() + + async def resolve_channel_fn(_client, _interaction): + return dm_channel + + result = await threads.create_thread( + client=MagicMock(), + interaction=SimpleNamespace(user=SimpleNamespace(display_name="Jezza")), + name="Planning", + message="", + auto_archive_duration=1440, + resolve_channel_fn=resolve_channel_fn, + ) + + assert result == {"error": "Discord threads can only be created inside server text channels, not DMs."} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("name", "message", "auto_archive_duration", "resolve_mode", "expected_error"), + [ + ("", "", 1440, "unused", "Thread name is required."), + ( + "Planning", + "", + 999, + "unused", + "auto_archive_duration must be one of: 60, 1440, 4320, 10080.", + ), + ( + "Planning", + "", + 1440, + "missing", + "Could not resolve the current Discord channel.", + ), + ], +) +async def test_create_thread_returns_expected_errors( + name, + message, + auto_archive_duration, + resolve_mode, + expected_error, +): + if resolve_mode == "missing": + async def resolve_channel_fn(_client, _interaction): + return None + else: + async def resolve_channel_fn(_client, _interaction): + return SimpleNamespace(parent=SimpleNamespace()) + + result = await threads.create_thread( + client=MagicMock(), + interaction=SimpleNamespace(user=SimpleNamespace(display_name="Jezza")), + name=name, + message=message, + auto_archive_duration=auto_archive_duration, + resolve_channel_fn=resolve_channel_fn, + ) + + assert result == {"error": expected_error} + + +def test_thread_parent_channel_returns_parent_or_self(): + parent = SimpleNamespace(id=1) + assert threads.thread_parent_channel(SimpleNamespace(parent=parent)) is parent + channel = SimpleNamespace(parent=None) + assert threads.thread_parent_channel(channel) is channel + + +@pytest.mark.asyncio +async def test_resolve_interaction_channel_returns_available_channel(): + channel = SimpleNamespace(id=123) + interaction = SimpleNamespace(channel=channel, channel_id=123) + + result = await threads.resolve_interaction_channel(MagicMock(), interaction) + + assert result is channel + + +@pytest.mark.asyncio +async def test_resolve_interaction_channel_returns_none_when_missing(): + client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(side_effect=RuntimeError("missing")), + ) + interaction = SimpleNamespace(channel=None, channel_id=123) + + result = await threads.resolve_interaction_channel(client, interaction) + + assert result is None diff --git a/tests/gateway/test_discord_model_picker.py b/tests/gateway/test_discord_model_picker.py new file mode 100644 index 000000000000..2f7956272586 --- /dev/null +++ b/tests/gateway/test_discord_model_picker.py @@ -0,0 +1,328 @@ +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock +import importlib +import sys + +import pytest + + +class FakeView: + def __init__(self, timeout=None): + self.timeout = timeout + self.children = [] + + def add_item(self, item): + item.view = self + self.children.append(item) + + +class FakeButton: + def __init__( + self, + *, + label=None, + style=None, + custom_id=None, + row=None, + disabled=False, + emoji=None, + url=None, + ): + self.label = label + self.style = style + self.custom_id = custom_id + self.row = row + self.disabled = disabled + self.emoji = emoji + self.url = url + self.view = None + + +class FakeSelect: + def __init__( + self, + *, + placeholder=None, + min_values=1, + max_values=1, + options=None, + custom_id=None, + row=None, + disabled=False, + ): + self.placeholder = placeholder + self.min_values = min_values + self.max_values = max_values + self.options = list(options or []) + self.custom_id = custom_id + self.row = row + self.disabled = disabled + self.values = [] + self.view = None + + +class FakeUserSelect(FakeSelect): + pass + + +class FakeRoleSelect(FakeSelect): + pass + + +class FakeMentionableSelect(FakeSelect): + pass + + +class FakeChannelSelect(FakeSelect): + pass + + +class FakeModal: + def __init__(self, *, title=None, custom_id=None, timeout=None): + self.title = title + self.custom_id = custom_id + self.timeout = timeout + self.children = [] + + def add_item(self, item): + self.children.append(item) + + +class FakeTextInput: + def __init__( + self, + *, + label, + placeholder=None, + default=None, + required=True, + min_length=None, + max_length=None, + style=None, + ): + self.label = label + self.placeholder = placeholder + self.default = default + self.required = required + self.min_length = min_length + self.max_length = max_length + self.style = style + self.value = default or "" + + +def _load_model_picker_module(): + discord_mod = ModuleType("discord") + discord_mod.__file__ = "mock-discord.py" + discord_mod.Message = type("Message", (), {}) + discord_mod.Intents = SimpleNamespace(default=lambda: SimpleNamespace()) + discord_mod.Client = object + discord_mod.File = object + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.Interaction = object + discord_mod.Embed = object + discord_mod.ui = SimpleNamespace( + View=FakeView, + Button=FakeButton, + Select=FakeSelect, + UserSelect=FakeUserSelect, + RoleSelect=FakeRoleSelect, + MentionableSelect=FakeMentionableSelect, + ChannelSelect=FakeChannelSelect, + Modal=FakeModal, + TextInput=FakeTextInput, + button=lambda *a, **k: (lambda fn: fn), + ) + discord_mod.ButtonStyle = SimpleNamespace( + primary=1, + secondary=2, + success=3, + danger=4, + link=5, + green=3, + blurple=1, + red=4, + ) + discord_mod.Color = SimpleNamespace( + green=lambda: "green", + blue=lambda: "blue", + red=lambda: "red", + orange=lambda: "orange", + ) + discord_mod.SelectOption = lambda **kwargs: SimpleNamespace(**kwargs) + discord_mod.TextStyle = SimpleNamespace(short="short", paragraph="paragraph") + discord_mod.opus = SimpleNamespace( + is_loaded=lambda: True, + load_opus=lambda *_args, **_kwargs: None, + Decoder=object, + ) + discord_mod.FFmpegPCMAudio = object + discord_mod.PCMVolumeTransformer = object + discord_mod.http = SimpleNamespace(Route=object) + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + sys.modules["discord"] = discord_mod + ext_mod = ModuleType("discord.ext") + commands_mod = ModuleType("discord.ext.commands") + commands_mod.Bot = object + ext_mod.commands = commands_mod + sys.modules["discord.ext"] = ext_mod + sys.modules["discord.ext.commands"] = commands_mod + components_mod = importlib.reload(importlib.import_module("gateway.platforms.discord_impl.components")) + model_picker_mod = importlib.reload(importlib.import_module("gateway.platforms.discord_impl.model_picker")) + return model_picker_mod, components_mod + + +model_picker, components = _load_model_picker_module() + + +def _interaction(*, message_id="123", response_done=False): + return SimpleNamespace( + user=SimpleNamespace(id="42", display_name="alan"), + message=SimpleNamespace(id=message_id), + response=SimpleNamespace( + send_message=AsyncMock(), + edit_message=AsyncMock(), + is_done=lambda: response_done, + ), + followup=SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=message_id))), + ) + + +def _provider_catalog(): + return [ + {"id": "openrouter", "label": "OpenRouter", "authenticated": True}, + {"id": "anthropic", "label": "Anthropic", "authenticated": True}, + ] + + +def _models_for(provider): + data = { + "openrouter": [ + ("anthropic/claude-opus-4.6", "recommended"), + ("openai/gpt-5.4", ""), + ], + "anthropic": [ + ("claude-opus-4-6", ""), + ("claude-sonnet-4-6", ""), + ], + } + return data.get(provider, []) + + +def test_record_recent_model_deduplicates_and_caps(tmp_path): + hermes_home = tmp_path / "hermes" + for index in range(7): + model_picker.record_recent_model( + "42", + "openrouter", + f"model-{index}", + hermes_home=hermes_home, + ) + model_picker.record_recent_model( + "42", + "openrouter", + "model-3", + hermes_home=hermes_home, + ) + + recents = model_picker._read_recents(hermes_home)["42"] + + assert recents[0] == {"provider": "openrouter", "model": "model-3"} + assert len(recents) == model_picker.RECENTS_LIMIT + + +def test_load_recent_models_filters_unknown_entries(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + model_picker._write_recents( + { + "42": [ + {"provider": "openrouter", "model": "anthropic/claude-opus-4.6"}, + {"provider": "unknown", "model": "ghost"}, + {"provider": "anthropic", "model": "missing"}, + ] + }, + hermes_home, + ) + monkeypatch.setattr(model_picker, "list_available_providers", _provider_catalog) + monkeypatch.setattr(model_picker, "curated_models_for_provider", _models_for) + + recents = model_picker.load_recent_models("42", hermes_home=hermes_home) + + assert recents == [ + model_picker.RecentModel( + provider="openrouter", + model="anthropic/claude-opus-4.6", + ) + ] + + +@pytest.mark.asyncio +async def test_open_model_picker_sends_ephemeral_provider_view(monkeypatch): + monkeypatch.setattr(model_picker, "list_available_providers", _provider_catalog) + monkeypatch.setattr(model_picker, "curated_models_for_provider", _models_for) + apply_selection = AsyncMock(return_value="changed") + adapter = SimpleNamespace(_component_runtime=components.DiscordComponentRuntime()) + interaction = _interaction(response_done=True) + + await model_picker.open_model_picker( + adapter=adapter, + interaction=interaction, + command_name="models", + user_id="42", + current_provider="openrouter", + current_model="anthropic/claude-opus-4.6", + apply_selection=apply_selection, + ) + + interaction.followup.send.assert_awaited_once() + kwargs = interaction.followup.send.await_args.kwargs + assert kwargs["ephemeral"] is True + assert kwargs["view"] is not None + assert "Discord Model Picker" in interaction.followup.send.await_args.args[0] + + +@pytest.mark.asyncio +async def test_picker_select_and_submit_flow_applies_pending_model(monkeypatch, tmp_path): + monkeypatch.setattr(model_picker, "list_available_providers", _provider_catalog) + monkeypatch.setattr(model_picker, "curated_models_for_provider", _models_for) + model_picker.record_recent_model( + "42", + "openrouter", + "openai/gpt-5.4", + hermes_home=tmp_path / "hermes", + ) + apply_selection = AsyncMock(return_value="updated") + controller = model_picker.DiscordModelPickerController( + runtime=components.DiscordComponentRuntime(), + command_name="model", + user_id="42", + current_provider="openrouter", + current_model="anthropic/claude-opus-4.6", + apply_selection=apply_selection, + hermes_home=tmp_path / "hermes", + ) + + _content, provider_view = controller._build_provider_view() + provider_select = next(child for child in provider_view.children if isinstance(child, FakeSelect)) + interaction = _interaction(message_id="321") + provider_select.values = ["anthropic"] + await provider_select.callback(interaction) + + assert controller.state.pending_provider == "anthropic" + assert interaction.response.edit_message.await_count == 1 + + _content, model_view = controller._build_model_view() + model_select = next(child for child in model_view.children if isinstance(child, FakeSelect)) + model_select.values = ["claude-sonnet-4-6"] + await model_select.callback(interaction) + + submit_button = next(child for child in model_view.children if getattr(child, "label", "") == "Submit") + await submit_button.callback(interaction) + + apply_selection.assert_awaited_once_with("anthropic", "claude-sonnet-4-6", "42") + assert interaction.response.edit_message.await_count >= 3 diff --git a/tests/gateway/test_discord_native_command_registration.py b/tests/gateway/test_discord_native_command_registration.py new file mode 100644 index 000000000000..70555467bc64 --- /dev/null +++ b/tests/gateway/test_discord_native_command_registration.py @@ -0,0 +1,37 @@ +import importlib +import sys +from types import SimpleNamespace + +import pytest + + +def _real_discord_available() -> bool: + try: + importlib.import_module("discord") + return True + except ImportError: + return False + + +@pytest.mark.skipif(not _real_discord_available(), reason="discord.py not installed") +def test_register_slash_commands_with_real_command_tree(monkeypatch): + for module_name in ( + "discord", + "discord.ext", + "discord.ext.commands", + "gateway.platforms.discord_impl.native_commands", + ): + monkeypatch.delitem(sys.modules, module_name, raising=False) + + importlib.invalidate_caches() + discord = importlib.import_module("discord") + native_commands = importlib.import_module("gateway.platforms.discord_impl.native_commands") + + client = discord.Client(intents=discord.Intents.none()) + tree = discord.app_commands.CommandTree(client) + + native_commands.register_slash_commands(tree, SimpleNamespace()) + + assert tree.get_command("status") is not None + assert tree.get_command("model") is not None + assert tree.get_command("bash") is not None diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py new file mode 100644 index 000000000000..a6bd26368a21 --- /dev/null +++ b/tests/gateway/test_discord_reactions.py @@ -0,0 +1,67 @@ +"""Smoke coverage for the reserved Discord reactions test path. + +This module keeps the expected pytest path stable for staged Discord v2 +verification commands until dedicated reaction behavior tests are added. +""" + +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock + + +def _ensure_discord_mock(): + """Install a lightweight discord mock when discord.py isn't available.""" + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + 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.Color = SimpleNamespace( + orange=lambda: 1, + green=lambda: 2, + blue=lambda: 3, + red=lambda: 4, + ) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + + +def test_discord_reactions_path_preserves_public_import_surface(): + from gateway.platforms.discord import DiscordAdapter + + assert DiscordAdapter is not None diff --git a/tests/gateway/test_discord_runtime_controls.py b/tests/gateway/test_discord_runtime_controls.py new file mode 100644 index 000000000000..1251929d7eba --- /dev/null +++ b/tests/gateway/test_discord_runtime_controls.py @@ -0,0 +1,256 @@ +"""Tests for Discord runtime control commands added in slice 0025.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, HomeChannel, Platform, PlatformConfig +from gateway.delivery import DeliveryTarget +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionSource, build_session_key + + +def _make_source(**overrides) -> SessionSource: + data = { + "platform": Platform.DISCORD, + "user_id": "u1", + "user_name": "alan", + "chat_id": "c1", + "chat_name": "Hermes / #general", + "chat_type": "group", + } + data.update(overrides) + return SessionSource(**data) + + +def _make_event( + text: str, + *, + source: SessionSource | None = None, + session_source: SessionSource | None = None, + target_source: SessionSource | None = None, + raw_channel=None, +) -> MessageEvent: + source = source or target_source or _make_source() + metadata = {} + if session_source is not None: + metadata["session_source"] = session_source + if target_source is not None: + metadata["command_target_source"] = target_source + raw_message = SimpleNamespace(channel=raw_channel) if raw_channel is not None else None + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + source=source, + raw_message=raw_message, + message_id="m1", + metadata=metadata, + ) + + +def _make_binding(**overrides): + data = { + "thread_id": "thr-1", + "session_key": "discord:thread:thr-1", + "chat_name": "Release Thread", + "bound_by": "alan", + "parent_chat_id": "c1", + "idle_timeout_minutes": 1440, + "max_age_minutes": 0, + } + data.update(overrides) + return SimpleNamespace(**data) + + +def _make_runner(): + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig(enabled=True, token="***"), + Platform.TELEGRAM: PlatformConfig( + enabled=True, + token="***", + home_channel=HomeChannel(platform=Platform.TELEGRAM, chat_id="tg-home", name="Ops Feed"), + ), + Platform.SLACK: PlatformConfig( + enabled=True, + token="***", + home_channel=HomeChannel(platform=Platform.SLACK, chat_id="slack-home", name="Slack Ops"), + ), + } + ) + adapter = MagicMock() + adapter._get_parent_channel_id = MagicMock(return_value="c1") + runner.adapters = {Platform.DISCORD: adapter} + runner.session_store = MagicMock() + runner.session_store._generate_session_key = lambda source: build_session_key(source) + runner._running_agents = {} + runner._voice_mode = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_send_policies = {} + runner._session_docks = {} + runner._save_runtime_controls = MagicMock() + runner._schedule_gateway_restart = MagicMock() + runner.delivery_router = SimpleNamespace(deliver=AsyncMock()) + return runner, adapter + + +@pytest.mark.asyncio +async def test_focus_command_binds_current_thread(): + runner, adapter = _make_runner() + target_source = _make_source( + chat_id="thr-1", + chat_name="Hermes / #general / Release Thread", + chat_type="thread", + thread_id="thr-1", + ) + adapter.focus_thread_binding.return_value = _make_binding(session_key=build_session_key(target_source)) + raw_channel = SimpleNamespace(parent_id="c1") + + result = await runner._handle_focus_command( + _make_event("/focus release", target_source=target_source, raw_channel=raw_channel) + ) + + assert "Thread focused" in result + adapter.focus_thread_binding.assert_called_once() + kwargs = adapter.focus_thread_binding.call_args.kwargs + assert kwargs["thread_id"] == "thr-1" + assert kwargs["parent_chat_id"] == "c1" + assert kwargs["bound_by"] == "alan" + + +@pytest.mark.asyncio +async def test_unfocus_command_removes_current_thread_binding(): + runner, adapter = _make_runner() + target_source = _make_source( + chat_id="thr-1", + chat_type="thread", + thread_id="thr-1", + ) + adapter.unfocus_thread_binding.return_value = _make_binding() + + result = await runner._handle_unfocus_command( + _make_event("/unfocus", target_source=target_source, raw_channel=SimpleNamespace(parent_id="c1")) + ) + + assert "Thread unfocused" in result + adapter.unfocus_thread_binding.assert_called_once_with("thr-1") + + +@pytest.mark.asyncio +async def test_session_command_updates_idle_timeout(): + runner, adapter = _make_runner() + target_source = _make_source( + chat_id="thr-1", + chat_type="thread", + thread_id="thr-1", + ) + adapter.get_thread_binding.return_value = _make_binding() + adapter.update_thread_binding_limits.return_value = _make_binding(idle_timeout_minutes=120) + + result = await runner._handle_session_command( + _make_event("/session idle 2h", target_source=target_source, raw_channel=SimpleNamespace(parent_id="c1")) + ) + + assert "Updated thread session `idle` to `2h`" in result + adapter.update_thread_binding_limits.assert_called_once_with("thr-1", idle_timeout_minutes=120) + + +@pytest.mark.asyncio +async def test_agents_command_lists_focused_threads_for_current_chat(): + runner, adapter = _make_runner() + target_source = _make_source(chat_id="c1", chat_type="group") + binding = _make_binding(session_key="discord:thread:thr-1") + adapter.list_thread_bindings.return_value = [binding] + runner._running_agents[binding.session_key] = object() + + result = await runner._handle_agents_command(_make_event("/agents", target_source=target_source)) + + assert "Focused Discord Threads" in result + assert "`thr-1`" in result + assert "running: yes" in result + + +@pytest.mark.asyncio +async def test_send_command_persists_session_policy(): + runner, _adapter = _make_runner() + target_source = _make_source(chat_id="c9", chat_type="group") + session_key = build_session_key(target_source) + + result = await runner._handle_send_command(_make_event("/send off", target_source=target_source)) + + assert result == "📮 Send policy for this session is now `off`." + assert runner._session_send_policies[session_key] == "off" + runner._save_runtime_controls.assert_called_once() + + +@pytest.mark.asyncio +async def test_activation_command_sets_discord_chat_override(): + runner, adapter = _make_runner() + target_source = _make_source(chat_id="c7", chat_type="group") + + result = await runner._handle_activation_command( + _make_event("/activation always", target_source=target_source) + ) + + assert result == "🎛️ Activation mode for this chat is now `always`." + adapter.set_activation_mode.assert_called_once_with("c7", "always") + + +@pytest.mark.asyncio +async def test_dock_command_persists_home_channel_target(): + runner, _adapter = _make_runner() + target_source = _make_source(chat_id="c1", chat_type="group") + session_key = build_session_key(target_source) + + result = await runner._handle_dock_command( + _make_event("/dock-telegram", target_source=target_source), + Platform.TELEGRAM, + ) + + assert "Replies for this session are now docked to telegram home channel" in result + assert runner._session_docks[session_key] == { + "platform": "telegram", + "chat_id": "tg-home", + "thread_id": None, + "name": "Ops Feed", + } + runner._save_runtime_controls.assert_called_once() + + +@pytest.mark.asyncio +async def test_restart_command_schedules_restart(): + runner, _adapter = _make_runner() + + result = await runner._handle_restart_command(_make_event("/restart")) + + assert result == "♻️ Gateway restart scheduled. Hermes will reconnect shortly." + runner._schedule_gateway_restart.assert_called_once_with() + + +@pytest.mark.asyncio +async def test_deliver_docked_response_routes_to_dock_target_and_suppresses_echo(): + runner, _adapter = _make_runner() + session_key = "discord:session:1" + runner._session_docks[session_key] = { + "platform": "telegram", + "chat_id": "tg-home", + "thread_id": None, + "name": "Ops Feed", + } + dock_target = DeliveryTarget(platform=Platform.TELEGRAM, chat_id="tg-home", thread_id=None) + runner.delivery_router.deliver.return_value = { + dock_target.to_string(): {"success": True}, + } + + result = await runner._deliver_docked_response( + session_key=session_key, + response="hello world", + source=_make_source(chat_id="c1", chat_type="group"), + ) + + assert result is None + runner.delivery_router.deliver.assert_awaited_once() diff --git a/tests/gateway/test_discord_runtime_views.py b/tests/gateway/test_discord_runtime_views.py new file mode 100644 index 000000000000..18ca6162d58b --- /dev/null +++ b/tests/gateway/test_discord_runtime_views.py @@ -0,0 +1,263 @@ +"""Tests for Discord-native runtime status, help, commands, and whoami views.""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(**overrides) -> SessionSource: + base = { + "platform": Platform.DISCORD, + "user_id": "u1", + "chat_id": "c1", + "user_name": "alan", + "chat_name": "Hermes / #general", + "chat_type": "group", + "thread_id": "thr-1", + "chat_topic": "release room", + } + base.update(overrides) + return SessionSource(**base) + + +def _make_event( + text: str, + *, + source: SessionSource | None = None, + session_source: SessionSource | None = None, + target_source: SessionSource | None = None, +) -> MessageEvent: + source = source or _make_source() + metadata = {} + if session_source is not None: + metadata["session_source"] = session_source + if target_source is not None: + metadata["command_target_source"] = target_source + return MessageEvent( + text=text, + source=source, + message_id="m1", + metadata=metadata, + ) + + +def _make_runner(session_entry: SessionEntry): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={ + Platform.DISCORD: PlatformConfig(enabled=True, token="***"), + Platform.TELEGRAM: PlatformConfig(enabled=True, token="***"), + } + ) + discord_adapter = MagicMock() + discord_adapter.get_activation_mode = MagicMock(return_value="always") + discord_adapter.get_thread_binding = MagicMock( + return_value=SimpleNamespace( + chat_name="release room", + idle_timeout_minutes=120, + max_age_minutes=1440, + ) + ) + runner.adapters = { + Platform.DISCORD: discord_adapter, + Platform.TELEGRAM: MagicMock(), + } + runner._voice_mode = {} + runner._session_send_policies = {} + runner._session_docks = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner.session_store.append_to_transcript = MagicMock() + runner.session_store.rewrite_transcript = MagicMock() + runner.session_store.update_session = MagicMock() + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._session_db = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_args, **_kwargs: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None + runner._emit_gateway_run_progress = AsyncMock() + runner._load_current_model_selection = lambda: { + "current_model": "anthropic/claude-opus-4.6", + "current_provider": "openrouter", + } + runner._resolve_model_runtime_details = lambda _provider: ( + { + "provider": "openrouter", + "api_mode": "responses", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "secret-key", + "source": "OPENROUTER_API_KEY", + }, + None, + ) + runner._effective_model = None + runner._effective_provider = None + return runner + + +@pytest.mark.asyncio +async def test_discord_status_command_returns_rich_runtime_summary(monkeypatch): + source = _make_source() + slash_source = _make_source(session_namespace="slash:u1") + session_key = build_session_key(slash_source) + session_entry = SessionEntry( + session_key=session_key, + session_id="sess-1", + created_at=datetime(2026, 3, 19, 10, 0), + updated_at=datetime(2026, 3, 19, 11, 15), + platform=Platform.DISCORD, + chat_type="group", + input_tokens=220, + output_tokens=110, + cache_read_tokens=45, + cache_write_tokens=12, + total_tokens=387, + last_prompt_tokens=144, + estimated_cost_usd=0.0234, + cost_status="estimated", + ) + runner = _make_runner(session_entry) + runner._running_agents[session_key] = MagicMock() + runner._pending_messages[session_key] = {"text": "queued"} + runner._pending_approvals[session_key] = {"command": "rm -rf /tmp/not-real"} + runner._voice_mode[source.chat_id] = "all" + runner._session_send_policies[session_key] = "off" + runner._session_docks[session_key] = { + "platform": "telegram", + "chat_id": "-1001", + "thread_id": None, + "name": "Ops Feed", + } + + monkeypatch.setattr( + "agent.model_metadata.get_model_context_length", + lambda *_args, **_kwargs: 200000, + ) + monkeypatch.setattr( + "tools.process_registry.process_registry.has_active_for_session", + lambda key: key == session_key, + ) + + event = _make_event( + "/status", + source=source, + session_source=slash_source, + target_source=source, + ) + result = await runner._handle_status_command(event) + + assert "📊 **Hermes Status**" in result + assert "**Session**" in result + assert "**Model**" in result + assert "**Usage & Context**" in result + assert "**Runtime**" in result + assert "**Platforms**" in result + assert "`slash:u1`" in result + assert "`anthropic/claude-opus-4.6`" in result + assert "Context Window: 200,000 tokens" in result + assert "Pending Approval: Yes" in result + assert "Background Processes: Yes" in result + assert "Send Policy: `off`" in result + assert "Dock Target: telegram:Ops Feed (`-1001`)" in result + assert "Activation: `always`" in result + assert "Focused Thread: yes (release room; idle 120m; max-age 1440m)" in result + assert "Connected: discord, telegram" in result + assert "secret-key" not in result + + +@pytest.mark.asyncio +async def test_discord_help_and_commands_are_distinct(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.DISCORD, + chat_type="group", + ) + runner = _make_runner(session_entry) + event = _make_event("/help") + + help_result = await runner._handle_help_command(event) + commands_result = await runner._handle_commands_command(_make_event("/commands")) + + assert help_result != commands_result + assert "📖 **Hermes Help**" in help_result + assert "Use `/commands` for the full command catalog." in help_result + assert "**Quick Start**" in help_result + assert "`/whoami` — Show the sender identity Hermes sees (alias: `/id`)" in help_result + + assert "🧭 **Hermes Command Catalog**" in commands_result + assert "Use `/help` for the guided overview." in commands_result + assert "**Session**" in commands_result + assert "**Configuration**" in commands_result + assert "`/models` — Open the Discord model picker or list models" in commands_result + assert "`/whoami` — Show the sender identity Hermes sees (alias: `/id`)" in commands_result + + +@pytest.mark.asyncio +async def test_discord_whoami_includes_routing_context(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.DISCORD, + chat_type="group", + ) + runner = _make_runner(session_entry) + target_source = _make_source(thread_id="thr-1", chat_topic="release room") + session_source = _make_source(session_namespace="slash:u1") + + result = await runner._handle_whoami_command( + _make_event( + "/whoami", + source=target_source, + session_source=session_source, + target_source=target_source, + ) + ) + + assert "👤 **Hermes Sees You As**" in result + assert "• Platform: discord" in result + assert "• User ID: `u1`" in result + assert "• Chat ID: `c1`" in result + assert "• Thread ID: `thr-1`" in result + assert "• Chat Topic: release room" in result + assert "• Session Namespace: `slash:u1`" in result + + +@pytest.mark.asyncio +async def test_id_alias_dispatches_to_discord_whoami(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.DISCORD, + chat_type="group", + ) + runner = _make_runner(session_entry) + + result = await runner._handle_message(_make_event("/id")) + + assert "👤 **Hermes Sees You As**" in result diff --git a/tests/gateway/test_discord_send.py b/tests/gateway/test_discord_send.py index de253146e6c4..3f7605cd7feb 100644 --- a/tests/gateway/test_discord_send.py +++ b/tests/gateway/test_discord_send.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import sys @@ -78,3 +79,459 @@ async def fake_send(*, content, reference=None): assert channel.send.await_count == 2 assert send_calls[0]["reference"] is ref_msg assert send_calls[1]["reference"] is None + + +@pytest.mark.asyncio +async def test_edit_message_updates_existing_message(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + message = SimpleNamespace(edit=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + result = await adapter.edit_message("555", "99", "updated") + + assert result.success is True + assert result.message_id == "99" + channel.fetch_message.assert_awaited_once_with(99) + message.edit.assert_awaited_once_with(content="updated") + + +@pytest.mark.asyncio +async def test_edit_message_returns_not_connected_when_client_missing(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = None + + result = await adapter.edit_message("555", "99", "updated") + + assert result.success is False + assert result.error == "Not connected" + + +@pytest.mark.asyncio +async def test_edit_message_returns_channel_error_for_missing_channel(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(return_value=None), + ) + + result = await adapter.edit_message("555", "99", "updated") + + assert result.success is False + assert result.error == "Channel 555 not found" + + +@pytest.mark.asyncio +async def test_delete_message_delegates_to_message_delete(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + message = SimpleNamespace(delete=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + result = await adapter.delete_message("555", "99") + + assert result.success is True + assert result.message_id == "99" + message.delete.assert_awaited_once_with() + + +@pytest.mark.asyncio +async def test_add_and_remove_reaction_delegate_to_message_methods(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + message = SimpleNamespace(add_reaction=AsyncMock(), remove_reaction=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + user=SimpleNamespace(id=999), + ) + + add_result = await adapter.add_reaction("555", "99", "🔥") + remove_result = await adapter.remove_reaction("555", "99", "🔥") + + assert add_result.success is True + assert remove_result.success is True + message.add_reaction.assert_awaited_once_with("🔥") + message.remove_reaction.assert_awaited_once_with("🔥", adapter._client.user) + + +def test_apply_runtime_policy_overrides_refreshes_cached_policy(): + adapter = DiscordAdapter( + PlatformConfig( + enabled=True, + token="***", + extra={ + "allow_bots": "none", + "free_response_channels": ["10"], + "require_mention": True, + "auto_thread": True, + }, + ) + ) + + original = adapter._get_discord_policy() + updated = adapter.apply_runtime_policy_overrides( + { + "allow_bots": "mentions", + "free_response_channels": ["77", "88"], + "require_mention": False, + "auto_thread": False, + } + ) + + assert original.bot_filter_policy == "none" + assert updated.bot_filter_policy == "mentions" + assert updated.free_response_channels == {"77", "88"} + assert updated.require_mention is False + assert updated.auto_thread is False + + +class _FakeHistoryIterator: + def __init__(self, messages): + self._messages = list(messages) + self._index = 0 + + def __aiter__(self): + return self + + async def __anext__(self): + if self._index >= len(self._messages): + raise StopAsyncIteration + message = self._messages[self._index] + self._index += 1 + return message + + +class _FakeHistoryChannel: + def __init__(self, messages, readable=True): + self.id = 123 + self.name = "general" + self.guild = SimpleNamespace(me=SimpleNamespace(id=999), name="Hermes") + self._messages = list(messages) + self.permissions_for = MagicMock( + return_value=SimpleNamespace( + view_channel=readable, + read_message_history=readable, + send_messages=True, + attach_files=True, + embed_links=True, + add_reactions=True, + manage_threads=False, + create_public_threads=False, + create_private_threads=False, + ) + ) + + def history(self, *, limit, before=None, after=None): + filtered = list(self._messages) + if before is not None: + filtered = [message for message in filtered if int(message.id) < int(before.id)] + if after is not None: + filtered = [message for message in filtered if int(message.id) > int(after.id)] + return _FakeHistoryIterator(filtered[:limit]) + + +def _history_message( + message_id, + *, + content, + author_id="42", + author_name="Jezza", + is_bot=False, + timestamp=None, +): + if timestamp is None: + timestamp = datetime(2026, 3, 18, 12, 0, 0, tzinfo=timezone.utc) + return SimpleNamespace( + id=message_id, + author=SimpleNamespace(id=author_id, name=author_name, display_name=author_name, bot=is_bot), + content=content, + created_at=timestamp, + attachments=[], + reference=None, + ) + + +@pytest.mark.asyncio +async def test_fetch_channel_history_returns_empty_for_missing_channel(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=None), + fetch_channel=AsyncMock(return_value=None), + user=SimpleNamespace(id=999), + ) + + result = await adapter.fetch_channel_history("123") + + assert result == [] + + +@pytest.mark.asyncio +async def test_search_channel_history_serializes_messages(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = SimpleNamespace( + get_channel=MagicMock( + return_value=_FakeHistoryChannel( + [ + _history_message(10, content="Hermes status update"), + _history_message(9, content="unrelated"), + ] + ) + ), + fetch_channel=AsyncMock(), + user=SimpleNamespace(id=999), + ) + + result = await adapter.search_channel_history("123", "hermes", limit=1) + + assert result == [ + { + "id": "10", + "author_id": "42", + "author_name": "Jezza", + "content": "Hermes status update", + "timestamp": "2026-03-18T12:00:00+00:00", + "is_bot": False, + "attachments": [], + "reply_to": None, + } + ] + + +@pytest.mark.asyncio +async def test_get_channel_permissions_serializes_dm_thread_flags_as_false(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + dm_channel = sys.modules["discord"].DMChannel() + dm_channel.id = 321 + dm_channel.name = None + dm_channel.guild = None + dm_channel.recipient = SimpleNamespace(name="Alan") + + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=dm_channel), + fetch_channel=AsyncMock(return_value=None), + user=SimpleNamespace(id=999), + ) + + result = await adapter.get_channel_permissions("321") + + assert result == { + "channel_id": "321", + "channel_name": "Alan", + "can_read": True, + "can_send": True, + "can_read_history": True, + "can_attach_files": True, + "can_embed_links": True, + "can_add_reactions": True, + "can_manage_threads": False, + "can_create_threads": False, + } + + +@pytest.mark.asyncio +async def test_get_accessible_channels_includes_normalized_target_metadata(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + perms = SimpleNamespace( + view_channel=True, + read_messages=True, + send_messages=True, + read_message_history=True, + attach_files=True, + embed_links=True, + add_reactions=True, + manage_threads=False, + create_public_threads=True, + create_private_threads=False, + ) + guild = SimpleNamespace(id=777, name="Hermes") + channel = SimpleNamespace( + id=123, + name="general", + guild=guild, + permissions_for=MagicMock(return_value=perms), + ) + guild.me = SimpleNamespace(id=999) + guild.text_channels = [channel] + adapter._client = SimpleNamespace(guilds=[guild], user=SimpleNamespace(id=999)) + + result = await adapter.get_accessible_channels() + + assert result == [ + { + "channel_id": "123", + "channel_name": "general", + "guild_id": "777", + "guild_name": "Hermes", + "channel_kind": "channel", + "qualified_name": "Hermes/general", + "mention": "<#123>", + "can_read": True, + "can_send": True, + "can_read_history": True, + "can_attach_files": True, + "can_embed_links": True, + "can_add_reactions": True, + "can_manage_threads": False, + "can_create_threads": True, + } + ] + + +@pytest.mark.asyncio +async def test_list_threads_returns_serialized_threads(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + thread = sys.modules["discord"].Thread() + thread.id = 77 + thread.name = "planning" + thread.parent = SimpleNamespace(id=5, name="general") + thread.guild = SimpleNamespace(id=1, name="Hermes") + thread.archived = False + thread.locked = False + thread.message_count = 4 + thread.member_count = 2 + channel = SimpleNamespace(threads=[thread]) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + result = await adapter.list_threads("123") + + assert result == [ + { + "id": "77", + "name": "planning", + "parent_id": "5", + "parent_name": "general", + "guild_id": "1", + "guild_name": "Hermes", + "archived": False, + "locked": False, + "message_count": 4, + "member_count": 2, + } + ] + + +@pytest.mark.asyncio +async def test_reply_in_thread_sends_to_valid_thread(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + thread = sys.modules["discord"].Thread() + thread.parent = SimpleNamespace(id=5) + thread.fetch_message = AsyncMock(return_value=SimpleNamespace(id=99)) + sent = SimpleNamespace(id=101) + thread.send = AsyncMock(return_value=sent) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=thread), + fetch_channel=AsyncMock(), + ) + + result = await adapter.reply_in_thread("123", "hello", reply_to="99") + + assert result.success is True + assert result.message_id == "101" + thread.fetch_message.assert_awaited_once_with(99) + thread.send.assert_awaited_once_with(content="hello", reference=thread.fetch_message.return_value) + + +@pytest.mark.asyncio +async def test_reply_in_thread_rejects_non_thread_channel(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=SimpleNamespace(parent=None)), + fetch_channel=AsyncMock(), + ) + + result = await adapter.reply_in_thread("123", "hello") + + assert result.success is False + assert result.error == "Channel 123 is not a thread" + + +@pytest.mark.asyncio +async def test_list_pins_returns_serialized_messages(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + author = SimpleNamespace(id=42, name="Jezza", display_name="Jezza", bot=False) + pinned = SimpleNamespace( + id=7, + author=author, + content="important", + created_at=datetime(2026, 3, 18, 12, 0, 0, tzinfo=timezone.utc), + attachments=[], + reference=None, + ) + channel = SimpleNamespace(pins=MagicMock(return_value=_FakeHistoryIterator([pinned]))) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + result = await adapter.list_pins("123") + + assert result == [ + { + "id": "7", + "author_id": "42", + "author_name": "Jezza", + "content": "important", + "timestamp": "2026-03-18T12:00:00+00:00", + "is_bot": False, + "attachments": [], + "reply_to": None, + } + ] + + +@pytest.mark.asyncio +async def test_pin_and_unpin_message_delegate_to_message_methods(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + message = SimpleNamespace(pin=AsyncMock(), unpin=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + pin_result = await adapter.pin_message("123", "456", reason="keep") + unpin_result = await adapter.unpin_message("123", "456", reason="drop") + + assert pin_result.success is True + assert unpin_result.success is True + message.pin.assert_awaited_once_with(reason="keep") + message.unpin.assert_awaited_once_with(reason="drop") + + +@pytest.mark.asyncio +async def test_list_reactions_returns_serialized_summaries(): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="***")) + reaction = SimpleNamespace( + emoji="🔥", + count=2, + users=MagicMock( + return_value=_FakeHistoryIterator([SimpleNamespace(id=1, username="alan", discriminator="1234")]) + ), + ) + message = SimpleNamespace(reactions=[reaction]) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + adapter._client = SimpleNamespace( + get_channel=MagicMock(return_value=channel), + fetch_channel=AsyncMock(), + ) + + result = await adapter.list_reactions("123", "456", limit=3) + + assert result == [ + { + "emoji": {"id": None, "name": "🔥", "raw": "🔥"}, + "count": 2, + "users": [{"id": "1", "username": "alan", "tag": "alan#1234"}], + } + ] + reaction.users.assert_called_once_with(limit=3) diff --git a/tests/gateway/test_discord_slash_commands.py b/tests/gateway/test_discord_slash_commands.py index eea4dc2cba97..c9f2e4151dce 100644 --- a/tests/gateway/test_discord_slash_commands.py +++ b/tests/gateway/test_discord_slash_commands.py @@ -86,6 +86,58 @@ async def test_registers_native_thread_slash_command(adapter): adapter._handle_thread_create_slash.assert_awaited_once_with(interaction, "Planning", "", 1440) +@pytest.mark.asyncio +async def test_run_simple_slash_sends_returned_native_response(adapter): + state = {"done": False} + + async def mark_deferred(*_args, **_kwargs): + state["done"] = True + + adapter._message_handler = AsyncMock(return_value="hello from status") + interaction = SimpleNamespace( + response=SimpleNamespace( + defer=AsyncMock(side_effect=mark_deferred), + send_message=AsyncMock(), + is_done=lambda: state["done"], + ), + followup=SimpleNamespace(send=AsyncMock()), + channel=SimpleNamespace( + id=123, + name="general", + guild=SimpleNamespace(name="Hermes"), + topic="topic", + ), + channel_id=123, + user=SimpleNamespace(id=42, display_name="Jezza"), + ) + + await adapter._run_simple_slash(interaction, "/status") + + adapter._message_handler.assert_awaited_once() + event = adapter._message_handler.await_args.args[0] + assert event.text == "/status" + interaction.response.defer.assert_awaited_once_with(ephemeral=True) + interaction.followup.send.assert_awaited_once_with("hello from status", ephemeral=True) + + +@pytest.mark.asyncio +async def test_send_native_slash_content_chunks_long_output(adapter): + interaction = SimpleNamespace( + response=SimpleNamespace( + send_message=AsyncMock(), + is_done=lambda: True, + ), + followup=SimpleNamespace(send=AsyncMock()), + ) + content = "x" * (adapter.MAX_MESSAGE_LENGTH + 200) + + await adapter._send_native_slash_content(interaction, content) + + assert interaction.followup.send.await_count == 2 + sent_chunks = [call.args[0] for call in interaction.followup.send.await_args_list] + assert all(len(chunk) <= adapter.MAX_MESSAGE_LENGTH for chunk in sent_chunks) + + # ------------------------------------------------------------------ # _handle_thread_create_slash — success, session dispatch, failure # ------------------------------------------------------------------ @@ -412,6 +464,32 @@ async def capture_handle(event): assert captured_events[0].source.chat_id == "100" # stays in channel +@pytest.mark.asyncio +async def test_auto_thread_platform_extra_overrides_env(monkeypatch): + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") + + config = PlatformConfig(enabled=True, token="fake-token", extra={"auto_thread": False}) + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + adapter._auto_create_thread = AsyncMock() + + captured_events = [] + + async def capture_handle(event): + captured_events.append(event) + + adapter.handle_message = capture_handle + + msg = _fake_message(_FakeTextChannel()) + + await adapter._handle_message(msg) + + adapter._auto_create_thread.assert_not_awaited() + assert len(captured_events) == 1 + assert captured_events[0].source.chat_id == "100" + + @pytest.mark.asyncio async def test_auto_thread_skips_threads_and_dms(adapter, monkeypatch): """Auto-thread should not create threads inside existing threads.""" diff --git a/tests/gateway/test_model_command.py b/tests/gateway/test_model_command.py new file mode 100644 index 000000000000..f8b83617f25b --- /dev/null +++ b/tests/gateway/test_model_command.py @@ -0,0 +1,225 @@ +"""Tests for gateway /model and /models behavior.""" + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import yaml + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +def _make_runner(): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.adapters = {Platform.DISCORD: SimpleNamespace(_component_runtime=object())} + runner._effective_model = None + runner._effective_provider = None + return runner + + +def _make_event(text: str, *, native_slash: bool = False): + source = SessionSource( + platform=Platform.DISCORD, + chat_id="c1", + chat_name="Hermes / #general", + chat_type="group", + user_id="u1", + user_name="alan", + ) + metadata = {"is_native_slash": True} if native_slash else {} + interaction = None + if native_slash: + interaction = SimpleNamespace( + response=SimpleNamespace(is_done=lambda: True, send_message=AsyncMock()), + followup=SimpleNamespace(send=AsyncMock()), + ) + return MessageEvent( + text=text, + source=source, + raw_message=interaction, + metadata=metadata, + ) + + +@pytest.mark.asyncio +async def test_model_without_args_returns_numbered_catalog(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + monkeypatch.setattr( + "hermes_cli.models.curated_models_for_provider", + lambda _provider: [ + ("anthropic/claude-opus-4.6", "recommended"), + ("openai/gpt-5.4", ""), + ], + ) + + runner = _make_runner() + result = await runner._handle_model_command(_make_event("/model")) + + assert "Model Catalog" in result + assert "1. `anthropic/claude-opus-4.6`" in result + assert "2. `openai/gpt-5.4`" in result + assert "/model " in result + + +@pytest.mark.asyncio +async def test_model_numeric_selection_persists_and_records_recent(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + monkeypatch.setattr( + "hermes_cli.models.curated_models_for_provider", + lambda _provider: [ + ("anthropic/claude-opus-4.6", "recommended"), + ("openai/gpt-5.4", ""), + ], + ) + monkeypatch.setattr( + "hermes_cli.models.validate_requested_model", + lambda *_args, **_kwargs: { + "accepted": True, + "persist": True, + "recognized": True, + "message": None, + }, + ) + recent_calls = [] + monkeypatch.setattr( + "gateway.platforms.discord_impl.model_picker.record_recent_model", + lambda user_id, provider, model: recent_calls.append((user_id, provider, model)), + ) + + runner = _make_runner() + runner._resolve_model_runtime_details = lambda _provider: ( + {"api_key": "test-key", "base_url": "https://openrouter.ai/api/v1"}, + None, + ) + + result = await runner._handle_model_command(_make_event("/model 2")) + + saved = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert result.startswith("🤖 Model changed to `openai/gpt-5.4`") + assert saved["model"]["default"] == "openai/gpt-5.4" + assert saved["model"]["provider"] == "openrouter" + assert recent_calls == [("u1", "openrouter", "openai/gpt-5.4")] + + +@pytest.mark.asyncio +async def test_model_status_reports_runtime_details(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + runner._resolve_model_runtime_details = lambda _provider: ( + { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "test-key", + "source": "env/config", + }, + None, + ) + + result = await runner._handle_model_command(_make_event("/model status")) + + assert "Model Status" in result + assert "**Runtime provider:** OpenRouter (`openrouter`)" in result + assert "**API mode:** `chat_completions`" in result + assert "**Base URL:** `https://openrouter.ai/api/v1`" in result + assert "**Credentials:** configured" in result + + +@pytest.mark.asyncio +async def test_native_slash_model_without_args_opens_picker(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + open_picker = AsyncMock() + monkeypatch.setattr( + "gateway.platforms.discord_impl.model_picker.open_model_picker", + open_picker, + ) + + runner = _make_runner() + result = await runner._handle_model_command(_make_event("/model", native_slash=True)) + + assert result is None + open_picker.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_native_slash_models_without_args_opens_picker(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + open_picker = AsyncMock() + monkeypatch.setattr( + "gateway.platforms.discord_impl.model_picker.open_model_picker", + open_picker, + ) + + runner = _make_runner() + result = await runner._handle_model_command(_make_event("/models", native_slash=True)) + + assert result is None + open_picker.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_native_slash_model_status_uses_ephemeral_followup(monkeypatch, tmp_path): + hermes_home = tmp_path / "hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + "model:\n default: anthropic/claude-opus-4.6\n provider: openrouter\n", + encoding="utf-8", + ) + monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) + + runner = _make_runner() + runner._resolve_model_runtime_details = lambda _provider: ( + { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "https://openrouter.ai/api/v1", + "api_key": "test-key", + }, + None, + ) + event = _make_event("/model status", native_slash=True) + + result = await runner._handle_model_command(event) + + assert result is None + event.raw_message.followup.send.assert_awaited_once() + sent = event.raw_message.followup.send.await_args.args[0] + assert "Model Status" in sent diff --git a/tests/gateway/test_session.py b/tests/gateway/test_session.py index bf698cdd15ec..89f462b95ae2 100644 --- a/tests/gateway/test_session.py +++ b/tests/gateway/test_session.py @@ -54,6 +54,17 @@ def test_full_roundtrip_with_chat_topic(self): assert restored.chat_topic == "Planning and coordination for Project X" assert restored.chat_name == "Server / #project-planning" + def test_full_roundtrip_with_session_namespace(self): + source = SessionSource( + platform=Platform.DISCORD, + chat_id="789", + chat_type="group", + user_id="42", + session_namespace="slash:42", + ) + restored = SessionSource.from_dict(source.to_dict()) + assert restored.session_namespace == "slash:42" + def test_minimal_roundtrip(self): source = SessionSource(platform=Platform.LOCAL, chat_id="cli") d = source.to_dict() @@ -553,6 +564,17 @@ def test_group_thread_sessions_are_isolated_per_user(self): key = build_session_key(source) assert key == "agent:main:telegram:group:-1002285219667:17585:42" + def test_session_namespace_extends_session_key(self): + source = SessionSource( + platform=Platform.DISCORD, + chat_id="guild-123", + chat_type="group", + user_id="alice", + session_namespace="slash:alice", + ) + key = build_session_key(source) + assert key == "agent:main:discord:group:guild-123:alice:slash:alice" + class TestSessionStoreEntriesAttribute: """Regression: /reset must access _entries, not _sessions.""" diff --git a/tests/gateway/test_session_env.py b/tests/gateway/test_session_env.py index 596df89ecf7e..19b76d74ba55 100644 --- a/tests/gateway/test_session_env.py +++ b/tests/gateway/test_session_env.py @@ -36,6 +36,7 @@ def test_clear_session_env_removes_thread_id(monkeypatch): monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "-1001") monkeypatch.setenv("HERMES_SESSION_CHAT_NAME", "Group") monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "17585") + monkeypatch.setenv("HERMES_SESSION_SEND_POLICY", "off") runner._clear_session_env() @@ -43,3 +44,4 @@ def test_clear_session_env_removes_thread_id(monkeypatch): assert os.getenv("HERMES_SESSION_CHAT_ID") is None assert os.getenv("HERMES_SESSION_CHAT_NAME") is None assert os.getenv("HERMES_SESSION_THREAD_ID") is None + assert os.getenv("HERMES_SESSION_SEND_POLICY") is None diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 1378ff1cb961..4a87967b91bf 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -138,3 +138,70 @@ async def test_handle_message_persists_agent_token_counts(monkeypatch): provider=None, base_url=None, ) + + +@pytest.mark.asyncio +async def test_commands_command_returns_gateway_catalog(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + + result = await runner._handle_commands_command(_make_event("/commands")) + + assert "Hermes Command Catalog" in result + assert "/commands" in result + assert "/whoami" in result + + +@pytest.mark.asyncio +async def test_whoami_command_returns_sender_identity(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + + result = await runner._handle_whoami_command(_make_event("/whoami")) + + assert "Hermes Sees You As" in result + assert "**Platform:** telegram" in result + assert "**User ID:** `u1`" in result + assert "**Chat ID:** `c1`" in result + + +def test_session_source_override_is_used_for_command_sessions(): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + ) + runner = _make_runner(session_entry) + command_source = SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + session_namespace="slash:u1", + ) + event = MessageEvent( + text="/status", + source=_make_source(), + message_id="m1", + metadata={"session_source": command_source}, + ) + + assert runner._session_source_for_event(event) == command_source diff --git a/tests/gateway/test_subagent_acp_commands.py b/tests/gateway/test_subagent_acp_commands.py new file mode 100644 index 000000000000..8825353c8744 --- /dev/null +++ b/tests/gateway/test_subagent_acp_commands.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from acp_adapter.session import SessionManager +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.run import GatewayRunner +from gateway.session import SessionEntry, SessionSource, build_session_key + + +def _make_source(**overrides) -> SessionSource: + data = { + "platform": Platform.DISCORD, + "user_id": "u1", + "chat_id": "c1", + "user_name": "alan", + "chat_name": "Hermes / #general", + "chat_type": "group", + } + data.update(overrides) + return SessionSource(**data) + + +def _make_event(text: str, *, source: SessionSource | None = None) -> MessageEvent: + return MessageEvent( + text=text, + message_type=MessageType.COMMAND, + source=source or _make_source(), + message_id="m1", + ) + + +def _make_runner() -> GatewayRunner: + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="***")} + ) + runner.adapters = { + Platform.DISCORD: SimpleNamespace(send=AsyncMock()) + } + source = _make_source() + session_entry = SessionEntry( + session_key=build_session_key(source), + session_id="sess-1", + created_at=datetime(2026, 3, 19, 12, 0), + updated_at=datetime(2026, 3, 19, 12, 30), + platform=Platform.DISCORD, + chat_type="group", + ) + runner.session_store = MagicMock() + runner.session_store._generate_session_key = lambda session_source: build_session_key(session_source) + runner.session_store.get_or_create_session.return_value = session_entry + runner.session_store.load_transcript.return_value = [] + runner._running_agents = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._voice_mode = {} + runner._session_send_policies = {} + runner._session_docks = {} + runner._effective_model = None + runner._effective_provider = None + runner._reasoning_config = {"enabled": True, "effort": "medium"} + runner._show_reasoning = False + runner._provider_routing = {} + runner._fallback_model = None + runner._smart_model_routing = {} + runner._runtime_debug_overrides = {} + runner._prefill_messages = [] + runner._ephemeral_system_prompt = "" + runner._session_db = None + runner._subagent_runtime = {} + runner._acp_session_bindings = {} + runner._acp_session_meta = {} + runner._acp_running_tasks = {} + runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False) + runner._is_user_authorized = lambda _source: True + return runner + + +@pytest.mark.asyncio +async def test_running_session_allows_subagent_command_without_interrupt(): + runner = _make_runner() + source = _make_source() + session_key = build_session_key(source) + child = MagicMock() + child.interrupt = MagicMock() + + runner._register_delegate_child( + session_key=session_key, + child=child, + task_index=0, + goal="inspect logs", + context="", + toolsets=["terminal"], + model="anthropic/claude-opus-4.6", + ) + runner._start_delegate_child(session_key=session_key, child=child, goal="inspect logs") + + active_agent = MagicMock() + runner._running_agents[session_key] = active_agent + + result = await runner._handle_message(_make_event("/subagents list", source=source)) + + assert "Subagents" in result + assert "sa-1" in result + active_agent.interrupt.assert_not_called() + + +@pytest.mark.asyncio +async def test_kill_and_steer_commands_control_live_subagent(): + runner = _make_runner() + source = _make_source() + session_key = build_session_key(source) + child = MagicMock() + child.interrupt = MagicMock() + + runner._register_delegate_child( + session_key=session_key, + child=child, + task_index=0, + goal="inspect logs", + context="", + toolsets=["terminal"], + model="anthropic/claude-opus-4.6", + ) + runner._start_delegate_child(session_key=session_key, child=child, goal="inspect logs") + + steer_result = await runner._handle_steer_command(_make_event("/steer #1 focus on tests", source=source)) + kill_result = await runner._handle_kill_command(_make_event("/kill #1", source=source)) + + entry = runner._get_subagent_session_state(session_key)["entries"]["sa-1"] + assert "Steering subagent" in steer_result + assert entry["pending_steer"] is None + assert "Requested stop" in kill_result + assert child.interrupt.call_count == 2 + + +@pytest.mark.asyncio +async def test_acp_spawn_status_option_commands_and_close(): + runner = _make_runner() + + class FakeACPAgent: + def __init__(self): + self.model = "anthropic/claude-opus-4.6" + self.interrupt = MagicMock() + + def run_conversation(self, user_message, conversation_history=None, task_id=None): + return { + "final_response": f"ACP:{user_message}", + "messages": list(conversation_history or []), + "completed": True, + } + + runner._acp_session_manager = SessionManager(agent_factory=FakeACPAgent) + source = _make_source() + session_key = build_session_key(source) + + spawn_result = await runner._handle_acp_command(_make_event("/acp spawn /tmp/work", source=source)) + session_id = runner._get_acp_bindings()[session_key] + + sessions_result = await runner._handle_acp_command(_make_event("/acp sessions", source=source)) + status_result = await runner._handle_acp_command(_make_event("/acp status", source=source)) + model_result = await runner._handle_acp_command(_make_event("/acp model openai/gpt-5.2", source=source)) + timeout_result = await runner._handle_acp_command(_make_event("/acp timeout 45", source=source)) + set_result = await runner._handle_acp_command(_make_event("/acp set profile fast", source=source)) + reset_result = await runner._handle_acp_command(_make_event("/acp reset-options", source=source)) + close_result = await runner._handle_acp_command(_make_event("/acp close", source=source)) + + assert "Created ACP session" in spawn_result + assert session_id in sessions_result + assert session_id in status_result + assert "model set to `openai/gpt-5.2`" in model_result + assert "timeout set to `45`s" in timeout_result + assert "option `profile` set to `fast`" in set_result + assert "reset `2` option" in reset_result + assert f"Closed ACP session `{session_id}`" in close_result diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 1a779f8a0bb7..37b935b08b14 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -877,5 +877,84 @@ def test_model_only_no_provider_inherits_parent_credentials(self, mock_creds, mo self.assertEqual(kwargs["base_url"], parent.base_url) +class TestGatewaySubagentHooks(unittest.TestCase): + def test_build_child_agent_registers_gateway_subagent_id(self): + from tools.delegate_tool import _build_child_agent + + parent = _make_mock_parent(depth=0) + parent.enabled_toolsets = ["terminal", "file"] + parent.max_tokens = None + parent.reasoning_config = None + parent.prefill_messages = None + parent.tool_progress_callback = None + parent._delegate_spinner = None + parent.iteration_budget = None + parent._register_delegate_child = MagicMock(return_value="sa-9") + + with patch("run_agent.AIAgent") as MockAgent: + mock_child = MagicMock() + MockAgent.return_value = mock_child + + child = _build_child_agent( + task_index=0, + goal="Inspect Discord UX parity", + context=None, + toolsets=None, + model=None, + max_iterations=10, + parent_agent=parent, + ) + + self.assertIs(child, mock_child) + self.assertEqual(child._gateway_subagent_id, "sa-9") + parent._register_delegate_child.assert_called_once() + + def test_run_single_child_restarts_on_pending_steer(self): + from tools.delegate_tool import _run_single_child + + parent = _make_mock_parent(depth=0) + parent._start_delegate_child = MagicMock() + parent._record_delegate_child_progress = MagicMock() + parent._complete_delegate_child = MagicMock() + + child = MagicMock() + child._delegate_saved_tool_names = [] + child._gateway_pending_steer = "Refocus on Discord slash commands" + child.run_conversation.side_effect = [ + { + "final_response": "Interrupted for steer", + "messages": [], + "api_calls": 1, + "completed": False, + "interrupted": True, + }, + { + "final_response": "Done after steer", + "messages": [], + "api_calls": 2, + "completed": True, + "interrupted": False, + }, + ] + + result = _run_single_child( + task_index=0, + goal="Original goal", + child=child, + parent_agent=parent, + ) + + self.assertEqual(child.run_conversation.call_count, 2) + self.assertEqual(child.run_conversation.call_args_list[0].kwargs["user_message"], "Original goal") + self.assertEqual( + child.run_conversation.call_args_list[1].kwargs["user_message"], + "Refocus on Discord slash commands", + ) + self.assertEqual(result["status"], "completed") + self.assertEqual(result["summary"], "Done after steer") + self.assertGreaterEqual(parent._start_delegate_child.call_count, 2) + parent._complete_delegate_child.assert_called_once() + + if __name__ == "__main__": unittest.main() diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 058678d36a99..278214140b9e 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -33,6 +33,23 @@ def _install_telegram_mock(monkeypatch, bot): class TestSendMessageTool: + def test_session_send_policy_off_blocks_send_message(self): + with patch.dict(os.environ, {"HERMES_SESSION_SEND_POLICY": "off"}, clear=False): + result = json.loads( + send_message_tool( + { + "action": "send", + "target": "telegram:-1001", + "message": "hello", + } + ) + ) + + assert result["error"] == ( + "send_message is disabled for this session. " + "Use /send on or /send inherit to re-enable it." + ) + def test_cron_duplicate_target_is_skipped_and_explained(self): home = SimpleNamespace(chat_id="-1001") config, _telegram_cfg = _make_config() diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 36c6dad98435..33ca789d014b 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -75,6 +75,18 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: return [t for t in toolsets if t not in blocked_toolset_names] +def _notify_parent(parent_agent, hook_name: str, **payload): + """Best-effort callback into parent-owned delegation hooks.""" + hook = getattr(parent_agent, hook_name, None) + if not callable(hook): + return None + try: + return hook(**payload) + except Exception as e: + logger.debug("Delegate parent hook %s failed: %s", hook_name, e) + return None + + def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]: """Build a callback that relays child agent tool calls to the parent display. @@ -98,7 +110,7 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in _BATCH_SIZE = 5 _batch: List[str] = [] - def _callback(tool_name: str, preview: str = None): + def _callback(tool_name: str, preview: str = None, args: dict = None): # Special "_thinking" event: model produced text content (reasoning) if tool_name == "_thinking": if spinner: @@ -131,8 +143,29 @@ def _callback(tool_name: str, preview: str = None): parent_cb("subagent_progress", f"🔀 {prefix}{summary}") except Exception as e: logger.debug("Parent callback failed: %s", e) + _notify_parent( + parent_agent, + "_record_delegate_child_progress", + child=getattr(_callback, "_delegate_child", None), + task_index=task_index, + tool_name=tool_name, + preview=preview, + args=args, + summary=summary, + ) _batch.clear() + _notify_parent( + parent_agent, + "_record_delegate_child_progress", + child=getattr(_callback, "_delegate_child", None), + task_index=task_index, + tool_name=tool_name, + preview=preview, + args=args, + summary=preview or tool_name, + ) + def _flush(): """Flush remaining batched tool names to gateway on completion.""" if parent_cb and _batch: @@ -232,9 +265,25 @@ def _build_child_agent( tool_progress_callback=child_progress_cb, iteration_budget=shared_budget, ) + child._delegate_saved_tool_names = list(model_tools._last_resolved_tool_names) + if child_progress_cb is not None: + child_progress_cb._delegate_child = child # Set delegation depth so children can't spawn grandchildren child._delegate_depth = getattr(parent_agent, '_delegate_depth', 0) + 1 + subagent_id = _notify_parent( + parent_agent, + "_register_delegate_child", + child=child, + task_index=task_index, + goal=goal, + context=context, + toolsets=child_toolsets, + model=effective_model, + ) + if subagent_id: + child._gateway_subagent_id = subagent_id + # Register child for interrupt propagation if hasattr(parent_agent, '_active_children'): lock = getattr(parent_agent, '_active_children_lock', None) @@ -268,8 +317,40 @@ def _run_single_child( _saved_tool_names = getattr(child, "_delegate_saved_tool_names", list(model_tools._last_resolved_tool_names)) + current_goal = goal + _notify_parent( + parent_agent, + "_start_delegate_child", + child=child, + goal=current_goal, + ) + try: - result = child.run_conversation(user_message=goal) + while True: + result = child.run_conversation(user_message=current_goal) + pending_steer = getattr(child, "_gateway_pending_steer", None) + if result.get("interrupted") and pending_steer: + setattr(child, "_gateway_pending_steer", None) + current_goal = str(pending_steer).strip() + _notify_parent( + parent_agent, + "_record_delegate_child_progress", + child=child, + task_index=task_index, + tool_name="subagent_steer", + preview=current_goal, + args=None, + summary=f"restart on steer: {current_goal[:120]}", + ) + _notify_parent( + parent_agent, + "_start_delegate_child", + child=child, + goal=current_goal, + restarted=True, + ) + continue + break # Flush any remaining batched progress to gateway if child_progress_cb and hasattr(child_progress_cb, '_flush'): @@ -360,12 +441,20 @@ def _run_single_child( if status == "failed": entry["error"] = result.get("error", "Subagent did not produce a response.") + _notify_parent( + parent_agent, + "_complete_delegate_child", + child=child, + entry=entry, + goal=current_goal, + ) + return entry except Exception as exc: duration = round(time.monotonic() - child_start, 2) logging.exception(f"[subagent-{task_index}] failed") - return { + entry = { "task_index": task_index, "status": "error", "summary": None, @@ -373,6 +462,14 @@ def _run_single_child( "api_calls": 0, "duration_seconds": duration, } + _notify_parent( + parent_agent, + "_complete_delegate_child", + child=child, + entry=entry, + goal=current_goal, + ) + return entry finally: # Restore the parent's tool names so the process-global is correct diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index ed0a5cb60e2d..f69db0f6ac79 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -74,6 +74,15 @@ def _handle_list(): def _handle_send(args): """Send a message to a platform target.""" + session_send_policy = os.getenv("HERMES_SESSION_SEND_POLICY", "inherit").strip().lower() + if session_send_policy == "off": + return json.dumps({ + "error": ( + "send_message is disabled for this session. " + "Use /send on or /send inherit to re-enable it." + ) + }) + target = args.get("target", "") message = args.get("message", "") if not target or not message: