diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 1839d57dd0..b41b2e9897 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -29,9 +29,7 @@ from contextlib import suppress from typing import Callable, Dict, List, Optional, Any, Tuple from urllib.parse import quote, urljoin -from agent.async_utils import ( - consume_detached_task_result as _consume_background_task_result, -) +from agent.async_utils import (consume_detached_task_result as _consume_background_task_result) from agent.display import ToolPreview logger = logging.getLogger(__name__) @@ -40,17 +38,30 @@ _DISCORD_MARKDOWN_LINK_LABEL_RE = re.compile(r"([\\\[\]])") _DISCORD_URL_LABEL_SCHEME_RE = re.compile(r"^https?://", re.IGNORECASE) +def _voice_mixer_module(): + """Sibling ``voice_mixer`` module: flat import (plugin dir on sys.path) else package-relative.""" + try: + import voice_mixer + return voice_mixer + except ImportError: + from . import voice_mixer + return voice_mixer + + +def _image_ext_from_content_type(content_type: str) -> str: + """Attachment extension for a downloaded image (png unless jpeg/gif/webp is evident).""" + if "jpeg" in content_type or "jpg" in content_type: + return "jpg" + if "gif" in content_type: + return "gif" + if "webp" in content_type: + return "webp" + return "png" + + def _format_discord_markdown_link(label: str, url: str) -> str: - """Return a Discord Markdown link whose label is not itself a URL. - - Discord gives URL-shaped link labels their own link behavior. A truncated - URL label can therefore win over the Markdown destination and remain a - broken link. Dropping only the scheme keeps the preview recognizable while - leaving one unambiguous click target. - - The destination is wrapped in angle brackets (````) so Discord does - not unfurl an OG-preview embed under every tool progress bubble. - """ + """Return a Discord Markdown link whose label is not itself a URL (URL-shaped labels can + win as a broken link; the ```` angle brackets stop Discord unfurling an embed).""" label = _DISCORD_URL_LABEL_SCHEME_RE.sub("", label, count=1) escaped_label = _DISCORD_MARKDOWN_LINK_LABEL_RE.sub(r"\\\1", label) escaped_url = quote(url, safe=":/?#[]@!$&'*+,;=%") @@ -58,11 +69,8 @@ def _format_discord_markdown_link(label: str, url: str) -> str: class _Snowflake: - """Minimal object exposing ``.id`` — satisfies discord.py's Snowflake - protocol for ``channel.history(before=...)`` without constructing a - ``discord.Object`` (which test doubles that stub the discord module - cannot build). Used to anchor reply-context scans inclusively. - """ + """``.id``-only Snowflake stand-in for ``channel.history(before=...)``; avoids + ``discord.Object``, which stubbed discord test doubles cannot build.""" __slots__ = ("id",) @@ -77,38 +85,101 @@ _DISCORD_NONCONVERSATIONAL_STATE_FILENAME = "discord_nonconversational_messages. _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5 _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0 -# Discord enforces a hard cap of 100 global application (slash) commands per -# app. Registering more makes the ENTIRE sync fail with error 30032 -# ("Maximum number of application commands reached"), which silently breaks -# every slash command — not just the overflow ones. We keep the desired set -# at or below this limit at registration time. +# Discord caps global slash commands at 100/app; exceeding it fails the ENTIRE sync (error 30032). _DISCORD_MAX_APP_COMMANDS = 100 +# Native slash commands (registered before COMMAND_REGISTRY/plugins so they survive the 100 cap): +# (discord name, description, [(arg, type, default-or-_REQUIRED, arg description, +# [(choice label, value), ...] or None)], command-text template, follow-up message) +# Placeholders are the arg names; text is `.strip()`ped unless ``strip`` is False. +_REQUIRED = object() +_NATIVE_SLASH_COMMANDS: tuple = ( + ("new", "Start a new conversation", (), "/reset", "New conversation started~"), + ("reset", "Reset your Hermes session", (), "/reset", "Session reset~"), + ("model", "Show or change the model", + (("name", str, "", "Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.", None),), + "/model {name}", None), + ("reasoning", "Show/change reasoning effort, or toggle showing it", + (("effort", str, "", "Pick a level, reset the override, or show/hide reasoning. Leave empty to see current.", + # One `/reasoning ` handler; Discord has no free-text subcommand, so list every value. + (("none — disable reasoning", "none"), ("minimal", "minimal"), ("low", "low"), + ("medium", "medium"), ("high", "high"), ("xhigh", "xhigh"), ("max", "max"), + ("ultra — maximum reasoning", "ultra"), ("reset — clear this session's override", "reset"), + ("show — reveal reasoning in replies", "show"), ("hide — hide reasoning from replies", "hide"))),), + "/reasoning {effort}", None), + ("personality", "Set a personality", + (("name", str, "", "Personality name. Leave empty to list available.", None),), + "/personality {name}", None), + ("retry", "Retry your last message", (), "/retry", "Retrying~"), + ("undo", "Remove the last exchange", (), "/undo", None), + ("status", "Show Hermes session status", (), "/status", "Status sent~"), + ("sethome", "Set this chat as the home channel", (), "/sethome", None), + ("stop", "Stop the running Hermes agent", (), "/stop", "Stop requested~"), + ("steer", "Inject a message after the next tool call (no interrupt)", + (("prompt", str, _REQUIRED, "Text to inject into the agent's next tool result", None),), + "/steer {prompt}", None), + ("plan", "Write a markdown implementation plan (no execution)", + (("task", str, "", "What to plan. Leave empty to infer from the conversation.", None),), + "/plan {task}", None), + ("compress", "Compress conversation context", (), "/compress", None), + ("title", "Set or show the session title", + (("name", str, "", "Session title. Leave empty to show current.", None),), + "/title {name}", None), + ("resume", "Resume a previously-named session", + (("name", str, "", "Session name to resume. Leave empty to list sessions.", None),), + "/resume {name}", None), + ("usage", "Show token usage for this session", (), "/usage", None), + ("help", "Show available commands", (), "/help", None), + ("insights", "Show usage insights and analytics", + (("days", int, 7, "Number of days to analyze (default: 7)", None),), + "/insights {days}", None), + ("reload-mcp", "Reload MCP servers from config", (), "/reload-mcp", None), + ("reload-skills", "Re-scan ~/.hermes/skills/ for new or removed skills", (), "/reload-skills", None), + ("voice", "Toggle voice reply mode", + (("mode", str, "", "Voice mode: join, channel, leave, on, tts, off, or status", + # `join` and `channel` both hit _handle_voice_channel_join; expose both to match docs. + (("join — join your voice channel", "join"), ("channel — join your voice channel (alias)", "channel"), + ("leave — leave voice channel", "leave"), ("on — voice reply to voice messages", "on"), + ("tts — voice reply to all messages", "tts"), ("off — text only", "off"), + ("status — show current mode", "status"))),), + "/voice {mode}", None), + ("update", "Update Hermes Agent to the latest version", (), "/update", "Update initiated~"), + ("restart", "Gracefully restart the Hermes gateway", (), "/restart", "Restart requested~"), + ("approve", "Approve a pending dangerous command", + (("scope", str, "", "Optional: 'all', 'session', 'always', 'all session', 'all always'", None),), + "/approve {scope}", None), + ("deny", "Deny a pending dangerous command", + (("scope", str, "", "Optional: 'all' to deny all pending commands", None),), + "/deny {scope}", None), + # /thread: template None -> registered by _register_thread_slash (auth-gated defer). + ("thread", "Create a new thread and start a Hermes session in it", (), None, None), + ("queue", "Queue a prompt for the next turn (doesn't interrupt)", + (("prompt", str, _REQUIRED, "The prompt to queue", None),), + "/queue {prompt}", "Queued for the next turn."), + ("bg", "Run a prompt in a separate background session", + (("prompt", str, _REQUIRED, "The prompt to run in the background", None),), + "/bg {prompt}", "Background task started~"), + ("btw", "Ask a side question about the current conversation", + (("question", str, _REQUIRED, "The side question to answer without interrupting", None),), + "/btw {question}", "Side question dispatched~"), +) _DISCORD_SELECT_FIELD_LIMIT = 100 # Discord caps a single select menu at 25 options; a View holds at most 5 rows. _DISCORD_SELECT_MAX_OPTIONS = 25 _DISCORD_SELECT_MAX_ROWS = 5 # Model-select capacity: keep 2 rows for Back/Cancel, fill the rest with selects. -_DISCORD_MODEL_SELECT_CAPACITY = ( - _DISCORD_SELECT_MAX_ROWS - 2 -) * _DISCORD_SELECT_MAX_OPTIONS +_DISCORD_MODEL_SELECT_CAPACITY = (_DISCORD_SELECT_MAX_ROWS - 2) * _DISCORD_SELECT_MAX_OPTIONS _DISCORD_BUTTON_LABEL_LIMIT = 80 _DISCORD_ELLIPSIS = "\u2026" _DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({ - "non_conversational", - "non_conversational_history", + "non_conversational", "non_conversational_history", }) _DISCORD_IMAGE_REDIRECT_STATUSES = {301, 302, 303, 307, 308} _DISCORD_IMAGE_MAX_REDIRECTS = 10 -# Upgrade-bridge fallback only. The primary mechanism is the persisted -# non-conversational message-ID set populated from explicitly marked sends -# (metadata["non_conversational"]). These regexes exist solely to recognize -# status bumps emitted by an older gateway version that pre-dates the marking, -# so they don't partition history after an upgrade. New emitters should set the -# metadata flag, not rely on a regex here. +# Upgrade-bridge fallback: recognizes status bumps from gateway versions pre-dating +# metadata["non_conversational"]. New emitters must set the metadata flag, not add regexes. _DISCORD_NONCONVERSATIONAL_HISTORY_MESSAGE_PATTERNS = ( re.compile(r"^\s*💾\s*Self-improvement review:\s+\S[\s\S]*$", re.IGNORECASE), - # Legacy/background-review test doubles used this shorter form before the - # self-improvement prefix became the stable emitter contract. + # Shorter legacy form still used by background-review test doubles. re.compile( r"^\s*💾\s+Skill\s+['\"].+?['\"]\s+(?:created|updated|improved|patched)\.?\s*$", re.IGNORECASE, @@ -144,15 +215,8 @@ sys.path.insert(0, str(_Path(__file__).resolve().parents[3])) def _is_discord_transport_error(exc: BaseException) -> bool: - """Return True for connection-shaped send failures (dead/dropping WS). - - These are the failures where the message demonstrably did NOT reach - Discord because the transport itself was down — the delivery-obligation - ledger can safely replay them after reconnect (#95382). HTTP-level - rejections (permissions, formatting, 4xx) are NOT transport errors and - must keep their original error string. Timeouts are excluded: a timed-out - send may have reached Discord, so replaying it risks a duplicate. - """ + """True for connection-shaped send failures (dead/dropping WS) that never reached Discord, so + the delivery ledger can replay them; timeouts excluded (a timed-out send may have landed).""" if isinstance(exc, asyncio.TimeoutError): return False if isinstance(exc, (ConnectionError, OSError)): @@ -173,12 +237,8 @@ def _is_discord_transport_error(exc: BaseException) -> bool: return any( marker in text for marker in ( - "websocket closed", - "connection reset", - "connection closed", - "session is closed", - "cannot write to closing transport", - "not connected", + "websocket closed", "connection reset", "connection closed", "session is closed", + "cannot write to closing transport", "not connected", ) ) @@ -191,49 +251,29 @@ except ImportError: from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import ( - MessageDeduplicator, - ThreadParticipationTracker, - convert_table_to_bullets, + MessageDeduplicator, ThreadParticipationTracker, convert_table_to_bullets, ) -from utils import atomic_json_write, env_float, env_int +from utils import atomic_json_write, env_float from gateway.platforms.base import ( - BasePlatformAdapter, - MessageEvent, - MessageType, - ProcessingOutcome, - SendResult, - cache_image_from_url, - cache_image_from_bytes, - cache_audio_from_url, - cache_audio_from_bytes, - cache_document_from_bytes, - SUPPORTED_DOCUMENT_TYPES, - _TEXT_INJECT_EXTENSIONS, - _prefix_within_utf16_limit, - utf16_len, - validate_inbound_media_size, + BasePlatformAdapter, MessageEvent, MessageType, ProcessingOutcome, SendResult, + cache_image_from_url, cache_image_from_bytes, cache_audio_from_url, cache_audio_from_bytes, + cache_document_from_bytes, SUPPORTED_DOCUMENT_TYPES, _TEXT_INJECT_EXTENSIONS, + _prefix_within_utf16_limit, utf16_len, validate_inbound_media_size, ) from tools.url_safety import is_safe_url +from gateway.platforms._shared import profile_scoped as _profile_scoped_config_load async def _read_url_image_with_redirect_guard( - session: Any, - url: str, - *, - timeout: Any, - request_kwargs: Dict[str, Any], + session: Any, url: str, *, timeout: Any, request_kwargs: Dict[str, Any], ) -> Tuple[int, bytes, Dict[str, str]]: """Read an image URL while re-checking every redirect target for SSRF.""" current_url = url for _ in range(_DISCORD_IMAGE_MAX_REDIRECTS + 1): if not is_safe_url(current_url): raise ValueError("Blocked unsafe image URL redirect") - async with session.get( - current_url, - timeout=timeout, - allow_redirects=False, - **request_kwargs, + current_url, timeout=timeout, allow_redirects=False, **request_kwargs, ) as resp: raw_headers = getattr(resp, "headers", {}) or {} headers = {str(key).lower(): value for key, value in dict(raw_headers).items()} @@ -247,9 +287,7 @@ async def _read_url_image_with_redirect_guard( raise ValueError("Blocked redirect to private/internal address") current_url = next_url continue - return status, await resp.read(), headers - raise ValueError("Too many image URL redirects") @@ -278,25 +316,14 @@ def _abort_discord_websocket_transport(websocket: Any) -> bool: async def _wait_for_ready_or_bot_exit( - ready_event: asyncio.Event, - bot_task: asyncio.Task, - timeout: Optional[float], + ready_event: asyncio.Event, bot_task: asyncio.Task, timeout: Optional[float], ) -> None: - """Wait until Discord is ready, or surface early bot startup failure. - - ``discord.py`` startup errors (including SOCKS/proxy failures from - aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()`` - only waits on ``ready_event``, a dead background task still burns the full - ready timeout before the gateway supervisor can reconnect. Racing the ready - event against the bot task keeps failures fast and preserves the original - exception for logging/classification. - """ + """Wait until Discord is ready, or surface early bot startup failure (``Bot.start()`` errors + would otherwise burn the full timeout on a dead task; racing preserves the exception).""" ready_task = asyncio.create_task(ready_event.wait()) try: done, _pending = await asyncio.wait( - {ready_task, bot_task}, - timeout=timeout, - return_when=asyncio.FIRST_COMPLETED, + {ready_task, bot_task}, timeout=timeout, return_when=asyncio.FIRST_COMPLETED, ) if not done: raise asyncio.TimeoutError @@ -315,15 +342,10 @@ async def _wait_for_ready_or_bot_exit( def _needs_server_members_intent( - allowed_user_ids: set[str] | list[str] | None, - allowed_role_ids: set[str] | list[str] | None, + allowed_user_ids: set[str] | list[str] | None, allowed_role_ids: set[str] | list[str] | None, ) -> bool: - """Return True when Hermes must request Discord's Server Members intent. - - Message Content is always requested. Server Members is only needed when the - allowlist contains usernames (not pure numeric IDs / ``*``) or when role - allowlists require member role lookups. - """ + """True when Server Members intent is needed: username allowlist entries (not IDs / ``*``) + or role allowlists needing member lookups. Message Content is always requested.""" entries = allowed_user_ids or () if any(entry != "*" and not str(entry).isdigit() for entry in entries): return True @@ -354,6 +376,33 @@ def _format_privileged_intents_guidance(*, needs_members: bool) -> str: return "\n".join(lines) +def _load_opus_codec() -> None: + """Try bundled (Windows) opus, then ``ctypes.util.find_library``, then Homebrew paths + (find_library misses Homebrew libs on macOS); warn once if none loads.""" + import ctypes.util + opus_candidates = [] + bundled_opus = _find_discord_windows_bundled_opus(discord) + if bundled_opus: + opus_candidates.append(bundled_opus) + opus_path = ctypes.util.find_library("opus") + if opus_path: + opus_candidates.append(opus_path) + elif sys.platform == "darwin": + for _hp in ("/opt/homebrew/lib/libopus.dylib", "/usr/local/lib/libopus.dylib"): # Apple Silicon, Intel + if os.path.isfile(_hp): + opus_candidates.append(_hp) + break + for opus_path in opus_candidates: + try: + discord.opus.load_opus(opus_path) + if discord.opus.is_loaded(): + break + except Exception: + logger.warning("Opus codec found at %s but failed to load", opus_path) + if not discord.opus.is_loaded(): + logger.warning("Opus codec not found — voice channel playback disabled") + + def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]: """Return discord.py's bundled Windows opus DLL path when present.""" if sys.platform != "win32": @@ -361,12 +410,10 @@ def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[s discord_module = discord if discord_module is None else discord_module if discord_module is None: return None - opus_module = getattr(discord_module, "opus", None) opus_file = getattr(opus_module, "__file__", None) if not opus_file: return None - target = "x64" if struct.calcsize("P") * 8 > 32 else "x86" bundled = _Path(opus_file).resolve().parent / "bin" / f"libopus-0.{target}.dll" if bundled.is_file(): @@ -385,7 +432,6 @@ class _DiscordNonConversationalMessageTracker: def _state_path(self) -> _Path: from hermes_constants import get_hermes_home - return ( get_hermes_home() / _DISCORD_COMMAND_SYNC_STATE_SUBDIR @@ -435,6 +481,13 @@ def _metadata_marks_nonconversational(metadata: Optional[Dict[str, Any]]) -> boo return any(bool(metadata.get(key)) for key in _DISCORD_NONCONVERSATIONAL_METADATA_KEYS) +def _prompt_target_id(chat_id: str, metadata: Optional[dict]) -> str: + """Interactive prompts post into ``metadata["thread_id"]`` when present, else ``chat_id``.""" + if metadata and metadata.get("thread_id"): + return metadata["thread_id"] + return chat_id + + def _looks_like_nonconversational_history_message(content: str) -> bool: """Fallback recognizer for legacy status bumps missing persisted IDs.""" text = content or "" @@ -442,44 +495,25 @@ def _looks_like_nonconversational_history_message(content: str) -> bool: 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. - """ + """Strip pasted prefixes (``user:123``, ``<@123>``, ``<@!123>``) to a bare ID/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() -# ── per-profile gate env reads (issue #72348) ──────────────────────────── -# Under gateway.multiplex_profiles, os.environ is process-global and the -# YAML→env bridge in _apply_yaml_config is first-writer-wins, so a raw -# os.getenv() on an allow/deny gate can return ANOTHER profile's value. -# _scoped_gate_env reads the active profile's secret scope when one is -# installed (secondary adapters connect — and their discord.py event tasks -# are created — inside _profile_runtime_scope, so the contextvar propagates) -# and falls back to os.getenv only outside multiplex. +# Under gateway.multiplex_profiles os.environ is process-global and first-writer-wins, so raw +# os.getenv() can return ANOTHER profile's value; _scoped_gate_env reads the active profile's +# secret scope (contextvar propagates into connect()) and falls back to os.getenv outside multiplex. # Authorization/gate env vars snapshotted per-adapter at connect() time. _GATE_ENV_KEYS = ( - "DISCORD_ALLOWED_USERS", - "DISCORD_ALLOWED_ROLES", - "DISCORD_ALLOWED_CHANNELS", - "DISCORD_IGNORED_CHANNELS", - "DISCORD_NO_THREAD_CHANNELS", - "DISCORD_FREE_RESPONSE_CHANNELS", - "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", - "DISCORD_ALLOW_ALL_USERS", - "DISCORD_ALLOW_BOTS", - "GATEWAY_ALLOW_ALL_USERS", - "GATEWAY_ALLOWED_USERS", + "DISCORD_ALLOWED_USERS", "DISCORD_ALLOWED_ROLES", "DISCORD_ALLOWED_CHANNELS", + "DISCORD_IGNORED_CHANNELS", "DISCORD_NO_THREAD_CHANNELS", "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "DISCORD_ALLOW_ALL_USERS", "DISCORD_ALLOW_BOTS", + "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", ) @@ -487,7 +521,6 @@ def _scoped_gate_env(name: str, default: str = "") -> str: """Scope-aware gate env read: profile secret scope first under multiplex.""" try: from gateway.authz_mixin import _platform_gate_env - return _platform_gate_env(name, default) except Exception: return (os.getenv(name) or default).strip() @@ -497,47 +530,20 @@ def _multiplex_active() -> bool: """True when the gateway is running in multiplex_profiles mode.""" try: from agent.secret_scope import is_multiplex_active - return bool(is_multiplex_active()) except Exception: return False -def _profile_scoped_config_load() -> bool: - """True when the current config load belongs to a multiplexed profile. - - Secondary profile configs load inside ``_profile_runtime_scope`` (secret - scope installed + multiplex active). In that case the YAML→env bridge in - ``_apply_yaml_config`` must NOT write process-global env vars: the values - belong to one profile only and the first-writer-wins guard would pin them - for every other profile (issue #72348). - """ - try: - from agent.secret_scope import current_secret_scope, is_multiplex_active - - return bool(is_multiplex_active() and current_secret_scope() is not None) - except Exception: - return False - - def discord_deps_present() -> bool: - """PASSIVE probe: is discord.py importable right now? - - Registry ``check_fn`` — called from status displays and config loading, - so it must never install anything. The ACTIVE lazy-installer - (``check_discord_requirements``) is registered as ``ensure_deps_fn`` - and runs from ``create_adapter()`` when this returns False (#79812). - """ + """PASSIVE probe: is discord.py importable? Registry ``check_fn`` — must never install + (the ACTIVE installer ``check_discord_requirements`` runs as ``ensure_deps_fn``).""" return DISCORD_AVAILABLE def check_discord_requirements() -> bool: - """Check if Discord dependencies are available. - - Lazy-installs discord.py via ``tools.lazy_deps.ensure("platform.discord")`` - on first call if not present. After successful install, re-binds module - globals so ``DISCORD_AVAILABLE`` becomes True. - """ + """Check Discord deps; lazy-installs discord.py on first call and re-binds + module globals so ``DISCORD_AVAILABLE`` becomes True.""" global DISCORD_AVAILABLE, discord, DiscordMessage, Intents, commands if DISCORD_AVAILABLE: return True @@ -562,17 +568,10 @@ def check_discord_requirements() -> bool: def _build_allowed_mentions(): - """Build Discord ``AllowedMentions`` with safe defaults, overridable via env. + """Build Discord ``AllowedMentions`` denying @everyone/@here/roles by default (any LLM output + with ``@everyone`` would otherwise ping the server); user / replied-user pings stay on. - Discord bots default to parsing ``@everyone``, ``@here``, role pings, and - user pings when ``allowed_mentions`` is unset on the client — any LLM - output or echoed user content that contains ``@everyone`` would therefore - ping the whole server. We explicitly deny ``@everyone`` and role pings - by default and keep user / replied-user pings enabled so normal - conversation still works. - - Override via environment variables (or ``discord.allow_mentions.*`` in - config.yaml): + Override via env (or ``discord.allow_mentions.*`` in config.yaml): DISCORD_ALLOW_MENTION_EVERYONE default false — @everyone + @here DISCORD_ALLOW_MENTION_ROLES default false — @role pings @@ -581,13 +580,7 @@ def _build_allowed_mentions(): """ if not DISCORD_AVAILABLE: return None - - def _b(name: str, default: bool) -> bool: - raw = os.getenv(name, "").strip().lower() - if not raw: - return default - return raw in {"true", "1", "yes", "on"} - + _b = _env_bool return discord.AllowedMentions( everyone=_b("DISCORD_ALLOW_MENTION_EVERYONE", False), roles=_b("DISCORD_ALLOW_MENTION_ROLES", False), @@ -603,21 +596,13 @@ def _discord_ready_timeout_seconds() -> float: try: return max(0.0, float(raw)) except ValueError: - logger.warning( - "Ignoring invalid HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT=%r", - raw, - ) + logger.warning("Ignoring invalid HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT=%r", raw) return 30.0 class VoiceReceiver: - """Captures and decodes voice audio from a Discord voice channel. - - Attaches to a VoiceClient's socket listener, decrypts RTP packets - (NaCl transport + DAVE E2EE), decodes Opus to PCM, and buffers - per-user audio. A polling loop detects silence and delivers - completed utterances via a callback. - """ + """Captures voice audio from a Discord voice channel: hooks the VoiceClient socket, decrypts + RTP (NaCl + DAVE E2EE), decodes Opus per user; a polling loop delivers utterances on silence.""" SILENCE_THRESHOLD = 1.5 # seconds of silence → end of utterance MIN_SPEECH_DURATION = 0.5 # minimum seconds to process (skip noise) @@ -628,32 +613,21 @@ class VoiceReceiver: self._vc = voice_client self._allowed_user_ids = allowed_user_ids or set() self._running = False - - # Decryption self._secret_key: Optional[bytes] = None self._dave_session = None self._bot_ssrc: int = 0 - - # SSRC -> user_id mapping (populated from SPEAKING events) self._ssrc_to_user: Dict[int, int] = {} self._lock = threading.Lock() - - # Per-user audio buffers self._buffers: Dict[int, bytearray] = defaultdict(bytearray) self._last_packet_time: Dict[int, float] = {} - # Opus decoder per SSRC (each user needs own decoder state) self._decoders: Dict[int, object] = {} - # Pause flag: don't capture while bot is playing TTS self._paused = False - # Debug logging counter (instance-level to avoid cross-instance races) self._packet_debug_count = 0 - # ------------------------------------------------------------------ - # Lifecycle - # ------------------------------------------------------------------ + # --- Lifecycle --- def start(self): """Start listening for voice packets.""" @@ -661,7 +635,6 @@ class VoiceReceiver: self._secret_key = bytes(conn.secret_key) self._dave_session = conn.dave_session self._bot_ssrc = conn.ssrc - self._install_speaking_hook(conn) conn.add_socket_listener(self._on_packet) self._running = True @@ -687,22 +660,15 @@ class VoiceReceiver: def resume(self): self._paused = False - # ------------------------------------------------------------------ - # SSRC -> user_id mapping via SPEAKING opcode hook - # ------------------------------------------------------------------ + # --- SSRC -> user_id mapping via SPEAKING opcode hook --- def map_ssrc(self, ssrc: int, user_id: int): with self._lock: self._ssrc_to_user[ssrc] = user_id def _install_speaking_hook(self, conn): - """Wrap the voice websocket hook to capture SPEAKING events (op 5). - - VoiceConnectionState stores the hook as ``conn.hook`` (public attr). - It is passed to DiscordVoiceWebSocket on each (re)connect, so we - must wrap it on the VoiceConnectionState level AND on the current - live websocket instance. - """ + """Wrap the voice websocket hook to capture SPEAKING events (op 5); ``conn.hook`` is + re-passed on each (re)connect, so wrap it on the state AND the live websocket.""" original_hook = conn.hook receiver_self = self @@ -716,10 +682,7 @@ class VoiceReceiver: receiver_self.map_ssrc(int(ssrc), int(user_id)) if original_hook: await original_hook(ws, msg) - - # Set on connection state (for future reconnects) conn.hook = wrapped_hook - # Set on the current live websocket (for immediate effect) try: from discord.utils import MISSING if hasattr(conn, 'ws') and conn.ws is not MISSING: @@ -728,56 +691,41 @@ class VoiceReceiver: except Exception as e: logger.warning("Could not install hook on live ws: %s", e) - # ------------------------------------------------------------------ - # Packet handler (called from SocketReader thread) - # ------------------------------------------------------------------ + # --- Packet handler (called from SocketReader thread) --- def _on_packet(self, data: bytes): if not self._running or self._paused: return - - # Log first few raw packets for debugging self._packet_debug_count += 1 if self._packet_debug_count <= 5: logger.debug( "Raw UDP packet: len=%d, first_bytes=%s", len(data), data[:4].hex() if len(data) >= 4 else "short", ) - if len(data) < 16: return - - # RTP version check: top 2 bits must be 10 (version 2). - # Lower bits may vary (padding, extension, CSRC count). - # Payload type (byte 1 lower 7 bits) = 0x78 (120) for voice. + # RTP v2: top 2 bits 10 (rest varies); voice payload type (byte 1 & 0x7F) is 0x78. if (data[0] >> 6) != 2 or (data[1] & 0x7F) != 0x78: if self._packet_debug_count <= 5: logger.debug("Skipped non-RTP: byte0=0x%02x byte1=0x%02x", data[0], data[1]) return - first_byte = data[0] _, _, seq, timestamp, ssrc = struct.unpack_from(">BBHII", data, 0) - - # Skip bot's own audio if ssrc == self._bot_ssrc: return - # Calculate dynamic RTP header size (RFC 9335 / rtpsize mode) cc = first_byte & 0x0F # CSRC count has_extension = bool(first_byte & 0x10) # extension bit has_padding = bool(first_byte & 0x20) # padding bit (RFC 3550 §5.1) header_size = 12 + (4 * cc) + (4 if has_extension else 0) - if len(data) < header_size + 4: # need at least header + nonce return - # Read extension length from preamble (for skipping after decrypt) ext_data_len = 0 if has_extension: ext_preamble_offset = 12 + (4 * cc) ext_words = struct.unpack_from(">H", data, ext_preamble_offset + 2)[0] ext_data_len = ext_words * 4 - if self._packet_debug_count <= 10: with self._lock: known_user = self._ssrc_to_user.get(ssrc, "unknown") @@ -785,17 +733,14 @@ class VoiceReceiver: "RTP packet: ssrc=%d, seq=%d, user=%s, hdr=%d, ext_data=%d", ssrc, seq, known_user, header_size, ext_data_len, ) - header = bytes(data[:header_size]) payload_with_nonce = data[header_size:] - # --- NaCl transport decrypt (aead_xchacha20_poly1305_rtpsize) --- if len(payload_with_nonce) < 4: return nonce = bytearray(24) nonce[:4] = payload_with_nonce[-4:] encrypted = bytes(payload_with_nonce[:-4]) - try: import nacl.secret # noqa: E402 — delayed import, only in voice path box = nacl.secret.Aead(self._secret_key) @@ -804,22 +749,14 @@ class VoiceReceiver: if self._packet_debug_count <= 10: logger.warning("NaCl decrypt failed: %s (hdr=%d, enc=%d)", e, header_size, len(encrypted)) return - # Skip encrypted extension data to get the actual opus payload if ext_data_len and len(decrypted) > ext_data_len: decrypted = decrypted[ext_data_len:] - - # --- Strip RTP padding (RFC 3550 §5.1) --- - # When the P bit is set, the last payload byte holds the count of - # trailing padding bytes (including itself) that must be removed - # before further processing. Skipping this passes padding-contaminated - # bytes into DAVE/Opus and corrupts inbound audio. + # Strip RTP padding (RFC 3550 §5.1): last payload byte is the count; leaving it corrupts DAVE/Opus. if has_padding: if not decrypted: if self._packet_debug_count <= 10: - logger.warning( - "RTP padding bit set but no payload (ssrc=%d)", ssrc, - ) + logger.warning("RTP padding bit set but no payload (ssrc=%d)", ssrc) return pad_len = decrypted[-1] if pad_len == 0 or pad_len > len(decrypted): @@ -831,9 +768,7 @@ class VoiceReceiver: return decrypted = decrypted[:-pad_len] if not decrypted: - # Padding consumed entire payload — nothing to decode return - # --- DAVE E2EE decrypt --- if self._dave_session: with self._lock: @@ -850,11 +785,7 @@ class VoiceReceiver: if self._packet_debug_count <= 10: logger.warning("DAVE decrypt failed for ssrc=%d: %s", ssrc, e) return - # If SSRC unknown (no SPEAKING event yet), skip DAVE and try - # Opus decode directly — audio may be in passthrough mode. - # Buffer will get a user_id when SPEAKING event arrives later. - - # --- Opus decode -> PCM --- + # Unknown SSRC (no SPEAKING yet): skip DAVE, try Opus directly; user_id arrives with SPEAKING. try: if ssrc not in self._decoders: self._decoders[ssrc] = discord.opus.Decoder() @@ -865,24 +796,14 @@ class VoiceReceiver: except Exception as e: with self._lock: self._decoders.pop(ssrc, None) - logger.debug( - "Opus decode error for SSRC %s; reset decoder: %s", - ssrc, - e, - ) + logger.debug("Opus decode error for SSRC %s; reset decoder: %s", ssrc, e) return - # ------------------------------------------------------------------ - # Silence detection - # ------------------------------------------------------------------ + # --- Silence detection --- def _infer_user_for_ssrc(self, ssrc: int) -> int: - """Try to infer user_id for an unmapped SSRC. - - When the bot rejoins a voice channel, Discord may not resend - SPEAKING events for users already speaking. If exactly one - allowed user is in the channel, map the SSRC to them. - """ + """Infer user_id for an unmapped SSRC: after a bot rejoin Discord may not resend + SPEAKING, so if exactly one allowed user is in the channel, map the SSRC to them.""" try: channel = self._vc.channel if not channel: @@ -906,23 +827,19 @@ class VoiceReceiver: """Return list of (user_id, pcm_bytes) for completed utterances.""" now = time.monotonic() completed = [] - with self._lock: ssrc_user_map = dict(self._ssrc_to_user) ssrc_list = list(self._buffers.keys()) - for ssrc in ssrc_list: last_time = self._last_packet_time.get(ssrc, now) silence_duration = now - last_time buf = self._buffers[ssrc] # 48kHz, 16-bit, stereo = 192000 bytes/sec buf_duration = len(buf) / (self.SAMPLE_RATE * self.CHANNELS * 2) - if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION: user_id = ssrc_user_map.get(ssrc, 0) if not user_id: - # SSRC not mapped (SPEAKING event missing after bot rejoin). - # Infer from allowed users in the voice channel. + # SSRC unmapped (SPEAKING missing after rejoin) — infer from channel. user_id = self._infer_user_for_ssrc(ssrc) if user_id: completed.append((user_id, bytes(buf))) @@ -932,13 +849,11 @@ class VoiceReceiver: # Stale buffer with no valid user — discard self._buffers.pop(ssrc, None) self._last_packet_time.pop(ssrc, None) - return completed def flush_pending(self) -> list: """Return buffered utterances that have not yet reached silence.""" completed = [] - with self._lock: ssrc_user_map = dict(self._ssrc_to_user) for ssrc, buf in list(self._buffers.items()): @@ -952,61 +867,33 @@ class VoiceReceiver: completed.append((user_id, bytes(buf))) self._buffers.pop(ssrc, None) self._last_packet_time.pop(ssrc, None) - return completed - # ------------------------------------------------------------------ - # PCM -> WAV conversion (for Whisper STT) - # ------------------------------------------------------------------ + # --- PCM -> WAV conversion (for Whisper STT) --- @staticmethod - def pcm_to_wav(pcm_data: bytes, output_path: str, - src_rate: int = 48000, src_channels: int = 2): - """Convert raw PCM to 16kHz mono WAV via ffmpeg. - - The PCM is fed straight to ffmpeg's stdin, which avoids staging it in a - temp file on every utterance. The WAV is still written to *output_path* - rather than captured from stdout: ffmpeg cannot seek on a pipe, so a - piped WAV carries placeholder (0xFFFFFFFF) RIFF/data sizes that make - strict readers misreport the length. - """ + def pcm_to_wav(pcm_data: bytes, output_path: str, src_rate: int = 48000, src_channels: int = 2): + """Convert raw PCM to 16kHz mono WAV via ffmpeg into *output_path* (not stdout: ffmpeg + can't seek a pipe, so piped WAV carries placeholder RIFF sizes strict readers misreport).""" from hermes_cli._subprocess_compat import windows_hide_flags - subprocess.run( [ - resolve_ffmpeg_executable(), "-y", "-loglevel", "error", - "-f", "s16le", - "-ar", str(src_rate), - "-ac", str(src_channels), - "-i", "pipe:0", - "-ar", "16000", - "-ac", "1", - output_path, + resolve_ffmpeg_executable(), "-y", "-loglevel", "error", "-f", "s16le", + "-ar", str(src_rate), "-ac", str(src_channels), "-i", "pipe:0", "-ar", "16000", + "-ac", "1", output_path, ], input=pcm_data, check=True, timeout=10, - # Capture ffmpeg's -loglevel error output so a failure's - # CalledProcessError carries the actual message (parity with - # tools/transcription_tools' ffmpeg call sites) instead of - # "returned non-zero exit status N" with stderr detached. + # Capture stderr so a failure's CalledProcessError carries ffmpeg's real message. stderr=subprocess.PIPE, creationflags=windows_hide_flags(), ) def _read_dm_role_auth_guild() -> Optional[int]: - """Return the guild ID opted-in for DM role-based auth, or None. - - Reads ``discord.dm_role_auth_guild`` from config.yaml. This is - deliberately a config.yaml-only setting (not an env var): per repo - policy, ``~/.hermes/.env`` is for secrets only, and this is a - behavioral setting. Guild IDs aren't secrets. - - Accepts ints or numeric strings in the config. Anything else - (empty, malformed, None) returns None, which keeps the secure - default (DM role-auth disabled). - """ + """Return the guild ID opted-in for DM role-based auth, or None (secure default). Read from + config.yaml ``discord.dm_role_auth_guild`` only (behavioral, not a secret); int or numeric string.""" try: from hermes_cli.config import read_raw_config cfg = read_raw_config() or {} @@ -1023,12 +910,8 @@ def _read_dm_role_auth_guild() -> Optional[int]: return guild_id if guild_id > 0 else None -# Default timeout for Discord interactive button views (exec approval, slash -# confirm, update prompt, clarify choice). Used when the user has not set -# ``approvals.discord_prompt_timeout`` in config.yaml. 300s (5 min) matches -# the previous hardcoded value. Bounded to a sane range — Discord -# interaction tokens expire from the API's side at ~15 minutes, so 900s is -# the practical ceiling. +# Default timeout for Discord button views when ``approvals.discord_prompt_timeout`` is unset; +# Discord interaction tokens expire at ~15 minutes, so 900s is the practical ceiling. _DISCORD_PROMPT_TIMEOUT_DEFAULT = 300 _DISCORD_PROMPT_TIMEOUT_MIN = 30 _DISCORD_PROMPT_TIMEOUT_MAX = 900 @@ -1042,15 +925,8 @@ def _env_bool(name: str, default: bool = False) -> bool: def _read_discord_prompt_timeout() -> int: - """Return the timeout (in seconds) for Discord button views. - - Reads ``approvals.discord_prompt_timeout`` from config.yaml. Falls back - to the historical 300s default for any missing / malformed value, and - clamps the result to ``[_DISCORD_PROMPT_TIMEOUT_MIN, - _DISCORD_PROMPT_TIMEOUT_MAX]`` so a typo can't accidentally make - interactive prompts disappear (too short) or outlive Discord's own - 15-minute interaction-token expiry (too long). - """ + """Timeout (seconds) for Discord button views from ``approvals.discord_prompt_timeout`` + (default 300), clamped to [MIN, MAX] so a typo can't make prompts vanish or outlive tokens.""" raw: Any = None try: from hermes_cli.config import read_raw_config @@ -1073,37 +949,18 @@ def _read_discord_prompt_timeout() -> int: class DiscordAdapter(BasePlatformAdapter): - """ - Discord bot adapter. + """Discord bot adapter: guild/DM messages, threads, slash commands, button approvals, reactions.""" - Handles: - - Receiving messages from servers and DMs - - Sending responses with Discord markdown - - Thread support - - Native slash commands (/ask, /reset, /status, /stop) - - Button-based exec approvals - - Auto-threading for long conversations - - Reaction-based feedback - """ - - # Discord message limits MAX_MESSAGE_LENGTH = 2000 _SPLIT_THRESHOLD = 1900 # near the 2000-char split point supports_code_blocks = True # Discord markdown renders fenced code blocks natively splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) - # Safety ceiling on split deliveries (#86581): a degenerate turn can - # produce tens of thousands of characters — without a cap the adapter - # posts every 2000-char chunk back-to-back and floods the channel (the - # incident delivered 60,698 chars as 31 messages). Chunks beyond the - # cap are replaced by a short notice. + # Safety ceiling on split deliveries: chunks beyond the cap become a notice (degenerate turns). MAX_SPLIT_MESSAGES = 8 - # Auto-disconnect from voice channel after this many seconds of inactivity. - # Config key: discord.voice_channel_inactivity_timeout_seconds (0 disables) + # Voice auto-disconnect after N idle seconds (discord.voice_channel_inactivity_timeout_seconds; 0 off). VOICE_TIMEOUT = 300 - # Minimum seconds to wait for a single voice playback. The effective limit - # scales with the probed clip duration so long readbacks are not cut off at - # a hard two-minute ceiling. + # Minimum wait for one voice playback; the effective limit scales with clip duration. PLAYBACK_TIMEOUT = 120 PLAYBACK_TIMEOUT_PADDING = 30 @@ -1111,7 +968,6 @@ class DiscordAdapter(BasePlatformAdapter): """Keep a truncated URL preview clickable in Discord markdown.""" if not preview.url: return preview.text - return _format_discord_markdown_link(preview.text, preview.url) def __init__(self, config: PlatformConfig): @@ -1120,12 +976,9 @@ class DiscordAdapter(BasePlatformAdapter): self._ready_event = asyncio.Event() self._allowed_user_ids: set = set() # For button approval authorization self._allowed_role_ids: set = set() # For DISCORD_ALLOWED_ROLES filtering - # Per-adapter snapshot of authorization gate env vars, captured inside - # the owning profile's runtime scope during connect(). None until then; - # accessors fall back to live scope-aware reads (issue #72348). + # Gate env snapshot captured in connect() inside the owning profile's scope; None until then. self._gate_env_snapshot: Optional[Dict[str, str]] = None self.gateway_runner = None # Set by gateway/run.py for cross-platform delivery - # Voice channel state (per-guild) self._voice_clients: Dict[int, Any] = {} # guild_id -> VoiceClient self._voice_locks: Dict[int, asyncio.Lock] = {} # guild_id -> serialize join/leave # Text batching: merge rapid successive messages (Telegram-style) @@ -1138,92 +991,62 @@ class DiscordAdapter(BasePlatformAdapter): self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task self._voice_timeout_seconds = self._load_voice_timeout() self._playback_timeout_seconds = self._load_playback_timeout() - # Phase 2: voice listening self._voice_receivers: Dict[int, VoiceReceiver] = {} # guild_id -> VoiceReceiver self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop self._voice_input_callback: Optional[Callable] = None # set by run.py self._on_voice_disconnect: Optional[Callable] = None # set by run.py - # Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a - # linked text-channel id; set by run.py. Lets the inactivity timer leave - # the bot in the channel when the user deliberately picked text-only - # (/voice off) instead of leaving (/voice leave). + # Voice-reply mode ("off"|"voice_only"|"all") per linked text-channel id (set by run.py) so + # the inactivity timer keeps the bot in channel for /voice off, unlike /voice leave. self._voice_mode_getter: Optional[Callable] = None # set by run.py - # Phase 3: continuous voice mixer (ambient idle bed + ducked speech). - # Installed once per guild on join; lets acks / TTS / the "thinking" - # loop overlap in one outgoing stream instead of stop-and-swap. + # Continuous voice mixer per guild (ambient bed + ducked speech) so acks/TTS/thinking overlap. self._voice_mixers: Dict[int, Any] = {} # guild_id -> VoiceMixer self._ambient_pcm_cache: Optional[bytes] = None # decoded ambient bed self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config() - # Track threads where the bot has participated so follow-up messages - # in those threads don't require @mention. Persisted to disk so the - # set survives gateway restarts. + # Threads the bot participated in (no @mention needed there); persisted across restarts. self._threads = ThreadParticipationTracker("discord") - # Persistent typing indicator loops per channel (DMs don't reliably - # show the standard typing gateway event for bots) + # Persistent typing loops per channel (DMs don't reliably show bot typing events). self._typing_tasks: Dict[str, asyncio.Task] = {} self._bot_task: Optional[asyncio.Task] = None self._post_connect_task: Optional[asyncio.Task] = None - # WebSocket-level liveness probe. Discord REST and Gateway are distinct - # transports: a REST 200 cannot prove that this client is still receiving - # Gateway events. Sample the current Discord WebSocket's ready/open/ACK - # state and heartbeat latency instead; after consecutive unhealthy samples - # use the existing retryable-fatal path so GatewayRunner rebuilds a fresh - # adapter. The values are compatibility inputs from config; zero disables - # the probe without changing the rest of the adapter lifecycle. + # WS liveness probe: REST 200 can't prove Gateway events still arrive, so sample WS + # ready/open/ACK + heartbeat latency; consecutive failures -> retryable-fatal. 0 disables. self._liveness_interval_seconds = self._finite_positive_config_float( - "websocket_liveness_interval_seconds", - 15.0, + "websocket_liveness_interval_seconds", 15.0, env_key="HERMES_DISCORD_LIVENESS_INTERVAL_SECONDS", ) self._liveness_failure_threshold = self._config_int( - "websocket_liveness_failure_threshold", - 2, + "websocket_liveness_failure_threshold", 2, env_key="HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD", ) self._heartbeat_ack_max_age_seconds = self._finite_positive_config_float( - "websocket_heartbeat_ack_max_age_seconds", - 60.0, + "websocket_heartbeat_ack_max_age_seconds", 60.0, ) self._max_latency_seconds = self._finite_positive_config_float( - "websocket_max_latency_seconds", - 30.0, + "websocket_max_latency_seconds", 30.0, ) self._liveness_task: Optional[asyncio.Task] = None self._liveness_notification_task: Optional[asyncio.Task] = None - # True while disconnect() is intentionally closing discord.py. The - # bot task's done callback uses this to distinguish an operator/service - # shutdown from a runtime websocket crash. + # True while disconnect() intentionally closes discord.py (done callback: shutdown vs crash). self._disconnecting = False self._missed_message_backfill_task: Optional[asyncio.Task] = None from hermes_constants import get_hermes_home from plugins.platforms.discord.recovery import DiscordRecoveryStore self._discord_recovery_store = DiscordRecoveryStore(get_hermes_home()) - # Dedup cache: prevents duplicate bot responses when Discord - # RESUME replays events after reconnects. + # Dedup cache: Discord RESUME replays events after reconnects. self._dedup = MessageDeduplicator() - # Reply threading mode: "off" (no replies), "first" (reply on first - # chunk only, default), "all" (reply-reference on every chunk). + # Reply threading mode: "off", "first" (default; first chunk only), "all" (every chunk). self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' self._slash_commands: bool = self.config.extra.get("slash_commands", True) - # In-memory cache of the bot's last message ID per channel, used by - # history backfill to skip the full scan on hot paths. Falls back to - # scanning channel.history() on cache miss (cold start / restart). + # Bot's last message ID per channel: lets history backfill skip the full channel.history() scan. self._last_self_message_id: Dict[str, str] = {} - # Persistent set of bot-authored lifecycle/status message IDs that - # should not act as conversational history boundaries after restart. + # Bot-authored lifecycle/status message IDs that must not bound history after restart. self._nonconversational_messages = _DiscordNonConversationalMessageTracker() - # Last truncated mid-stream preview delivered per (chat_id, message_id). - # Once an oversized streaming edit saturates at the 2000-char preview - # cap, every subsequent progressive edit truncates to the SAME text; - # re-sending it is a no-op that still counts against Discord's edit - # rate limit (~1 edit per stream tick for the rest of a long reply). - # Mirrors the Telegram #58563 fix. Entries are dropped on finalize. + # Last truncated mid-stream preview per (chat_id, message_id): past the 2000 cap every edit + # truncates to the SAME text, and re-sending only burns edit rate limit. Dropped on finalize. self._last_overflow_preview: Dict[tuple, str] = {} self._warned_fail_closed_default = False - def _config_value( - self, key: str, default: Any, *, env_key: Optional[str] = None - ) -> Any: + def _config_value(self, key: str, default: Any, *, env_key: Optional[str] = None) -> Any: """Resolve a liveness value from profile config, legacy env, or default.""" extra = self.config.extra if isinstance(getattr(self.config, "extra", None), dict) else {} value = extra.get(key) @@ -1241,9 +1064,7 @@ class DiscordAdapter(BasePlatformAdapter): return 0.0 return value if math.isfinite(value) and value > 0 else 0.0 - def _config_int( - self, key: str, default: int, *, env_key: Optional[str] = None - ) -> int: + def _config_int(self, key: str, default: int, *, env_key: Optional[str] = None) -> int: """Resolve a positive liveness count; invalid values disable it.""" value = self._config_value(key, default, env_key=env_key) if isinstance(value, bool): @@ -1254,48 +1075,33 @@ class DiscordAdapter(BasePlatformAdapter): return 0 def _handle_bot_task_done(self, task: asyncio.Task) -> None: - """Surface post-startup discord.py task exits to the gateway supervisor. - - discord.py reconnects normal gateway interruptions internally. When its - top-level ``Bot.start()`` task actually exits after the adapter has been - marked running, the Discord websocket is dead while the Hermes gateway - process can remain alive. Treat that split-brain state as a retryable - fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can - remove this adapter and queue Discord for the existing reconnect watcher. - """ + """Surface post-startup discord.py task exits as a retryable fatal so GatewayRunner + re-queues us (otherwise the websocket is dead while the gateway process lives).""" if getattr(self, "_disconnecting", False): - # Intentional service/operator shutdown. Drain the task result so - # asyncio doesn't emit "exception was never retrieved" warnings. + # Intentional shutdown: drain the result to avoid "exception was never retrieved". with suppress(asyncio.CancelledError, Exception): task.exception() return - - # Ignore stale callbacks from an older client if a reconnect already - # installed a newer Bot.start() task on this adapter instance. + # Ignore stale callbacks from an older client after a reconnect installed a newer task. if self._bot_task is not None and task is not self._bot_task: with suppress(asyncio.CancelledError, Exception): task.exception() return - if not self._running: - # Startup failures are handled by _wait_for_ready_or_bot_exit() in - # connect(); this callback is only for post-startup split-brain. + # Startup failures are handled in connect(); this is only for post-startup exits. with suppress(asyncio.CancelledError, Exception): task.exception() return - try: exc = task.exception() except asyncio.CancelledError: return except Exception as err: # pragma: no cover - defensive exc = err - if exc is None: message = "Discord gateway task exited without an exception" else: message = f"Discord gateway task exited: {exc}" - logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False) self._set_fatal_error("discord_gateway_task_exited", message, retryable=True) @@ -1305,11 +1111,8 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as notify_exc: # pragma: no cover - defensive logging logger.warning( "[%s] Failed to notify gateway supervisor about Discord task exit: %s", - self.name, - notify_exc, - exc_info=True, + self.name, notify_exc, exc_info=True, ) - asyncio.create_task(_notify()) async def connect(self, *, is_reconnect: bool = False) -> bool: @@ -1318,97 +1121,37 @@ class DiscordAdapter(BasePlatformAdapter): logger.error("[%s] discord.py not installed. Run: pip install discord.py", self.name) self._set_fatal_error("missing_dependency", "discord.py not installed", retryable=False) return False - - # Load opus codec for voice channel support if not discord.opus.is_loaded(): - import ctypes.util - opus_candidates = [] - bundled_opus = _find_discord_windows_bundled_opus(discord) - if bundled_opus: - opus_candidates.append(bundled_opus) - opus_path = ctypes.util.find_library("opus") - if opus_path: - opus_candidates.append(opus_path) - # ctypes.util.find_library fails on macOS with Homebrew-installed libs, - # so fall back to known Homebrew paths if needed. - if not opus_path: - _homebrew_paths = ( - "/opt/homebrew/lib/libopus.dylib", # Apple Silicon - "/usr/local/lib/libopus.dylib", # Intel Mac - ) - if sys.platform == "darwin": - for _hp in _homebrew_paths: - if os.path.isfile(_hp): - opus_candidates.append(_hp) - break - for opus_path in opus_candidates: - try: - discord.opus.load_opus(opus_path) - if discord.opus.is_loaded(): - break - except Exception: - logger.warning("Opus codec found at %s but failed to load", opus_path) - if not discord.opus.is_loaded(): - logger.warning("Opus codec not found — voice channel playback disabled") - + _load_opus_codec() if not self.config.token: logger.error("[%s] No bot token configured", self.name) self._set_fatal_error("missing_credentials", "No bot token configured", retryable=False) return False - try: if not self._acquire_platform_lock('discord-bot-token', self.config.token, 'Discord bot token'): return False - - # Snapshot this profile's gate env vars (issue #72348): connect() - # runs inside the owning profile's runtime scope under multiplex, - # so the snapshot holds THIS adapter's values, immune to the - # first-writer-wins process-global env bridge. + # Snapshot gate env inside the owning profile's scope (immune to the first-writer-wins bridge). self._snapshot_gate_env() - - # Parse allowed user entries (may contain usernames or IDs) self._allowed_user_ids = self._get_allowed_users() - - # Parse DISCORD_ALLOWED_ROLES — comma-separated role IDs. - # Users with ANY of these roles can interact with the bot. + # DISCORD_ALLOWED_ROLES: comma-separated role IDs; ANY match grants access. self._allowed_role_ids = self._get_allowed_roles() - - # Set up intents. - # Message Content is required for normal text replies. - # Server Members is only needed when the allowlist contains usernames - # that must be resolved to numeric IDs. Requesting privileged intents - # that aren't enabled in the Discord Developer Portal can prevent the - # bot from coming online at all, so avoid requesting members intent - # unless it is actually necessary. - # ``"*"`` is the open-mode wildcard (honored in _is_allowed_user), not - # a username to resolve — requesting Members for it would silently - # fail bots that never enabled Members Intent in the Developer Portal. + # Intents: Server Members only when usernames must be resolved — an unenabled privileged + # intent can keep the bot offline. ``"*"`` is the open-mode wildcard, not a username. intents = Intents.default() intents.message_content = True intents.dm_messages = True intents.guild_messages = True intents.members = _needs_server_members_intent( - self._allowed_user_ids, - self._allowed_role_ids, + self._allowed_user_ids, self._allowed_role_ids, ) intents.voice_states = True - # Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy) from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_bot proxy_url = resolve_proxy_url(platform_env_var="DISCORD_PROXY") if proxy_url: logger.info("[%s] Using proxy for Discord: %s", self.name, proxy_url) - - # Create bot — proxy= for HTTP, connector= for SOCKS. - # allowed_mentions is set with safe defaults (no @everyone/roles) - # so LLM output or echoed user content can't ping the whole - # server; override per DISCORD_ALLOW_MENTION_* env vars or the - # discord.allow_mentions.* block in config.yaml. - - # Close any existing client to prevent zombie websocket connections - # on reconnect (see #18187). Without this, the old client remains - # connected to Discord gateway and both fire on_message, causing - # double responses. + # proxy= for HTTP, connector= for SOCKS; allowed_mentions per _build_allowed_mentions. + # Close any existing client first: a zombie client also fires on_message -> double responses. if self._client is not None: try: if not self._client.is_closed(): @@ -1418,7 +1161,6 @@ class DiscordAdapter(BasePlatformAdapter): finally: self._client = None self._ready_event.clear() - self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, @@ -1427,15 +1169,11 @@ class DiscordAdapter(BasePlatformAdapter): ) adapter_self = self # capture for closure - # Register event handlers @self._client.event async def on_ready(): logger.info("[%s] Connected as %s", adapter_self.name, adapter_self._client.user) - - # Resolve any usernames in the allowed list to numeric IDs await adapter_self._resolve_allowed_usernames() adapter_self._ready_event.set() - if adapter_self._post_connect_task and not adapter_self._post_connect_task.done(): adapter_self._post_connect_task.cancel() adapter_self._post_connect_task = asyncio.create_task( @@ -1467,17 +1205,14 @@ class DiscordAdapter(BasePlatformAdapter): @self._client.event async def on_voice_state_update(member, before, after): """Track voice channel join/leave events.""" - # Only track channels where the bot is connected bot_guild_ids = set(adapter_self._voice_clients.keys()) if not bot_guild_ids: return guild_id = member.guild.id if guild_id not in bot_guild_ids: return - # Ignore the bot itself if member == adapter_self._client.user: return - joined = before.channel is None and after.channel is not None left = before.channel is not None and after.channel is None switched = ( @@ -1485,7 +1220,6 @@ class DiscordAdapter(BasePlatformAdapter): and after.channel is not None and before.channel != after.channel ) - if joined or left or switched: logger.info( "Voice state: %s (%d) %s (guild %d)", @@ -1496,77 +1230,49 @@ class DiscordAdapter(BasePlatformAdapter): else f"moved {before.channel.name} -> {after.channel.name}", guild_id, ) - - # Register slash commands if self._slash_commands: self._register_slash_commands() - - # Start the bot in background self._disconnecting = False self._bot_task = asyncio.create_task(self._client.start(self.config.token)) self._bot_task.add_done_callback(self._handle_bot_task_done) - ready_timeout = _discord_ready_timeout_seconds() - # Wait for ready, but fail fast if discord.py's background startup - # task dies first (for example on SOCKS/proxy connect errors). + # Wait for ready, failing fast if the startup task dies first (e.g. SOCKS errors). await _wait_for_ready_or_bot_exit( - self._ready_event, - self._bot_task, + self._ready_event, self._bot_task, timeout=None if ready_timeout <= 0 else ready_timeout, ) - self._running = True self._start_liveness_probe() # Plugin-registered native handlers (discord.py Bot — add_listener()/event hooks). self._wire_plugin_handlers(self._client) return True - except asyncio.TimeoutError: logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True) - # Cancel the background bot task so it cannot fire on_message after - # this adapter is discarded. Without this, the task keeps running and - # a later successful reconnect leaves two active Discord clients that - # each process every message, producing duplicate threads/responses. + # Cancel the bot task so a discarded adapter can't fire on_message (two clients answering). await self._cancel_bot_task() self._release_platform_lock() - # Always set an explicit fatal code (OOF-152): a code-less failure - # forces the gateway into its "no info = probably transient" guess. + # Always set an explicit fatal code: a code-less failure makes the gateway guess "transient". self._set_fatal_error( "discord_connect_timeout", - "Timed out waiting for the Discord gateway to become ready", - retryable=True, + "Timed out waiting for the Discord gateway to become ready", retryable=True, ) return False except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True) - # Same zombie-client hazard as the timeout branch: the background - # client.start() task may already be running when a later setup - # step raises. Cancel it so the discarded adapter cannot connect. + # Same zombie-client hazard: client.start() may already run when a later step raises. await self._cancel_bot_task() self._release_platform_lock() - # Classify by exception TYPE (OOF-152). Previously this branch set - # no fatal error at all, so the gateway treated every startup - # failure — including a revoked token or a privileged intent that - # was never enabled in the Developer Portal — as transient and - # retried it forever with zero owner signal. Auth/permission - # failures can never self-heal: mark them retryable=False so they - # drop out of the reconnect queue and surface as fatal. + # Classify by exception TYPE: auth/permission failures can't self-heal, so + # retryable=False drops them from the reconnect queue and surfaces them as fatal. code, message, retryable = self._classify_connect_exception(e) self._set_fatal_error(code, message, retryable=retryable) return False def _classify_connect_exception(self, error: Exception) -> tuple: - """Map a Discord startup exception to ``(code, message, retryable)``. - - Type-based only — never match on message text. Unknown exception - types stay ``retryable=True``: a false terminal on a transient error - would leave a recovered platform silently dead, which is the exact - failure mode the auto-pause removal fixed. The reconnect watcher's - NEEDS_ATTENTION escalation covers misclassified permanent failures. - """ + """Map a startup exception to ``(code, message, retryable)`` by TYPE only (never message + text); unknown types stay retryable — a false terminal leaves a recovered platform dead.""" def _is(type_name: str) -> bool: - # Class-name check covers mocked discord.py (tests) and failed - # imports; the isinstance check additionally covers subclasses. + # Class-name check covers mocked discord.py / failed imports; isinstance adds subclasses. if error.__class__.__name__ == type_name: return True try: @@ -1575,7 +1281,6 @@ class DiscordAdapter(BasePlatformAdapter): return isinstance(exc_type, type) and isinstance(error, exc_type) except Exception: return False - if _is("LoginFailure"): return ( "discord_auth_error", @@ -1585,9 +1290,7 @@ class DiscordAdapter(BasePlatformAdapter): False, ) if _is("PrivilegedIntentsRequired"): - # Name the exact intents Hermes requested (#79430): Message - # Content always; Server Members only when username/role - # allowlists actually need member lookups. + # Name the exact intents requested (Server Members only when allowlists need lookups). guidance = _format_privileged_intents_guidance( needs_members=_needs_server_members_intent( getattr(self, "_allowed_user_ids", None), @@ -1597,12 +1300,7 @@ class DiscordAdapter(BasePlatformAdapter): return ("discord_intents_required", guidance, False) return ("discord_connect_error", f"Discord startup failed: {error}", True) - def _discord_message_admission( - self, - message: Any, - *, - claim: bool, - ) -> tuple[bool, bool]: + def _discord_message_admission(self, message: Any, *, claim: bool) -> tuple[bool, bool]: """Return ``(admitted, role_authorized)`` for one Discord event.""" message_id = str(getattr(message, "id", "")) if claim: @@ -1614,7 +1312,6 @@ class DiscordAdapter(BasePlatformAdapter): return False, False if message.type not in {discord.MessageType.default, discord.MessageType.reply}: return False, False - role_authorized = False if getattr(message.author, "bot", False): allow_bots = self._get_allow_bots() @@ -1637,16 +1334,12 @@ class DiscordAdapter(BasePlatformAdapter): if parent_id: msg_channel_ids.add(parent_id) if not self._is_allowed_user( - str(message.author.id), - message.author, - guild=msg_guild, - is_dm=is_dm, + str(message.author.id), message.author, guild=msg_guild, is_dm=is_dm, channel_ids=msg_channel_ids, ): self._warn_if_fail_closed_default() return False, False role_authorized = bool(getattr(self, "_allowed_role_ids", set())) - raw_self_mention = self._self_is_explicitly_mentioned(message) if not isinstance(message.channel, discord.DMChannel) and ( message.mentions or raw_self_mention @@ -1668,7 +1361,6 @@ class DiscordAdapter(BasePlatformAdapter): channel_keys = self._discord_channel_keys(message, parent_id) if "*" not in free_channels and not (channel_keys & free_channels): return False, False - return True, role_authorized async def _dispatch_discord_message(self, message: Any) -> bool: @@ -1678,26 +1370,15 @@ class DiscordAdapter(BasePlatformAdapter): await asyncio.wait_for(self._ready_event.wait(), timeout=30.0) except asyncio.TimeoutError: pass - admitted, role_authorized = self._discord_message_admission( - message, claim=True, - ) + admitted, role_authorized = self._discord_message_admission(message, claim=True) if not admitted: return False - return await self._handle_message( - message, role_authorized=role_authorized, - ) + return await self._handle_message(message, role_authorized=role_authorized) - # ------------------------------------------------------------------ - # gateway_platform_event fire-sites (#64176) - # ------------------------------------------------------------------ + # --- gateway_platform_event fire-sites --- def _thread_id_and_chat_for_channel(self, channel) -> tuple[Optional[str], Optional[str]]: - """Return ``(thread_id, chat_id)`` for a message channel. - - For a thread, ``chat_id`` is the thread id itself (matching how - Discord message dispatch keys sessions) and ``thread_id`` is set; - for a plain channel, ``thread_id`` is None. - """ + """Return ``(thread_id, chat_id)``; for a thread chat_id is the thread id (dispatch session key).""" if channel is None: return None, None chan_id = getattr(channel, "id", None) @@ -1707,235 +1388,138 @@ class DiscordAdapter(BasePlatformAdapter): return (str(chan_id) if is_thread else None), str(chan_id) def _source_for_platform_event( - self, - *, - chat_id: str, - user_id: Optional[str], - user_name: Optional[str], - thread_id: Optional[str], - guild_id: Optional[str], - message_id: Optional[str] = None, + self, *, chat_id: str, user_id: Optional[str], user_name: Optional[str], + thread_id: Optional[str], guild_id: Optional[str], message_id: Optional[str] = None, ): - """Build the internal SessionSource the gateway authorizes against. - - Raises ``ValueError`` when the actor or chat identity is missing so the - post-auth boundary fails closed instead of authorizing an incomplete - source (mirrors the Telegram reaction extractor). - """ + """Build the SessionSource the gateway authorizes against; missing identity raises (fail closed).""" if not user_id or not chat_id: - raise ValueError( - "gateway_platform_event requires actor and chat identities" - ) + raise ValueError("gateway_platform_event requires actor and chat identities") return self.build_source( - chat_id=chat_id, - chat_type="thread" if thread_id else "group", - user_id=user_id, - user_name=user_name, - thread_id=thread_id, - guild_id=guild_id, - message_id=message_id, + chat_id=chat_id, chat_type="thread" if thread_id else "group", user_id=user_id, + user_name=user_name, thread_id=thread_id, guild_id=guild_id, message_id=message_id, ) async def _fire_platform_event(self, event: Dict[str, Any], source) -> None: - """Forward one normalized envelope to the gateway-owned boundary. - - No installed callback means no trusted auth boundary — fail closed. - Dispatch errors never propagate into discord.py's event loop. - """ + """Forward one envelope to the gateway boundary; no callback -> fail closed, errors never escape.""" handler = getattr(self, "_platform_event_handler", None) if handler is None: return try: await handler(event, source) except Exception: - logger.debug( - "[%s] gateway_platform_event dispatch error", self.name, exc_info=True, - ) + logger.debug("[%s] gateway_platform_event dispatch error", self.name, exc_info=True) @staticmethod def _platform_events_subscribed() -> bool: """has_hook fast-path shared by every Discord fire-site.""" try: from hermes_cli.lifecycle import has_hook - return has_hook("gateway_platform_event") except Exception: return False + async def _emit_platform_event(self, event_type: str, build) -> None: + """Normalize one event via ``build()`` -> ``(payload, source_kwargs)`` (None drops) and dispatch.""" + if not self._platform_events_subscribed(): + return + try: + built = build() + if built is None: + return + payload, source_kwargs = built + event = {"platform": "discord", "event_type": event_type, "payload": payload} + source = self._source_for_platform_event(**source_kwargs) + except Exception: + logger.debug("[%s] %s normalize error", self.name, event_type, exc_info=True) + return + await self._fire_platform_event(event, source) + + def _message_event_parts(self, message, extra_payload): + """Shared normalizer for message edit/delete: (payload, source kwargs) or None.""" + author = getattr(message, "author", None) + if author is not None and getattr(author, "bot", False): + return None # bot's own progressive edits are noise, not user events + thread_id, chat_id = self._thread_id_and_chat_for_channel(getattr(message, "channel", None)) + message_id = getattr(message, "id", None) + if chat_id is None or message_id is None: + return None + guild = getattr(message, "guild", None) + payload = { + "chat_id": str(chat_id)[:128], "message_id": str(message_id)[:128], + "thread_id": thread_id[:128] if thread_id else None, **extra_payload(message, author), + } + return payload, dict( + chat_id=str(chat_id), user_id=str(getattr(author, "id", "") or "") or None, + user_name=getattr(author, "display_name", None), thread_id=thread_id, + guild_id=str(getattr(guild, "id", "")) if guild else None, message_id=str(message_id), + ) + + @staticmethod + def _thread_event_parts(thread, extra_payload): + """Shared normalizer for thread create/rename; the owner is the authorized actor + because Discord's event carries none (same trade-off as ``message_deleted``).""" + thread_id = getattr(thread, "id", None) + owner_id = getattr(thread, "owner_id", None) + if thread_id is None: + return None + parent_id = getattr(thread, "parent_id", None) + guild = getattr(thread, "guild", None) + payload = { + "thread_id": str(thread_id)[:128], + "parent_chat_id": str(parent_id)[:128] if parent_id is not None else None, + **extra_payload(thread, owner_id), + } + return payload, dict( + chat_id=str(thread_id), user_id=str(owner_id) if owner_id is not None else None, + user_name=None, thread_id=str(thread_id), + guild_id=str(getattr(guild, "id", "")) if guild else None, + ) + async def _on_platform_message_edit(self, before, after) -> None: """Normalize ``on_message_edit`` into event_type ``message_edited``.""" - if not self._platform_events_subscribed(): - return - try: - message = after if after is not None else before - author = getattr(message, "author", None) - if author is not None and getattr(author, "bot", False): - return # bot's own progressive edits are noise, not user events - thread_id, chat_id = self._thread_id_and_chat_for_channel( - getattr(message, "channel", None) - ) - message_id = getattr(message, "id", None) - if chat_id is None or message_id is None: - return + def _extra(message, author): text = getattr(message, "content", None) edited_at = getattr(message, "edited_at", None) - guild = getattr(message, "guild", None) - event = { - "platform": "discord", - "event_type": "message_edited", - "payload": { - "chat_id": str(chat_id)[:128], - "message_id": str(message_id)[:128], - "thread_id": thread_id[:128] if thread_id else None, - "text": text[:8192] if isinstance(text, str) else None, - "edited_at": ( - str(edited_at.isoformat())[:64] - if edited_at is not None and hasattr(edited_at, "isoformat") - else None - ), - }, + return { + "text": text[:8192] if isinstance(text, str) else None, + "edited_at": ( + str(edited_at.isoformat())[:64] + if edited_at is not None and hasattr(edited_at, "isoformat") + else None + ), } - source = self._source_for_platform_event( - chat_id=str(chat_id), - user_id=str(getattr(author, "id", "") or "") or None, - user_name=getattr(author, "display_name", None), - thread_id=thread_id, - guild_id=str(getattr(guild, "id", "")) if guild else None, - message_id=str(message_id), - ) - except Exception: - logger.debug( - "[%s] message_edited normalize error", self.name, exc_info=True, - ) - return - await self._fire_platform_event(event, source) + message = after if after is not None else before + await self._emit_platform_event("message_edited", lambda: self._message_event_parts(message, _extra)) async def _on_platform_message_delete(self, message) -> None: - """Normalize ``on_message_delete`` into event_type ``message_deleted``. - - Discord does not identify the deleter in this event; the source - authorized is the deleted message's author (the only identity the - cached event carries). Uncached deletions never fire. - """ - if not self._platform_events_subscribed(): - return - try: - author = getattr(message, "author", None) - if author is not None and getattr(author, "bot", False): - return - thread_id, chat_id = self._thread_id_and_chat_for_channel( - getattr(message, "channel", None) - ) - message_id = getattr(message, "id", None) - if chat_id is None or message_id is None: - return - guild = getattr(message, "guild", None) - event = { - "platform": "discord", - "event_type": "message_deleted", - "payload": { - "chat_id": str(chat_id)[:128], - "message_id": str(message_id)[:128], - "thread_id": thread_id[:128] if thread_id else None, - "author_id": str(getattr(author, "id", "") or "")[:128] or None, - }, - } - source = self._source_for_platform_event( - chat_id=str(chat_id), - user_id=str(getattr(author, "id", "") or "") or None, - user_name=getattr(author, "display_name", None), - thread_id=thread_id, - guild_id=str(getattr(guild, "id", "")) if guild else None, - message_id=str(message_id), - ) - except Exception: - logger.debug( - "[%s] message_deleted normalize error", self.name, exc_info=True, - ) - return - await self._fire_platform_event(event, source) + """Normalize ``on_message_delete`` into ``message_deleted``. Discord omits the + deleter, so the author (the only cached identity) is the source; uncached deletions never fire.""" + def _extra(message, author): + return {"author_id": str(getattr(author, "id", "") or "")[:128] or None} + await self._emit_platform_event("message_deleted", lambda: self._message_event_parts(message, _extra)) async def _on_platform_thread_create(self, thread) -> None: """Normalize ``on_thread_create`` into event_type ``thread_created``.""" - if not self._platform_events_subscribed(): - return - try: - thread_id = getattr(thread, "id", None) - owner_id = getattr(thread, "owner_id", None) - if thread_id is None: - return - parent_id = getattr(thread, "parent_id", None) - guild = getattr(thread, "guild", None) + def _extra(thread, owner_id): name = getattr(thread, "name", None) - event = { - "platform": "discord", - "event_type": "thread_created", - "payload": { - "thread_id": str(thread_id)[:128], - "parent_chat_id": str(parent_id)[:128] if parent_id is not None else None, - "name": name[:256] if isinstance(name, str) else None, - "owner_id": str(owner_id)[:128] if owner_id is not None else None, - }, + return { + "name": name[:256] if isinstance(name, str) else None, + "owner_id": str(owner_id)[:128] if owner_id is not None else None, } - source = self._source_for_platform_event( - chat_id=str(thread_id), - user_id=str(owner_id) if owner_id is not None else None, - user_name=None, - thread_id=str(thread_id), - guild_id=str(getattr(guild, "id", "")) if guild else None, - ) - except Exception: - logger.debug( - "[%s] thread_created normalize error", self.name, exc_info=True, - ) - return - await self._fire_platform_event(event, source) + await self._emit_platform_event("thread_created", lambda: self._thread_event_parts(thread, _extra)) async def _on_platform_thread_update(self, before, after) -> None: - """Normalize a rename observed via ``on_thread_update`` into - event_type ``thread_renamed``. Non-rename updates (archive state, - slowmode, tags) are dropped. - - Discord's thread-update event carries no actor; the thread owner is - the only stable identity available, so that is what the gateway - authorizes (same trade-off as ``message_deleted``'s author). - """ - if not self._platform_events_subscribed(): - return - try: + """Normalize ``on_thread_update`` renames into ``thread_renamed``; non-rename updates are dropped.""" + def _build(): old_name = getattr(before, "name", None) new_name = getattr(after, "name", None) if old_name == new_name or not isinstance(new_name, str): - return - thread_id = getattr(after, "id", None) - owner_id = getattr(after, "owner_id", None) - if thread_id is None: - return - parent_id = getattr(after, "parent_id", None) - guild = getattr(after, "guild", None) - event = { - "platform": "discord", - "event_type": "thread_renamed", - "payload": { - "thread_id": str(thread_id)[:128], - "parent_chat_id": str(parent_id)[:128] if parent_id is not None else None, - "old_name": old_name[:256] if isinstance(old_name, str) else None, - "new_name": new_name[:256], - }, - } - source = self._source_for_platform_event( - chat_id=str(thread_id), - user_id=str(owner_id) if owner_id is not None else None, - user_name=None, - thread_id=str(thread_id), - guild_id=str(getattr(guild, "id", "")) if guild else None, - ) - except Exception: - logger.debug( - "[%s] thread_renamed normalize error", self.name, exc_info=True, - ) - return - await self._fire_platform_event(event, source) + return None + return self._thread_event_parts(after, lambda _t, _o: { + "old_name": old_name[:256] if isinstance(old_name, str) else None, + "new_name": new_name[:256], + }) + await self._emit_platform_event("thread_renamed", _build) async def _cancel_bot_task(self) -> None: """Cancel and await the background client.start() task, if running.""" @@ -1948,11 +1532,7 @@ class DiscordAdapter(BasePlatformAdapter): self._bot_task = None def _start_liveness_probe(self) -> None: - """Start the periodic Discord Gateway WebSocket health probe. - - REST success does not prove Gateway event delivery. Sample the active - Gateway WebSocket's ready/open/ACK state instead. - """ + """Start the periodic Gateway WS health probe (REST success doesn't prove event delivery).""" if ( self._liveness_interval_seconds <= 0 or self._liveness_failure_threshold <= 0 @@ -1972,26 +1552,19 @@ class DiscordAdapter(BasePlatformAdapter): return False, "not_ready" if not ready: return False, "not_ready" - try: if client.is_closed(): return False, "client_closed" except Exception: return False, "client_closed" - websocket = getattr(client, "ws", None) try: - socket_open = bool( - websocket is not None and getattr(websocket, "open", False) - ) + socket_open = bool(websocket is not None and getattr(websocket, "open", False)) except Exception: - # A transport object that cannot report its open state is not a - # usable event stream. Treat it as unhealthy rather than letting - # the periodic liveness task crash silently. + # A transport that can't report open state isn't a usable event stream: treat as unhealthy. return False, "socket_state_unavailable" if not socket_open: return False, "socket_closed" - keep_alive = getattr(websocket, "_keep_alive", None) last_ack = getattr(keep_alive, "_last_ack", None) if not isinstance(last_ack, (int, float)): @@ -1999,7 +1572,6 @@ class DiscordAdapter(BasePlatformAdapter): ack_age = time.perf_counter() - last_ack if not math.isfinite(ack_age) or ack_age > self._heartbeat_ack_max_age_seconds: return False, "ack_stale" - latency = getattr(client, "latency", None) if not isinstance(latency, (int, float)) or not math.isfinite(latency): return False, "latency_non_finite" @@ -2023,38 +1595,28 @@ class DiscordAdapter(BasePlatformAdapter): try: healthy, reason = self._read_websocket_health(client) except Exception: - # Health sampling must fail closed: an unexpected discord.py - # attribute change cannot be allowed to kill this watchdog - # task and leave an apparently-running adapter unrecovered. + # Fail closed: a discord.py attribute change must not kill this watchdog silently. healthy = False reason = "health_check_error" if healthy: failures = 0 continue - failures += 1 logger.warning( - "[%s] Discord Gateway WebSocket unhealthy (%s, %d/%d)", - self.name, - reason, - failures, + "[%s] Discord Gateway WebSocket unhealthy (%s, %d/%d)", self.name, reason, failures, threshold, ) if failures < threshold: continue - # Mark intentional recovery before closing the client. Closing a - # healthy-looking but stale transport can complete Bot.start(); its - # done callback must not overwrite this more specific fatal reason. + # Mark recovery before closing: Bot.start()'s done callback must not overwrite this reason. self._disconnecting = True logger.error( "[%s] Discord Gateway WebSocket remained unhealthy (%s); forcing reconnect", - self.name, - reason, + self.name, reason, ) self._set_fatal_error( "discord_websocket_health_stale", - f"Discord Gateway WebSocket health check failed: {reason}", - retryable=True, + f"Discord Gateway WebSocket health check failed: {reason}", retryable=True, ) self._liveness_notification_task = asyncio.create_task( self._notify_liveness_fatal_error(client) @@ -2062,12 +1624,8 @@ class DiscordAdapter(BasePlatformAdapter): return async def _notify_liveness_fatal_error(self, client: Any) -> None: - """Close the failed client, then notify the runner outside the sampler. - - The sampler must not await itself through ``disconnect()``. Running the - close and fatal callback in this sibling task also means the runner owns - the bounded full teardown before it creates a replacement adapter. - """ + """Close the failed client, then notify the runner outside the sampler (which must not + await itself via ``disconnect()``); the runner owns the bounded teardown.""" failed_websocket = getattr(client, "ws", None) try: close_task = asyncio.create_task(client.close()) @@ -2084,29 +1642,21 @@ class DiscordAdapter(BasePlatformAdapter): if isinstance(closing_task, asyncio.Task): closing_task.cancel() closing_task.add_done_callback(_consume_background_task_result) - # discord.Client.close() caches this task. Clear the cache - # before the runner's bounded disconnect makes another - # cleanup attempt; the stale task remains owned by its - # done callback until it actually exits. + # Client.close() caches this task; clear it before the runner's disconnect retries. client._closing_task = None try: if _abort_discord_websocket_transport(failed_websocket): logger.warning( - "[%s] Aborted unresponsive Discord WebSocket transport", - self.name, + "[%s] Aborted unresponsive Discord WebSocket transport", self.name, ) except Exception: logger.debug( - "[%s] Error aborting unhealthy Discord WebSocket transport", - self.name, + "[%s] Error aborting unhealthy Discord WebSocket transport", self.name, exc_info=True, ) except Exception: logger.debug("[%s] Error closing unhealthy Discord client", self.name, exc_info=True) - # The runner's bounded teardown can execute ``disconnect()`` inside - # a timeout wrapper, which is a different task from this notifier. - # Drop the self-reference before notifying so disconnect() cannot - # cancel this in-flight fatal callback as though it were unrelated. + # Runner may run disconnect() elsewhere; drop the self-ref so it can't cancel this callback. if self._liveness_notification_task is asyncio.current_task(): self._liveness_notification_task = None await self._notify_fatal_error() @@ -2133,21 +1683,9 @@ class DiscordAdapter(BasePlatformAdapter): setattr(self, task_name, None) async def cancel_background_tasks(self) -> None: - """Cancel background tasks, but first flush any pending text-batch sends. - - The base-class implementation only cancels tasks in self._background_tasks. - Discord keeps its own _pending_text_batch_tasks dict for the message-merge - logic, and those tasks are NOT in _background_tasks. On shutdown/restart - this caused a race where in-flight response deliveries were cancelled before - Discord had a chance to actually send them, resulting in silent dropped - messages visible to the user as tool-log-only replies with no text. - - Fix: await all pending text-batch tasks before delegating to the base - cancel. The flush deadline is clamped below the gateway's per-adapter - disconnect budget (``HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT``, default - 5s) so the gateway's outer ``wait_for`` can't hard-cancel us mid-flush — - we cancel our own stragglers cleanly inside the budget instead. - """ + """Cancel background tasks, but first flush pending text-batch sends (cancelling + ``_pending_text_batch_tasks`` mid-send dropped replies); the flush deadline stays below the + gateway's per-adapter disconnect budget so the outer ``wait_for`` can't hard-cancel us.""" pending = list(self._pending_text_batch_tasks.values()) if pending: logger.info( @@ -2161,8 +1699,7 @@ class DiscordAdapter(BasePlatformAdapter): ) except asyncio.TimeoutError: logger.warning( - "[%s] Text-batch flush timed out; cancelling remaining tasks", - self.name, + "[%s] Text-batch flush timed out; cancelling remaining tasks", self.name, ) for task in pending: if not task.done(): @@ -2172,14 +1709,8 @@ class DiscordAdapter(BasePlatformAdapter): await super().cancel_background_tasks() def _text_batch_flush_deadline_seconds(self) -> float: - """Deadline for flushing pending text batches during shutdown. - - Kept strictly below the gateway's per-adapter disconnect budget so the - gateway's outer ``asyncio.wait_for`` (which wraps this whole method) does - not cancel an in-progress flush before we get a chance to cancel our own - stragglers gracefully. Mirrors the env var the gateway reads in - ``GatewayRunner._adapter_disconnect_timeout_secs``. - """ + """Deadline for flushing pending text batches during shutdown: strictly below the gateway's + per-adapter disconnect budget so its outer ``wait_for`` can't cancel the flush first.""" budget = 5.0 # mirrors gateway _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT raw = os.getenv("HERMES_GATEWAY_ADAPTER_DISCONNECT_TIMEOUT", "").strip() if raw: @@ -2189,10 +1720,7 @@ class DiscordAdapter(BasePlatformAdapter): budget = parsed except ValueError: pass - # Stay strictly below the budget so the gateway's outer wait_for can't - # pre-empt our own straggler cancellation. Reserve ~20% (min 0.5s) of - # headroom, and never let the floor push us back up to/over the budget - # on tiny budgets — cap at 90% of the budget as a hard ceiling. + # Reserve ~20% (min 0.5s) headroom, hard-capped at 90% so the floor can't exceed the budget. headroom = max(0.5, budget * 0.2) deadline = max(1.0, budget - headroom) return min(deadline, budget * 0.9) @@ -2200,63 +1728,41 @@ class DiscordAdapter(BasePlatformAdapter): async def disconnect(self) -> None: """Disconnect from Discord.""" self._disconnecting = True - # Cancel the liveness probe first so it can't fire a spurious fatal - # error / reconnect while we're intentionally tearing the adapter down. + # Cancel the liveness probe first so it can't fire a spurious fatal/reconnect mid-teardown. await self._cancel_liveness_task() - # Clean up all active voice connections *before* cancelling the bot task. - # leave_voice_channel() ends in `await vc.disconnect()`, and discord.py's - # VoiceClient.disconnect() sends a voice state update over the main - # gateway websocket and then waits for the voice socket to close. The - # bot task is the loop running that gateway connection, so cancelling it - # first leaves the handshake with no transport: it can never complete and - # blocks until the caller's shutdown timeout fires. + # Leave voice *before* cancelling the bot task: VoiceClient.disconnect() needs the main + # gateway WS (run by the bot task) or it blocks until the timeout. for guild_id in list(self._voice_clients.keys()): try: await self.leave_voice_channel(guild_id) except Exception as e: # pragma: no cover - defensive logging logger.debug("[%s] Error leaving voice channel %s: %s", self.name, guild_id, e) - - # Cancel the bot task before closing the client. If connect() timed out - # and returned False, the background client.start() task may still be - # running; calling client.close() alone is not enough to stop it because - # discord.py's reconnect loop can ignore the closed flag while a - # WebSocket handshake is in flight. Explicitly cancelling the task here - # ensures the zombie client cannot receive or dispatch any further events. + # Cancel the bot task before closing: after a connect() timeout client.start() may still run + # and discord.py's reconnect loop can ignore the closed flag mid-handshake. await self._cancel_bot_task() - if self._client: try: await self._client.close() except Exception as e: # pragma: no cover - defensive logging logger.warning("[%s] Error during disconnect: %s", self.name, e, exc_info=True) - - if self._post_connect_task and not self._post_connect_task.done(): - self._post_connect_task.cancel() - try: - await self._post_connect_task - except asyncio.CancelledError: - pass - if self._missed_message_backfill_task and not self._missed_message_backfill_task.done(): - self._missed_message_backfill_task.cancel() - try: - await self._missed_message_backfill_task - except asyncio.CancelledError: - pass - + for task in (self._post_connect_task, self._missed_message_backfill_task): + if task and not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass self._running = False self._client = None self._ready_event.clear() self._post_connect_task = None self._liveness_task = None self._missed_message_backfill_task = None - self._release_platform_lock() - logger.info("[%s] Disconnected", self.name) def _command_sync_state_path(self) -> _Path: from hermes_constants import get_hermes_home - directory = get_hermes_home() / _DISCORD_COMMAND_SYNC_STATE_SUBDIR try: directory.mkdir(parents=True, exist_ok=True) @@ -2276,10 +1782,7 @@ class DiscordAdapter(BasePlatformAdapter): def _write_command_sync_state(self, state: dict) -> None: atomic_json_write( - self._command_sync_state_path(), - state, - indent=None, - separators=(",", ":"), + self._command_sync_state_path(), state, indent=None, separators=(",", ":"), ) def _command_sync_state_key(self, app_id: Any) -> str: @@ -2316,44 +1819,33 @@ class DiscordAdapter(BasePlatformAdapter): return "same slash-command fingerprint already synced" return None - def _record_command_sync_attempt(self, app_id: Any, fingerprint: str) -> None: + def _update_command_sync_entry(self, app_id: Any, fingerprint: str, *, keep_existing: bool, drop=(), fields=None) -> None: + """Rewrite this app's sync-state entry (optionally merged over the existing one). + ``fields`` is a callable of ``now`` so timestamps derive from one clock read.""" state = self._read_command_sync_state() key = self._command_sync_state_key(app_id) - entry = state.get(key) if isinstance(state.get(key), dict) else {} - entry.pop("last_success_at", None) - entry.pop("summary", None) - state[key] = { - **entry, - "fingerprint": fingerprint, - "last_attempt_at": time.time(), - } + entry = dict(state.get(key)) if keep_existing and isinstance(state.get(key), dict) else {} + for name in drop: + entry.pop(name, None) + now = time.time() + state[key] = {**entry, "fingerprint": fingerprint, "last_attempt_at": now, **(fields(now) if fields else {})} self._write_command_sync_state(state) + def _record_command_sync_attempt(self, app_id: Any, fingerprint: str) -> None: + self._update_command_sync_entry(app_id, fingerprint, keep_existing=True, drop=("last_success_at", "summary")) + def _record_command_sync_rate_limit(self, app_id: Any, fingerprint: str, retry_after: float) -> None: retry_after = max(1.0, float(retry_after)) - state = self._read_command_sync_state() - state[self._command_sync_state_key(app_id)] = { - **( - state.get(self._command_sync_state_key(app_id)) - if isinstance(state.get(self._command_sync_state_key(app_id)), dict) - else {} - ), - "fingerprint": fingerprint, - "last_attempt_at": time.time(), - "retry_after_until": time.time() + retry_after, - "retry_after": retry_after, - } - self._write_command_sync_state(state) + self._update_command_sync_entry( + app_id, fingerprint, keep_existing=True, + fields=lambda now: {"retry_after_until": time.time() + retry_after, "retry_after": retry_after}, + ) def _record_command_sync_success(self, app_id: Any, fingerprint: str, summary: dict) -> None: - state = self._read_command_sync_state() - state[self._command_sync_state_key(app_id)] = { - "fingerprint": fingerprint, - "last_attempt_at": time.time(), - "last_success_at": time.time(), - "summary": summary, - } - self._write_command_sync_state(state) + self._update_command_sync_entry( + app_id, fingerprint, keep_existing=False, + fields=lambda now: {"last_success_at": time.time(), "summary": summary}, + ) @staticmethod def _extract_discord_retry_after(exc: BaseException) -> Optional[float]: @@ -2381,15 +1873,9 @@ class DiscordAdapter(BasePlatformAdapter): @staticmethod def _is_discord_rate_limit(exc: BaseException) -> bool: - """True only for exceptions that look like Discord 429 rate limits. - - Narrower than ``hasattr(exc, 'retry_after')``: discord.py's own - ``RateLimited`` exception and any HTTPException with status 429 - qualify. This prevents suppressing unrelated failures that happen - to expose a ``retry_after`` attribute.""" - # discord.py emits RateLimited / HTTPException subclasses for 429s. - # Guard with isinstance-of-class so a mocked ``discord`` module - # (where attrs are MagicMocks, not types) doesn't trip isinstance. + """True only for Discord 429 rate-limit exceptions (``RateLimited`` or HTTPException + status 429) — narrower than ``hasattr(exc, 'retry_after')``.""" + # isinstance-of-class guard: a mocked ``discord`` module has MagicMock attrs, not types. if DISCORD_AVAILABLE and discord is not None: for attr_name in ("RateLimited", "HTTPException"): cls = getattr(discord, attr_name, None) @@ -2401,17 +1887,13 @@ class DiscordAdapter(BasePlatformAdapter): status = getattr(exc, "status", None) if status == 429: return True - # Fallback duck-type: something named like a rate-limit with a - # numeric retry_after. Covers mocked clients in tests and exotic - # transports, without swallowing arbitrary exceptions. + # Duck-type fallback: rate-limit-ish name plus numeric retry_after (mocks, exotic transports). name = type(exc).__name__.lower() if ("ratelimit" in name or "rate_limit" in name) and getattr(exc, "retry_after", None) is not None: return True response = getattr(exc, "response", None) status = getattr(response, "status", None) or getattr(response, "status_code", None) - if status == 429: - return True - return False + return status == 429 @staticmethod def _is_discord_unknown_interaction(exc: BaseException) -> bool: @@ -2425,7 +1907,6 @@ class DiscordAdapter(BasePlatformAdapter): code = int(code) except (TypeError, ValueError): code = None - status = getattr(exc, "status", None) response = getattr(exc, "response", None) if status is None and response is not None: @@ -2434,7 +1915,6 @@ class DiscordAdapter(BasePlatformAdapter): status = int(status) except (TypeError, ValueError): status = None - message = str(exc).lower() return code == 10062 or (status == 404 and "unknown interaction" in message) @@ -2455,12 +1935,10 @@ class DiscordAdapter(BasePlatformAdapter): if sync_policy == "off": logger.info("[%s] Skipping Discord slash command sync (policy=off)", self.name) return - if sync_policy == "bulk": synced = await asyncio.wait_for(self._client.tree.sync(), timeout=30) logger.info("[%s] Synced %d slash command(s) via bulk tree sync", self.name, len(synced)) return - app_id = getattr(self._client, "application_id", None) or getattr(getattr(self._client, "user", None), "id", None) fingerprint = self._desired_command_sync_fingerprint() skip_reason = self._command_sync_skip_reason(app_id, fingerprint) @@ -2468,48 +1946,35 @@ class DiscordAdapter(BasePlatformAdapter): logger.info("[%s] Skipping Discord slash command sync: %s", self.name, skip_reason) return self._record_command_sync_attempt(app_id, fingerprint) - http = getattr(self._client, "http", None) has_ratelimit_timeout = http is not None and hasattr(http, "max_ratelimit_timeout") previous_ratelimit_timeout = getattr(http, "max_ratelimit_timeout", None) if has_ratelimit_timeout else None if has_ratelimit_timeout: http.max_ratelimit_timeout = _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS - try: - # Discord's per-app command-management bucket is small, and - # discord.py can otherwise sit inside one long retry sleep - # before surfacing the 429. Keep the whole sync bounded and - # persist Discord's retry-after when it refuses the batch. + # The command-management bucket is small and discord.py may sleep long on a 429: bound it. summary = await asyncio.wait_for(self._safe_sync_slash_commands(), timeout=600) except Exception as e: if not self._is_discord_rate_limit(e): raise retry_after = self._extract_discord_retry_after(e) if retry_after is None: - # Rate-limited but no retry-after signal — back off for a - # conservative default so we don't slam the bucket again. + # Rate-limited with no retry-after: back off a conservative default. retry_after = _DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS self._record_command_sync_rate_limit(app_id, fingerprint, retry_after) logger.warning( - "[%s] Discord rate-limited slash command sync; retrying after %.0fs", - self.name, + "[%s] Discord rate-limited slash command sync; retrying after %.0fs", self.name, retry_after, ) return finally: if has_ratelimit_timeout: http.max_ratelimit_timeout = previous_ratelimit_timeout - self._record_command_sync_success(app_id, fingerprint, summary) logger.info( "[%s] Safely reconciled %d slash command(s): unchanged=%d updated=%d recreated=%d created=%d deleted=%d", - self.name, - summary["total"], - summary["unchanged"], - summary["updated"], - summary["recreated"], - summary["created"], - summary["deleted"], + self.name, summary["total"], summary["unchanged"], summary["updated"], + summary["recreated"], summary["created"], summary["deleted"], ) except asyncio.TimeoutError: logger.warning( @@ -2534,13 +1999,8 @@ class DiscordAdapter(BasePlatformAdapter): return str(raw).strip().lower() in ("true", "1", "yes", "on") def _missed_message_backfill_channels(self) -> set[str]: - """Channels to scan for missed messages after Discord reconnects. - - Defaults to the union of allowed and free-response channels so both - mention-gated requests and mention-free work can be recovered. - Operators can set ``channels: "*"`` to scan every reachable text - channel, but the safe default is scoped. - """ + """Channels to scan for missed messages after reconnect: union of allowed and + free-response channels by default; ``channels: "*"`` scans every text channel.""" configured = self.config.extra.get("missed_message_backfill") if isinstance(configured, dict) and "channels" in configured: raw = configured.get("channels") @@ -2555,44 +2015,26 @@ class DiscordAdapter(BasePlatformAdapter): return allowed | self._discord_free_response_channels() return {item.strip() for item in raw.split(",") if item.strip()} - def _missed_message_backfill_window_seconds(self) -> float: + def _missed_message_backfill_number(self, key: str, env_key: str, default, cast, lo, hi=None): + """Numeric ``missed_message_backfill.`` (dict extra wins over env), clamped to [lo, hi].""" configured = self.config.extra.get("missed_message_backfill") - raw = ( - configured.get("window_seconds", 21600) - if isinstance(configured, dict) - else os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", "21600") - ) + raw = configured.get(key, default) if isinstance(configured, dict) else os.getenv(env_key, str(default)) try: - value = float(raw) + value = cast(raw) except (TypeError, ValueError): - value = 21600.0 - return max(60.0, value) + value = cast(default) + return max(lo, value) if hi is None else max(lo, min(value, hi)) + + def _missed_message_backfill_window_seconds(self) -> float: + return self._missed_message_backfill_number( + "window_seconds", "DISCORD_MISSED_MESSAGE_BACKFILL_WINDOW_SECONDS", 21600, float, 60.0) def _missed_message_backfill_limit(self) -> int: - configured = self.config.extra.get("missed_message_backfill") - raw = ( - configured.get("limit", 100) - if isinstance(configured, dict) - else os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", "100") - ) - try: - value = int(raw) - except (TypeError, ValueError): - value = 100 - return max(1, min(value, 500)) + return self._missed_message_backfill_number("limit", "DISCORD_MISSED_MESSAGE_BACKFILL_LIMIT", 100, int, 1, 500) def _missed_message_backfill_max_dispatches(self) -> int: - configured = self.config.extra.get("missed_message_backfill") - raw = ( - configured.get("max_dispatches", 10) - if isinstance(configured, dict) - else os.getenv("DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", "10") - ) - try: - value = int(raw) - except (TypeError, ValueError): - value = 10 - return max(1, min(value, 100)) + return self._missed_message_backfill_number( + "max_dispatches", "DISCORD_MISSED_MESSAGE_BACKFILL_MAX_DISPATCHES", 10, int, 1, 100) def _ensure_missed_message_backfill_task(self) -> asyncio.Task: """Return the active recovery task, or start one when none is running.""" @@ -2610,65 +2052,44 @@ class DiscordAdapter(BasePlatformAdapter): tasks.append(task) return task - async def _run_missed_message_backfill(self) -> None: - """Find and enqueue recent Discord messages missed while the bot was down. + async def _finish_recovery_scan(self, scan_id: str, status: str, counts: dict, error: Optional[str] = None) -> None: + await asyncio.to_thread(self._record_recovery_scan_complete, scan_id, status=status, error=error, **counts) - Discord gateway events are not replayed for messages sent while the bot - is offline. Normal startup resume only handles sessions already marked - resume_pending; this pass scans recent channel/thread history, records - what it saw durably, and reuses the normal message handler for messages - that lack a substantive non-outage Hermes response. Emoji-only acks are - deliberately not sufficient completion evidence. - """ + async def _run_missed_message_backfill(self) -> None: + """Enqueue recent Discord messages missed while the bot was down: Gateway events aren't + replayed offline, so scan history and re-dispatch messages lacking a substantive bot + response (emoji-only acks aren't completion evidence).""" if not self._client: return channels = self._missed_message_backfill_channels() ledger_ok = await self._with_discord_recovery_db_async( - lambda conn: conn.execute("SELECT 1").fetchone() is not None, - False, + lambda conn: conn.execute("SELECT 1").fetchone() is not None, False, ) if not ledger_ok: logger.error( - "[%s] Missed-message recovery aborted: durable ledger unavailable", - self.name, + "[%s] Missed-message recovery aborted: durable ledger unavailable", self.name, ) return - scan_id = await asyncio.to_thread( - self._record_recovery_scan_start, - channels, - ) + scan_id = await asyncio.to_thread(self._record_recovery_scan_start, channels) if not channels: logger.info("[%s] Missed-message backfill enabled but no channels configured", self.name) - await asyncio.to_thread( - self._record_recovery_scan_complete, - scan_id, - status="skipped", - scanned=0, - missed=0, - dispatched=0, - ) + await self._finish_recovery_scan(scan_id, "skipped", dict(scanned=0, missed=0, dispatched=0)) return - max_dispatches = self._missed_message_backfill_max_dispatches() - dispatched = 0 - scanned = 0 - missed = 0 + counts = dict(scanned=0, missed=0, dispatched=0) try: async for message in self._iter_missed_message_backfill_candidates(channels): - scanned += 1 + counts["scanned"] += 1 message_id = str(getattr(message, "id", "")) self._record_discord_message_seen(message, status="discovered") - # A live gateway event may race this REST scan. Check without - # claiming the ID; the shared ingress helper owns the dedup - # write immediately before normal auth/filter dispatch. + # Live events may race this REST scan: check without claiming; ingress owns the dedup write. if self._dedup.contains(message_id): continue if not await self._should_backfill_discord_message(message): continue - missed += 1 + counts["missed"] += 1 logger.info( - "[%s] Backfilling missed Discord message %s in channel %s", - self.name, + "[%s] Backfilling missed Discord message %s in channel %s", self.name, getattr(message, "id", "unknown"), getattr(getattr(message, "channel", None), "id", "unknown"), ) @@ -2676,7 +2097,7 @@ class DiscordAdapter(BasePlatformAdapter): try: admitted = await self._dispatch_recovered_message(message) if admitted: - dispatched += 1 + counts["dispatched"] += 1 except asyncio.CancelledError: self._dedup.discard(message_id) self._record_recovery_attempt(message, status="cancelled") @@ -2685,74 +2106,47 @@ class DiscordAdapter(BasePlatformAdapter): self._dedup.discard(message_id) self._record_recovery_attempt(message, status="failed", error=str(exc)) raise - if dispatched >= max_dispatches: + if counts["dispatched"] >= max_dispatches: break - await asyncio.to_thread( - self._record_recovery_scan_complete, - scan_id, - status="success", - scanned=scanned, - missed=missed, - dispatched=dispatched, - ) + await self._finish_recovery_scan(scan_id, "success", counts) logger.info( "[%s] Missed-message backfill complete: scanned=%d missed=%d dispatched=%d", - self.name, - scanned, - missed, - dispatched, + self.name, counts["scanned"], counts["missed"], counts["dispatched"], ) except asyncio.CancelledError: - await asyncio.to_thread( - self._record_recovery_scan_complete, - scan_id, - status="cancelled", - scanned=scanned, - missed=missed, - dispatched=dispatched, - ) + await self._finish_recovery_scan(scan_id, "cancelled", counts) raise except Exception as exc: # pragma: no cover - defensive logging - await asyncio.to_thread( - self._record_recovery_scan_complete, - scan_id, - status="failed", - scanned=scanned, - missed=missed, - dispatched=dispatched, - error=str(exc), - ) + await self._finish_recovery_scan(scan_id, "failed", counts, error=str(exc)) logger.warning("[%s] Missed-message backfill failed: %s", self.name, exc, exc_info=True) + def _in_bot_thread(self, message: Any) -> bool: + """Thread the bot already joined skips the mention check — unless + thread_require_mention (multi-bot threads) gates threads like channels.""" + return ( + isinstance(message.channel, discord.Thread) + and str(message.channel.id) in self._threads + and not self._discord_thread_require_mention() + ) + async def _dispatch_recovered_message(self, message: Any) -> bool: """Run one recovered message through the live Discord ingress gates.""" if not isinstance(message.channel, discord.DMChannel): parent_id = self._get_parent_channel_id(message.channel) channel_keys = self._discord_channel_keys(message, parent_id) free_channels = self._discord_free_response_channels() - in_bot_thread = ( - isinstance(message.channel, discord.Thread) - and str(message.channel.id) in self._threads - and not self._discord_thread_require_mention() - ) if ( self._discord_require_mention() and "*" not in free_channels and not (channel_keys & free_channels) - and not in_bot_thread + and not self._in_bot_thread(message) and not self._self_is_explicitly_mentioned(message) ): return False - admitted, role_authorized = self._discord_message_admission( - message, claim=False, - ) + admitted, role_authorized = self._discord_message_admission(message, claim=False) if not admitted: return False - return await self._handle_message( - message, - role_authorized=role_authorized, - recovered=True, - ) + return await self._handle_message(message, role_authorized=role_authorized, recovered=True) async def _iter_missed_message_backfill_candidates(self, channel_ids: set[str]): if not self._client: @@ -2762,7 +2156,6 @@ class DiscordAdapter(BasePlatformAdapter): ) limit = self._missed_message_backfill_limit() seen: set[str] = set() - candidate_channels = [] if "*" in channel_ids: for guild in getattr(self._client, "guilds", []) or []: @@ -2781,13 +2174,9 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("[%s] Cannot fetch backfill channel %s: %s", self.name, channel_id, exc) continue candidate_channels.append(channel) - iterators = [ self._iter_channel_and_thread_messages( - channel, - limit=limit, - after=after, - seen_channels=seen, + channel, limit=limit, after=after, seen_channels=seen, ).__aiter__() for channel in candidate_channels ] @@ -2812,7 +2201,6 @@ class DiscordAdapter(BasePlatformAdapter): if not channel_key or channel_key in seen_channels: return seen_channels.add(channel_key) - cursor = self._discord_recovery_cursor(channel_key) if cursor: with suppress(ValueError, TypeError): @@ -2820,14 +2208,8 @@ class DiscordAdapter(BasePlatformAdapter): history = getattr(channel, "history", None) if callable(history): try: - # Fetch the latest N messages in the window, then restore - # chronological dispatch order. With oldest_first=True the API - # returns the earliest N and can permanently starve newer work. - history_iter = history( - limit=limit, - after=after, - oldest_first=False, - ) + # Fetch the latest N then restore order; oldest_first=True could starve newer work forever. + history_iter = history(limit=limit, after=after, oldest_first=False) messages = [] async for message in history_iter: # type: ignore[attr-defined] messages.append(message) @@ -2835,7 +2217,6 @@ class DiscordAdapter(BasePlatformAdapter): yield message except Exception as exc: logger.debug("[%s] Cannot read history for %s: %s", self.name, channel_key, exc) - child_threads = list(getattr(channel, "threads", []) or []) archived_threads = getattr(channel, "archived_threads", None) if callable(archived_threads): @@ -2844,7 +2225,6 @@ class DiscordAdapter(BasePlatformAdapter): child_threads.append(thread) except Exception as exc: logger.debug("[%s] Cannot list archived threads for %s: %s", self.name, channel_key, exc) - for thread in child_threads: thread_key = str(getattr(thread, "id", "")) if not thread_key or thread_key in seen_channels: @@ -2862,7 +2242,6 @@ class DiscordAdapter(BasePlatformAdapter): (channel_id,), ).fetchone() return str(row[0]) if row else None - return self._with_discord_recovery_db(_op) def _advance_discord_recovery_cursor(self, channel_id: str, message_id: str) -> None: @@ -2881,7 +2260,6 @@ class DiscordAdapter(BasePlatformAdapter): """, (channel_id, message_id, now), ) - self._with_discord_recovery_db(_op) async def _should_backfill_discord_message(self, message: Any) -> bool: @@ -2894,11 +2272,8 @@ class DiscordAdapter(BasePlatformAdapter): return False if self._discord_message_has_active_claim(str(getattr(message, "id", ""))): return False - # A success reaction alone is only an acknowledgement. It is not - # enough evidence that the substantive response/action completed. - if await self._message_has_non_down_bot_response(message): - return False - return True + # A success reaction is only an ack, not evidence the substantive response completed. + return not await self._message_has_non_down_bot_response(message) def _is_down_notice_content(self, content: str) -> bool: """Recognize only explicit Hermes/gateway outage notices.""" @@ -2933,31 +2308,18 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: return False return False - message_channel = getattr(message, "channel", None) - # Only an explicit reply reference proves which input a bot response - # completed. An arbitrary later bot post can otherwise mask multiple - # unanswered requests in the same parent channel or thread. + # Only an explicit reply reference proves which input a bot response completed. if await _scan_history(message_channel): return True - thread = getattr(message, "thread", None) - if thread is not None and await _scan_history(thread): - return True - return False - - def _discord_recovery_db_path(self) -> _Path: - return self._discord_recovery_store.path() + return thread is not None and await _scan_history(thread) def _with_discord_recovery_db(self, fn, default=None): return self._discord_recovery_store.call(fn, default) async def _with_discord_recovery_db_async(self, fn, default=None): - return await asyncio.to_thread( - self._discord_recovery_store.call, - fn, - default, - ) + return await asyncio.to_thread(self._discord_recovery_store.call, fn, default) @staticmethod def _utc_now_iso() -> str: @@ -3001,7 +2363,6 @@ class DiscordAdapter(BasePlatformAdapter): """, (message_id, channel_id, thread_id, parent_id, author_id, created_text, final_status, now, final_status), ) - self._with_discord_recovery_db(_op) def _record_recovery_attempt(self, message: Any, *, status: str, error: Optional[str] = None) -> None: @@ -3022,7 +2383,6 @@ class DiscordAdapter(BasePlatformAdapter): """, (status, now, error, now, message_id), ) - self._with_discord_recovery_db(_op) def _record_discord_processing_start(self, event: MessageEvent, *, emoji_ack: bool) -> None: @@ -3040,7 +2400,6 @@ class DiscordAdapter(BasePlatformAdapter): "UPDATE discord_messages SET status='processing', emoji_ack=?, updated_at=? WHERE message_id=?", (1 if emoji_ack else 0, now, message_id), ) - self._with_discord_recovery_db(_op) def _record_discord_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: @@ -3059,16 +2418,17 @@ class DiscordAdapter(BasePlatformAdapter): "updated_at=? WHERE message_id=?", (status, now, message_id), ) - self._with_discord_recovery_db(_op) + async def _record_response_async(self, reply_to, result: SendResult, content: str, final: bool) -> SendResult: + """Record a send outcome in the recovery ledger off-loop and hand back ``result``.""" + await asyncio.to_thread( + self._record_discord_response, reply_to=reply_to, result=result, content=content, final=final, + ) + return result + def _record_discord_response( - self, - *, - reply_to: Optional[str], - result: SendResult, - content: str, - final: bool, + self, *, reply_to: Optional[str], result: SendResult, content: str, final: bool, ) -> None: if not self._missed_message_backfill_enabled() or not reply_to: return @@ -3089,19 +2449,11 @@ class DiscordAdapter(BasePlatformAdapter): updated_at=? """, ( - reply_to, - status, - 1 if completed else 0, - result.message_id, - now, - 1 if completed else 0, - 1 if completed else 0, - 1 if completed else 0, - result.message_id, - now, + reply_to, status, 1 if completed else 0, result.message_id, now, + 1 if completed else 0, 1 if completed else 0, 1 if completed else 0, + result.message_id, now, ), ) - self._with_discord_recovery_db(_op) if completed: def _channel_for_message(conn): @@ -3111,7 +2463,6 @@ class DiscordAdapter(BasePlatformAdapter): (reply_to,), ).fetchone() return str(row[0]) if row and row[0] else None - channel_id = self._with_discord_recovery_db(_channel_for_message) if channel_id: self._advance_discord_recovery_cursor(channel_id, reply_to) @@ -3126,27 +2477,18 @@ class DiscordAdapter(BasePlatformAdapter): return False status, replied, outage = row return status == "responded" and bool(replied) and not bool(outage) - return bool(self._with_discord_recovery_db(_op, default=False)) def _discord_message_has_active_claim(self, message_id: str) -> bool: if not message_id: return False - cutoff = ( - dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=10) - ).isoformat() + cutoff = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=10)).isoformat() def _op(conn): row = conn.execute( - "SELECT status, updated_at FROM discord_messages WHERE message_id=?", - (message_id,), + "SELECT status, updated_at FROM discord_messages WHERE message_id=?", (message_id,), ).fetchone() - return bool( - row - and row[0] in {"queued", "processing"} - and row[1] >= cutoff - ) - + return bool(row and row[0] in {"queued", "processing"} and row[1] >= cutoff) return bool(self._with_discord_recovery_db(_op, default=True)) def _record_recovery_scan_start(self, channels: set[str]) -> str: @@ -3158,7 +2500,6 @@ class DiscordAdapter(BasePlatformAdapter): "INSERT OR REPLACE INTO discord_recovery_scans (scan_id, started_at, status, channels, window_seconds, limit_count) VALUES (?, ?, ?, ?, ?, ?)", (scan_id, now, "running", json.dumps(sorted(channels)), self._missed_message_backfill_window_seconds(), self._missed_message_backfill_limit()), ) - self._with_discord_recovery_db(_op) return scan_id @@ -3170,7 +2511,6 @@ class DiscordAdapter(BasePlatformAdapter): "UPDATE discord_recovery_scans SET completed_at=?, status=?, scanned=?, missed=?, dispatched=?, error=? WHERE scan_id=?", (now, status, scanned, missed, dispatched, error, scan_id), ) - self._with_discord_recovery_db(_op) def _get_discord_command_sync_policy(self) -> str: @@ -3179,8 +2519,7 @@ class DiscordAdapter(BasePlatformAdapter): return raw if raw: logger.warning( - "[%s] Invalid DISCORD_COMMAND_SYNC_POLICY=%r; falling back to 'safe'", - self.name, + "[%s] Invalid DISCORD_COMMAND_SYNC_POLICY=%r; falling back to 'safe'", self.name, raw, ) return "safe" @@ -3211,21 +2550,14 @@ class DiscordAdapter(BasePlatformAdapter): @staticmethod def _normalize_permissions(value: Any) -> Optional[str]: - """Discord emits default_member_permissions as str server-side but discord.py - sets it as int locally. Normalize to str-or-None so the comparison is stable.""" + """Normalize default_member_permissions to str-or-None (Discord returns str, discord.py sets int).""" if value is None: return None return str(value) def _existing_command_to_payload(self, command: Any) -> Dict[str, Any]: - """Build a canonical-ready dict from an AppCommand. - - discord.py's AppCommand.to_dict() does NOT include nsfw, - dm_permission, or default_member_permissions (they live only on the - attributes). Pull them from the attributes so the canonicalizer sees - the real server-side values instead of defaults — otherwise any - command using non-default permissions would diff on every startup. - """ + """Build a canonical-ready dict from an AppCommand; ``to_dict()`` omits nsfw/dm_permission/ + default_member_permissions, so pull them from attributes or every startup diffs.""" payload = dict(command.to_dict()) nsfw = getattr(command, "nsfw", None) if nsfw is not None: @@ -3249,8 +2581,7 @@ class DiscordAdapter(BasePlatformAdapter): "autocomplete": bool(payload.get("autocomplete", False)), "choices": [ { - "name": str(choice.get("name", "") or ""), - "value": choice.get("value"), + "name": str(choice.get("name", "") or ""), "value": choice.get("value"), } for choice in payload.get("choices", []) or [] if isinstance(choice, dict) @@ -3271,28 +2602,19 @@ class DiscordAdapter(BasePlatformAdapter): """Fields supported by discord.py's edit_global_command route.""" canonical = self._canonicalize_app_command_payload(payload) return { - "name": canonical["name"], - "description": canonical["description"], + "name": canonical["name"], "description": canonical["description"], "options": canonical["options"], } async def _safe_sync_slash_commands(self) -> Dict[str, int]: """Diff existing global commands and only mutate the commands that changed.""" + summary = {"total": 0, "unchanged": 0, "updated": 0, "recreated": 0, "created": 0, "deleted": 0} if not self._client: - return { - "total": 0, - "unchanged": 0, - "updated": 0, - "recreated": 0, - "created": 0, - "deleted": 0, - } - + return summary tree = self._client.tree app_id = getattr(self._client, "application_id", None) or getattr(getattr(self._client, "user", None), "id", None) if not app_id: raise RuntimeError("Discord application ID is unavailable for slash command sync") - desired_payloads = [command.to_dict(tree) for command in tree.get_commands()] desired_by_key = { (int(payload.get("type", 1) or 1), str(payload.get("name", "") or "").lower()): payload @@ -3306,12 +2628,6 @@ class DiscordAdapter(BasePlatformAdapter): ): command for command in existing_commands } - - unchanged = 0 - updated = 0 - recreated = 0 - created = 0 - deleted = 0 http = self._client.http mutation_count = 0 @@ -3322,51 +2638,34 @@ class DiscordAdapter(BasePlatformAdapter): result = await call(*args) mutation_count += 1 return result - - # Delete obsolete commands FIRST to stay under Discord's 100-command - # limit. Discord rejects an upsert that would push the live total over - # 100 (error 30032), which silently breaks ALL slash commands. If a new - # command is created before the obsolete ones are removed, an app that - # is already at the cap momentarily exceeds it and the whole sync fails. - # Removing the no-longer-desired commands up front guarantees the live - # total never rises above the cap mid-sync. + # Delete obsolete commands FIRST: an upsert pushing the live total over 100 fails with + # 30032 (breaks ALL slash commands), so an app at the cap must shrink before creating. obsolete_keys = set(existing_by_key.keys()) - set(desired_by_key.keys()) for key in obsolete_keys: current = existing_by_key.pop(key) await mutate(http.delete_global_command, app_id, current.id) - deleted += 1 - + summary["deleted"] += 1 for key, desired in desired_by_key.items(): current = existing_by_key.pop(key, None) if current is None: await mutate(http.upsert_global_command, app_id, desired) - created += 1 + summary["created"] += 1 continue - current_existing_payload = self._existing_command_to_payload(current) current_payload = self._canonicalize_app_command_payload(current_existing_payload) desired_payload = self._canonicalize_app_command_payload(desired) if current_payload == desired_payload: - unchanged += 1 + summary["unchanged"] += 1 continue - if self._patchable_app_command_payload(current_existing_payload) == self._patchable_app_command_payload(desired): await mutate(http.delete_global_command, app_id, current.id) await mutate(http.upsert_global_command, app_id, desired) - recreated += 1 + summary["recreated"] += 1 continue - await mutate(http.edit_global_command, app_id, current.id, desired) - updated += 1 - - return { - "total": len(desired_payloads), - "unchanged": unchanged, - "updated": updated, - "recreated": recreated, - "created": created, - "deleted": deleted, - } + summary["updated"] += 1 + summary["total"] = len(desired_payloads) + return summary async def _add_reaction(self, message: Any, emoji: str) -> bool: """Add an emoji reaction to a Discord message.""" @@ -3400,19 +2699,11 @@ class DiscordAdapter(BasePlatformAdapter): acked = False if self._reactions_enabled() and hasattr(message, "add_reaction"): acked = await self._add_reaction(message, "👀") - await asyncio.to_thread( - self._record_discord_processing_start, - event, - emoji_ack=acked, - ) + await asyncio.to_thread(self._record_discord_processing_start, event, emoji_ack=acked) async def on_processing_complete(self, event: MessageEvent, outcome: ProcessingOutcome) -> None: """Swap the in-progress reaction for final reaction and durable state.""" - await asyncio.to_thread( - self._record_discord_processing_complete, - event, - outcome, - ) + await asyncio.to_thread(self._record_discord_processing_complete, event, outcome) if not self._reactions_enabled(): return message = event.raw_message @@ -3425,26 +2716,15 @@ class DiscordAdapter(BasePlatformAdapter): @staticmethod def _message_reference_from_ids(message_id, channel) -> "discord.MessageReference": - """ids-built reply reference — no fetch_message round trip. - - Discord resolves message_reference from the ids alone, and - fail_if_not_exists=False keeps sends to deleted targets working - exactly as the fetched form did (a dead target degrades to the - send-side 10008 retry instead of a fetch failure). - """ + """ids-built reply reference — no fetch_message round trip. fail_if_not_exists=False + keeps sends to deleted targets degrading to the send-side 10008 retry.""" return discord.MessageReference( - message_id=int(message_id), - channel_id=getattr(channel, "id", None), - guild_id=getattr(getattr(channel, "guild", None), "id", None), - fail_if_not_exists=False, + message_id=int(message_id), channel_id=getattr(channel, "id", None), + guild_id=getattr(getattr(channel, "guild", None), "id", None), fail_if_not_exists=False, ) def _reply_reference_for_send(self, reply_to, channel): - """Reply anchor for send paths, honoring reply_to_mode. - - Mirrors telegram's _reply_to_message_id_for_send: raw reply_to in, - platform send-time anchor out, ``off`` mode suppressed. - """ + """Reply anchor for send paths honoring reply_to_mode (``off`` suppresses); mirrors telegram.""" if not reply_to or self._reply_to_mode == "off": return None try: @@ -3454,15 +2734,8 @@ class DiscordAdapter(BasePlatformAdapter): return None def _cap_split_chunks(self, chunks: List[str]) -> List[str]: - """Cap the number of chunks sent for one logical response (#86581). - - A degenerate turn can produce tens of thousands of characters; the - #86581 incident delivered 60,698 chars as 31 back-to-back Discord - messages. When ``chunks`` exceeds ``MAX_SPLIT_MESSAGES``, keep the - first ``N-1`` chunks and replace the rest with a short notice so the - user sees a clear signal instead of a flood. The full response - remains available in the gateway session history / logs. - """ + """Cap chunks at ``MAX_SPLIT_MESSAGES``: keep the first N-1 and replace the rest with a + notice so a degenerate turn can't flood the channel (full text stays in session history).""" if len(chunks) <= self.MAX_SPLIT_MESSAGES: return chunks kept = chunks[: self.MAX_SPLIT_MESSAGES - 1] @@ -3483,210 +2756,107 @@ class DiscordAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: - """Send a message to a Discord channel or thread. - - When metadata contains a thread_id, the message is sent to that - thread instead of the parent channel identified by chat_id. - - Forum channels (type 15) reject direct messages — a thread post is - created automatically. - """ + """Send a message to a Discord channel or thread (metadata thread_id wins over + chat_id; forum channels auto-create a thread post since they reject direct sends).""" if not self._client: - # Dead transport (client gone / gateway reconnecting): classify as - # send_path_degraded so the delivery-obligation ledger's reconnect - # sweep (_redeliver_failed_obligations_for_platform) can replay - # this final response once the adapter is live again — a generic - # "Not connected" error is not runtime-retryable and left the - # turn's output stranded until a full process restart (#95382). - return SendResult( - success=False, error="send_path_degraded", retryable=True - ) + # Dead transport: classify as send_path_degraded so the delivery ledger's reconnect + # sweep can replay this; a generic "Not connected" error would strand the output. + return SendResult(success=False, error="send_path_degraded", retryable=True) if not (content or "").strip(): logger.warning( - "[%s] Dropped empty message to chat=%s (caller bug). Call site:\n%s", - self.name, - chat_id, - "".join(traceback.format_stack(limit=12)[:-1]), + "[%s] Dropped empty message to chat=%s (caller bug). Call site:\n%s", self.name, + chat_id, "".join(traceback.format_stack(limit=12)[:-1]), ) - result = SendResult( - success=False, - error="Refusing to send empty message", - ) - # Mirror the exception path's recovery bookkeeping. Missed-message - # backfill decides what to replay from this table, so a dropped - # final reply must be recorded as failed — otherwise the reply is - # both never sent and never retried. - await asyncio.to_thread( - self._record_discord_response, - reply_to=reply_to, - result=result, - content=content, - final=bool(metadata and metadata.get("notify")), - ) - return result - + result = SendResult(success=False, error="Refusing to send empty message") + # Backfill replays from this table: record the dropped final reply as failed or it is lost. + return await self._record_response_async(reply_to, result, content, bool(metadata and metadata.get("notify"))) try: - # Determine target channel: thread_id in metadata takes precedence. thread_id = None if metadata and metadata.get("thread_id"): thread_id = metadata["thread_id"] nonconversational = _metadata_marks_nonconversational(metadata) final_delivery = bool(metadata and metadata.get("notify")) - if thread_id: - # Fetch the thread directly — threads are addressed by their own ID. - channel = self._client.get_channel(int(thread_id)) - if not channel: - channel = await self._client.fetch_channel(int(thread_id)) + channel = await self._resolve_channel(thread_id) if not channel: return SendResult(success=False, error=f"Thread {thread_id} not found") else: - # Get the parent channel - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + channel = await self._resolve_channel(chat_id) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - # Forum channels reject channel.send() — create a thread post instead. if self._is_forum_parent(channel): result = await self._send_to_forum(channel, content) - await asyncio.to_thread( - self._record_discord_response, - reply_to=reply_to, - result=result, - content=content, - final=final_delivery, - ) - return result - - # Format and split message if needed + return await self._record_response_async(reply_to, result, content, final_delivery) formatted = self.format_message(content) chunks = self._cap_split_chunks( self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) ) - message_ids = [] - # Build the reference from ids — no fetch_message round trip. reference = self._reply_reference_for_send(reply_to, channel) - for i, chunk in enumerate(chunks): if self._reply_to_mode == "all": chunk_reference = reference else: # "first" (default) or "off" chunk_reference = reference if i == 0 else None try: - msg = await channel.send( - content=chunk, - reference=chunk_reference, - ) + 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 - ) - or "error code: 10008" in err_text - ) - ): + if chunk_reference is not None and self._is_reply_reference_rejected(e): logger.warning( "[%s] Reply target %s rejected the reply reference; retrying send without reply reference", - self.name, - reply_to, + self.name, reply_to, ) reference = None - msg = await channel.send( - content=chunk, - reference=None, - ) + msg = await channel.send(content=chunk, reference=None) else: raise message_ids.append(str(msg.id)) - - # Track the last message we sent in this channel for history - # backfill — avoids a full channel.history() scan on hot paths. + # Track the last sent message for history backfill (skips the full history scan). if message_ids: _target_id = thread_id or chat_id if nonconversational: self._nonconversational_messages.mark_many(message_ids) elif not _looks_like_nonconversational_history_message(content): self._last_self_message_id[_target_id] = message_ids[-1] - result = SendResult( success=True, message_id=message_ids[0] if message_ids else None, raw_response={"message_ids": message_ids} ) - await asyncio.to_thread( - self._record_discord_response, - reply_to=reply_to, - result=result, - content=content, - final=final_delivery, - ) - return result - + return await self._record_response_async(reply_to, result, content, final_delivery) except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to send Discord message: %s", self.name, e, exc_info=True) if _is_discord_transport_error(e): - # Connection-shaped failure (WS drop / closed session): use - # the ledger's runtime-retryable marker so the reconnect - # sweep can replay this final response instead of stranding - # it until a process restart (#95382 silent partial loss). - result = SendResult( - success=False, error="send_path_degraded", retryable=True - ) + # Connection-shaped failure: runtime-retryable marker so the reconnect sweep can replay it. + result = SendResult(success=False, error="send_path_degraded", retryable=True) else: result = SendResult(success=False, error=str(e)) - await asyncio.to_thread( - self._record_discord_response, - reply_to=reply_to, - result=result, - content=content, - final=bool(metadata and metadata.get("notify")), - ) - return result - - async def _send_to_forum(self, forum_channel: Any, content: str) -> SendResult: - """Create a thread post in a forum channel with the message as starter content. - - Forum channels (type 15) don't support direct messages. Instead we - POST to /channels/{forum_id}/threads with a thread name derived from - the first line of the message. Any follow-up chunk failures are - reported in ``raw_response['warnings']`` so the caller can surface - partial-send issues. - """ - # _derive_forum_thread_name is defined further down in this same - # module — no cross-module import needed. - - formatted = self.format_message(content) - chunks = self._cap_split_chunks( - self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) - ) - - thread_name = _derive_forum_thread_name(content) - - starter_content = chunks[0] if chunks else thread_name - - try: - thread = await forum_channel.create_thread( - name=thread_name, - content=starter_content, - ) - except Exception as e: - logger.error("[%s] Failed to create forum thread in %s: %s", self.name, forum_channel.id, e) - return SendResult(success=False, error=f"Forum thread creation failed: {e}") + return await self._record_response_async(reply_to, result, content, bool(metadata and metadata.get("notify"))) + @staticmethod + def _forum_thread_parts(thread: Any) -> tuple: + """``create_thread`` returns a Thread or a ThreadWithMessage; normalise to + ``(thread_channel, thread_id, starter_msg, starter_message_id)``.""" thread_channel = thread if hasattr(thread, "send") else getattr(thread, "thread", None) thread_id = str(getattr(thread_channel, "id", getattr(thread, "id", ""))) starter_msg = getattr(thread, "message", None) message_id = str(getattr(starter_msg, "id", thread_id)) if starter_msg else thread_id + return thread_channel, thread_id, starter_msg, message_id - # Send remaining chunks into the newly created thread. Track any - # per-chunk failures so the caller sees partial-send outcomes. + async def _send_to_forum(self, forum_channel: Any, content: str) -> SendResult: + """Create a forum thread post with the message as starter (forum channels reject direct + sends; name from the first line). Chunk failures land in ``raw_response['warnings']``.""" + formatted = self.format_message(content) + chunks = self._cap_split_chunks(self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)) + thread_name = _derive_forum_thread_name(content) + starter_content = chunks[0] if chunks else thread_name + try: + thread = await forum_channel.create_thread(name=thread_name, content=starter_content) + except Exception as e: + logger.error("[%s] Failed to create forum thread in %s: %s", self.name, forum_channel.id, e) + return SendResult(success=False, error=f"Forum thread creation failed: {e}") + thread_channel, thread_id, starter_msg, message_id = self._forum_thread_parts(thread) message_ids = [message_id] warnings: list[str] = [] for chunk in chunks[1:]: @@ -3697,39 +2867,17 @@ class DiscordAdapter(BasePlatformAdapter): warning = f"Failed to send follow-up chunk to forum thread {thread_id}: {e}" logger.warning("[%s] %s", self.name, warning) warnings.append(warning) - raw_response: Dict[str, Any] = {"message_ids": message_ids, "thread_id": thread_id} if warnings: raw_response["warnings"] = warnings - - return SendResult( - success=True, - message_id=message_ids[0], - raw_response=raw_response, - ) + return SendResult(success=True, message_id=message_ids[0], raw_response=raw_response) async def _forum_post_file( - self, - forum_channel: Any, - *, - thread_name: Optional[str] = None, - content: str = "", - file: Any = None, - files: Optional[list] = None, + self, forum_channel: Any, *, thread_name: Optional[str] = None, content: str = "", + file: Any = None, files: Optional[list] = None, ) -> SendResult: - """Create a forum thread whose starter message carries file attachments. - - Used by the send_voice / send_image_file / send_document paths when - the target channel is a forum (type 15). ``create_thread`` on a - ForumChannel accepts the same file/files/content kwargs as - ``channel.send``, creating the thread and starter message atomically. - """ - # _derive_forum_thread_name is defined further down in this same - # module — no cross-module import needed. - + """Create a forum thread whose starter message carries file attachments.""" if not thread_name: - # Prefer the text content, fall back to the first attached - # filename, fall back to the generic default. hint = content or "" if not hint.strip(): if file is not None: @@ -3737,7 +2885,6 @@ class DiscordAdapter(BasePlatformAdapter): elif files: hint = getattr(files[0], "filename", "") or "" thread_name = _derive_forum_thread_name(hint) if hint.strip() else "New Post" - kwargs: Dict[str, Any] = {"name": thread_name} if content: kwargs["content"] = content @@ -3745,23 +2892,15 @@ class DiscordAdapter(BasePlatformAdapter): kwargs["file"] = file if files: kwargs["files"] = files - try: thread = await forum_channel.create_thread(**kwargs) except Exception as e: logger.error( - "[%s] Failed to create forum thread with file in %s: %s", - self.name, - getattr(forum_channel, "id", "?"), - e, + "[%s] Failed to create forum thread with file in %s: %s", self.name, + getattr(forum_channel, "id", "?"), e, ) return SendResult(success=False, error=f"Forum thread creation failed: {e}") - - thread_channel = thread if hasattr(thread, "send") else getattr(thread, "thread", None) - thread_id = str(getattr(thread_channel, "id", getattr(thread, "id", ""))) - starter_msg = getattr(thread, "message", None) - message_id = str(getattr(starter_msg, "id", thread_id)) if starter_msg else thread_id - + thread_channel, thread_id, starter_msg, message_id = self._forum_thread_parts(thread) if file is not None or files: attachments = getattr(starter_msg, "attachments", None) or [] if not attachments: @@ -3771,9 +2910,7 @@ class DiscordAdapter(BasePlatformAdapter): elif files: filename = getattr(files[0], "filename", "") or "" logger.warning( - "[%s] Forum thread %s starter has no attachments for %s", - self.name, - thread_id, + "[%s] Forum thread %s starter has no attachments for %s", self.name, thread_id, filename or "file", ) return SendResult( @@ -3785,93 +2922,50 @@ class DiscordAdapter(BasePlatformAdapter): message_id=message_id or None, raw_response={"thread_id": thread_id}, ) - return SendResult( - success=True, - message_id=message_id, - raw_response={"thread_id": thread_id}, + success=True, message_id=message_id, raw_response={"thread_id": thread_id}, ) async def edit_message( - self, - chat_id: str, - message_id: str, - content: str, - *, - finalize: bool = False, + self, chat_id: str, message_id: str, content: str, *, finalize: bool = False, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Edit a previously sent Discord message. - - Discord caps single-message text at 2,000 chars. Edits that grow - past this limit must NOT be silently truncated (the stream consumer - would believe the full reply was delivered and stop) and must NOT - return failure (the consumer would re-send and create a duplicate). - - Mid-stream (``finalize=False``) we keep editing the original message - with a truncated preview — splitting mid-stream would move the edit - target to a continuation and the next accumulated-token tick would - re-split, looping forever (the Telegram #48648 lesson). The complete - text is delivered when ``finalize=True`` via ``_edit_overflow_split``. - """ + """Edit a sent Discord message. Oversized text (>2,000) must neither truncate silently nor + fail (consumer re-sends -> dupe): mid-stream keep a truncated preview (splitting would move + the edit target every tick); ``finalize=True`` delivers all via ``_edit_overflow_split``.""" 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)) + channel = await self._resolve_channel(chat_id) msg = channel.get_partial_message(int(message_id)) formatted = self.format_message(content) - _preview_key = (str(chat_id), str(message_id)) _saturated_preview = False if finalize: - # Any saturation state for this message is finished with — - # the final edit always delivers real (full) content. + # Saturation state is finished — the final edit delivers full content. self._last_overflow_preview.pop(_preview_key, None) - - # Pre-flight: oversized payload. Final edits split-and-deliver; - # streaming edits truncate a one-message preview in place. + # Pre-flight oversize: final edits split-and-deliver; streaming edits truncate in place. if len(formatted) > self.MAX_MESSAGE_LENGTH: if finalize: - return await self._edit_overflow_split( - channel, msg, message_id, content, - ) - formatted = self.truncate_message( - formatted, self.MAX_MESSAGE_LENGTH, - )[0] + return await self._edit_overflow_split(channel, msg, message_id, content) + formatted = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)[0] _saturated_preview = True - # Saturated-preview dedup: past the cap, every progressive - # edit truncates to the same text. Re-sending it is a visual - # no-op that still counts against Discord's edit rate limit — - # skip silently until finalize (mirrors the Telegram #58563 - # fix). + # Saturated-preview dedup: past the cap every edit is the same text; skip until finalize. if self._last_overflow_preview.get(_preview_key) == formatted: return SendResult(success=True, message_id=message_id) elif not finalize: - # Content shrank back under the cap (segment break / new - # message id) — clear stale saturation state so dedup can't - # mask a real edit later. + # Content shrank under the cap: clear saturation state so dedup can't mask a real edit. self._last_overflow_preview.pop(_preview_key, None) - try: await msg.edit(content=formatted) if _saturated_preview: self._last_overflow_preview[_preview_key] = formatted except Exception as edit_err: - # Reactive split-and-deliver: format_message inflation (or a - # server-side rule change) can push the payload past 2,000 - # even when the pre-flight check passed. Discord reports this - # as "error code: 50035 ... Must be 2000 or fewer in length". + # Reactive split: format_message inflation can exceed 2,000 (50035) even after pre-flight. if self._is_length_overflow_error(edit_err): if finalize: - return await self._edit_overflow_split( - channel, msg, message_id, content, - ) - # Mid-stream: truncate and retry in place (no split). - truncated = self.truncate_message( - formatted, self.MAX_MESSAGE_LENGTH, - )[0] + return await self._edit_overflow_split(channel, msg, message_id, content) + truncated = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)[0] if self._last_overflow_preview.get(_preview_key) == truncated: # Saturated-preview dedup (see pre-flight path above). return SendResult(success=True, message_id=message_id) @@ -3881,77 +2975,49 @@ class DiscordAdapter(BasePlatformAdapter): raise result = SendResult(success=True, message_id=message_id) if finalize: - await asyncio.to_thread( - self._record_discord_response, - reply_to=(metadata or {}).get("reply_to_message_id"), - result=result, - content=content, - final=True, - ) + await self._record_response_async((metadata or {}).get("reply_to_message_id"), result, content, True) return result 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)) @staticmethod - def _is_length_overflow_error(err: Exception) -> bool: - """True when a Discord edit/send failed because text exceeded 2,000. + def _is_reply_reference_rejected(err: Exception) -> bool: + """Discord refused the reply anchor: system-message target (50035) or deleted target (10008).""" + err_text = str(err) + return ( + "error code: 50035" in err_text and "Cannot reply to a system message" in err_text + ) or "error code: 10008" in err_text - Discord returns ``error code: 50035`` with a ``Must be 2000 or fewer - in length`` validation detail. We match on the stable error code plus - the length phrasing so unrelated 50035 validation errors (e.g. a bad - reply reference) don't get mistaken for an overflow. - """ + @staticmethod + def _is_length_overflow_error(err: Exception) -> bool: + """True when a Discord edit/send failed for >2,000 chars: code 50035 plus the length phrasing, + so other 50035 validation errors (e.g. bad reply reference) aren't mistaken for overflow.""" text = str(err).lower() return "error code: 50035" in text and ( "2000 or fewer" in text or "fewer in length" in text ) async def _edit_overflow_split( - self, - channel: Any, - msg: Any, - message_id: str, - content: str, + self, channel: Any, msg: Any, message_id: str, content: str, ) -> SendResult: - """Deliver an oversized final edit across message + continuations. - - Edit the original ``message_id`` with chunk 1 (fence-aware, with the - usual ``(1/N)`` indicator), then send chunks 2..N as new messages each - threaded as a reply to the previous chunk so Discord groups them - visually. Returns ``SendResult(success=True, message_id=, - continuation_message_ids=(...))`` so the stream consumer keeps editing - the most recent visible message and can clean up every chunk on a - fresh-final. - - On a mid-stream continuation send failure we still report success with - however many continuations landed AND a ``partial_overflow`` - raw_response so the consumer can deliver the missing tail rather than - treating a clipped reply as complete — dropping chunks the user already - saw would be the worse outcome. Only a first-chunk edit failure - returns ``success=False`` (a real adapter problem, not overflow). - """ + """Deliver an oversized final edit: edit ``message_id`` with chunk 1, send chunks 2..N as + replies to the previous. Returns ``message_id=`` + ``continuation_message_ids``. + A continuation failure still reports success plus ``partial_overflow`` so the consumer + delivers the tail; only a first-chunk edit failure returns ``success=False``.""" formatted = self.format_message(content) - chunks = self._cap_split_chunks( - self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) - ) + chunks = self._cap_split_chunks(self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)) if len(chunks) <= 1: - # Defensive: caller's pre-flight should guarantee >1 chunk, but if - # not, just edit normally. + # Defensive: pre-flight should guarantee >1 chunk; otherwise edit normally. await msg.edit(content=chunks[0] if chunks else formatted) return SendResult(success=True, message_id=message_id) - - # Step 1 — edit the existing message with the first chunk. try: await msg.edit(content=chunks[0]) except Exception as e: logger.error( - "[%s] Overflow split: first-chunk edit failed: %s", - self.name, e, exc_info=True, + "[%s] Overflow split: first-chunk edit failed: %s", self.name, e, exc_info=True, ) return SendResult(success=False, error=str(e)) - - # Step 2 — send each remaining chunk threaded as a reply to the prior. continuation_ids: list[str] = [] delivered = 1 prev_msg = msg @@ -3963,17 +3029,12 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: reference = None elif getattr(prev_msg, "id", None): - # Duck-typed prior message without to_reference (real - # discord.py PartialMessage HAS to_reference, so this is - # belt-and-suspenders): build the reference from ids so - # overflow continuations stay threaded. + # Prior message without to_reference (duck-typed): build the reference from ids. reference = self._message_reference_from_ids(prev_msg.id, channel) try: sent = await channel.send(content=chunk, reference=reference) except Exception as send_err: - # Drop the reply anchor and retry once — a deleted/expired - # anchor (10008) or system-message reply (50035) shouldn't lose - # the chunk. + # Drop the reply anchor and retry once: deleted anchor (10008) / system message (50035). logger.warning( "[%s] Overflow continuation send failed (%s); retrying without reply reference", self.name, send_err, @@ -3991,10 +3052,8 @@ class DiscordAdapter(BasePlatformAdapter): message_id=last_id, continuation_message_ids=tuple(continuation_ids), raw_response={ - "partial_overflow": True, - "delivered_chunks": delivered, - "total_chunks": len(chunks), - "last_message_id": last_id, + "partial_overflow": True, "delivered_chunks": delivered, + "total_chunks": len(chunks), "last_message_id": last_id, "continuation_message_ids": tuple(continuation_ids), }, ) @@ -4002,87 +3061,50 @@ class DiscordAdapter(BasePlatformAdapter): continuation_ids.append(new_id) delivered += 1 prev_msg = sent - last_id = continuation_ids[-1] if continuation_ids else message_id - # Keep the history-backfill fast path pointed at the final visible - # chunk so a later non-streaming send threads below the full reply. + # Point the history-backfill fast path at the final visible chunk. if not _looks_like_nonconversational_history_message(content): self._last_self_message_id[str(channel.id)] = last_id logger.debug( - "[%s] Overflow split delivered %d chunks; last_id=%s", - self.name, delivered, last_id, + "[%s] Overflow split delivered %d chunks; last_id=%s", self.name, delivered, last_id, ) return SendResult( - success=True, - message_id=last_id, - continuation_message_ids=tuple(continuation_ids), + success=True, message_id=last_id, continuation_message_ids=tuple(continuation_ids), ) async def _send_file_attachment( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, + self, chat_id: str, file_path: str, caption: Optional[str] = None, file_name: Optional[str] = None, ) -> SendResult: - """Send a local file as a Discord attachment. - - Forum channels (type 15) get a new thread whose starter message - carries the file — they reject direct POST /messages. - - Uses a path-based ``discord.File`` (same pattern as - ``send_multiple_images``) rather than an open file handle. The - handle form can race with Discord's multipart encoder after an - earlier image batch on the same channel and produce a successful - message with zero attachments — a silent drop for video/document - MEDIA tags (#66797). - """ + """Send a local file as a Discord attachment (forum channels get a new thread). Path-based + ``discord.File`` only: the open-handle form can race the multipart encoder after an image + batch and yield zero attachments — a silent drop for video/document MEDIA tags.""" if not self._client: return SendResult(success=False, error="Not connected") - if not os.path.isfile(file_path): return SendResult(success=False, error=f"File not found: {file_path}") - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + channel = await self._resolve_channel(chat_id) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - filename = file_name or os.path.basename(file_path) logger.info( - "[%s] Sending file attachment %s (%s) to %s", - self.name, - filename, - os.path.splitext(filename)[1].lower() or "no-ext", - chat_id, + "[%s] Sending file attachment %s (%s) to %s", self.name, filename, + os.path.splitext(filename)[1].lower() or "no-ext", chat_id, ) - # Path-based File: discord.py owns open/close for the upload, matching - # the working image-batch path. Prefer ``files=[...]`` over deprecated - # singular ``file=`` for the same reason. + # Path-based File (discord.py owns open/close); ``files=[...]`` over deprecated ``file=``. discord_file = discord.File(file_path, filename=filename) if self._is_forum_parent(channel): result = await self._forum_post_file( - channel, - content=(caption or "").strip(), - files=[discord_file], + channel, content=(caption or "").strip(), files=[discord_file], ) return result - msg = await channel.send( - content=caption if caption else None, - files=[discord_file], - ) + msg = await channel.send(content=caption if caption else None, files=[discord_file]) attachments = getattr(msg, "attachments", None) or [] if not attachments: - # Discord accepted the message but attached nothing — the failure - # mode reported in #66797 (MEDIA video stripped from text, no - # attachment, no prior log line). Fail loud so the dispatch loop - # surfaces a warning instead of a silent drop. + # Discord accepted the message but attached nothing: fail loud instead of a silent drop. logger.warning( - "[%s] Discord returned message %s with no attachments for %s", - self.name, - getattr(msg, "id", "?"), - filename, + "[%s] Discord returned message %s with no attachments for %s", self.name, + getattr(msg, "id", "?"), filename, ) return SendResult( success=False, @@ -4092,26 +3114,15 @@ class DiscordAdapter(BasePlatformAdapter): return SendResult(success=True, message_id=str(msg.id)) async def send_multiple_images( - self, - chat_id: str, - images: List[Tuple[str, str]], - metadata: Optional[Dict[str, Any]] = None, - human_delay: float = 0.0, + self, chat_id: str, images: List[Tuple[str, str]], + metadata: Optional[Dict[str, Any]] = None, human_delay: float = 0.0, ) -> None: - """Send a batch of images as a single Discord message with multiple attachments. - - Discord permits up to 10 file attachments per message. Batches are - chunked accordingly. URL images are downloaded into memory and - uploaded as inline attachments (same pattern as ``send_image`` so - they render inline, not as bare links). Local files are opened - directly. On per-chunk failure the remaining images in that chunk - fall back to the base per-image loop. - """ + """Send images as one Discord message (<=10 attachments): URLs are downloaded and uploaded + inline (bare links don't render); on chunk failure the remainder uses the per-image loop.""" if not self._client: return if not images: return - try: import discord as _discord_mod import io as _io @@ -4119,11 +3130,8 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: # pragma: no cover await super().send_multiple_images(chat_id, images, metadata, human_delay) return - try: - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + channel = await self._resolve_channel(chat_id) if not channel: logger.warning("[%s] Channel %s not found for multi-image send", self.name, chat_id) return @@ -4131,14 +3139,11 @@ class DiscordAdapter(BasePlatformAdapter): logger.warning("[%s] Failed to resolve channel for multi-image send: %s", self.name, e) await super().send_multiple_images(chat_id, images, metadata, human_delay) return - CHUNK = 10 chunks = [images[i:i + CHUNK] for i in range(0, len(images), CHUNK)] - for chunk_idx, chunk in enumerate(chunks): if human_delay > 0 and chunk_idx > 0: await asyncio.sleep(human_delay) - files: List[Any] = [] captions: List[str] = [] aiohttp_session = None @@ -4165,10 +3170,8 @@ class DiscordAdapter(BasePlatformAdapter): if aiohttp_session is None: aiohttp_session = _aiohttp.ClientSession(**_sess_kw) status, data, headers = await _read_url_image_with_redirect_guard( - aiohttp_session, - image_url, - timeout=_aiohttp.ClientTimeout(total=30), - request_kwargs=_req_kw, + aiohttp_session, image_url, + timeout=_aiohttp.ClientTimeout(total=30), request_kwargs=_req_kw, ) if status != 200: logger.warning( @@ -4176,42 +3179,29 @@ class DiscordAdapter(BasePlatformAdapter): self.name, status, image_url[:80], ) continue - ct = headers.get("content-type", "image/png") - ext = "png" - if "jpeg" in ct or "jpg" in ct: - ext = "jpg" - elif "gif" in ct: - ext = "gif" - elif "webp" in ct: - ext = "webp" + ext = _image_ext_from_content_type(headers.get("content-type", "image/png")) files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}")) except Exception as dl_err: logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err) continue - if not files: continue - # Use the first caption if any (Discord only has one message body for the group) content = captions[0] if captions else None logger.info( "[%s] Sending %d image(s) as single Discord message (chunk %d/%d)", self.name, len(files), chunk_idx + 1, len(chunks), ) - if self._is_forum_parent(channel): await self._forum_post_file( - channel, - content=(content or "").strip(), - files=files, + channel, content=(content or "").strip(), files=files, ) else: await channel.send(content=content, files=files) except Exception as e: logger.warning( "[%s] Multi-image Discord send failed (chunk %d/%d), falling back to per-image: %s", - self.name, chunk_idx + 1, len(chunks), e, - exc_info=True, + self.name, chunk_idx + 1, len(chunks), e, exc_info=True, ) await super().send_multiple_images(chat_id, chunk, metadata, human_delay=human_delay) finally: @@ -4221,17 +3211,8 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: pass - async def play_tts( - self, - chat_id: str, - audio_path: str, - **kwargs, - ) -> SendResult: - """Play auto-TTS audio. - - When the bot is in a voice channel for this chat's guild, play - directly in the VC instead of sending as a file attachment. - """ + async def play_tts(self, chat_id: str, audio_path: str, **kwargs) -> SendResult: + """Play auto-TTS audio: in the guild's VC if joined, else as a file attachment.""" for gid, text_ch_id in self._voice_text_channels.items(): if str(text_ch_id) == str(chat_id) and self.is_in_voice_channel(gid): logger.info("[%s] Playing TTS in voice channel (guild=%d)", self.name, gid) @@ -4240,84 +3221,48 @@ class DiscordAdapter(BasePlatformAdapter): return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs) async def send_voice( - self, - chat_id: str, - audio_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - **kwargs, + self, chat_id: str, audio_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs, ) -> SendResult: """Send audio as a Discord file attachment.""" try: import io - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + channel = await self._resolve_channel(chat_id) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - if not os.path.exists(audio_path): return SendResult(success=False, error=f"Audio file not found: {audio_path}") - filename = os.path.basename(audio_path) - - # ids-only reference — same no-fetch rationale as the text path. reference = self._reply_reference_for_send(reply_to, channel) - with open(audio_path, "rb") as f: file_data = f.read() - - # Forum channels (type 15) reject direct POST /messages — the - # native voice flag path also targets /messages so it would fail - # too. Create a thread post with the audio as the starter - # attachment instead. + # Forum channels reject POST /messages (native voice path too); create a thread post instead. if self._is_forum_parent(channel): forum_file = discord.File(io.BytesIO(file_data), filename=filename) return await self._forum_post_file( - channel, - content=(caption or "").strip(), - file=forum_file, + channel, content=(caption or "").strip(), file=forum_file, ) - # Try sending as a native voice message via raw API (flags=8192). try: import base64 - - duration_secs = 5.0 try: from mutagen.oggopus import OggOpus - info = OggOpus(audio_path) - duration_secs = info.info.length + duration_secs = OggOpus(audio_path).info.length except Exception: duration_secs = max(1.0, len(file_data) / 2000.0) - - waveform_bytes = bytes([128] * 256) - waveform_b64 = base64.b64encode(waveform_bytes).decode() - - import json as _json payload_data = { "flags": 8192, "attachments": [{ - "id": "0", - "filename": "voice-message.ogg", - "duration_secs": round(duration_secs, 2), - "waveform": waveform_b64, + "id": "0", "filename": "voice-message.ogg", "duration_secs": round(duration_secs, 2), + "waveform": base64.b64encode(bytes([128] * 256)).decode(), }], } if reference is not None: - payload_data["message_reference"] = { - "message_id": str(reply_to), - "fail_if_not_exists": False, - } - payload = _json.dumps(payload_data) + payload_data["message_reference"] = {"message_id": str(reply_to), "fail_if_not_exists": False} form = [ - {"name": "payload_json", "value": payload}, + {"name": "payload_json", "value": json.dumps(payload_data)}, { - "name": "files[0]", - "value": file_data, - "filename": "voice-message.ogg", + "name": "files[0]", "value": file_data, "filename": "voice-message.ogg", "content_type": "audio/ogg", }, ] @@ -4332,17 +3277,7 @@ class DiscordAdapter(BasePlatformAdapter): try: msg = await channel.send(file=file, reference=reference) except Exception as send_err: - err_text = str(send_err) - if ( - reference is not None - and ( - ( - "error code: 50035" in err_text - and "Cannot reply to a system message" in err_text - ) - or "error code: 10008" in err_text - ) - ): + if reference is not None and self._is_reply_reference_rejected(send_err): msg = await channel.send(file=file, reference=None) else: raise @@ -4351,19 +3286,10 @@ class DiscordAdapter(BasePlatformAdapter): logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True) return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata) - # ------------------------------------------------------------------ - # Voice channel methods (join / leave / play) - # ------------------------------------------------------------------ + # --- Voice channel methods (join / leave / play) --- def _load_voice_fx_config(self) -> Dict[str, Any]: - """Read voice mixer / ambient / ack settings from config.yaml. - - All settings live under ``discord.voice_fx`` in config.yaml (NOT the - .env file — these are behavioral, not secrets). The feature is OFF by - default; users opt in with ``discord.voice_fx.enabled: true``. - - Returns a dict with safe defaults so callers never KeyError. - """ + """Read ``discord.voice_fx`` from config.yaml (not .env; off by default) with safe defaults.""" defaults: Dict[str, Any] = { "enabled": False, # master switch for the mixer subsystem "ambient_enabled": True, # idle "thinking" bed while tools run @@ -4372,14 +3298,10 @@ class DiscordAdapter(BasePlatformAdapter): "duck_gain": 0.06, # ambient loudness while speech plays "speech_gain": 1.0, # TTS / ack loudness "lead_silence_ms": 200, # silence prepended to each clip so the - # voice socket's warm-up doesn't clip - # the first word/syllable + # voice socket's warm-up doesn't clip the first word "ack_enabled": True, # speak a short phrase before tool calls "ack_phrases": [ - "Let me look into that.", - "One moment.", - "Checking on that now.", - "Give me a sec.", + "Let me look into that.", "One moment.", "Checking on that now.", "Give me a sec.", "On it.", ], } @@ -4410,17 +3332,13 @@ class DiscordAdapter(BasePlatformAdapter): def _load_voice_timeout(self) -> int: """Return voice-channel inactivity timeout seconds; 0 disables it.""" return self._load_discord_int_config( - "voice_channel_inactivity_timeout_seconds", - self.VOICE_TIMEOUT, - minimum=0, + "voice_channel_inactivity_timeout_seconds", self.VOICE_TIMEOUT, minimum=0, ) def _load_playback_timeout(self) -> int: """Return minimum playback wait seconds for Discord VC audio.""" return self._load_discord_int_config( - "voice_playback_timeout_seconds", - self.PLAYBACK_TIMEOUT, - minimum=1, + "voice_playback_timeout_seconds", self.PLAYBACK_TIMEOUT, minimum=1, ) def _voice_timeout_limit(self) -> int: @@ -4440,15 +3358,11 @@ class DiscordAdapter(BasePlatformAdapter): return float(length) except Exception: pass - try: proc = subprocess.run( [ - "ffprobe", - "-v", "error", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", - audio_path, + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", audio_path, ], check=False, capture_output=True, @@ -4473,20 +3387,13 @@ class DiscordAdapter(BasePlatformAdapter): return max(floor, duration + float(self.PLAYBACK_TIMEOUT_PADDING)) def _get_ambient_pcm(self) -> Optional[bytes]: - """Return decoded 48k/stereo/s16le PCM for the ambient idle bed. - - Uses a custom file when ``ambient_path`` is set and decodable, else a - synthesised pad. Cached after first build. - """ + """Return cached 48k/stereo/s16le PCM for the ambient bed: custom ``ambient_path`` if decodable, else synthesised.""" if self._ambient_pcm_cache is not None: return self._ambient_pcm_cache if not self._voice_fx_cfg.get("ambient_enabled"): return None - try: - from voice_mixer import decode_to_pcm, synth_ambient_pcm - except ImportError: - from .voice_mixer import decode_to_pcm, synth_ambient_pcm - + vm = _voice_mixer_module() + decode_to_pcm, synth_ambient_pcm = vm.decode_to_pcm, vm.synth_ambient_pcm pcm: Optional[bytes] = None path = (self._voice_fx_cfg.get("ambient_path") or "").strip() if path and os.path.isfile(path): @@ -4499,16 +3406,8 @@ class DiscordAdapter(BasePlatformAdapter): return pcm async def _install_voice_mixer(self, guild_id: int, vc) -> None: - """Create a VoiceMixer, start the ambient bed, and play it on the VC. - - The mixer runs continuously for the life of the connection: one - ``vc.play(mixer)`` call, never stopped until leave. - """ - try: - from voice_mixer import VoiceMixer - except ImportError: - from .voice_mixer import VoiceMixer - + """Install a VoiceMixer on the VC; one ``vc.play(mixer)`` runs for the whole connection.""" + VoiceMixer = _voice_mixer_module().VoiceMixer mixer = VoiceMixer( ambient_gain=float(self._voice_fx_cfg.get("ambient_gain", 0.18)), duck_gain=float(self._voice_fx_cfg.get("duck_gain", 0.06)), @@ -4521,7 +3420,6 @@ class DiscordAdapter(BasePlatformAdapter): def _after(error): if error: logger.error("Voice mixer stream error (guild=%d): %s", guild_id, error) - if vc.is_playing(): vc.stop() vc.play(mixer, after=_after) @@ -4529,13 +3427,8 @@ class DiscordAdapter(BasePlatformAdapter): logger.info("Voice mixer installed (guild=%d, ambient=%s)", guild_id, bool(ambient)) def _lead_silence_bytes(self) -> bytes: - """PCM silence prepended to speech clips on the mixer path. - - Discord's voice socket needs a brief warm-up before receiving clients - actually hear audio; the first ~100-200ms is otherwise clipped, cutting - off the first word/syllable. Returns b"" when ``lead_silence_ms`` is - unset or <= 0 so the behaviour is opt-out. - """ + """Silence prepended to speech clips: Discord's voice socket warm-up otherwise clips + the first ~100-200ms. Returns b"" when ``lead_silence_ms`` <= 0 (opt-out).""" cfg = getattr(self, "_voice_fx_cfg", None) or {} try: lead_ms = int(cfg.get("lead_silence_ms", 0) or 0) @@ -4543,19 +3436,10 @@ class DiscordAdapter(BasePlatformAdapter): return b"" if lead_ms <= 0: return b"" - try: - from voice_mixer import BYTES_PER_MS - except ImportError: - from .voice_mixer import BYTES_PER_MS - return b"\x00" * (BYTES_PER_MS * lead_ms) + return b"\x00" * (_voice_mixer_module().BYTES_PER_MS * lead_ms) async def play_ack_in_voice(self, guild_id: int, phrase: Optional[str] = None) -> bool: - """Speak a short acknowledgement over the ambient bed. - - Called from the gateway's tool-progress hook on the first tool call of - a turn, so the user hears "let me look into that" before the bot goes - quiet to work. No-op unless the mixer is installed and acks enabled. - """ + """Speak a short ack over the ambient bed (first tool call of a turn); no-op without mixer/acks.""" if not self._voice_fx_cfg.get("ack_enabled"): return False mixer = self._voice_mixers.get(guild_id) @@ -4565,12 +3449,9 @@ class DiscordAdapter(BasePlatformAdapter): import random phrases = self._voice_fx_cfg.get("ack_phrases") or ["One moment."] phrase = random.choice(phrases) - - # Synthesise the ack via the configured TTS provider, then layer it. import uuid as _uuid audio_path = os.path.join( - tempfile.gettempdir(), "hermes_voice", - f"ack_{_uuid.uuid4().hex[:12]}.mp3", + tempfile.gettempdir(), "hermes_voice", f"ack_{_uuid.uuid4().hex[:12]}.mp3", ) os.makedirs(os.path.dirname(audio_path), exist_ok=True) try: @@ -4582,10 +3463,7 @@ class DiscordAdapter(BasePlatformAdapter): actual = result.get("file_path", audio_path) if not result.get("success") or not os.path.isfile(actual): return False - try: - from voice_mixer import decode_to_pcm - except ImportError: - from .voice_mixer import decode_to_pcm + decode_to_pcm = _voice_mixer_module().decode_to_pcm pcm = await asyncio.to_thread(decode_to_pcm, actual) if not pcm: return False @@ -4612,20 +3490,12 @@ class DiscordAdapter(BasePlatformAdapter): return bool(mixers) and mixers.get(guild_id) is not None async def join_voice_channel(self, channel, *, text_channel_id: int = None, source: dict = None) -> bool: - """Join a Discord voice channel. Returns True on success. - - When ``text_channel_id`` is provided, the binding is stored so - voice transcriptions are routed to the correct text channel - (``_voice_text_channels``) without requiring `/voice join`. - This supports automatic/programmatic voice joins where the - command flow that normally establishes the binding is absent. - """ + """Join a voice channel; returns True on success. ``text_channel_id`` stores the + transcription-routing binding so programmatic joins work without ``/voice join``.""" if not self._client or not DISCORD_AVAILABLE: return False guild_id = channel.guild.id - async with self._voice_locks.setdefault(guild_id, asyncio.Lock()): - # Already connected in this guild? existing = self._voice_clients.get(guild_id) if existing and existing.is_connected(): if existing.channel.id == channel.id: @@ -4634,19 +3504,13 @@ class DiscordAdapter(BasePlatformAdapter): await existing.move_to(channel) self._reset_voice_timeout(guild_id) return True - vc = await channel.connect() self._voice_clients[guild_id] = vc self._reset_voice_timeout(guild_id) - - # Store text-channel binding for automatic/programmatic joins - # so voice transcriptions can be routed without /voice join. if text_channel_id is not None: self._voice_text_channels[guild_id] = text_channel_id if source is not None: self._voice_sources[guild_id] = source - - # Start voice receiver (Phase 2: listen to users) try: receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids) receiver.start() @@ -4656,22 +3520,17 @@ class DiscordAdapter(BasePlatformAdapter): ) except Exception as e: logger.warning("Voice receiver failed to start: %s", e) - - # Phase 3: install the continuous mixer (ambient bed + ducked - # speech). Best-effort — if it fails we fall back to the legacy - # one-shot FFmpegPCMAudio playback path in play_in_voice_channel. + # Mixer is best-effort; failure falls back to one-shot FFmpegPCMAudio playback. if getattr(self, "_voice_fx_cfg", {}).get("enabled"): try: await self._install_voice_mixer(guild_id, vc) except Exception as e: logger.warning("Voice mixer failed to start: %s", e) - return True async def leave_voice_channel(self, guild_id: int) -> None: """Disconnect from the voice channel in a guild.""" async with self._voice_locks.setdefault(guild_id, asyncio.Lock()): - # Stop voice receiver first receiver = self._voice_receivers.pop(guild_id, None) pending_inputs = [] if receiver: @@ -4680,16 +3539,13 @@ class DiscordAdapter(BasePlatformAdapter): listen_task = self._voice_listen_tasks.pop(guild_id, None) if listen_task: listen_task.cancel() - guild = self._client.get_guild(guild_id) if self._client is not None else None for user_id, pcm_data in pending_inputs: if self._is_allowed_user(str(user_id), guild=guild, is_dm=False): await self._process_voice_input(guild_id, user_id, pcm_data) - # Tear down the mixer (stops the continuous outgoing stream). if getattr(self, "_voice_mixers", None) is not None: self._voice_mixers.pop(guild_id, None) - vc = self._voice_clients.pop(guild_id, None) if vc and vc.is_connected(): try: @@ -4705,38 +3561,24 @@ class DiscordAdapter(BasePlatformAdapter): self._voice_sources.pop(guild_id, None) async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool: - """Play an audio file in the connected voice channel. - - When the continuous mixer is installed for this guild, the clip is - decoded to PCM and layered over the ambient bed (ducking it) so the - reply can overlap the idle "thinking" loop seamlessly. Otherwise we - fall back to the legacy one-shot FFmpegPCMAudio path. - """ + """Play audio in the VC: via the mixer (layered over the ambient bed, ducking it) + when installed, else the legacy one-shot FFmpegPCMAudio path.""" vc = self._voice_clients.get(guild_id) if not vc or not vc.is_connected(): return False - - # Playback is activity. Do not let the inactivity timer disconnect the - # bot while duration probing, decoding, or speaking; re-arm it when this - # attempt finishes, even if decoding/playback raises. + # Playback counts as activity: suspend the inactivity timer, re-arm in finally. self._cancel_voice_timeout(guild_id) try: playback_timeout = await self._playback_timeout_for_audio(audio_path) - # ── Mixer path (overlap + ducking) ────────────────────────────── mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None if mixer is not None: - try: - from voice_mixer import decode_to_pcm - except ImportError: - from .voice_mixer import decode_to_pcm + decode_to_pcm = _voice_mixer_module().decode_to_pcm pcm = await asyncio.to_thread(decode_to_pcm, audio_path) if pcm: speech_gain = float(self._voice_fx_cfg.get("speech_gain", 1.0)) mixer.play_speech(self._lead_silence_bytes() + pcm, gain=speech_gain) - # Block until the speech child drains so callers serialise - # replies (mirrors legacy semantics) but the ambient keeps - # playing underneath the whole time. + # Block until speech drains so callers serialise replies; ambient keeps playing. wait_start = time.monotonic() while mixer.speech_active: if time.monotonic() - wait_start > playback_timeout: @@ -4746,15 +3588,11 @@ class DiscordAdapter(BasePlatformAdapter): await asyncio.sleep(0.05) return True logger.warning("Mixer decode failed for %s; falling back to legacy playback", audio_path) - - # ── Legacy one-shot path (no mixer) ───────────────────────── - # Pause voice receiver while playing (echo prevention) + # Legacy one-shot path: pause receiver while playing (echo prevention). receiver = self._voice_receivers.get(guild_id) if receiver: receiver.pause() - try: - # Wait for current playback to finish (with timeout) wait_start = time.monotonic() while vc.is_playing(): if time.monotonic() - wait_start > playback_timeout: @@ -4762,7 +3600,6 @@ class DiscordAdapter(BasePlatformAdapter): vc.stop() break await asyncio.sleep(0.1) - done = asyncio.Event() loop = asyncio.get_running_loop() @@ -4770,9 +3607,7 @@ class DiscordAdapter(BasePlatformAdapter): if error: logger.error("Voice playback error: %s", error) loop.call_soon_threadsafe(done.set) - - # Prepend a short lead of silence so the voice socket's warm-up - # doesn't clip the first word (mirrors the mixer path above). + # Lead silence so socket warm-up doesn't clip the first word (mirrors mixer path). ffmpeg_opts: Dict[str, Any] = {} _fx_cfg = getattr(self, "_voice_fx_cfg", None) or {} try: @@ -4782,9 +3617,7 @@ class DiscordAdapter(BasePlatformAdapter): if lead_ms > 0: ffmpeg_opts["options"] = f"-af adelay={lead_ms}:all=1" source = discord.FFmpegPCMAudio( - audio_path, - executable=resolve_ffmpeg_executable(), - **ffmpeg_opts, + audio_path, executable=resolve_ffmpeg_executable(), **ffmpeg_opts, ) source = discord.PCMVolumeTransformer(source, volume=1.0) vc.play(source, after=_after) @@ -4838,13 +3671,8 @@ class DiscordAdapter(BasePlatformAdapter): except asyncio.CancelledError: return text_ch_id = self._voice_text_channels.get(guild_id) - # ``/voice off`` mutes spoken replies but deliberately keeps the bot in - # the channel (leaving is ``/voice leave``). The inactivity timer only - # counts the bot's OWN audio as activity, so under voice-off mode it - # fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the - # text channel with "Left voice channel (inactivity timeout)." Honor the - # user's choice: skip the auto-disconnect while voice replies are off. - # (The timer re-arms when the bot next speaks or hears a user.) + # ``/voice off`` keeps the bot in the channel; only the bot's own audio counts as + # activity, so the timer would fire every VOICE_TIMEOUT and spam "Left voice channel". _mode_getter = getattr(self, "_voice_mode_getter", None) if text_ch_id is not None and _mode_getter is not None: try: @@ -4873,79 +3701,50 @@ class DiscordAdapter(BasePlatformAdapter): return vc is not None and vc.is_connected() def get_voice_channel_info(self, guild_id: int) -> Optional[Dict[str, Any]]: - """Return voice channel awareness info for the given guild. - - Returns None if the bot is not in a voice channel. Otherwise - returns a dict with channel name, member list, count, and - currently-speaking user IDs (from SSRC mapping). - """ + """Return voice channel info (name, members, count, speaking user IDs) or None if not connected.""" vc = self._voice_clients.get(guild_id) if not vc or not vc.is_connected(): return None - channel = vc.channel if not channel: return None - - # Members currently in the voice channel (includes bot) members_info = [] bot_user = self._client.user if self._client else None for m in channel.members: if bot_user and m.id == bot_user.id: continue # skip the bot itself - members_info.append({ - "user_id": m.id, - "display_name": m.display_name, - "is_bot": m.bot, - }) - - # Currently speaking users (from SSRC mapping + active buffers) + members_info.append({"user_id": m.id, "display_name": m.display_name, "is_bot": m.bot}) speaking_user_ids: set = set() receiver = self._voice_receivers.get(guild_id) if receiver: now = time.monotonic() with receiver._lock: for ssrc, last_t in receiver._last_packet_time.items(): - # Consider "speaking" if audio received within last 2 seconds if now - last_t < 2.0: uid = receiver._ssrc_to_user.get(ssrc) if uid: speaking_user_ids.add(uid) - - # Tag speaking status on members for info in members_info: info["is_speaking"] = info["user_id"] in speaking_user_ids - return { - "channel_name": channel.name, - "member_count": len(members_info), - "members": members_info, - "speaking_count": len(speaking_user_ids), + "channel_name": channel.name, "member_count": len(members_info), + "members": members_info, "speaking_count": len(speaking_user_ids), } def get_voice_channel_context(self, guild_id: int) -> str: - """Return a human-readable voice channel context string. - - Suitable for injection into the system/ephemeral prompt so the - agent is always aware of voice channel state. - """ + """Return a human-readable voice channel context string for prompt injection.""" info = self.get_voice_channel_info(guild_id) if not info: return "" - parts = [f"[Voice channel: #{info['channel_name']} — {info['member_count']} participant(s)]"] for m in info["members"]: status = " (speaking)" if m["is_speaking"] else "" parts.append(f" - {m['display_name']}{status}") - return "\n".join(parts) - # ------------------------------------------------------------------ - # Voice listening (Phase 2) - # ------------------------------------------------------------------ + # --- Voice listening (Phase 2) --- - # UDP keepalive interval in seconds — prevents Discord from dropping - # the UDP route after ~60s of silence. + # UDP keepalive interval; Discord drops the UDP route after ~60s of silence. _KEEPALIVE_INTERVAL = 15 async def _voice_listen_loop(self, guild_id: int): @@ -4957,9 +3756,6 @@ class DiscordAdapter(BasePlatformAdapter): try: while receiver._running: await asyncio.sleep(0.2) - - # Send periodic UDP keepalive to prevent Discord from - # dropping the UDP session after ~60s of silence. now = time.monotonic() if now - last_keepalive >= self._KEEPALIVE_INTERVAL: last_keepalive = now @@ -4969,23 +3765,13 @@ class DiscordAdapter(BasePlatformAdapter): vc._connection.send_packet(b'\xf8\xff\xfe') except Exception: pass - completed = receiver.check_silence() - # Voice inputs always originate from a specific guild - # (guild_id is in scope). Pass it so role checks are - # guild-scoped and not cross-guild. + # Pass guild so role checks stay guild-scoped. _vc_guild = self._client.get_guild(guild_id) if self._client is not None else None for user_id, pcm_data in completed: - if not self._is_allowed_user( - str(user_id), - guild=_vc_guild, - is_dm=False, - ): + if not self._is_allowed_user(str(user_id), guild=_vc_guild, is_dm=False): continue - # A user speaking to the bot is activity too — not just the - # bot's own playback. Reset the inactivity timer so an active - # listener isn't disconnected mid-conversation (this also - # covers voice-on text-only sessions that never play audio). + # User speech is activity too; keeps active listeners connected. self._reset_voice_timeout(guild_id) await self._process_voice_input(guild_id, user_id, pcm_data) except asyncio.CancelledError: @@ -4996,33 +3782,25 @@ class DiscordAdapter(BasePlatformAdapter): async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes): """Convert PCM -> WAV -> STT -> callback.""" from tools.voice_mode import is_whisper_hallucination - tmp_f = tempfile.NamedTemporaryFile(suffix=".wav", prefix="vc_listen_", delete=False) wav_path = tmp_f.name tmp_f.close() try: await asyncio.to_thread(VoiceReceiver.pcm_to_wav, pcm_data, wav_path) - from tools.transcription_tools import transcribe_audio result = await asyncio.to_thread(transcribe_audio, wav_path) - if not result.get("success"): return transcript = result.get("transcript", "").strip() if not transcript or is_whisper_hallucination(transcript): return - logger.info("Voice input from user %d: %s", user_id, transcript[:100]) - if self._voice_input_callback: await self._voice_input_callback( - guild_id=guild_id, - user_id=user_id, - transcript=transcript, + guild_id=guild_id, user_id=user_id, transcript=transcript, ) except Exception as e: - # CalledProcessError from pcm_to_wav carries ffmpeg's captured - # stderr — surface it, or the log only says "exit status N". + # Surface ffmpeg's captured stderr from CalledProcessError, else log just says "exit status N". _ff_err = getattr(e, "stderr", None) if _ff_err: if isinstance(_ff_err, bytes): @@ -5057,67 +3835,32 @@ class DiscordAdapter(BasePlatformAdapter): return False try: from gateway.pairing import PairingStore - return bool(PairingStore().is_approved("discord", user_id)) except Exception: return False def _is_allowed_user( - self, - user_id: str, - author=None, - *, - guild=None, - is_dm: bool = False, + self, user_id: str, author=None, *, guild=None, is_dm: bool = False, channel_ids: Optional[set[str]] = None, ) -> bool: - """Check if user is allowed via DISCORD_ALLOWED_USERS or DISCORD_ALLOWED_ROLES. - - Uses OR semantics: if the user matches EITHER allowlist, they're allowed. - With no user/role allowlists configured, guild traffic may still pass when - ``channel_ids`` matches ``DISCORD_ALLOWED_CHANNELS`` — but only when the - caller supplies the validated channel context (on_message, slash). Calls - without channel context (e.g. voice utterances) do not get this bypass. - - Role checks are **scoped to the guild the message originated from**. - For DMs (no guild context), role-based auth is disabled by default and - only user-ID allowlist applies. Set ``discord.dm_role_auth_guild`` - in config.yaml to a specific guild ID to opt-in: role membership in - that one guild will authorize DMs. This prevents cross-guild - privilege escalation where a user with the configured role in any - shared public server could DM the bot and pass the allowlist. - - Args: - user_id: Author ID as a string. - author: Optional Member/User object for in-guild role lookup. - guild: The guild the message arrived in (None for DMs). - is_dm: True if the message came from a DM channel. - channel_ids: Resolved text-channel ids for guild traffic when an - upstream gate has already scoped the message to a channel. + """Allow via DISCORD_ALLOWED_USERS/ROLES (OR); with no allowlists, validated channel + context may pass on DISCORD_ALLOWED_CHANNELS (never voice). Role checks are guild-scoped: + DMs use user IDs only unless ``discord.dm_role_auth_guild`` names one guild (no escalation). """ - # ``getattr`` fallbacks here guard against test fixtures that build - # an adapter via ``object.__new__(DiscordAdapter)`` and skip __init__ - # (see AGENTS.md pitfall #17 — same pattern as gateway.run). + # getattr fallbacks: test fixtures build the adapter via object.__new__ and skip __init__. allowed_users = getattr(self, "_allowed_user_ids", set()) allowed_roles = getattr(self, "_allowed_role_ids", set()) has_users = bool(allowed_users) has_roles = bool(allowed_roles) - - # Pairing is a first-class auth grant in the gateway auth union and in - # Discord component buttons. Honor it here too so normal guild/DM text - # messages do not get dropped at the adapter before the pairing-aware - # gateway layer can see them. + # Pairing is a first-class grant in the gateway auth union; honor it here too. if self._is_pairing_approved_user(user_id): return True - if not has_users and not has_roles: if self._discord_allow_all_users(): return True if self._gateway_allow_all_users(): return True - # Channel-scoped guild access requires validated channel context. - # Do not treat DISCORD_ALLOWED_CHANNELS alone as a user-wide bypass - # (voice loops and other guild-scoped callers may lack channel ids). + # Channel-scoped access needs validated channel context; not a user-wide bypass. if ( not is_dm and channel_ids is not None @@ -5125,21 +3868,12 @@ class DiscordAdapter(BasePlatformAdapter): ): return True return False - # Check user ID allowlist (works for both DMs and guild messages). - # ``"*"`` is honored as an open-mode wildcard, mirroring - # ``SIGNAL_ALLOWED_USERS`` and the existing ``DISCORD_ALLOWED_CHANNELS`` / - # ``DISCORD_IGNORED_CHANNELS`` / ``DISCORD_FREE_RESPONSE_CHANNELS`` - # semantics. This is the convention ``claw migrate`` emits ("*"). + # "*" is the open-mode wildcard (mirrors other DISCORD_* lists; ``claw migrate`` emits it). if has_users and ("*" in allowed_users or user_id in allowed_users): return True - # Role allowlist is only consulted when configured. if not has_roles: return False - - # DM path: roles require explicit opt-in via - # ``discord.dm_role_auth_guild`` in config.yaml. Without this, a - # user with the configured role in ANY mutual guild could DM the - # bot and bypass the allowlist (cross-guild leakage). + # DM path: roles need explicit opt-in via ``discord.dm_role_auth_guild`` (else cross-guild leakage). if is_dm or guild is None: dm_guild_id = _read_dm_role_auth_guild() if dm_guild_id is None: @@ -5149,25 +3883,19 @@ class DiscordAdapter(BasePlatformAdapter): dm_guild = self._client.get_guild(dm_guild_id) if dm_guild is None: return False - try: - uid_int = int(user_id) - except (TypeError, ValueError): - return False - m = dm_guild.get_member(uid_int) - if m is None: - return False - m_roles = getattr(m, "roles", None) or [] - return any(getattr(r, "id", None) in allowed_roles for r in m_roles) - - # Guild path: role check is scoped to THIS guild only. - # 1) Prefer the direct Member object passed in (correct guild by construction). + return self._guild_member_has_role(dm_guild, user_id, allowed_roles) + # Guild path: scoped to THIS guild. 1) Prefer the passed Member (correct guild by construction). direct_roles = getattr(author, "roles", None) if author is not None else None author_guild = getattr(author, "guild", None) if direct_roles and (author_guild is None or author_guild.id == guild.id): if any(getattr(r, "id", None) in allowed_roles for r in direct_roles): return True - # 2) Fallback: resolve the Member in the message's guild only — NEVER - # scan other mutual guilds (that is the cross-guild bypass bug). + # 2) Fallback: resolve Member in this guild only — NEVER scan other mutual guilds. + return self._guild_member_has_role(guild, user_id, allowed_roles) + + @staticmethod + def _guild_member_has_role(guild, user_id: str, allowed_roles: set) -> bool: + """Look ``user_id`` up as a member of ``guild`` only and test its roles.""" try: uid_int = int(user_id) except (TypeError, ValueError): @@ -5201,154 +3929,86 @@ class DiscordAdapter(BasePlatformAdapter): ) # ── Slash command authorization ───────────────────────────────────── - # Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``) - # are a separate Discord interaction surface from regular messages and - # historically ran with NO authorization check — bypassing every gate - # ``on_message`` enforces (DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, - # DISCORD_ALLOWED_CHANNELS, DISCORD_IGNORED_CHANNELS). Any guild member - # could invoke ``/bg``, ``/restart``, ``/sethome``, etc. as the - # operator. ``_check_slash_authorization`` mirrors the on_message gates - # one-for-one so the slash surface honors the same trust boundary. - # - # Deployments with no allowlist env vars fail closed unless an explicit - # allow-all opt-in is set. When only ``DISCORD_ALLOWED_CHANNELS`` is - # configured, guild traffic is authorized per validated channel context - # (not as a user-wide bypass). Slash and on_message both pass the - # resolved channel ids into ``_is_allowed_user`` after the channel gate. + # ``_check_slash_authorization`` mirrors the on_message gates one-for-one. No allowlist => + # fail closed unless allow-all; DISCORD_ALLOWED_CHANNELS alone authorizes per validated channel. def _evaluate_slash_authorization( self, interaction: "discord.Interaction", ) -> Tuple[bool, Optional[str]]: - """Evaluate slash authorization without producing any response. - - Returns ``(allowed, reason)``. ``reason`` is populated only when - ``allowed`` is False. This is the shared core used by both the - responding wrapper (``_check_slash_authorization``) and side-effect- - free callers like the ``/skill`` autocomplete callback, which must - return an empty list for unauthorized users instead of leaking an - ephemeral rejection per-keystroke. - - Fail-closed semantics for malformed payloads: when an allowlist is - configured but the interaction is missing the data needed to - evaluate it (no channel id with channel policy active, no user - with user/role policy active), the gate REJECTS rather than - falling through. Without these guards a guild interaction that - happens to deserialize without a channel id would silently bypass - ``DISCORD_ALLOWED_CHANNELS`` and a payload missing ``user`` would - raise ``AttributeError`` in the user check below, surfacing as - an opaque interaction failure rather than a clean rejection. + """Evaluate slash authorization without responding; returns ``(allowed, reason)``. + Shared with side-effect-free callers (``/skill`` autocomplete returns [] per keystroke). + Fail closed on malformed payloads: with an allowlist, a missing channel id/user REJECTS. """ chan_obj = getattr(interaction, "channel", None) in_dm = isinstance(chan_obj, discord.DMChannel) if chan_obj is not None else False - channel_ids: set = set() channel_keys: set = set() - # ── Channel scope (mirrors on_message lines 3374-3388) ── - # DMs aren't channel-gated — DMs follow on_message's DM lockdown - # path which has its own user-allowlist enforcement. + # Channel scope mirrors on_message; DMs use on_message's DM lockdown path instead. if not in_dm: - chan_id_raw = getattr(interaction, "channel_id", None) or getattr( - chan_obj, "id", None, - ) + chan_id_raw = getattr(interaction, "channel_id", None) or getattr(chan_obj, "id", None) if chan_id_raw is not None: channel_ids.add(str(chan_id_raw)) - # Mirror on_message: also test the parent channel for threads - # so per-channel allow/deny lists work consistently. + # Threads: also test the parent channel, as on_message does. if isinstance(chan_obj, discord.Thread): parent_id = self._get_parent_channel_id(chan_obj) if parent_id: channel_ids.add(str(parent_id)) - - # Name-form keys (ID + bare name + #name + parent) so allow/ignore - # lists configured by channel name work for slash-command - # interactions too, matching the on_message gates. + # Name-form keys (ID, name, #name, parent) so name-based lists work for slash too. channel_keys = self._discord_channel_keys_from_channel( chan_obj, self._get_parent_channel_id(chan_obj) if isinstance(chan_obj, discord.Thread) else None, ) - allowed = self._get_allowed_channels() if allowed: if "*" not in allowed: if not channel_ids: - # Channel policy is configured but the interaction - # has no resolvable channel id. Fail closed. + # Channel policy configured but no resolvable channel id: fail closed. return ( - False, - "channel id missing with DISCORD_ALLOWED_CHANNELS configured", + False, "channel id missing with DISCORD_ALLOWED_CHANNELS configured", ) if not (channel_keys & allowed): return (False, "channel not in DISCORD_ALLOWED_CHANNELS") - - # Ignored beats allowed: even when a thread's parent channel - # is on the allowlist, an explicit DISCORD_IGNORED_CHANNELS - # entry on the thread or its parent rejects the interaction. + # Ignored beats allowed, including via a thread's parent. ignored = self._get_ignored_channels() if ignored and channel_ids: if "*" in ignored or (channel_keys & ignored): return (False, "channel in DISCORD_IGNORED_CHANNELS") - # ── User / role allowlist (mirrors on_message line 681) ── user = getattr(interaction, "user", None) allowed_users = getattr(self, "_allowed_user_ids", set()) or set() allowed_roles = getattr(self, "_allowed_role_ids", set()) or set() if user is None or getattr(user, "id", None) is None: - # No identifiable user — fail closed even with allow-all opt-in. - # Downstream slash handlers (_build_slash_event, etc.) require - # interaction.user.id and do not synthesize a safe identity. + # No identifiable user: fail closed even with allow-all; downstream handlers need interaction.user.id. if allowed_users or allowed_roles: return (False, "missing interaction.user with allowlist configured") return (False, "missing interaction.user") - user_id = str(user.id) - # Pass guild + is_dm so role check is scoped to the originating - # guild and cross-guild DM bypass (#12136) can't land via the - # slash surface either. + # guild + is_dm scope the role check so the cross-guild DM bypass can't land via slash. interaction_guild = getattr(interaction, "guild", None) if not self._is_allowed_user( - user_id, - author=user, - guild=interaction_guild, - is_dm=in_dm, + user_id, author=user, guild=interaction_guild, is_dm=in_dm, channel_ids=channel_keys if not in_dm else None, ): - return ( - False, - "user not in DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES", - ) - + return (False, "user not in DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES") return (True, None) async def _check_slash_authorization( self, interaction: "discord.Interaction", command_text: str, ) -> bool: - """Mirror on_message's user/role/channel gates onto a slash invocation. - - Returns True to proceed. Returns False *after* sending an ephemeral - rejection, logging a warning, and scheduling a cross-platform admin - alert — the caller must stop on False (the interaction has already - been responded to). - """ + """Mirror on_message's gates onto a slash invocation. + Returns False only *after* sending the ephemeral rejection, so the caller just stops.""" allowed, reason = self._evaluate_slash_authorization(interaction) if allowed: return True - return await self._reject_slash( - interaction, command_text, reason=reason or "unauthorized", - ) + return await self._reject_slash(interaction, command_text, reason=reason or "unauthorized") async def _reject_slash( self, interaction: "discord.Interaction", command_text: str, *, reason: str, ) -> bool: - """Send ephemeral reject + log warning + schedule admin alert. Returns False. - - Tolerates a missing ``interaction.user`` -- the fail-closed branch - in ``_evaluate_slash_authorization`` deliberately routes here for - malformed payloads (no user) when an allowlist is configured, and - ``str(interaction.user.id)`` would raise AttributeError before the - ephemeral rejection could be sent. - """ + """Send ephemeral reject + log + schedule admin alert; returns False. + Tolerates a missing ``interaction.user`` (fail-closed branch routes malformed payloads here).""" user = getattr(interaction, "user", None) if user is not None: user_id = str(getattr(user, "id", "?")) @@ -5360,23 +4020,18 @@ class DiscordAdapter(BasePlatformAdapter): getattr(interaction, "channel", None), "id", None, ) guild_id = getattr(interaction, "guild_id", None) - logger.warning( "[Discord] Unauthorized slash attempt: user=%s id=%s channel=%s " "guild=%s cmd=%r reason=%r", user_name, user_id, chan_id, guild_id, command_text, reason, ) - try: await interaction.response.send_message( - "You're not authorized to use this command.", - ephemeral=True, + "You're not authorized to use this command.", ephemeral=True, ) except Exception as e: - # Interaction may already be responded to (e.g. caller deferred - # before the auth check, or Discord retried). Best-effort only. + # Interaction may already be responded to (caller deferred, Discord retry). logger.debug("[Discord] Could not send unauthorized ephemeral: %s", e) - # Fire-and-forget: don't block the interaction handler on Telegram I/O. try: asyncio.create_task(self._notify_unauthorized_slash( @@ -5384,27 +4039,13 @@ class DiscordAdapter(BasePlatformAdapter): )) except Exception as e: logger.debug("[Discord] Could not schedule admin notify task: %s", e) - return False async def _notify_unauthorized_slash( - self, user_name: str, user_id: str, chan_id, guild_id, - command_text: str, reason: str, + self, user_name: str, user_id: str, chan_id, guild_id, command_text: str, reason: str, ) -> None: - """Best-effort cross-platform alert to the gateway operator. - - Tries TELEGRAM first (most operators set TELEGRAM_HOME_CHANNEL), - then SLACK. Silently no-ops if no other platform is configured - with a home channel. - - A soft send failure -- adapter.send() returning a result with - ``success=False`` rather than raising -- continues the fallback - chain. Treating a SendResult(success=False) as delivered would - mean a Telegram outage that the adapter politely surfaces (e.g. - rate-limit, auth failure) silently swallows the alert without - attempting Slack. Hard exceptions still take the same path via - the except branch below. - """ + """Best-effort operator alert: TELEGRAM first, then SLACK; no-op without a home channel. + A soft failure (``SendResult(success=False)``, e.g. rate-limit) continues the fallback chain.""" runner = getattr(self, "gateway_runner", None) if not runner: return @@ -5424,8 +4065,7 @@ class DiscordAdapter(BasePlatformAdapter): f"Reason: {reason}" ) result = await adapter.send(str(home.chat_id), msg) - # Only return on confirmed delivery. SendResult(success=False) - # -> continue to the next platform. + # Only return on confirmed delivery. if getattr(result, "success", None) is False: logger.debug( "[Discord] Admin notify via %s returned success=False" @@ -5437,228 +4077,112 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.debug("[Discord] Admin notify via %s failed: %s", target, e) + async def _send_local_file(self, chat_id, path, caption, *, file_name=None, not_found: str, kind: str, fallback): + """Native attachment upload for a local file; missing file -> error, other failure -> base adapter.""" + try: + return await self._send_file_attachment(chat_id, path, caption, file_name=file_name) + except FileNotFoundError: + return SendResult(success=False, error=f"{not_found}: {path}") + except Exception as e: # pragma: no cover - defensive logging + logger.error("[%s] Failed to send %s, falling back to base adapter: %s", self.name, kind, e, exc_info=True) + return await fallback() + async def send_image_file( - self, - chat_id: str, - image_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, image_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file natively as a Discord file attachment.""" + return await self._send_local_file( + chat_id, image_path, caption, not_found="Image file not found", kind="local image", + fallback=lambda: super(DiscordAdapter, self).send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata), + ) + + async def _send_url_media( + self, chat_id: str, url: str, caption: Optional[str], *, kind: str, + filename_for, fallback, metadata: Optional[dict], error_metadata: Optional[dict], + ) -> SendResult: + """Download ``url`` and post it as a native attachment (Discord renders those inline). + ``fallback(metadata)`` is the base-adapter URL send (``error_metadata`` after download failure).""" + if not self._client: + return SendResult(success=False, error="Not connected") + if not is_safe_url(url): + logger.warning("[%s] Blocked unsafe %s URL during Discord send_%s", self.name, kind, kind) + return await fallback(metadata) try: - return await self._send_file_attachment(chat_id, image_path, caption) - except FileNotFoundError: - return SendResult(success=False, error=f"Image file not found: {image_path}") + import aiohttp + channel = await self._resolve_channel(chat_id) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp + _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(resolve_proxy_url(platform_env_var="DISCORD_PROXY")) + async with aiohttp.ClientSession(**_sess_kw) as session: + status, data, headers = await _read_url_image_with_redirect_guard( + session, url, timeout=aiohttp.ClientTimeout(total=30), request_kwargs=_req_kw, + ) + if status != 200: + raise Exception(f"Failed to download {kind}: HTTP {status}") + import io + file = discord.File(io.BytesIO(data), filename=filename_for(headers)) + if self._is_forum_parent(channel): + return await self._forum_post_file(channel, content=(caption or "").strip(), file=file) + msg = await channel.send(content=caption if caption else None, file=file) + return SendResult(success=True, message_id=str(msg.id)) + except ImportError: + logger.warning("[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", self.name, exc_info=True) + return await fallback(error_metadata) except Exception as e: # pragma: no cover - defensive logging - logger.error("[%s] Failed to send local image, falling back to base adapter: %s", self.name, e, exc_info=True) - return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata) + logger.error("[%s] Failed to send %s attachment, falling back to URL: %s", self.name, kind, e, exc_info=True) + return await fallback(error_metadata) async def send_image( - self, - chat_id: str, - image_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, image_url: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an image natively as a Discord file attachment.""" - if not self._client: - return SendResult(success=False, error="Not connected") - - if not is_safe_url(image_url): - logger.warning("[%s] Blocked unsafe image URL during Discord send_image", self.name) - return await super().send_image(chat_id, image_url, caption, reply_to, metadata=metadata) - - try: - import aiohttp - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - if not channel: - return SendResult(success=False, error=f"Channel {chat_id} not found") - - # Download the image and send as a Discord file attachment - # (Discord renders attachments inline, unlike plain URLs) - from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp - _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") - _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) - async with aiohttp.ClientSession(**_sess_kw) as session: - status, image_data, headers = await _read_url_image_with_redirect_guard( - session, - image_url, - timeout=aiohttp.ClientTimeout(total=30), - request_kwargs=_req_kw, - ) - if status != 200: - raise Exception(f"Failed to download image: HTTP {status}") - - # Determine filename from URL or content type - content_type = headers.get("content-type", "image/png") - ext = "png" - if "jpeg" in content_type or "jpg" in content_type: - ext = "jpg" - elif "gif" in content_type: - ext = "gif" - elif "webp" in content_type: - ext = "webp" - - import io - file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}") - - if self._is_forum_parent(channel): - return await self._forum_post_file( - channel, - content=(caption or "").strip(), - file=file, - ) - - msg = await channel.send( - content=caption if caption else None, - file=file, - ) - return SendResult(success=True, message_id=str(msg.id)) - - except ImportError: - logger.warning( - "[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", - self.name, - exc_info=True, - ) - return await super().send_image(chat_id, image_url, caption, reply_to) - except Exception as e: # pragma: no cover - defensive logging - logger.error( - "[%s] Failed to send image attachment, falling back to URL: %s", - self.name, - e, - exc_info=True, - ) - return await super().send_image(chat_id, image_url, caption, reply_to) + return await self._send_url_media( + chat_id, image_url, caption, kind="image", + filename_for=lambda h: f"image.{_image_ext_from_content_type(h.get('content-type', 'image/png'))}", + fallback=lambda md: super(DiscordAdapter, self).send_image(chat_id, image_url, caption, reply_to, metadata=md), + metadata=metadata, error_metadata=None, + ) async def send_animation( - self, - chat_id: str, - animation_url: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, animation_url: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an animated GIF natively as a Discord file attachment.""" - if not self._client: - return SendResult(success=False, error="Not connected") - - if not is_safe_url(animation_url): - logger.warning("[%s] Blocked unsafe animation URL during Discord send_animation", self.name) - return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata) - - try: - import aiohttp - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - if not channel: - return SendResult(success=False, error=f"Channel {chat_id} not found") - - # Download the GIF and send as a Discord file attachment - # (Discord renders .gif attachments as auto-playing animations inline) - from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp - _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") - _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) - async with aiohttp.ClientSession(**_sess_kw) as session: - status, animation_data, _headers = await _read_url_image_with_redirect_guard( - session, - animation_url, - timeout=aiohttp.ClientTimeout(total=30), - request_kwargs=_req_kw, - ) - if status != 200: - raise Exception(f"Failed to download animation: HTTP {status}") - - import io - file = discord.File(io.BytesIO(animation_data), filename="animation.gif") - - if self._is_forum_parent(channel): - return await self._forum_post_file( - channel, - content=(caption or "").strip(), - file=file, - ) - - msg = await channel.send( - content=caption if caption else None, - file=file, - ) - return SendResult(success=True, message_id=str(msg.id)) - - except ImportError: - logger.warning( - "[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", - self.name, - exc_info=True, - ) - return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata) - except Exception as e: # pragma: no cover - defensive logging - logger.error( - "[%s] Failed to send animation attachment, falling back to URL: %s", - self.name, - e, - exc_info=True, - ) - return await super().send_animation(chat_id, animation_url, caption, reply_to, metadata=metadata) + return await self._send_url_media( + chat_id, animation_url, caption, kind="animation", filename_for=lambda _h: "animation.gif", + fallback=lambda md: super(DiscordAdapter, self).send_animation(chat_id, animation_url, caption, reply_to, metadata=md), + metadata=metadata, error_metadata=metadata, + ) async def send_video( - self, - chat_id: str, - video_path: str, - caption: Optional[str] = None, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, video_path: str, caption: Optional[str] = None, + reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local video file natively as a Discord attachment.""" - try: - return await self._send_file_attachment(chat_id, video_path, caption) - except FileNotFoundError: - return SendResult(success=False, error=f"Video file not found: {video_path}") - except Exception as e: # pragma: no cover - defensive logging - logger.error("[%s] Failed to send local video, falling back to base adapter: %s", self.name, e, exc_info=True) - return await super().send_video(chat_id, video_path, caption, reply_to, metadata=metadata) + return await self._send_local_file( + chat_id, video_path, caption, not_found="Video file not found", kind="local video", + fallback=lambda: super(DiscordAdapter, self).send_video(chat_id, video_path, caption, reply_to, metadata=metadata), + ) async def send_document( - self, - chat_id: str, - file_path: str, - caption: Optional[str] = None, - file_name: Optional[str] = None, - reply_to: Optional[str] = None, + self, chat_id: str, file_path: str, caption: Optional[str] = None, + file_name: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an arbitrary file natively as a Discord attachment.""" - try: - return await self._send_file_attachment(chat_id, file_path, caption, file_name=file_name) - except FileNotFoundError: - return SendResult(success=False, error=f"File not found: {file_path}") - except Exception as e: # pragma: no cover - defensive logging - logger.error("[%s] Failed to send document, falling back to base adapter: %s", self.name, e, exc_info=True) - return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata) + return await self._send_local_file( + chat_id, file_path, caption, file_name=file_name, not_found="File not found", kind="document", + fallback=lambda: super(DiscordAdapter, self).send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata), + ) async def send_typing(self, chat_id: str, metadata=None) -> None: - """Start a persistent typing indicator for a channel. - - Discord's TYPING_START gateway event is unreliable in DMs for bots. - Instead, start a background loop that hits the typing endpoint every - 12 seconds (typing indicator lasts ~10s). The loop is cancelled when - stop_typing() is called (after the response is sent). - - Rate-limit handling: if a 429 is encountered, the loop logs a - warning, sleeps for the ``retry_after`` duration (or a sensible - default), and continues — it does NOT die on a single rate-limit - hit. Only CancelledError (from stop_typing) stops the loop. - """ + """Start a persistent typing loop (POST typing every 12s; indicator lasts ~10s). + TYPING_START is unreliable for bots in DMs; 429 sleeps ``retry_after``; CancelledError ends it.""" if not self._client: return - # Don't start a duplicate loop if chat_id in self._typing_tasks: return @@ -5667,14 +4191,12 @@ class DiscordAdapter(BasePlatformAdapter): while True: try: route = discord.http.Route( - "POST", "/channels/{channel_id}/typing", - channel_id=chat_id, + "POST", "/channels/{channel_id}/typing", channel_id=chat_id, ) await self._client.http.request(route) except asyncio.CancelledError: return except Exception as e: - # Don't die on 429 — backoff and continue retry_after = self._extract_discord_retry_after(e) if retry_after is not None: logger.warning( @@ -5682,10 +4204,7 @@ class DiscordAdapter(BasePlatformAdapter): chat_id, retry_after, ) else: - logger.debug( - "Discord typing indicator failed for %s: %s", - chat_id, e, - ) + logger.debug("Discord typing indicator failed for %s: %s", chat_id, e) return await asyncio.sleep(retry_after) continue @@ -5694,7 +4213,6 @@ class DiscordAdapter(BasePlatformAdapter): pass finally: self._typing_tasks.pop(chat_id, None) - self._typing_tasks[chat_id] = asyncio.create_task(_typing_loop()) async def stop_typing(self, chat_id: str) -> None: @@ -5711,16 +4229,10 @@ class DiscordAdapter(BasePlatformAdapter): """Get information about a Discord channel.""" if not self._client: return {"name": "Unknown", "type": "dm"} - try: - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - + channel = await self._resolve_channel(chat_id) if not channel: return {"name": str(chat_id), "type": "dm"} - - # Determine channel type if isinstance(channel, discord.DMChannel): chat_type = "dm" name = channel.recipient.name if channel.recipient else str(chat_id) @@ -5735,10 +4247,8 @@ class DiscordAdapter(BasePlatformAdapter): else: chat_type = "channel" name = getattr(channel, "name", str(chat_id)) - return { - "name": name, - "type": chat_type, + "name": name, "type": chat_type, "guild_id": str(channel.guild.id) if hasattr(channel, "guild") and channel.guild else None, "guild_name": channel.guild.name if hasattr(channel, "guild") and channel.guild else None, } @@ -5747,40 +4257,23 @@ class DiscordAdapter(BasePlatformAdapter): return {"name": str(chat_id), "type": "dm", "error": str(e)} async def _resolve_allowed_usernames(self) -> None: - """ - Resolve non-numeric entries in DISCORD_ALLOWED_USERS to Discord user IDs. - - Users can specify usernames (e.g. "teknium") or display names instead of - raw numeric IDs. After resolution, the env var and internal set are updated - so authorization checks work with IDs only. - """ + """Resolve username/display-name entries in DISCORD_ALLOWED_USERS to numeric IDs.""" if not self._allowed_user_ids or not self._client: return - numeric_ids = set() to_resolve = set() - for entry in self._allowed_user_ids: if entry.isdigit(): numeric_ids.add(entry) elif entry == "*": - # Preserve the open-mode wildcard verbatim. It is not a - # username to resolve; without this branch it would land in - # ``to_resolve``, fail to match any guild member, and then be - # silently dropped from both ``self._allowed_user_ids`` and - # ``DISCORD_ALLOWED_USERS`` by the rewrite below — quietly - # undoing the wildcard fix in ``_is_allowed_user`` after the - # first ``on_ready``. + # Keep the "*" wildcard verbatim; it can't resolve and would be silently dropped. numeric_ids.add(entry) else: to_resolve.add(entry.lower()) - if not to_resolve: return - print(f"[{self.name}] Resolving {len(to_resolve)} username(s): {', '.join(to_resolve)}") resolved_count = 0 - for guild in self._client.guilds: # Fetch full member list (requires members intent) try: @@ -5790,12 +4283,10 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.warning("Failed to fetch members for guild %s: %s", guild.name, e) continue - for member in members: name_lower = member.name.lower() display_lower = member.display_name.lower() global_lower = (member.global_name or "").lower() - matched = name_lower in to_resolve or display_lower in to_resolve or global_lower in to_resolve if matched: uid = str(member.id) @@ -5806,87 +4297,62 @@ class DiscordAdapter(BasePlatformAdapter): ) to_resolve.discard(matched_name) print(f"[{self.name}] Resolved '{matched_name}' -> {uid} ({member.name}#{member.discriminator})") - if not to_resolve: break - if to_resolve: print(f"[{self.name}] Could not resolve usernames: {', '.join(to_resolve)}") - - # Update the internal set. Keep the resolved IDs adapter-local first: - # under multiplex_profiles, writing os.environ here would clobber - # every OTHER profile's DISCORD_ALLOWED_USERS after this adapter's - # on_ready — an unguarded runtime mutation of process-global state - # (issue #72348). Refresh this adapter's own snapshot instead. + # Adapter-local: under multiplex_profiles os.environ writes would clobber other profiles. self._allowed_user_ids = numeric_ids snap = getattr(self, "_gate_env_snapshot", None) if snap is not None: snap["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) if not _multiplex_active(): - # Single-profile: preserve the legacy env rewrite so the gateway's - # env-based auth checks match the resolved numeric IDs. + # Single-profile: legacy env rewrite so gateway env-based auth sees numeric IDs. os.environ["DISCORD_ALLOWED_USERS"] = ",".join(sorted(numeric_ids)) if resolved_count: print(f"[{self.name}] Updated DISCORD_ALLOWED_USERS with {resolved_count} resolved ID(s)") def format_message(self, content: str) -> str: - """Format message for Discord. - - Converts GFM markdown tables to bullet-list groups since Discord - does not render pipe tables natively. - """ + """Format for Discord: GFM tables become bullet lists (Discord doesn't render pipe tables).""" if not content: return content return convert_table_to_bullets(content) - async def _run_simple_slash( - self, - interaction: discord.Interaction, - command_text: str, - followup_msg: str | None = None, - ) -> None: - """Common handler for simple slash commands that dispatch a command string. + async def _defer_unless_expired(self, interaction: discord.Interaction, warn_fmt: str, *warn_args) -> bool: + """Ephemeral defer(); False (after a warning) when the interaction token already expired + so the caller still runs the command but skips followups. Other errors propagate.""" + try: + await interaction.response.defer(ephemeral=True) + return True + except Exception as e: + if not self._is_discord_unknown_interaction(e): + raise + logger.warning(warn_fmt, *warn_args) + return False - Defers the interaction (shows "thinking..."), dispatches the command, - then cleans up the deferred response. If *followup_msg* is provided - the "thinking..." indicator is replaced with that text; otherwise it - is deleted so the channel isn't cluttered. - """ - # Log the invoker so ghost-command reports can be triaged. Discord - # native slash invocations are always user-initiated (no bot can fire - # them), but mobile autocomplete / keyboard shortcuts / other users - # in the same channel are easy to miss in post-mortems. + async def _run_simple_slash( + self, interaction: discord.Interaction, command_text: str, followup_msg: str | None = None, + ) -> None: + """Defer, dispatch the command string, then replace/delete the "thinking..." indicator.""" + # Log the invoker so ghost-command reports can be triaged post-mortem. try: _user = interaction.user _chan_id = getattr(interaction.channel, "id", None) or getattr(interaction, "channel_id", None) logger.info( - "[Discord] slash '%s' invoked by user=%s id=%s channel=%s guild=%s", - command_text, - getattr(_user, "name", "?"), - getattr(_user, "id", "?"), - _chan_id, + "[Discord] slash '%s' invoked by user=%s id=%s channel=%s guild=%s", command_text, + getattr(_user, "name", "?"), getattr(_user, "id", "?"), _chan_id, getattr(interaction, "guild_id", None), ) except Exception: pass # logging must never block command dispatch - - # Auth gate — must run before defer() so an ephemeral rejection can - # be delivered on the still-unresponded interaction. + # Auth gate must precede defer() so the ephemeral rejection can still be sent. if not await self._check_slash_authorization(interaction, command_text): return - - deferred_response = False - try: - await interaction.response.defer(ephemeral=True) - deferred_response = True - except Exception as e: - if not self._is_discord_unknown_interaction(e): - raise - logger.warning( - "[Discord] slash %s: interaction expired before defer. " - "Executing command anyway, skipping interaction followup.", - command_text, - ) + deferred_response = await self._defer_unless_expired( + interaction, + "[Discord] slash %s: interaction expired before defer. " + "Executing command anyway, skipping interaction followup.", command_text, + ) event = self._build_slash_event(interaction, command_text) await self.handle_message(event) if not deferred_response: @@ -5899,310 +4365,104 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.debug("Discord interaction cleanup failed: %s", e) + def _slash_proxy(self, name: str, args: tuple, template: str, followup: Optional[str], *, + strip: bool = True, prefix: str = "slash_"): + """Build a slash callback rendering ``template`` from its args via ``_run_simple_slash``; + the introspected signature is synthesised from ``args`` (see ``_NATIVE_SLASH_COMMANDS``).""" + async def _handler(interaction: discord.Interaction, **kwargs): + text = template.format(**kwargs) + call_args = (text.strip() if strip else text,) + (() if followup is None else (followup,)) + await self._run_simple_slash(interaction, *call_args) + _handler.__name__ = prefix + {"bg": "background"}.get(name, name).replace("-", "_") + params = [inspect.Parameter("interaction", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=discord.Interaction)] + for arg_name, arg_type, default, _desc, _choices in args: + params.append(inspect.Parameter( + arg_name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=arg_type, + default=inspect.Parameter.empty if default is _REQUIRED else default, + )) + _handler.__signature__ = inspect.Signature(params) + if args: + _handler = discord.app_commands.describe(**{a[0]: a[3] for a in args})(_handler) + choices = {a[0]: [discord.app_commands.Choice(name=lbl, value=val) for lbl, val in a[4]] for a in args if a[4]} + if choices: + _handler = discord.app_commands.choices(**choices)(_handler) + return _handler + + def _register_thread_slash(self, tree, name: str, description: str) -> None: + @tree.command(name=name, description=description) + @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, + ): + # defer() happens inside the handler *after* the auth gate. + await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration) + 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/change reasoning effort, or toggle showing it") - @discord.app_commands.describe(effort="Pick a level, reset the override, or show/hide reasoning. Leave empty to see current.") - @discord.app_commands.choices(effort=[ - # Effort levels and the reset/show/hide subcommands all arrive on the - # gateway's single `/reasoning ` handler. Discord's native UI has - # no subcommand affordance for a free-text field (it just funnels the - # user into the `effort` box), so expose every accepted value as an - # explicit choice. --global persistence stays reachable by typing the - # command as plain text. - discord.app_commands.Choice(name="none — disable reasoning", value="none"), - discord.app_commands.Choice(name="minimal", value="minimal"), - discord.app_commands.Choice(name="low", value="low"), - discord.app_commands.Choice(name="medium", value="medium"), - discord.app_commands.Choice(name="high", value="high"), - discord.app_commands.Choice(name="xhigh", value="xhigh"), - discord.app_commands.Choice(name="max", value="max"), - discord.app_commands.Choice(name="ultra — maximum reasoning", value="ultra"), - discord.app_commands.Choice(name="reset — clear this session's override", value="reset"), - discord.app_commands.Choice(name="show — reveal reasoning in replies", value="show"), - discord.app_commands.Choice(name="hide — hide reasoning from replies", value="hide"), - ]) - async def slash_reasoning(interaction: discord.Interaction, effort: str = ""): - await self._run_simple_slash(interaction, f"/reasoning {effort}".strip()) - - @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="steer", description="Inject a message after the next tool call (no interrupt)") - @discord.app_commands.describe(prompt="Text to inject into the agent's next tool result") - async def slash_steer(interaction: discord.Interaction, prompt: str): - await self._run_simple_slash(interaction, f"/steer {prompt}".strip()) - - @tree.command(name="plan", description="Write a markdown implementation plan (no execution)") - @discord.app_commands.describe(task="What to plan. Leave empty to infer from the conversation.") - async def slash_plan(interaction: discord.Interaction, task: str = ""): - await self._run_simple_slash(interaction, f"/plan {task}".strip()) - - @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="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="reload-skills", description="Re-scan ~/.hermes/skills/ for new or removed skills") - async def slash_reload_skills(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/reload-skills") - - @tree.command(name="voice", description="Toggle voice reply mode") - @discord.app_commands.describe(mode="Voice mode: join, channel, leave, on, tts, off, or status") - @discord.app_commands.choices(mode=[ - # `join` and `channel` both route to _handle_voice_channel_join in - # gateway/run.py — expose both in the slash UI so autocomplete - # matches what the docs advertise and what the runner accepts when - # the command is typed as plain text. - discord.app_commands.Choice(name="join — join your voice channel", value="join"), - discord.app_commands.Choice(name="channel — join your voice channel (alias)", 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 self._run_simple_slash(interaction, f"/voice {mode}".strip()) - - @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="restart", description="Gracefully restart the Hermes gateway") - async def slash_restart(interaction: discord.Interaction): - await self._run_simple_slash(interaction, "/restart", "Restart requested~") - - @tree.command(name="approve", description="Approve a pending dangerous command") - @discord.app_commands.describe(scope="Optional: 'all', 'session', 'always', 'all session', 'all always'") - async def slash_approve(interaction: discord.Interaction, scope: str = ""): - await self._run_simple_slash(interaction, f"/approve {scope}".strip()) - - @tree.command(name="deny", description="Deny a pending dangerous command") - @discord.app_commands.describe(scope="Optional: 'all' to deny all pending commands") - async def slash_deny(interaction: discord.Interaction, scope: str = ""): - await self._run_simple_slash(interaction, f"/deny {scope}".strip()) - - @tree.command(name="thread", description="Create a new thread and start a Hermes session in it") - @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, - ): - # defer() is performed inside the handler *after* the auth gate - # so a rejected invoker can receive an ephemeral rejection. - await self._handle_thread_create_slash(interaction, name, message, auto_archive_duration) - - @tree.command(name="queue", description="Queue a prompt for the next turn (doesn't interrupt)") - @discord.app_commands.describe(prompt="The prompt to queue") - async def slash_queue(interaction: discord.Interaction, prompt: str): - await self._run_simple_slash(interaction, f"/queue {prompt}", "Queued for the next turn.") - - @tree.command(name="bg", description="Run a prompt in a separate background session") - @discord.app_commands.describe(prompt="The prompt to run in the background") - async def slash_background(interaction: discord.Interaction, prompt: str): - await self._run_simple_slash(interaction, f"/bg {prompt}", "Background task started~") - - @tree.command(name="btw", description="Ask a side question about the current conversation") - @discord.app_commands.describe(question="The side question to answer without interrupting") - async def slash_btw(interaction: discord.Interaction, question: str): - await self._run_simple_slash(interaction, f"/btw {question}", "Side question dispatched~") - - # ── Auto-register any gateway-available commands not yet on the tree ── - # This ensures new commands added to COMMAND_REGISTRY in - # hermes_cli/commands.py automatically appear as Discord slash - # commands without needing a manual entry here. - def _build_auto_slash_command(_name: str, _description: str, _args_hint: str = ""): - """Build a discord.app_commands.Command that proxies to _run_simple_slash.""" - discord_name = _name.lower()[:32] - desc = (_description or f"Run /{_name}")[:100] - has_args = bool(_args_hint) - - if has_args: - def _make_args_handler(__name: str, __hint: str): - @discord.app_commands.describe(args=f"Arguments: {__hint}"[:100]) - async def _handler(interaction: discord.Interaction, args: str = ""): - await self._run_simple_slash( - interaction, f"/{__name} {args}".strip() - ) - _handler.__name__ = f"auto_slash_{__name.replace('-', '_')}" - return _handler - - handler = _make_args_handler(_name, _args_hint) - else: - def _make_simple_handler(__name: str): - async def _handler(interaction: discord.Interaction): - await self._run_simple_slash(interaction, f"/{__name}") - _handler.__name__ = f"auto_slash_{__name.replace('-', '_')}" - return _handler - - handler = _make_simple_handler(_name) - - return discord.app_commands.Command( - name=discord_name, - description=desc, - callback=handler, + for name, description, args, template, followup in _NATIVE_SLASH_COMMANDS: + if template is None: + self._register_thread_slash(tree, name, description) + continue + tree.command(name=name, description=description)( + self._slash_proxy(name, args, template, followup, strip=name != "insights") ) - + # Auto-register COMMAND_REGISTRY + plugin commands not yet on the tree. Native + # commands above always survive the 100-command cap; reserve one slot for /skill. already_registered: set[str] = set() - # Native commands above are registered first and are the highest - # priority, so they always survive the 100-command cap. Reserve one - # slot for the consolidated ``/skill`` group registered further below. slot_cap = _DISCORD_MAX_APP_COMMANDS - 1 dropped_over_cap = 0 + + def _auto_register(name: str, description: str, args_hint: str) -> None: + nonlocal dropped_over_cap + # Discord command names: lowercase, hyphens OK, max 32 chars. + discord_name = name.lower()[:32] + if discord_name in already_registered: + return + if len(already_registered) >= slot_cap: + dropped_over_cap += 1 + return + args = (("args", str, "", f"Arguments: {args_hint}"[:100], None),) if args_hint else () + template = f"/{name} {{args}}" if args_hint else f"/{name}" + auto_cmd = discord.app_commands.Command( + name=discord_name, description=(description or f"Run /{name}")[:100], + callback=self._slash_proxy(name, args, template, None, strip=bool(args_hint), prefix="auto_slash_"), + ) + try: + tree.add_command(auto_cmd) + already_registered.add(discord_name) + except Exception: + # e.g. name conflict with a subcommand group. + pass try: from hermes_cli.commands import COMMAND_REGISTRY, _is_gateway_available, _resolve_config_gates - try: already_registered = {cmd.name for cmd in tree.get_commands()} except Exception: pass - config_overrides = _resolve_config_gates() - for cmd_def in COMMAND_REGISTRY: - if not _is_gateway_available(cmd_def, config_overrides): - continue - # Discord command names: lowercase, hyphens OK, max 32 chars. - discord_name = cmd_def.name.lower()[:32] - if discord_name in already_registered: - continue - if len(already_registered) >= slot_cap: - dropped_over_cap += 1 - continue - auto_cmd = _build_auto_slash_command( - cmd_def.name, - cmd_def.description, - cmd_def.args_hint, - ) - try: - tree.add_command(auto_cmd) - already_registered.add(discord_name) - except Exception: - # Silently skip commands that fail registration (e.g. - # name conflict with a subcommand group). - pass - - logger.debug( - "Discord auto-registered %d commands from COMMAND_REGISTRY", - len(already_registered), - ) + if _is_gateway_available(cmd_def, config_overrides): + _auto_register(cmd_def.name, cmd_def.description, cmd_def.args_hint) + logger.debug("Discord auto-registered %d commands from COMMAND_REGISTRY", len(already_registered)) except Exception as e: logger.warning("Discord auto-register from COMMAND_REGISTRY failed: %s", e) - - # ── Plugin-registered slash commands ── - # Plugins register via PluginContext.register_command(); we mirror - # those into Discord's native slash picker so users get the same - # autocomplete UX as for built-in commands. No per-platform plugin - # API needed — plugin commands are platform-agnostic. + # Mirror PluginContext.register_command() commands into the native slash picker. try: from hermes_cli.commands import _iter_plugin_command_entries - for plugin_name, plugin_desc, plugin_args_hint in _iter_plugin_command_entries(): - discord_name = plugin_name.lower()[:32] - if discord_name in already_registered: - continue - if len(already_registered) >= slot_cap: - dropped_over_cap += 1 - continue - auto_cmd = _build_auto_slash_command( - plugin_name, - plugin_desc, - plugin_args_hint, - ) - try: - tree.add_command(auto_cmd) - already_registered.add(discord_name) - except Exception: - # Silently skip commands that fail registration (e.g. - # name conflict with a subcommand group). - pass + _auto_register(plugin_name, plugin_desc, plugin_args_hint) except Exception as e: - logger.warning( - "Discord auto-register from plugin commands failed: %s", e - ) - - # Register skills under a single /skill command group with category - # subcommand groups. This uses 1 top-level slot instead of N, - # supporting up to 25 categories × 25 skills = 625 skills. + logger.warning("Discord auto-register from plugin commands failed: %s", e) self._register_skill_group(tree) - if dropped_over_cap: - # Staying under the cap keeps the whole sync succeeding; without - # this guard a single over-limit command makes Discord reject the - # entire batch (error 30032), breaking every slash command. + # One over-limit command makes Discord reject the entire sync (error 30032). logger.warning( "[%s] Reached Discord's limit of %d slash commands; skipped %d " "lower-priority command(s) to keep the command sync working. " @@ -6212,13 +4472,7 @@ class DiscordAdapter(BasePlatformAdapter): _DISCORD_MAX_APP_COMMANDS, dropped_over_cap, ) - - # Optional defense-in-depth: hide every slash command from non-admin - # guild members in Discord's slash picker. Server-side authorization - # (``_check_slash_authorization``) is the actual gate; this is purely - # UX so users don't see commands they can't invoke. Off by default - # to preserve the slash UX for deployments that intentionally allow - # everyone in the guild. + # Opt-in UX only: hide slash commands from non-admins; real gate is _check_slash_authorization. if os.getenv("DISCORD_HIDE_SLASH_COMMANDS", "false").strip().lower() in { "true", "1", "yes", "on", }: @@ -6226,23 +4480,13 @@ class DiscordAdapter(BasePlatformAdapter): def _apply_owner_only_visibility(self, tree) -> None: """Set default_member_permissions=0 on every registered slash command. - - Discord interprets ``Permissions(0)`` as "requires no permissions", - which paradoxically means the command is hidden from every guild - member except those with the Administrator permission. Server admins - can re-grant per user/role via Server Settings → Integrations → - → Permissions. - - Authoritative gate is ``_check_slash_authorization`` on every - invocation, which catches stale clients, role grants made by - mistake, and direct API calls bypassing Discord's UI hide. - """ + Discord hides ``Permissions(0)`` commands from all but Administrators (re-grantable via + Integrations); ``_check_slash_authorization`` remains the authoritative gate.""" try: no_perms = discord.Permissions(0) except Exception as e: logger.warning( - "[Discord] _apply_owner_only_visibility: cannot build Permissions(0): %s", - e, + "[Discord] _apply_owner_only_visibility: cannot build Permissions(0): %s", e, ) return applied = 0 @@ -6262,75 +4506,30 @@ class DiscordAdapter(BasePlatformAdapter): ) def _register_skill_group(self, tree) -> None: - """Register a single ``/skill`` command with autocomplete on the name. - - Discord enforces an ~8000-byte per-command payload limit. The older - nested layout (``/skill ``) registered one giant - command whose serialized payload grew linearly with the skill - catalog — with the default ~75 skills the payload was ~14 KB and - ``tree.sync()`` rejected the entire slash-command batch (issues - #11321, #10259, #11385, #10261, #10214). - - Autocomplete options are fetched dynamically by Discord when the - user types — they do NOT count against the per-command registration - budget. So we register ONE flat ``/skill`` command with - ``name: str`` (autocompleted) and ``args: str = ""``. This scales - to thousands of skills with no size math, no splitting, and no - hidden skills. The slash picker also becomes more discoverable — - Discord live-filters by the user's typed prefix against both the - skill name and its description. - - The entries list and lookup dict are stored on ``self`` rather - than captured in closure variables so :meth:`refresh_skill_group` - can repopulate them when the user runs ``/reload-skills`` without - needing to touch the Discord slash-command tree or trigger a - ``tree.sync()`` call. - """ + """Register one flat ``/skill`` command with autocomplete on ``name``. + A nested ``/skill `` layout blew Discord's ~8000-byte payload cap and broke + ``tree.sync()``; autocomplete options are fetched dynamically. Entries live on ``self``.""" try: existing_names = set() try: existing_names = {cmd.name for cmd in tree.get_commands()} except Exception: pass - - # Populate the instance-level entries/lookup so the - # autocomplete + handler callbacks below always read the - # freshest state. refresh_skill_group() re-runs the same - # collector and mutates these two attributes in place. + # Instance-level state so the callbacks always read the freshest entries. self._skill_entries: list[tuple[str, str, str]] = [] self._skill_lookup: dict[str, tuple[str, str]] = {} self._skill_group_reserved_names: set[str] = set(existing_names) self._refresh_skill_catalog_state() - if not self._skill_entries: return - async def _autocomplete_name( - interaction: "discord.Interaction", current: str, - ) -> list: - """Filter skills by the user's typed prefix. - - Matches both the skill name and its description so - "/skill pdf" surfaces skills whose description mentions - PDFs even if the name doesn't. Discord caps this list at - 25 entries per query. - - Authorization: a quiet pre-check evaluates the slash - allowlists and returns ``[]`` for unauthorized users so - the installed skill catalog is not leaked to anyone who - can see the command in the picker. Returning a generic - empty list here is intentional — sending a per-keystroke - ephemeral rejection would produce a barrage of error - popups during typing. - - Reads ``self._skill_entries`` so a ``/reload-skills`` run - since process start shows up on the very next keystroke. - """ + async def _autocomplete_name(interaction: "discord.Interaction", current: str) -> list: + """Filter skills by typed prefix against name and description (Discord caps at 25). + Unauthorized users get ``[]``: no catalog leak, no per-keystroke ephemeral rejections.""" try: allowed, _reason = self._evaluate_slash_authorization(interaction) except Exception: - # Defensive: never raise from autocomplete. Fail - # closed by returning an empty suggestion list. + # Never raise from autocomplete; fail closed. return [] if not allowed: return [] @@ -6338,32 +4537,21 @@ class DiscordAdapter(BasePlatformAdapter): choices: list = [] for name, desc, _key in self._skill_entries: if not q or q in name.lower() or (desc and q in desc.lower()): - if desc: - label = f"{name} — {desc}" - else: - label = name + label = f"{name} — {desc}" if desc else name # Discord's Choice.name is capped at 100 chars. if len(label) > 100: label = label[:97] + "..." - choices.append( - discord.app_commands.Choice(name=label, value=name) - ) + choices.append(discord.app_commands.Choice(name=label, value=name)) if len(choices) >= 25: break return choices @discord.app_commands.describe( - name="Which skill to run", - args="Optional arguments for the skill", + name="Which skill to run", args="Optional arguments for the skill", ) @discord.app_commands.autocomplete(name=_autocomplete_name) - async def _skill_handler( - interaction: "discord.Interaction", name: str, args: str = "", - ): - # Authorize BEFORE any skill lookup so that known and - # unknown skill names produce identical rejections for - # unauthorized users (no probing the installed catalog - # via "Unknown skill: " responses). + async def _skill_handler(interaction: "discord.Interaction", name: str, args: str = ""): + # Authorize BEFORE lookup so unknown/known names reject identically (no catalog probing). if not await self._check_slash_authorization(interaction, "/skill"): return entry = self._skill_lookup.get(name) @@ -6375,17 +4563,11 @@ class DiscordAdapter(BasePlatformAdapter): ) return _desc, cmd_key = entry - await self._run_simple_slash( - interaction, f"{cmd_key} {args}".strip() - ) - + await self._run_simple_slash(interaction, f"{cmd_key} {args}".strip()) cmd = discord.app_commands.Command( - name="skill", - description="Run a Hermes skill", - callback=_skill_handler, + name="skill", description="Run a Hermes skill", callback=_skill_handler, ) tree.add_command(cmd) - logger.info( "[%s] Registered /skill command with %d skill(s) via autocomplete", self.name, len(self._skill_entries), @@ -6399,16 +4581,9 @@ class DiscordAdapter(BasePlatformAdapter): logger.warning("[%s] Failed to register /skill command: %s", self.name, exc) def _refresh_skill_catalog_state(self) -> None: - """Re-scan disk for skills and repopulate ``self._skill_entries``. - - Called once from :meth:`_register_skill_group` at startup and - again from :meth:`refresh_skill_group` whenever the user runs - ``/reload-skills``. No Discord API calls are made — autocomplete - and the handler both read from these instance attributes - directly, so an in-place mutation is sufficient. - """ + """Re-scan disk and repopulate ``self._skill_entries``/``_skill_lookup`` in place. + No Discord API calls: autocomplete and handler read these attributes directly.""" from hermes_cli.commands import discord_skill_commands_by_category - reserved = getattr(self, "_skill_group_reserved_names", set()) categories, uncategorized, hidden = discord_skill_commands_by_category( reserved_names=set(reserved), @@ -6416,43 +4591,25 @@ class DiscordAdapter(BasePlatformAdapter): entries: list[tuple[str, str, str]] = list(uncategorized) for cat_skills in categories.values(): entries.extend(cat_skills) - # Stable alphabetical order so the autocomplete suggestion - # list is predictable across restarts. + # Stable alphabetical order so autocomplete is predictable across restarts. entries.sort(key=lambda t: t[0]) - self._skill_entries = entries self._skill_lookup = {n: (d, k) for n, d, k in entries} self._skill_group_hidden_count = hidden def refresh_skill_group(self) -> tuple[int, int]: - """Rescan skills and update the live ``/skill`` autocomplete state. - - Invoked by :meth:`gateway.run.GatewayOrchestrator._handle_reload_skills_command` - after :func:`agent.skill_commands.reload_skills` has refreshed - the in-process skill-command registry. Without this call, the - ``/skill`` autocomplete dropdown keeps showing the list captured - at process start — new skills stay invisible and deleted skills - return an "Unknown skill" error when clicked. - - Because autocomplete options are fetched dynamically by Discord, - we only need to mutate the entries/lookup attributes read by the - callbacks — no ``tree.sync()`` is required. - - Returns ``(new_count, hidden_count)``. - """ + """Rescan skills and refresh live ``/skill`` autocomplete; returns ``(new_count, hidden_count)``. + Called after ``reload_skills``; no ``tree.sync()`` since autocomplete options are dynamic.""" try: self._refresh_skill_catalog_state() except Exception as exc: logger.warning( - "[%s] Failed to refresh /skill autocomplete after reload: %s", - self.name, exc, + "[%s] Failed to refresh /skill autocomplete after reload: %s", self.name, exc, ) return (len(getattr(self, "_skill_entries", [])), 0) logger.info( - "[%s] Refreshed /skill autocomplete: %d skill(s) available (%d filtered)", - self.name, - len(self._skill_entries), - self._skill_group_hidden_count, + "[%s] Refreshed /skill autocomplete: %d skill(s) available (%d filtered)", self.name, + len(self._skill_entries), self._skill_group_hidden_count, ) return (len(self._skill_entries), self._skill_group_hidden_count) @@ -6469,7 +4626,6 @@ class DiscordAdapter(BasePlatformAdapter): is_dm = isinstance(interaction.channel, discord.DMChannel) is_thread = isinstance(interaction.channel, discord.Thread) thread_id = None - if is_dm: chat_type = "dm" elif is_thread: @@ -6477,152 +4633,96 @@ class DiscordAdapter(BasePlatformAdapter): thread_id = str(interaction.channel_id) else: chat_type = "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). - # For forum threads, inherit the parent forum's topic. + # Forum threads inherit the parent forum's topic. chat_topic = self._get_effective_topic(interaction.channel, is_thread=is_thread) - - # guild_id/parent_chat_id feed profile_routes matching in build_source, - # exactly as on_message passes them — without them a guild- or - # channel-routed profile never matches a native slash command (#69178). - parent_id = ( - self._get_parent_channel_id(interaction.channel) if is_thread else None - ) or "" + # guild_id/parent_chat_id feed profile_routes matching, as on_message does. + parent_id = (self._get_parent_channel_id(interaction.channel) if is_thread else None) or "" 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, - thread_id=thread_id, - chat_topic=chat_topic, - guild_id=self._interaction_guild_id(interaction), - parent_chat_id=parent_id or None, + 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, + thread_id=thread_id, chat_topic=chat_topic, + guild_id=self._interaction_guild_id(interaction), parent_chat_id=parent_id or None, ) - msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT channel_id = str(interaction.channel_id) return MessageEvent( - text=text, - message_type=msg_type, - source=source, - raw_message=interaction, + text=text, message_type=msg_type, source=source, raw_message=interaction, channel_prompt=self._resolve_channel_prompt(channel_id, parent_id or None), ) - # ------------------------------------------------------------------ - # Thread creation helpers - # ------------------------------------------------------------------ + # --- Thread creation helpers --- async def _handle_thread_create_slash( - self, - interaction: discord.Interaction, - name: str, - message: str = "", + self, interaction: discord.Interaction, name: str, message: str = "", auto_archive_duration: int = 1440, ) -> None: """Create a Discord thread from a slash command and start a session in it.""" if not await self._check_slash_authorization(interaction, "/thread"): return - deferred_response = False - try: - await interaction.response.defer(ephemeral=True) - deferred_response = True - except Exception as e: - if not self._is_discord_unknown_interaction(e): - raise - logger.warning( - "[Discord] /thread: interaction expired before defer. " - "Creating the thread anyway, skipping interaction followups.", - ) - result = await self._create_thread( + deferred_response = await self._defer_unless_expired( interaction, - name=name, - message=message, - auto_archive_duration=auto_archive_duration, + "[Discord] /thread: interaction expired before defer. " + "Creating the thread anyway, skipping interaction followups.", + ) + result = await self._create_thread( + interaction, name=name, message=message, auto_archive_duration=auto_archive_duration, ) - if not result.get("success"): error = result.get("error", "unknown error") if deferred_response: 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}**" if deferred_response: await interaction.followup.send(f"Created thread {link}", ephemeral=True) - # Track thread participation so follow-ups don't require @mention if thread_id: self._threads.mark(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, - thread_id: str, - thread_name: str, - text: str, + self, interaction: discord.Interaction, thread_id: str, thread_name: str, 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 - # Inherit forum topic when the thread was created inside a forum channel. _chan = getattr(interaction, "channel", None) chat_topic = self._get_effective_topic(_chan, is_thread=True) if _chan else None - _parent_channel = self._thread_parent_channel(getattr(interaction, "channel", None)) _parent_id = str(getattr(_parent_channel, "id", "") or "") 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, - chat_topic=chat_topic, - guild_id=self._interaction_guild_id(interaction), - parent_chat_id=_parent_id or None, + 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, chat_topic=chat_topic, + guild_id=self._interaction_guild_id(interaction), parent_chat_id=_parent_id or None, ) - _skills = self._resolve_channel_skills(thread_id, _parent_id or None) _channel_prompt = self._resolve_channel_prompt(thread_id, _parent_id or None) event = MessageEvent( - text=text, - message_type=MessageType.TEXT, - source=source, - raw_message=interaction, - auto_skill=_skills, - channel_prompt=_channel_prompt, + text=text, message_type=MessageType.TEXT, source=source, raw_message=interaction, + auto_skill=_skills, channel_prompt=_channel_prompt, ) await self.handle_message(event) def _resolve_channel_skills(self, channel_id: str, parent_id: str | None = None) -> list[str] | None: - """Look up auto-skill bindings for a Discord channel/forum thread. + """Look up auto-skill bindings for a channel (parent_id lets forum threads inherit). Config format (in platform extra): channel_skill_bindings: - id: "123456" skills: ["skill-a", "skill-b"] - Also checks parent_id so forum threads inherit the forum's bindings. """ from gateway.platforms.base import resolve_channel_skills return resolve_channel_skills(self.config.extra, channel_id, parent_id) @@ -6632,37 +4732,24 @@ class DiscordAdapter(BasePlatformAdapter): from gateway.platforms.base import resolve_channel_prompt return resolve_channel_prompt(self.config.extra, channel_id, parent_id) - def _discord_require_mention(self) -> bool: - """Return whether Discord channel messages require a bot mention.""" - configured = self.config.extra.get("require_mention") + def _extra_or_env_flag(self, key: str, env_key: str, env_default: str, *, truthy: bool) -> bool: + """Boolean from ``config.extra[key]`` (str parsed permissively) else ``env_key``. + ``truthy=True`` env values must be in {true,1,yes,on}; ``truthy=False`` env values are on + unless in {false,0,no,off} — matching each flag's historical default shape.""" + configured = self.config.extra.get(key) if configured is not None: if isinstance(configured, str): return configured.lower() not in {"false", "0", "no", "off"} return bool(configured) - return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in {"false", "0", "no", "off"} + env = os.getenv(env_key, env_default).lower() + return env in {"true", "1", "yes", "on"} if truthy else env not in {"false", "0", "no", "off"} - def _discord_allow_any_attachment(self) -> bool: - """Return whether Discord attachments bypass the SUPPORTED_DOCUMENT_TYPES allowlist. - - When True, any uploaded file is cached to disk and surfaced to the - agent as a local path so it can be inspected via terminal / read_file - / ffprobe / etc. Default False preserves the historical behaviour of - dropping unsupported types with a warning log. - """ - configured = self.config.extra.get("allow_any_attachment") - if configured is not None: - if isinstance(configured, str): - return configured.lower() not in {"false", "0", "no", "off", ""} - return bool(configured) - return os.getenv("DISCORD_ALLOW_ANY_ATTACHMENT", "false").lower() in {"true", "1", "yes", "on"} + def _discord_require_mention(self) -> bool: + """Return whether Discord channel messages require a bot mention.""" + return self._extra_or_env_flag("require_mention", "DISCORD_REQUIRE_MENTION", "true", truthy=False) def _discord_max_attachment_bytes(self) -> int: - """Return the per-attachment byte cap. 0 means unlimited. - - The whole attachment is held in memory while being written to the - cache, so unlimited carries a real memory cost. Default 32 MiB - matches the historical hardcoded value. - """ + """Per-attachment byte cap; 0 = unlimited (whole attachment is held in memory). Default 32 MiB.""" configured = self.config.extra.get("max_attachment_bytes") if configured is None: configured = os.getenv("DISCORD_MAX_ATTACHMENT_BYTES") @@ -6690,33 +4777,19 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("[Discord] is_voice_message() failed for attachment: %s", exc) return False return bool(marker) - return ( getattr(att, "duration", None) is not None and getattr(att, "waveform", None) is not None ) - # ── per-adapter authorization gates (issue #72348) ─────────────────── - # Under gateway.multiplex_profiles every Discord adapter must enforce - # ITS OWN profile's allow/deny lists. os.environ is process-global and - # the YAML→env bridge is first-writer-wins, so raw os.getenv reads here - # would leak profile A's gates into profile B. Each accessor reads, in - # order: the per-adapter env snapshot taken inside the owning profile's - # runtime scope at connect() (authoritative under multiplex), then this - # adapter's PlatformConfig.extra (per-profile YAML), with the live - # scope-aware env read as the pre-connect fallback. Single-profile - # deployments resolve to plain os.getenv, unchanged. + # ── per-adapter authorization gates ────────────────────────────────── + # Under multiplex_profiles os.environ is process-global (first-writer-wins), so raw os.getenv + # would leak profile A into B. Order: connect()-time env snapshot, config.extra, scoped env read. def _snapshot_gate_env(self) -> None: - """Capture authorization env vars for THIS adapter's profile. - - Must be called inside the owning profile's runtime scope (connect() - runs there under multiplex) so the snapshot holds the profile's own - values, not whichever profile bridged os.environ first. - """ - self._gate_env_snapshot = { - key: _scoped_gate_env(key) for key in _GATE_ENV_KEYS - } + """Snapshot gate env vars; must run inside the owning profile's runtime scope + (connect() does under multiplex) to capture that profile's values.""" + self._gate_env_snapshot = {key: _scoped_gate_env(key) for key in _GATE_ENV_KEYS} def _gate_env(self, name: str, default: str = "") -> str: """Read a gate env var from this adapter's snapshot (scope fallback).""" @@ -6777,23 +4850,9 @@ class DiscordAdapter(BasePlatformAdapter): } def resolved_allowlist_user_ids(self) -> set: - """Numeric user IDs from the connect-time username resolution. - - ``_resolve_allowed_usernames`` turns username-shaped - ``DISCORD_ALLOWED_USERS`` entries into numeric IDs and keeps the - authoritative result in ``self._allowed_user_ids``. The env-var - mirror of that result does NOT survive the gateway's per-turn .env - hot-reload (``load_hermes_dotenv(override=True)`` restores the raw - usernames from the file), so the gateway authz layer - (``GatewayAuthorizationMixin._is_user_authorized``) calls this to - union the resolved IDs back into the allowlist it builds from env. - - Returns only numeric entries: usernames are not comparable to - ``source.user_id`` and the ``"*"`` wildcard is env-file-persistent - already (resolution preserves it in the file's value), so passing it - through here would only widen access on the gateway layer beyond - what the operator's current .env says. - """ + """Numeric IDs from connect-time username resolution. + The env mirror of ``_allowed_user_ids`` doesn't survive the per-turn .env hot-reload, so the + gateway authz layer unions these in. Numeric only: passing "*" through would widen access.""" allowed = getattr(self, "_allowed_user_ids", None) or set() return {str(uid) for uid in allowed if str(uid).isdigit()} @@ -6811,46 +4870,26 @@ class DiscordAdapter(BasePlatformAdapter): return self._gate_env("DISCORD_ALLOW_BOTS", "none").lower().strip() or "none" def _discord_free_response_channels(self) -> set: - """Return Discord channel IDs/names where no bot mention is required. - - A single ``"*"`` entry (either from a list or a comma-separated - string) is preserved in the returned set so callers can short-circuit - on wildcard membership, consistent with ``allowed_channels``. - """ + """Channel IDs/names needing no mention; a lone "*" is preserved for wildcard short-circuit.""" raw = self.config.extra.get("free_response_channels") if raw is None: raw = self._gate_env("DISCORD_FREE_RESPONSE_CHANNELS") if isinstance(raw, list): return {str(part).strip() for part in raw if str(part).strip()} - # Coerce non-list scalars (str/int/float) to str before splitting. - # YAML parses a bare numeric value such as - # `free_response_channels: 1491973769726791812` as int, which was - # previously falling through the isinstance(str) branch and silently - # returning an empty set. str() here accepts whatever scalar the YAML - # loader hands us without changing existing string/CSV semantics. + # YAML parses a bare numeric value as int; str() any scalar before splitting. s = str(raw).strip() if raw is not None else "" if s: return {part.strip() for part in s.split(",") if part.strip()} return set() def _raw_mentioned_user_ids(self, message: Any) -> set: - """Extract Discord user-mention IDs directly from raw message content. - - Covers both raw forms — ``<@ID>`` and the legacy ``<@!ID>`` nickname - form — which ``message.mentions`` does not always populate (mobile, - edited, or relayed messages can carry the mention in the content while - leaving the resolved ``mentions`` list empty). - """ + """Extract user-mention IDs (``<@ID>`` and legacy ``<@!ID>``) from raw content, + since ``message.mentions`` isn't always populated (mobile/edited/relayed).""" content = getattr(message, "content", "") or "" return {match.group(1) for match in re.finditer(r"<@!?(\d+)>", content)} def _self_is_explicitly_mentioned(self, message: Any) -> bool: - """Return True when this bot is explicitly @mentioned in the message. - - Treats the bot as mentioned if it is either present in the resolved - ``message.mentions`` list OR referenced by its raw ``<@ID>`` / ``<@!ID>`` - form in the message content. - """ + """True when the bot is in ``message.mentions`` or raw-mentioned in the content.""" if not self._client or not self._client.user: return False if self._client.user in getattr(message, "mentions", []): @@ -6858,123 +4897,63 @@ class DiscordAdapter(BasePlatformAdapter): return str(self._client.user.id) in self._raw_mentioned_user_ids(message) def _self_is_raw_mentioned(self, message: Any) -> bool: - """Return True only when this bot has an inline mention token. - - Discord reply-pings can add the replied-to bot to ``message.mentions`` - without a literal ``<@bot>`` token in ``message.content``. This helper - intentionally ignores the resolved mentions list so the bot admission - gate can distinguish an explicit cross-bot address from a reply chip. - """ + """True only for a literal ``<@bot>`` token: reply-pings add us to ``message.mentions`` + without one, and the bot admission gate must tell those apart.""" if not self._client or not self._client.user: return False return str(self._client.user.id) in self._raw_mentioned_user_ids(message) def _discord_bots_require_inline_mention(self) -> bool: - """Whether another bot must type an inline @mention to trigger us. - - Off by default. When on, a bot-authored message only wakes this bot - if its content contains a literal ``<@thisbot>`` token. A Discord - reply/quote to one of our messages is NOT enough on its own, because - Discord's reply-ping silently adds us to ``message.mentions`` even - though the author never typed our handle — which otherwise lets two - bots ping-pong replies at each other indefinitely. Humans are never - affected by this gate; it only applies to bot authors. - - Config: ``discord.bots_require_inline_mention`` (or env - ``DISCORD_BOTS_REQUIRE_INLINE_MENTION``). - """ + """Whether a bot author must type a literal ``<@thisbot>`` to wake us (off by default). + A reply-ping adds us to ``message.mentions`` silently, letting two bots ping-pong forever. + Config: ``discord.bots_require_inline_mention`` / ``DISCORD_BOTS_REQUIRE_INLINE_MENTION``.""" configured = self.config.extra.get("bots_require_inline_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() in {"true", "1", "yes", "on"} - return bool(configured) - return os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION", "false").lower() in { - "true", - "1", - "yes", - "on", - } + if isinstance(configured, str): + return configured.lower() in {"true", "1", "yes", "on"} + return self._extra_or_env_flag( + "bots_require_inline_mention", "DISCORD_BOTS_REQUIRE_INLINE_MENTION", "false", truthy=True) def _discord_channel_keys(self, message: Any, parent_channel_id: Optional[str] = None) -> set[str]: - """Return channel identifiers accepted by Discord channel config gates. - - Users commonly configure channels by Discord snowflake ID, bare name, or - ``#name``. Include the current channel and, for threads, the parent - channel so free-response/no-thread/allow/ignore rules work with either - form. - """ + """Channel keys (ID, bare name, ``#name``, plus parent for threads) accepted by channel gates.""" channel = getattr(message, "channel", None) return self._discord_channel_keys_from_channel(channel, parent_channel_id) def _discord_channel_keys_from_channel( self, channel: Any, parent_channel_id: Optional[str] = None ) -> set[str]: - """Build channel-config gate keys directly from a channel object. - - Same key set as :meth:`_discord_channel_keys` (ID, bare name, ``#name``, - and the parent channel for threads) but takes the channel directly so - callers holding an ``interaction.channel`` (slash-command authorization) - get name-form matching too — not just the ``on_message`` path. - """ + """Same keys as :meth:`_discord_channel_keys` but from a channel object (slash-command path).""" keys: set[str] = set() - channel_id = getattr(channel, "id", None) if channel_id is not None: keys.add(str(channel_id)) - channel_name = str(getattr(channel, "name", "")).strip() if channel_name: keys.add(channel_name) keys.add(f"#{channel_name}") - parent_id = parent_channel_id or getattr(channel, "parent_id", None) if parent_id: keys.add(str(parent_id)) - parent_channel = getattr(channel, "parent", None) parent_name = str(getattr(parent_channel, "name", "")).strip() if parent_channel else "" if parent_name: keys.add(parent_name) keys.add(f"#{parent_name}") - return keys def _discord_thread_require_mention(self) -> bool: - """Return whether thread participation requires @mention to follow up. - - When ``False`` (default), once the bot has participated in a thread it - keeps responding to every message in that thread without needing to be - mentioned again — useful for one-on-one conversations. - - When ``True``, the @mention requirement is enforced inside threads as - well. Set this when multiple bots share a thread and you want each - one to only fire on explicit @mention, avoiding bot-to-bot loops or - unwanted cross-replies. - """ - configured = self.config.extra.get("thread_require_mention") - if configured is not None: - if isinstance(configured, str): - return configured.lower() not in {"false", "0", "no", "off"} - return bool(configured) - return os.getenv("DISCORD_THREAD_REQUIRE_MENTION", "false").lower() in {"true", "1", "yes", "on"} + """Whether threads still require @mention after the bot has participated (default False). + Set True when multiple bots share a thread to avoid bot-to-bot loops.""" + return self._extra_or_env_flag("thread_require_mention", "DISCORD_THREAD_REQUIRE_MENTION", "false", truthy=True) def _discord_history_backfill(self) -> bool: """Return whether history backfill is enabled for shared sessions.""" configured = self.config.extra.get("history_backfill") if configured is not None: - if isinstance(configured, str): - return configured.lower() not in {"false", "0", "no", "off"} - return bool(configured) + return self._extra_or_env_flag("history_backfill", "DISCORD_HISTORY_BACKFILL", "true", truthy=True) return os.getenv("DISCORD_HISTORY_BACKFILL", "true").lower() in {"true", "1", "yes"} def _discord_history_backfill_limit(self) -> int: - """Return the max number of messages to scan backwards for context. - - In practice the scan usually stops much earlier — at the bot's own - last message in the channel (the natural partition point). This - limit is a safety cap for cold starts and long gaps where no prior - bot message exists in recent history. - """ + """Max messages scanned backwards; a safety cap since scans usually stop at the bot's last message.""" configured = self.config.extra.get("history_backfill_limit") if configured is not None: try: @@ -6988,47 +4967,17 @@ class DiscordAdapter(BasePlatformAdapter): return 50 async def _fetch_channel_context( - self, - channel: Any, - before: "DiscordMessage", - reply_target: Optional[Any] = None, + self, channel: Any, before: "DiscordMessage", reply_target: Optional[Any] = None, ) -> str: - """Fetch recent channel messages for conversational context. - - Scans backwards from *before* and collects messages until it hits - a message sent by this bot (the natural partition point between - bot turns) or reaches ``history_backfill_limit``. - - When ``reply_target`` is provided (the user replied to a specific - message), a second backward scan is run ending at that target so the - agent sees the conversation surrounding what the user pointed at — - even when the reply target sits *before* the most recent bot turn and - would otherwise be cut off by the self-message partition. The two - windows are merged chronologically and de-duplicated by message ID. - - Returns a formatted block like:: - - [Recent channel messages] - [Alice] some message - [Bob [bot]] another message - - Returns an empty string if no context is available. - """ + """Fetch recent channel messages; returns a ``[Recent channel messages]`` block or "". + Scans back from *before* to the bot's own message or ``history_backfill_limit``; with + ``reply_target`` a second scan ending at the target is merged chronologically, deduped by ID.""" limit = self._discord_history_backfill_limit() if limit <= 0: return "" - - # Determine which bot messages to include in context allow_bots_raw = self._get_allow_bots() include_other_bots = allow_bots_raw != "none" - - # Use the in-memory cache to narrow the fetch window on hot paths. - # If we know our last message ID in this channel, pass it as `after` - # to avoid scanning the full limit. Falls back to scanning on cache - # miss (cold start / restart). - # Guard: only use the cache when it's chronologically before the - # trigger — Discord snowflake IDs are monotonically increasing, so - # a simple int comparison suffices. + # Narrow via cached last-self-message id (`after`) only if it predates the trigger; miss => full scan. channel_id = str(getattr(channel, "id", "")) _cached_id = self._last_self_message_id.get(channel_id) _after_obj = None @@ -7037,19 +4986,12 @@ class DiscordAdapter(BasePlatformAdapter): _after_obj = discord.Object(id=int(_cached_id)) except (ValueError, TypeError): pass # Malformed cache entry — fall back to cold-start scan - is_thread_channel = isinstance(channel, discord.Thread) has_unverified = False - try: def _keep(msg) -> Optional[str]: - """Return a formatted ``[name] content`` line, or None to skip. - - Encapsulates the system-message / non-conversational / other-bot - filtering so both the primary and reply-anchored scans apply - identical rules. Does NOT enforce the self-message partition — - callers decide where to stop. - """ + """Format ``[name] content`` or None to skip; shared filter for both scans. + Does NOT enforce the self-message partition — callers decide where to stop.""" nonlocal has_unverified if msg.type not in {discord.MessageType.default, discord.MessageType.reply}: return None @@ -7059,15 +5001,9 @@ class DiscordAdapter(BasePlatformAdapter): or _looks_like_nonconversational_history_message(content) ): return None - # Respect DISCORD_ALLOW_BOTS for other bots. For history - # context, "mentions" is treated as "all" — we are deciding - # what context to show, not whether to respond. + # DISCORD_ALLOW_BOTS: for history, "mentions" counts as "all" (context, not response). is_bot_author = getattr(msg.author, "bot", False) - if ( - is_bot_author - and msg.author != self._client.user - and not include_other_bots - ): + if (is_bot_author and msg.author != self._client.user and not include_other_bots): return None if not content and msg.attachments: content = "(attachment)" @@ -7080,55 +5016,35 @@ class DiscordAdapter(BasePlatformAdapter): ) if is_bot_author: name = f"{name} [bot]" - # Mark senders not on the allowlist as [unverified] so the LLM - # treats their content as background reference rather than - # authoritative input — mirrors the Slack thread-context fix. - # Bot messages bypass the check; the auth check is configured - # by GatewayRunner. + # Tag non-allowlisted senders [unverified] so the LLM treats them as background; bots bypass. trust_tag = "" if not is_bot_author: author_id = str(getattr(msg.author, "id", "")) is_authorized = self._is_sender_authorized( - author_id, - chat_type="thread" if is_thread_channel else "group", + author_id, chat_type="thread" if is_thread_channel else "group", chat_id=channel_id, ) if is_authorized is False: trust_tag = "[unverified] " has_unverified = True return f"{trust_tag}[{name}] {content}" - # ── Primary window: recent channel activity since the last bot turn ── collected: List[Tuple[str, str]] = [] # (message_id, line) seen_ids: set = set() - # IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x - # silently flips the default to True when `after=` is supplied, - # which would select the *earliest* N messages after our last - # response instead of the *latest* N before the trigger. In - # high-traffic windows that returns stale tool traces and drops - # the actual final answer. See the regression test - # `test_fetch_channel_context_cache_uses_latest_window_when_after_set`. + # oldest_first=False explicitly — discord.py 2.x flips the default to True when `after=` + # is given, selecting the *earliest* N messages (see test_fetch_channel_context_cache_*). async for msg in channel.history( - limit=limit, - before=before, - after=_after_obj, - oldest_first=False, + limit=limit, before=before, after=_after_obj, oldest_first=False, ): - # Non-conversational lifecycle/status bumps (self-improvement - # reviews, background-process notices, restart banners) must be - # skipped BEFORE the partition check — otherwise a delayed - # status bump authored by us would be mistaken for the real - # last bot turn and hide messages that came after it. + # Skip non-conversational status bumps BEFORE the partition check, else a + # delayed bump authored by us masquerades as the last bot turn. _content = getattr(msg, "clean_content", msg.content) or "" if ( str(getattr(msg, "id", "")) in self._nonconversational_messages or _looks_like_nonconversational_history_message(_content) ): continue - # Stop at our own (conversational) message — this is the - # partition point. Everything before this is already in the - # session transcript. (Redundant when _after_obj is set, but - # needed for cold start.) + # Partition point: our own conversational message (needed for cold start). if msg.author == self._client.user: break line = _keep(msg) @@ -7138,33 +5054,20 @@ class DiscordAdapter(BasePlatformAdapter): collected.append((mid, line)) if mid: seen_ids.add(mid) - - # ── Reply window: context around the message the user pointed at ── - # When the user replied to a specific message that sits BEFORE the - # primary window's partition point, the surrounding exchange isn't - # captured above. Fetch a small window ending just after the reply - # target so the agent sees what it was referencing. This window is - # NOT partitioned on the self-message boundary — the whole point is - # to surface older context the transcript lacks. + # Reply window: context around the replied-to message; deliberately NOT self-partitioned. reply_collected: List[Tuple[str, str]] = [] reply_target_id = str(getattr(reply_target, "id", "")) if reply_target else "" if reply_target is not None and reply_target_id and reply_target_id not in seen_ids: - # Reuse the same cap as the primary scan but keep the reply - # window modest — it's anchored context, not a full backfill. + # Modest cap: anchored context, not a full backfill. reply_limit = max(1, min(limit, 10)) - # `before` is exclusive in discord.py, so to *include* the - # target we anchor at target_id + 1. Use a minimal snowflake - # shim (any object exposing ``.id`` satisfies discord.py's - # Snowflake protocol) rather than discord.Object, so this path - # works under test doubles that stub the discord module too. + # `before` is exclusive; anchor at target_id + 1 to include the target. A + # minimal ``.id`` shim (not discord.Object) works under stubbed discord too. try: _before_obj = _Snowflake(int(reply_target_id) + 1) except (ValueError, TypeError): _before_obj = before async for msg in channel.history( - limit=reply_limit, - before=_before_obj, - oldest_first=False, + limit=reply_limit, before=_before_obj, oldest_first=False, ): line = _keep(msg) if line is None: @@ -7175,16 +5078,11 @@ class DiscordAdapter(BasePlatformAdapter): reply_collected.append((mid, line)) if mid: seen_ids.add(mid) - if not collected and not reply_collected: return "" - - # channel.history returns newest-first; reverse each window for - # chronological order, then present reply context first (it is - # older) followed by the recent activity. + # history is newest-first; reverse each window, reply context (older) first. collected.reverse() reply_collected.reverse() - blocks: List[str] = [] if has_unverified: blocks.append( @@ -7204,7 +5102,6 @@ class DiscordAdapter(BasePlatformAdapter): + "\n".join(line for _id, line in collected) ) return "\n\n".join(blocks) - except discord.Forbidden: logger.debug("[%s] Missing permissions to fetch channel history", self.name) return "" @@ -7212,6 +5109,13 @@ class DiscordAdapter(BasePlatformAdapter): logger.warning("[%s] Failed to fetch channel history: %s", self.name, e) return "" + async def _resolve_channel(self, channel_id: Any) -> Any: + """Cached ``get_channel`` first, REST ``fetch_channel`` on miss (raises on API error).""" + channel = self._client.get_channel(int(channel_id)) + if not channel: + channel = await self._client.fetch_channel(int(channel_id)) + return channel + 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 @@ -7235,68 +5139,42 @@ class DiscordAdapter(BasePlatformAdapter): return None async def _create_thread( - self, - interaction: discord.Interaction, - *, - name: str, - message: str = "", + self, interaction: discord.Interaction, *, name: str, message: str = "", auto_archive_duration: int = 1440, ) -> Dict[str, Any]: - """Create a thread in the current Discord channel. - - Tries ``parent_channel.create_thread()`` first. If Discord rejects - that (e.g. permission issues), falls back to sending a seed message - and creating the thread from it. - """ + """Create a thread in the current channel; falls back to seed message + create_thread on rejection (e.g. permissions).""" 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, + 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, - } + return self._thread_created(thread, 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, + 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, - } + return self._thread_created(thread, name) except Exception as fallback_error: return { "error": ( @@ -7305,20 +5183,17 @@ class DiscordAdapter(BasePlatformAdapter): ) } + @staticmethod + def _thread_created(thread: Any, name: str) -> Dict[str, Any]: + return {"success": True, "thread_id": str(thread.id), "thread_name": getattr(thread, "name", None) or name} + # ------------------------------------------------------------------ # Auto-thread helpers # ------------------------------------------------------------------ def _derive_auto_thread_name(self, content: str) -> str: - """Return the fast placeholder name used at Discord thread creation time. - - Strip Discord mention syntax (users / roles / channels) so thread - titles don't show raw <@id>, <@&id>, or <#id> markers — the ID - isn't meaningful to humans glancing at the thread list (#6336). - Real semantic naming is done after the first agent turn, when - Hermes has an LLM-generated session title and can safely rename - only this newly-created thread. - """ + """Fast placeholder thread name with mentions stripped (raw <@id> tokens mean nothing to humans). + Semantic renaming happens after the first agent turn, once an LLM session title exists.""" content = (content or "").strip() # <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces. content = re.sub(r"<@[!&]?\d+>", "", content) @@ -7329,92 +5204,64 @@ class DiscordAdapter(BasePlatformAdapter): thread_name = thread_name[:77] + "..." return thread_name - async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: - """Create a thread from a user message for auto-threading. + @staticmethod + def _stamp_auto_thread_name(thread: Any, thread_name: str) -> Any: + """Remember the placeholder name so the semantic rename can verify it wasn't changed by a human.""" + try: + setattr(thread, "_hermes_auto_thread_initial_name", thread_name) + except Exception: + pass + return thread - Returns the created thread object, or ``None`` on failure. Both the - primary ``message.create_thread`` and the seed-message fallback are - retried once after a short backoff so transient connect errors - (e.g. ``Cannot connect to host discord.com:443``) don't immediately - burn through to the caller's failure path (#20243). - """ + async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]: + """Create an auto-thread from a user message; returns the thread or ``None``. + Primary path and seed-message fallback each retry once after a short backoff (transient errors).""" thread_name = self._derive_auto_thread_name(message.content or "") display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user" reason = f"Auto-threaded from mention by {display_name}" - last_direct_error: Exception | None = None last_fallback_error: Exception | None = None - for attempt in range(2): try: thread = await message.create_thread(name=thread_name, auto_archive_duration=1440) - try: - setattr(thread, "_hermes_auto_thread_initial_name", thread_name) - except Exception: - pass - return thread + return self._stamp_auto_thread_name(thread, thread_name) except Exception as direct_error: last_direct_error = direct_error try: seed_msg = await message.channel.send( f"\U0001f9f5 Thread created by Hermes: **{thread_name}**" ) - thread = await seed_msg.create_thread( - name=thread_name, - auto_archive_duration=1440, - reason=reason, - ) - try: - setattr(thread, "_hermes_auto_thread_initial_name", thread_name) - except Exception: - pass - return thread + thread = await seed_msg.create_thread(name=thread_name, auto_archive_duration=1440, reason=reason) + return self._stamp_auto_thread_name(thread, thread_name) except Exception as fallback_error: last_fallback_error = fallback_error if attempt == 0: - # Brief backoff before the second attempt — most failures - # in this path are transient connect errors that recover - # within a second or two. + # Brief backoff: most failures here are transient connect errors. await asyncio.sleep(0.75) continue - logger.warning( "[%s] Auto-thread creation failed after retry. Direct error: %s. Fallback error: %s", - self.name, - last_direct_error, - last_fallback_error, + self.name, last_direct_error, last_fallback_error, ) return None async def rename_thread( - self, - thread_id: str, - name: str, - *, - only_if_current_name: Optional[str] = None, + self, thread_id: str, name: str, *, only_if_current_name: Optional[str] = None, ) -> bool: - """Best-effort Discord thread rename. - - ``only_if_current_name`` prevents overwriting human-renamed or - pre-existing threads. This is intentionally a no-op on mismatch. - """ + """Best-effort rename; ``only_if_current_name`` protects human-renamed/pre-existing threads (no-op on mismatch).""" if not self._client or not DISCORD_AVAILABLE: return False - try: thread_id_int = int(str(thread_id)) except (TypeError, ValueError): return False - cleaned = re.sub(r"\s+", " ", str(name or "")).strip() if not cleaned: return False - # Discord thread names are budgeted in UTF-16 code units (emoji count - # double) — truncate with the UTF-16 helpers, not code-point slices. + # Thread names are budgeted in UTF-16 code units (emoji count double) — use the UTF-16 helpers. from gateway.platforms.base import utf16_len, _prefix_within_utf16_limit if utf16_len(cleaned) > 80: cleaned = _prefix_within_utf16_limit(cleaned, 77).rstrip() + "..." - try: thread = self._client.get_channel(thread_id_int) if thread is None: @@ -7422,7 +5269,6 @@ class DiscordAdapter(BasePlatformAdapter): except Exception: logger.debug("[%s] Failed to resolve Discord thread %s for rename", self.name, thread_id, exc_info=True) return False - current_name = getattr(thread, "name", None) if only_if_current_name is not None and current_name != only_if_current_name: logger.info( @@ -7432,7 +5278,6 @@ class DiscordAdapter(BasePlatformAdapter): return False if current_name == cleaned: return True - edit = getattr(thread, "edit", None) if edit is None: return False @@ -7447,38 +5292,24 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("[%s] Failed to rename Discord thread %s", self.name, thread_id, exc_info=True) return False - async def create_handoff_thread( - self, - parent_chat_id: str, - name: str, - ) -> Optional[str]: - """Create a Discord thread under a text channel for a handoff. - - Falls back to a seed-message + ``message.create_thread`` path if - ``parent.create_thread`` is rejected (some channel types or - permission setups). Returns the new thread id as a string, or - ``None`` on failure or when the parent isn't a text channel - (DMs, voice channels, threads themselves can't host threads). - """ + async def create_handoff_thread(self, parent_chat_id: str, name: str) -> Optional[str]: + """Create a handoff thread under a text channel; returns the thread id or ``None``. + Falls back to seed-message + ``message.create_thread``; DMs/voice/threads can't host threads.""" if not self._client or not DISCORD_AVAILABLE: return None - try: parent_id = int(parent_chat_id) except (TypeError, ValueError): return None - try: parent = self._client.get_channel(parent_id) if parent is None: parent = await self._client.fetch_channel(parent_id) except Exception as exc: logger.warning( - "[%s] Handoff thread: cannot resolve parent %s: %s", - self.name, parent_chat_id, exc, + "[%s] Handoff thread: cannot resolve parent %s: %s", self.name, parent_chat_id, exc, ) return None - # DMs, voice channels, and existing threads can't host child threads. if isinstance(parent, getattr(discord, "DMChannel", ())): logger.info( @@ -7486,36 +5317,25 @@ class DiscordAdapter(BasePlatformAdapter): self.name, parent_chat_id, ) return None - thread_name = (name or "handoff").strip()[:80] or "handoff" reason = "Hermes session handoff" - - # First try: create a thread directly on the channel. try: create = getattr(parent, "create_thread", None) if create is not None: - thread = await create( - name=thread_name, - auto_archive_duration=1440, - reason=reason, - ) + thread = await create(name=thread_name, auto_archive_duration=1440, reason=reason) return str(thread.id) except Exception as direct_error: logger.debug( "[%s] Handoff thread: direct create failed (%s); trying seed-message fallback", self.name, direct_error, ) - - # Fallback: post a seed message and create the thread from it. try: send = getattr(parent, "send", None) if send is None: return None seed_msg = await send(f"\U0001f9f5 Hermes handoff: **{thread_name}**") thread = await seed_msg.create_thread( - name=thread_name, - auto_archive_duration=1440, - reason=reason, + name=thread_name, auto_archive_duration=1440, reason=reason, ) return str(thread.id) except Exception as fallback_error: @@ -7528,13 +5348,8 @@ class DiscordAdapter(BasePlatformAdapter): def _self_contained_prompt_content( self, header: str, body: str, *, code_block: bool = False, tail: str = "" ) -> str: - """Build plain message content that mirrors an embed's payload. - - Discord embeds can be invisible or visually separated from the - component row on some clients (notably web/mobile), so interactive - prompts must carry their payload in plain ``content`` next to the - buttons. The embed stays as progressive enhancement. - """ + """Plain content mirroring an embed's payload. + Embeds can be invisible/detached on web/mobile, so ``content`` carries the payload.""" body = str(body or "") if code_block: prefix = f"{header}\n```bash\n" @@ -7549,12 +5364,8 @@ class DiscordAdapter(BasePlatformAdapter): return f"{prefix}{body}{suffix}" def _approval_mention_content(self) -> Optional[str]: - """Return user mentions for approval prompts when explicitly enabled. - - Gated on ``discord.approval_mentions`` in config.yaml (bridged to the - ``DISCORD_APPROVAL_MENTIONS`` env var). Only numeric allowlist entries - can be mentioned; default off avoids surprise pings. - """ + """User mentions for approval prompts, gated on ``discord.approval_mentions`` + (``DISCORD_APPROVAL_MENTIONS``). Only numeric allowlist entries; default off.""" if not _env_bool("DISCORD_APPROVAL_MENTIONS", False): return None user_ids = sorted(uid for uid in self._allowed_user_ids if str(uid).isdigit()) @@ -7562,43 +5373,42 @@ class DiscordAdapter(BasePlatformAdapter): return None return " ".join(f"<@{uid}>" for uid in user_ids) - async def send_exec_approval( - self, chat_id: str, command: str, session_key: str, - description: str = "dangerous command", - metadata: Optional[dict] = None, - allow_permanent: bool = True, - allow_session: bool = True, - smart_denied: bool = False, + async def _send_prompt( + self, chat_id: str, metadata: Optional[dict], build, *, fail_log: Optional[str] = None, ) -> SendResult: - """ - Send a button-based exec approval prompt for a dangerous command. - - The buttons call ``resolve_gateway_approval()`` to unblock the waiting - agent thread — this replaces the text-based ``/approve`` flow on Discord. - """ + """Shared tail for interactive prompts: resolve target channel, call ``build(channel) -> + (send_kwargs, view)``, send, remember the message on the view. ``fail_log`` labels failures.""" if not self._client or not DISCORD_AVAILABLE: return SendResult(success=False, error="Not connected") - try: - # Resolve channel — use thread_id from metadata if present - target_id = chat_id - if metadata and metadata.get("thread_id"): - target_id = metadata["thread_id"] + channel = await self._resolve_channel(_prompt_target_id(chat_id, metadata)) + send_kwargs, view = build(channel) + msg = await channel.send(**send_kwargs) + if view is not None: + view._message = msg + return SendResult(success=True, message_id=str(msg.id)) + except Exception as e: + if fail_log: + logger.warning("[%s] %s failed: %s", self.name, fail_log, e) + return SendResult(success=False, error=str(e)) - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) + @staticmethod + def _embed_body(text: str, limit: int = 4088) -> str: + """Trim to Discord's 4096-char embed description limit (conservatively).""" + return text if len(text) <= limit else text[: limit - 3] + "..." - # Keep the approval request self-contained in plain message content. - # Discord embeds can be invisible or visually separated from the - # component row on some clients (notably web/mobile), so the actual - # command and reason must be visible in the same content block as - # the approval buttons. + async def send_exec_approval( + self, chat_id: str, command: str, session_key: str, description: str = "dangerous command", + metadata: Optional[dict] = None, allow_permanent: bool = True, allow_session: bool = True, + smart_denied: bool = False, + ) -> SendResult: + """Button-based exec approval prompt; buttons call ``resolve_gateway_approval()`` (not /approve).""" + def _build(_channel): + # Payload in plain content: embeds can be invisible/detached on web/mobile. reason_budget = 300 reason_display = str(description or "dangerous command") if len(reason_display) > reason_budget: reason_display = reason_display[: reason_budget - 15] + "... [truncated]" - prompt_prefix = ( "⚠️ **Command Approval Required**\n\n" "Do you want Hermes to run this command?\n\n" @@ -7614,303 +5424,132 @@ class DiscordAdapter(BasePlatformAdapter): command_budget = max(0, self.MAX_MESSAGE_LENGTH - len(prompt_prefix) - len(prompt_tail)) content_cmd_display = str(command or "") if len(content_cmd_display) > command_budget: - content_cmd_display = ( - content_cmd_display[: max(0, command_budget - len(truncated_suffix))] - + truncated_suffix - ) + content_cmd_display = content_cmd_display[: max(0, command_budget - len(truncated_suffix))] + truncated_suffix content = f"{prompt_prefix}{content_cmd_display}{prompt_tail}" - - # Preserve the richer embed path and its larger description budget - # for clients where embeds render correctly. - max_embed_desc = 4088 - embed_cmd_display = str(command or "") - if len(embed_cmd_display) > max_embed_desc: - embed_cmd_display = embed_cmd_display[: max_embed_desc - 3] + "..." embed = discord.Embed( title="⚠️ Command Approval Required", - description=f"```\n{embed_cmd_display}\n```", + description=f"```\n{self._embed_body(str(command or ''))}\n```", color=discord.Color.orange(), ) embed.add_field(name="Reason", value=reason_display, inline=False) - - require_admin, admin_user_ids = _resolve_exec_approval_admin_gate( - getattr(self.config, "extra", None) - ) + require_admin, admin_user_ids = _resolve_exec_approval_admin_gate(getattr(self.config, "extra", None)) view = ExecApprovalView( - session_key=session_key, - allowed_user_ids=self._allowed_user_ids, - allowed_role_ids=self._allowed_role_ids, - require_admin=require_admin, - admin_user_ids=admin_user_ids, - allow_permanent=allow_permanent, - allow_session=allow_session, - smart_denied=smart_denied, + session_key=session_key, allowed_user_ids=self._allowed_user_ids, + allowed_role_ids=self._allowed_role_ids, require_admin=require_admin, + admin_user_ids=admin_user_ids, allow_permanent=allow_permanent, + allow_session=allow_session, smart_denied=smart_denied, ) - send_kwargs: Dict[str, Any] = {"content": content, "embed": embed, "view": view} if mention_content: allowed_mentions_cls = getattr(discord, "AllowedMentions", None) if allowed_mentions_cls is not None: send_kwargs["allowed_mentions"] = allowed_mentions_cls( - users=True, - roles=False, - everyone=False, - replied_user=False, + users=True, roles=False, everyone=False, replied_user=False, ) - msg = await channel.send(**send_kwargs) - view._message = msg # store for on_timeout expiration editing - return SendResult(success=True, message_id=str(msg.id)) - - except Exception as e: - return SendResult(success=False, error=str(e)) + return send_kwargs, view + return await self._send_prompt(chat_id, metadata, _build) async def send_slash_confirm( self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[dict] = None, ) -> SendResult: """Send a three-button slash-command confirmation prompt.""" - if not self._client or not DISCORD_AVAILABLE: - return SendResult(success=False, error="Not connected") - - try: - target_id = chat_id - if metadata and metadata.get("thread_id"): - target_id = metadata["thread_id"] - - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) - - # Embed description limit is 4096; message usually fits easily. - max_desc = 4088 - body = message if len(message) <= max_desc else message[: max_desc - 3] + "..." + def _build(_channel): embed = discord.Embed( - title=title or "Confirm", - description=body, - color=discord.Color.orange(), + title=title or "Confirm", description=self._embed_body(message), color=discord.Color.orange(), ) - # Mirror the payload in plain content — embeds are invisible on - # some clients (see send_exec_approval). - content = self._self_contained_prompt_content( - f"**{title or 'Confirm'}**", message - ) - + content = self._self_contained_prompt_content(f"**{title or 'Confirm'}**", message) view = SlashConfirmView( - session_key=session_key, - confirm_id=confirm_id, - allowed_user_ids=self._allowed_user_ids, - allowed_role_ids=self._allowed_role_ids, + session_key=session_key, confirm_id=confirm_id, + allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) - - msg = await channel.send(content=content, embed=embed, view=view) - view._message = msg # store for on_timeout expiration editing - return SendResult(success=True, message_id=str(msg.id)) - except Exception as e: - return SendResult(success=False, error=str(e)) + return {"content": content, "embed": embed, "view": view}, view + return await self._send_prompt(chat_id, metadata, _build) async def send_clarify( - self, - chat_id: str, - question: str, - choices: Optional[list], - clarify_id: str, - session_key: str, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, question: str, choices: Optional[list], clarify_id: str, + session_key: str, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Render a clarify prompt with one Discord button per choice. - - Multi-choice mode (``choices`` non-empty): renders a button per option - plus a final "✏️ Other (type answer)" button. Picking "Other" flips - the clarify entry into text-capture mode so the next user message in - the session becomes the response. Numeric clicks resolve immediately - via ``resolve_gateway_clarify(clarify_id, choice_text)``. - - Open-ended mode (``choices`` empty/None): renders the question as - plain embed text — no buttons. The gateway's text-intercept captures - the next message in this session and resolves the clarify. - - Choice normalisation: ``choices`` may contain bare strings OR dicts - (LLMs sometimes emit ``[{"description": "..."}]`` instead of bare - strings, which would otherwise render as raw Python repr on the - button label). Dict choices are unwrapped against the canonical - LLM tool-call keys ``label``, ``description``, ``text``, ``title`` - in that order. Dicts with none of those keys are dropped. - """ - if not self._client or not DISCORD_AVAILABLE: - return SendResult(success=False, error="Not connected") - - try: - target_id = chat_id - if metadata and metadata.get("thread_id"): - target_id = metadata["thread_id"] - - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) - - # Discord embed description limit is 4096; trim conservatively. - max_desc = 4088 - body = str(question or "").strip() - if len(body) > max_desc: - body = body[: max_desc - 3] + "..." + """Clarify prompt: one button per choice plus ``✏️ Other`` (text-capture); with no choices the + gateway's text-intercept captures the next message. Dict choices (LLMs emit + ``[{"description": ...}]``) are unwrapped via ``label``/``description``/``text``/``title``.""" + def _flatten_choice(c): + if c is None: + return "" + if isinstance(c, str): + return c.strip() + if isinstance(c, dict): + # 'name'/'value' excluded: Discord-component-shaped fields would leak raw enum values. + for key in ("label", "description", "text", "title"): + v = c.get(key) + if isinstance(v, str) and v.strip(): + return v.strip() + return "" + if isinstance(c, (list, tuple)): + return " ".join(_flatten_choice(x) for x in c).strip() + return str(c).strip() + def _build(_channel): embed = discord.Embed( title="❓ Hermes needs your input", - description=body, + description=self._embed_body(str(question or "").strip()), color=discord.Color.orange(), ) - - # Normalise choices: LLMs sometimes emit `[{"description": "..."}]` - # instead of bare strings, which would render as raw Python repr on - # the button label. Unwrap the common shapes, then stringify. - def _flatten_choice(c): - if c is None: - return "" - if isinstance(c, str): - return c.strip() - if isinstance(c, dict): - # Prefer the canonical LLM tool-call user-facing keys - # in the order the LLM is most likely to emit them. - # 'name' and 'value' are deliberately NOT here: they're - # Discord-component-shaped fields that could appear in - # dicts that aren't meant to be choices (e.g., a - # developer-error wiring that passes a Button-shaped - # object). Picking them would leak raw enum values - # or 4-char model identifiers onto user-facing buttons. - # If a dict has none of the canonical keys, drop it - # rather than picking some random field — a garbage - # button label is worse than no button at all. - for key in ("label", "description", "text", "title"): - v = c.get(key) - if isinstance(v, str) and v.strip(): - return v.strip() - return "" - if isinstance(c, (list, tuple)): - return " ".join(_flatten_choice(x) for x in c).strip() - return str(c).strip() - - clean_choices = [ - s for s in (_flatten_choice(c) for c in (choices or [])) if s - ] - # Discord allows up to 5 buttons per row, 5 rows per view = 25. - # We reserve one slot for the "Other" button, so cap at 24 choices. - clean_choices = clean_choices[:24] - + # 5 buttons × 5 rows = 25; one slot is reserved for "Other". + clean_choices = [s for s in (_flatten_choice(c) for c in (choices or [])) if s][:24] if clean_choices: - embed.add_field( - name="Choices", - value="Pick one below, or click ✏️ Other to type a custom answer.", - inline=False, - ) + hint = "Pick one below, or click ✏️ Other to type a custom answer." + embed.add_field(name="Choices", value=hint, inline=False) view = ClarifyChoiceView( - choices=clean_choices, - clarify_id=clarify_id, + choices=clean_choices, clarify_id=clarify_id, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) else: - embed.add_field( - name="Reply", - value="Reply in this channel with your answer.", - inline=False, - ) + hint = "Reply in this channel with your answer." + embed.add_field(name="Reply", value=hint, inline=False) view = None - - # Mirror the question in plain content — embeds are invisible on - # some clients (see send_exec_approval). - clarify_tail = ( - "\n\nPick one below, or click ✏️ Other to type a custom answer." - if clean_choices - else "\n\nReply in this channel with your answer." - ) content = self._self_contained_prompt_content( - "❓ **Hermes needs your input**", str(question or "").strip(), - tail=clarify_tail, + "❓ **Hermes needs your input**", str(question or "").strip(), tail=f"\n\n{hint}", ) - msg = await channel.send(content=content, embed=embed, view=view) if view else await channel.send(content=content, embed=embed) + send_kwargs = {"content": content, "embed": embed} if view: - view._message = msg # store for on_timeout expiration editing - return SendResult(success=True, message_id=str(msg.id)) - except Exception as e: - logger.warning("[%s] send_clarify failed: %s", self.name, e) - return SendResult(success=False, error=str(e)) + send_kwargs["view"] = view + return send_kwargs, view + return await self._send_prompt(chat_id, metadata, _build, fail_log="send_clarify") async def send_update_prompt( - self, chat_id: str, prompt: str, default: str = "", - session_key: str = "", + self, chat_id: str, prompt: str, default: str = "", session_key: str = "", metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send an interactive button-based update prompt (Yes / No). - - Used by the gateway ``/update`` watcher when ``hermes update --gateway`` - needs user input (stash restore, config migration). - """ - if not self._client or not DISCORD_AVAILABLE: - return SendResult(success=False, error="Not connected") - try: - target_id = metadata.get("thread_id") if metadata and metadata.get("thread_id") else chat_id - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) - + """Yes/No prompt for the gateway ``/update`` watcher when ``hermes update --gateway`` needs input.""" + def _build(_channel): default_hint = f" (default: {default})" if default else "" embed = discord.Embed( - title="⚕ Update Needs Your Input", - description=f"{prompt}{default_hint}", - color=discord.Color.gold(), + title="⚕ Update Needs Your Input", description=f"{prompt}{default_hint}", color=discord.Color.gold(), ) view = UpdatePromptView( - session_key=session_key, - allowed_user_ids=self._allowed_user_ids, + session_key=session_key, allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) - # Mirror the prompt in plain content — embeds are invisible on - # some clients (see send_exec_approval). - content = self._self_contained_prompt_content( - "⚕ **Update Needs Your Input**", f"{prompt}{default_hint}" - ) - msg = await channel.send(content=content, embed=embed, view=view) - view._message = msg # store for on_timeout expiration editing - if _metadata_marks_nonconversational(metadata): - self._nonconversational_messages.mark_many([str(msg.id)]) - return SendResult(success=True, message_id=str(msg.id)) - except Exception as e: - return SendResult(success=False, error=str(e)) + content = self._self_contained_prompt_content("⚕ **Update Needs Your Input**", f"{prompt}{default_hint}") + return {"content": content, "embed": embed, "view": view}, view + result = await self._send_prompt(chat_id, metadata, _build) + if result.success and _metadata_marks_nonconversational(metadata): + self._nonconversational_messages.mark_many([result.message_id]) + return result async def send_model_picker( - self, - chat_id: str, - providers: list, - current_model: str, - current_provider: str, - session_key: str, - on_model_selected, - metadata: Optional[Dict[str, Any]] = None, + self, chat_id: str, providers: list, current_model: str, current_provider: str, + session_key: str, on_model_selected, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send an interactive select-menu model picker. - - Two-step drill-down: provider dropdown → model dropdown. - Uses Discord embeds + Select menus via ``ModelPickerView``. - """ - if not self._client or not DISCORD_AVAILABLE: - return SendResult(success=False, error="Not connected") - - try: - # Resolve target channel (use thread_id if present) - target_id = chat_id - if metadata and metadata.get("thread_id"): - target_id = metadata["thread_id"] - - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) - + """Two-step select-menu model picker (provider → model) via ``ModelPickerView``.""" + def _build(_channel): try: from hermes_cli.providers import get_label provider_label = get_label(current_provider) except Exception: provider_label = current_provider - embed = discord.Embed( title="⚙ Model Configuration", description=( @@ -7920,72 +5559,31 @@ class DiscordAdapter(BasePlatformAdapter): ), color=discord.Color.blue(), ) - view = ModelPickerView( - providers=providers, - current_model=current_model, - current_provider=current_provider, - session_key=session_key, - on_model_selected=on_model_selected, - allowed_user_ids=self._allowed_user_ids, - allowed_role_ids=self._allowed_role_ids, + providers=providers, current_model=current_model, current_provider=current_provider, + session_key=session_key, on_model_selected=on_model_selected, + allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) - - msg = await channel.send(embed=embed, view=view) - view._message = msg # store for on_timeout expiration editing - return SendResult(success=True, message_id=str(msg.id)) - - except Exception as e: - logger.warning("[%s] send_model_picker failed: %s", self.name, e) - return SendResult(success=False, error=str(e)) + return {"embed": embed, "view": view}, view + return await self._send_prompt(chat_id, metadata, _build, fail_log="send_model_picker") async def send_choice_picker( - self, - chat_id: str, - title: str, - choices: list, - session_key: str, - on_choice_selected, + self, chat_id: str, title: str, choices: list, session_key: str, on_choice_selected, metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: - """Send a flat select-menu choice picker (one selection → one value). - - Generic single-level companion to ``send_model_picker`` used by - `/reasoning`, `/fast`, and any future finite-choice command. Each - choice dict: ``{"value": str, "label": str, "is_current": bool}``. - """ - if not self._client or not DISCORD_AVAILABLE: - return SendResult(success=False, error="Not connected") - - try: - target_id = chat_id - if metadata and metadata.get("thread_id"): - target_id = metadata["thread_id"] - - channel = self._client.get_channel(int(target_id)) - if not channel: - channel = await self._client.fetch_channel(int(target_id)) - + """Flat select-menu picker (one selection → one value) for `/reasoning`, `/fast`, + etc. Each choice: ``{"value": str, "label": str, "is_current": bool}``.""" + def _build(_channel): embed = discord.Embed( title="⚙ " + (title.splitlines()[0] if title else "Choose an option"), - description="\n".join(title.splitlines()[1:]) or None, - color=discord.Color.blue(), + description="\n".join(title.splitlines()[1:]) or None, color=discord.Color.blue(), ) - view = ChoicePickerView( - choices=choices, - on_choice_selected=on_choice_selected, - allowed_user_ids=self._allowed_user_ids, - allowed_role_ids=self._allowed_role_ids, + choices=choices, on_choice_selected=on_choice_selected, + allowed_user_ids=self._allowed_user_ids, allowed_role_ids=self._allowed_role_ids, ) - - msg = await channel.send(embed=embed, view=view) - view._message = msg # store for on_timeout expiration editing - return SendResult(success=True, message_id=str(msg.id)) - - except Exception as e: - logger.warning("[%s] send_choice_picker failed: %s", self.name, e) - return SendResult(success=False, error=str(e)) + return {"embed": embed, "view": view}, view + return await self._send_prompt(chat_id, metadata, _build, fail_log="send_choice_picker") def _get_parent_channel_id(self, channel: Any) -> Optional[str]: """Return the parent channel ID for a Discord thread-like channel, if present.""" @@ -8027,7 +5625,6 @@ class DiscordAdapter(BasePlatformAdapter): 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: @@ -8038,50 +5635,18 @@ class DiscordAdapter(BasePlatformAdapter): # ------------------------------------------------------------------ # Attachment download helpers - # - # Discord attachments (images / audio / documents) are fetched via the - # authenticated bot session whenever the Attachment object exposes - # ``read()``. That sidesteps two classes of bug that hit the older - # plain-HTTP path: - # - # 1. ``cdn.discordapp.com`` URLs increasingly require bot auth on - # download — unauthenticated httpx sees 403 Forbidden. - # (issue #8242) - # 2. Some user environments (VPNs, corporate DNS, tunnels) resolve - # ``cdn.discordapp.com`` to private-looking IPs that our - # ``is_safe_url`` guard classifies as SSRF risks. Routing the - # fetch through discord.py's own HTTP client handles DNS - # internally so our guard isn't consulted for the attachment - # path. (issue #6587) - # - # If ``att.read()`` is unavailable (unexpected object shape / test - # stub) or the bot session fetch fails, we fall back to the existing - # SSRF-gated URL downloaders. The fallback keeps defense-in-depth - # against any future Discord payload-schema drift that could slip a - # non-CDN URL into the ``att.url`` field. (issue #11345) + # Prefer the authenticated bot session (``att.read()``): CDN URLs increasingly 403 without + # bot auth and some VPN DNS setups make ``is_safe_url`` flag the CDN as SSRF. If ``read()`` + # is missing or fails, fall back to the SSRF-gated URL downloaders (defense-in-depth). # ------------------------------------------------------------------ - async def _read_attachment_bytes( - self, - att, - *, - media_type: str = "media", - ) -> Optional[bytes]: - """Read an attachment via discord.py's authenticated bot session. - - Returns the raw bytes on success, or ``None`` if ``att`` doesn't - expose a callable ``read()`` or the read itself fails. Callers - should treat ``None`` as a signal to fall back to the URL-based - downloaders. - - Oversized attachments (per ``gateway.max_inbound_media_bytes``) raise - ``ValueError`` BEFORE the bytes are pulled into memory when Discord - reports the size up front, so a hostile upload can't OOM the gateway. - """ + async def _read_attachment_bytes(self, att, *, media_type: str = "media") -> Optional[bytes]: + """Read an attachment via the authenticated bot session; ``None`` (no callable ``read()`` + or read failure) means fall back to the URL downloaders. Raises ``ValueError`` for oversized + attachments BEFORE pulling bytes when Discord reports the size, so a hostile upload can't OOM.""" attachment_size = getattr(att, "size", None) if attachment_size: validate_inbound_media_size(int(attachment_size), media_type=media_type) - reader = getattr(att, "read", None) if reader is None or not callable(reader): return None @@ -8090,21 +5655,14 @@ class DiscordAdapter(BasePlatformAdapter): except Exception as e: logger.warning( "[Discord] Authenticated attachment read failed for %s: %s", - getattr(att, "filename", None) or getattr(att, "url", ""), - e, + getattr(att, "filename", None) or getattr(att, "url", ""), e, ) return None validate_inbound_media_size(len(raw_bytes), media_type=media_type) return raw_bytes async def _cache_discord_image(self, att, ext: str) -> str: - """Cache a Discord image attachment to local disk. - - Primary path: ``att.read()`` + ``cache_image_from_bytes`` - (authenticated, no SSRF gate). - - Fallback: ``cache_image_from_url`` (plain httpx, SSRF-gated). - """ + """Cache an image attachment locally: ``att.read()`` first, SSRF-gated URL fallback.""" raw_bytes = await self._read_attachment_bytes(att, media_type="image") if raw_bytes is not None: try: @@ -8117,93 +5675,166 @@ class DiscordAdapter(BasePlatformAdapter): return await cache_image_from_url(att.url, ext=ext) async def _cache_discord_audio(self, att, ext: str) -> str: - """Cache a Discord audio attachment to local disk. - - Primary path: ``att.read()`` + ``cache_audio_from_bytes`` - (authenticated, no SSRF gate). - - Fallback: ``cache_audio_from_url`` (plain httpx, SSRF-gated). - """ + """Cache an audio attachment locally: ``att.read()`` first, SSRF-gated URL fallback.""" raw_bytes = await self._read_attachment_bytes(att, media_type="audio") if raw_bytes is not None: try: return cache_audio_from_bytes(raw_bytes, ext=ext) except Exception as e: - logger.debug( - "[Discord] cache_audio_from_bytes failed; falling back to URL: %s", - e, - ) + logger.debug("[Discord] cache_audio_from_bytes failed; falling back to URL: %s", e) return await cache_audio_from_url(att.url, ext=ext) async def _cache_discord_document(self, att, ext: str) -> bytes: - """Download a Discord document attachment and return the raw bytes. - - Primary path: ``att.read()`` (authenticated, no SSRF gate). - - Fallback: SSRF-gated ``aiohttp`` download. This closes the gap - where the old document path made raw ``aiohttp.ClientSession`` - requests with no safety check (#11345). The caller is responsible - for passing the returned bytes to ``cache_document_from_bytes`` - (and, where applicable, for injecting text content). - """ + """Download a document attachment: ``att.read()`` first, SSRF-gated aiohttp fallback. + Caller passes the bytes to ``cache_document_from_bytes`` (and injects text if applicable).""" raw_bytes = await self._read_attachment_bytes(att, media_type="document") if raw_bytes is not None: return raw_bytes - - # Fallback: SSRF-gated URL download. if not is_safe_url(att.url): - raise ValueError( - f"Blocked unsafe attachment URL (SSRF protection): {att.url}" - ) + raise ValueError(f"Blocked unsafe attachment URL (SSRF protection): {att.url}") import aiohttp from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) async with aiohttp.ClientSession(**_sess_kw) as session: async with session.get( - att.url, - timeout=aiohttp.ClientTimeout(total=30), - **_req_kw, + att.url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw, ) as resp: if resp.status != 200: raise Exception(f"HTTP {resp.status}") return await resp.read() + async def _cache_simple_media(self, att: Any, content_type: str, kind: str, exts: set, default_ext: str) -> str: + """Cache an image/audio attachment locally (CDN URLs expire); fall back to the CDN URL.""" + try: + ext = "." + content_type.split("/")[-1].split(";")[0] + if ext not in exts: + ext = default_ext + cacher = self._cache_discord_image if kind == "image" else self._cache_discord_audio + cached_path = await cacher(att, ext) + print(f"[Discord] Cached user {kind}: {cached_path}", flush=True) + return cached_path + except Exception as e: + print(f"[Discord] Failed to cache {kind} attachment: {e}", flush=True) + return att.url + + async def _collect_attachment_media(self, all_attachments: list) -> tuple: + """Cache every attachment and return ``(media_urls, media_types, pending_text_injection)``.""" + media_urls = [] + media_types = [] + pending_text_injection: Optional[str] = None + for att in all_attachments: + content_type = att.content_type or "unknown" + if content_type.startswith("image/"): + media_urls.append(await self._cache_simple_media( + att, content_type, "image", {".jpg", ".jpeg", ".png", ".gif", ".webp"}, ".jpg")) + media_types.append(content_type) + elif content_type.startswith("audio/"): + media_urls.append(await self._cache_simple_media( + att, content_type, "audio", {".ogg", ".mp3", ".wav", ".webm", ".m4a"}, ".ogg")) + media_types.append(content_type) + else: + ext = "" + if att.filename: + _, ext = os.path.splitext(att.filename) + ext = ext.lower() + if not ext and content_type: + mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} + ext = mime_to_ext.get(content_type, "") + in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES + # Any file type accepted (authorization is the gate); unknown types fall back to octet-stream. + max_doc_bytes = self._discord_max_attachment_bytes() + if max_doc_bytes and att.size and att.size > max_doc_bytes: + logger.warning( + "[Discord] Document too large (%s bytes > cap %s), skipping: %s", + att.size, max_doc_bytes, att.filename, + ) + continue + try: + raw_bytes = await self._cache_discord_document(att, ext) + cached_path = cache_document_from_bytes(raw_bytes, att.filename or f"document{ext or '.bin'}") + if in_allowlist: + doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] + else: + # Untyped: source content_type, else octet-stream (agent knows it's binary). + doc_mime = ( + content_type if content_type and content_type != "unknown" else "application/octet-stream" + ) + media_urls.append(cached_path) + media_types.append(doc_mime) + logger.info( + "[Discord] Cached user %s: %s", "document" if in_allowlist else "attachment", cached_path, + ) + # Inject text for text-readable documents (capped at 100 KB). Gate on text-like + # extension/MIME, NOT a blind UTF-8 decode (PDF/zip/docx have ASCII headers); other + # types rely on ``gateway/run.py`` emitting a (sandbox-translated) path note. + MAX_TEXT_INJECT_BYTES = 100 * 1024 + _is_text = ext in _TEXT_INJECT_EXTENSIONS or (content_type or "").startswith("text/") + if _is_text and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: + try: + text_content = raw_bytes.decode("utf-8") + display_name = att.filename or f"document{ext or '.txt'}" + display_name = re.sub(r'[^\w.\- ]', '_', display_name) + injection = f"[Content of {display_name}]:\n{text_content}" + if pending_text_injection: + pending_text_injection = f"{pending_text_injection}\n\n{injection}" + else: + pending_text_injection = injection + except UnicodeDecodeError: + pass + except Exception as e: + logger.warning("[Discord] Failed to cache document %s: %s", att.filename, e, exc_info=True) + return media_urls, media_types, pending_text_injection + + def _attachment_message_type(self, att: Any) -> MessageType: + """MessageType from the first attachment's MIME. Any non-media (or untyped) attachment + is a DOCUMENT regardless of extension — authorization is the gate, not the file type.""" + content_type = att.content_type or "" + if content_type.startswith("image/"): + return MessageType.PHOTO + if content_type.startswith("video/"): + return MessageType.VIDEO + if content_type.startswith("audio/"): + return MessageType.VOICE if self._is_discord_voice_message_attachment(att) else MessageType.AUDIO + return MessageType.DOCUMENT + + @staticmethod + def _reply_target(reference: Any) -> Optional[Any]: + """Something with ``.id`` for the replied-to message; duck-typed (test doubles mock ``discord``), + falling back to a bare snowflake from ``reference.message_id``.""" + _resolved = getattr(reference, "resolved", None) + if getattr(_resolved, "id", None) is not None: + return _resolved + _ref_mid = getattr(reference, "message_id", None) + if _ref_mid is not None: + with suppress(ValueError, TypeError): + return _Snowflake(int(_ref_mid)) + return None + async def _handle_message( - self, - message: DiscordMessage, - role_authorized: bool = False, - *, - recovered: bool = False, + self, message: DiscordMessage, role_authorized: bool = False, *, recovered: bool = False, ) -> bool: """Handle one Discord message and report whether it reached dispatch.""" - # In server channels (not DMs), require the bot to be @mentioned - # UNLESS the channel is in the free-response list or the message is - # in a thread where the bot has already participated. + # Server channels (not DMs) require @mention unless free-response or an already-joined thread. # - # Config (all settable via discord.* in config.yaml or DISCORD_* env vars): + # Config (discord.* in config.yaml or DISCORD_* env vars): # discord.require_mention: Require @mention in server channels (default: true) # discord.free_response_channels: Channel IDs where bot responds without mention # discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned) # discord.allowed_channels: If set, bot ONLY responds in these channels (whitelist) # discord.no_thread_channels: Channel IDs where bot responds directly without creating thread # discord.auto_thread: Auto-create thread on @mention in channels (default: true) - thread_id = None parent_channel_id = None is_thread = isinstance(message.channel, discord.Thread) if is_thread: thread_id = str(message.channel.id) parent_channel_id = self._get_parent_channel_id(message.channel) - is_voice_linked_channel = False - - # Save mention-stripped text before auto-threading since create_thread() - # can clobber message.content, breaking /command detection in channels. + # Save stripped text now: create_thread() can clobber message.content (breaks /command detection). raw_content = message.content.strip() normalized_content = raw_content mention_prefix = False - snapshot_attachments = [] if hasattr(message, "message_snapshots") and message.message_snapshots: snapshot_text_parts = [] @@ -8225,25 +5856,18 @@ class DiscordAdapter(BasePlatformAdapter): if parent_channel_id: channel_ids.add(parent_channel_id) channel_keys = self._discord_channel_keys(message, parent_channel_id) - - # Check allowed channels - if set, only respond in these channels allowed_channels = self._get_allowed_channels() if allowed_channels: if "*" not in allowed_channels and not (channel_keys & allowed_channels): logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_keys) return False - - # Check ignored channels - never respond even when mentioned ignored_channels = self._get_ignored_channels() if "*" in ignored_channels or (channel_keys & ignored_channels): logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_keys) return False - free_channels = self._discord_free_response_channels() - require_mention = self._discord_require_mention() - # Voice-linked text channels act as free-response while voice is active. - # Only the exact bound channel gets the exemption, not sibling threads. + # Voice-linked text channel is free-response while voice is active (exact channel only). voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()} current_channel_id = str(message.channel.id) is_voice_linked_channel = current_channel_id in voice_linked_ids @@ -8252,25 +5876,11 @@ class DiscordAdapter(BasePlatformAdapter): or bool(channel_keys & free_channels) or is_voice_linked_channel ) - - # Skip the mention check if the message is in a thread where - # the bot has previously participated (auto-created or replied in) - # — UNLESS thread_require_mention is enabled, in which case threads - # are gated the same as channels. Useful when multiple bots share - # a thread. - in_bot_thread = ( - is_thread - and thread_id in self._threads - and not self._discord_thread_require_mention() - ) - + in_bot_thread = self._in_bot_thread(message) if require_mention and not is_free_channel and not in_bot_thread: if not self._self_is_explicitly_mentioned(message) and not mention_prefix: return False - # 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. - # no_thread_channels: channels where bot responds directly without thread. + # Auto-thread: isolate each @mention in a text channel into its own thread (Slack-style). auto_threaded_channel = None if not is_thread and not isinstance(message.channel, discord.DMChannel): no_thread_channels = self._get_no_thread_channels() @@ -8285,24 +5895,12 @@ class DiscordAdapter(BasePlatformAdapter): thread_id = str(thread.id) auto_threaded_channel = thread self._threads.mark(thread_id) - # Pre-seed dedup: when _auto_create_thread creates a thread - # via message.create_thread(), Discord fires a second - # MESSAGE_CREATE event for the "thread starter message". - # That starter message carries id == thread.id and may - # arrive with type=default (not type=21/thread_starter_message), - # so the type filter above does not catch it. Marking the - # thread id in the dedup cache now ensures that duplicate - # event is dropped before it can trigger a second agent run. - # Fixes #51057. + # Pre-seed dedup: message.create_thread() fires a second MESSAGE_CREATE for the + # starter (id == thread.id, maybe type=default); mark it so it can't trigger a rerun. self._dedup.is_duplicate(str(thread.id)) else: - # Auto-threading is the configured routing target for this - # message; if it fails we must NOT silently fall back to an - # inline parent-channel reply (#20243). That breaks - # thread-first Discord workflows by dumping a new task into - # a shared channel. Surface a short visible error so the - # user can retry once Discord recovers, and skip agent - # invocation for this message. + # Auto-threading is the routing target; do NOT fall back to an inline parent-channel + # reply (dumps the task into a shared channel). Surface an error and skip the run. try: await message.channel.send( "⚠️ Hermes could not create a Discord thread for " @@ -8310,53 +5908,23 @@ class DiscordAdapter(BasePlatformAdapter): ) except Exception as notify_error: logger.warning( - "[%s] Failed to notify user of auto-thread failure: %s", - self.name, + "[%s] Failed to notify user of auto-thread failure: %s", self.name, notify_error, ) return False - referenced_attachments = [] reference = getattr(message, "reference", None) resolved_reference = getattr(reference, "resolved", None) if reference else None if resolved_reference is not None: referenced_attachments = list(getattr(resolved_reference, "attachments", []) or []) - all_attachments = list(message.attachments) + snapshot_attachments + referenced_attachments - - # Determine message type - msg_type = MessageType.TEXT if normalized_content.startswith("/"): msg_type = MessageType.COMMAND elif all_attachments: - # Check attachment types. Any non-media attachment is treated as a - # DOCUMENT regardless of extension — authorization to message the - # agent is the gate, not the file type. - for att in all_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/"): - if self._is_discord_voice_message_attachment(att): - msg_type = MessageType.VOICE - else: - msg_type = MessageType.AUDIO - else: - msg_type = MessageType.DOCUMENT - break - else: - # No content_type at all (rare — discord usually fills it - # in). Treat as a document so downstream pipelines surface - # the path to the agent. - msg_type = MessageType.DOCUMENT - break - - # When auto-threading kicked in, route responses to the new thread + msg_type = self._attachment_message_type(all_attachments[0]) + else: + msg_type = MessageType.TEXT effective_channel = auto_threaded_channel or message.channel - - # Determine chat type if isinstance(message.channel, discord.DMChannel): chat_type = "dm" chat_name = message.author.name @@ -8368,13 +5936,8 @@ class DiscordAdapter(BasePlatformAdapter): chat_name = getattr(message.channel, "name", str(message.channel.id)) if hasattr(message.channel, "guild") and message.channel.guild: chat_name = f"{message.channel.guild.name} / #{chat_name}" - - # Get channel topic (if available - TextChannels have topics, DMs/threads don't). - # For threads whose parent is a forum channel, inherit the parent's topic - # so forum descriptions (e.g. project instructions) appear in the session context. + # Channel topic (TextChannels only); forum-parented threads inherit the parent topic. chat_topic = self._get_effective_topic(message.channel, is_thread=is_thread) - - # Build source guild = getattr(message, "guild", None) source = self.build_source( chat_id=str(effective_channel.id), @@ -8395,267 +5958,62 @@ class DiscordAdapter(BasePlatformAdapter): or self._derive_auto_thread_name(message.content or "") ) if auto_threaded_channel is not None else None, ) - - # Build media URLs -- download image attachments to local cache so the - # vision tool can access them reliably (Discord CDN URLs can expire). - media_urls = [] - media_types = [] - pending_text_injection: Optional[str] = None - for att in all_attachments: - content_type = att.content_type or "unknown" - if content_type.startswith("image/"): - try: - # Determine extension from content type (image/png -> .png) - ext = "." + content_type.split("/")[-1].split(";")[0] - if ext not in {".jpg", ".jpeg", ".png", ".gif", ".webp"}: - ext = ".jpg" - cached_path = await self._cache_discord_image(att, ext) - media_urls.append(cached_path) - media_types.append(content_type) - print(f"[Discord] Cached user image: {cached_path}", flush=True) - except Exception as e: - print(f"[Discord] Failed to cache image attachment: {e}", flush=True) - # Fall back to the CDN URL if caching fails - media_urls.append(att.url) - media_types.append(content_type) - elif content_type.startswith("audio/"): - try: - ext = "." + content_type.split("/")[-1].split(";")[0] - if ext not in {".ogg", ".mp3", ".wav", ".webm", ".m4a"}: - ext = ".ogg" - cached_path = await self._cache_discord_audio(att, ext) - media_urls.append(cached_path) - media_types.append(content_type) - print(f"[Discord] Cached user audio: {cached_path}", flush=True) - except Exception as e: - print(f"[Discord] Failed to cache audio attachment: {e}", flush=True) - media_urls.append(att.url) - media_types.append(content_type) - else: - # Document attachments: download, cache, and optionally inject text - ext = "" - if att.filename: - _, ext = os.path.splitext(att.filename) - ext = ext.lower() - if not ext and content_type: - mime_to_ext = {v: k for k, v in SUPPORTED_DOCUMENT_TYPES.items()} - ext = mime_to_ext.get(content_type, "") - in_allowlist = ext in SUPPORTED_DOCUMENT_TYPES - # Any file type is accepted — authorization to message the agent - # is the gate, not the file extension. Known types keep their - # precise MIME; unknown types fall back to the source content_type - # or octet-stream so the agent reaches for terminal tools. - max_doc_bytes = self._discord_max_attachment_bytes() - if max_doc_bytes and att.size and att.size > max_doc_bytes: - logger.warning( - "[Discord] Document too large (%s bytes > cap %s), skipping: %s", - att.size, max_doc_bytes, att.filename, - ) - else: - try: - raw_bytes = await self._cache_discord_document(att, ext) - cached_path = cache_document_from_bytes( - raw_bytes, att.filename or f"document{ext or '.bin'}" - ) - if in_allowlist: - doc_mime = SUPPORTED_DOCUMENT_TYPES[ext] - else: - # Untyped file. Use the source content_type if - # discord gave us one, otherwise fall back to - # octet-stream so the agent knows it's binary and - # reaches for terminal tools. - doc_mime = ( - content_type - if content_type and content_type != "unknown" - else "application/octet-stream" - ) - media_urls.append(cached_path) - media_types.append(doc_mime) - logger.info( - "[Discord] Cached user %s: %s", - "document" if in_allowlist else "attachment", - cached_path, - ) - # Inject text content for any text-readable document - # Inject text content for text-readable documents - # (capped at 100 KB). Gate on a text-like extension/MIME - # — NOT a blind UTF-8 decode, since binary formats like - # PDF/zip/docx can have decodable ASCII headers. Unknown - # but clearly-textual types (text/* MIME or a known text - # extension) are inlined too; everything else relies on - # ``gateway/run.py`` to emit a path-pointing context note. - MAX_TEXT_INJECT_BYTES = 100 * 1024 - _is_text = ( - ext in _TEXT_INJECT_EXTENSIONS - or (content_type or "").startswith("text/") - ) - if _is_text and len(raw_bytes) <= MAX_TEXT_INJECT_BYTES: - try: - text_content = raw_bytes.decode("utf-8") - display_name = att.filename or f"document{ext or '.txt'}" - display_name = re.sub(r'[^\w.\- ]', '_', display_name) - injection = f"[Content of {display_name}]:\n{text_content}" - if pending_text_injection: - pending_text_injection = f"{pending_text_injection}\n\n{injection}" - else: - pending_text_injection = injection - except UnicodeDecodeError: - pass - # NOTE: for the untyped-attachment path we deliberately - # do NOT inject a path string here. ``gateway/run.py`` - # already detects DOCUMENT-typed events with - # ``application/octet-stream`` MIME and emits a context - # note with the sandbox-translated cache path via - # ``to_agent_visible_cache_path()`` (important for - # Docker/Modal terminal backends). - except Exception as e: - logger.warning( - "[Discord] Failed to cache document %s: %s", - att.filename, e, exc_info=True, - ) - - # Use normalized_content (saved before auto-threading) instead of message.content, - # to detect /slash commands in channel messages. + media_urls, media_types, pending_text_injection = await self._collect_attachment_media(all_attachments) event_text = normalized_content if pending_text_injection: event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection - # ── History backfill ───────────────────────────────────────── - # When require_mention is active, the bot only processes messages - # that @mention it. Messages in the channel between bot turns are - # invisible to the session transcript. To recover that context, - # fetch recent channel history and prepend it to the user message. - # - # The fetch window is: everything after the bot's last message in - # the channel up to (but not including) the current trigger. On - # cold start (no prior bot message found), fetch the last N messages - # and stop at the first self-message encountered. - # - # Threads naturally scope to thread-only history (channel.history() - # on a thread returns only that thread's messages). DMs are skipped - # because every DM message triggers the bot — there's no mention gap - # to fill; the session transcript already has everything. - # - # Per-user sessions also benefit: Alice's session is missing the - # other-channel-participants' context, and her own messages from - # before she mentioned the bot. Backfill fills that gap. - # - # Messages that arrive while the bot is processing (between trigger - # and response) are not captured — this is an accepted simplification - # to keep the partition rule clean. + # With require_mention, messages between bot turns never reach the transcript; fetch + # history after the bot's last message (cold start: last N, stop at first self-message) + # and prepend it. DMs skipped (every DM triggers the bot); in-flight arrivals not captured. _channel_context = None _is_dm = isinstance(message.channel, discord.DMChannel) if not _is_dm and self._discord_history_backfill(): - # Run backfill when there's a real gap to fill: - # - mention-gated channels with no free-response override - # (messages between bot turns aren't in the transcript) - # - any thread (in_bot_thread bypasses the mention check, but - # processing-window gaps and post-restart context still need - # recovery) - # - any reply (the user pointed at a specific message; hydrate - # the context around it even in a free-response channel where - # no mention gap exists — otherwise replies get only the short - # "[Replying to: ...]" snippet with no surrounding context) - # DMs skip entirely because every DM message triggers the bot, - # so the session transcript already has everything. - # Auto-threaded messages also skip — we just created the thread, - # there's nothing prior to backfill. + # Backfill on a gap: mention-gated channels, any thread (processing/restart gaps), any + # reply (hydrate context around the referenced message). DMs/fresh auto-threads: nothing. _has_mention_gap = require_mention and not is_free_channel and not in_bot_thread _is_reply = message.reference is not None - - # Resolve the replied-to message into an object exposing ``.id``. - # discord.py may give us a full Message (resolved), a - # DeletedReferencedMessage, or nothing. Duck-type on ``.id`` - # rather than isinstance(discord.Message) — under test doubles the - # discord module (and thus discord.Message) can be a mock, which is - # not a valid isinstance() second argument. Any object with an int - # id works as a scan anchor; otherwise fall back to a bare snowflake - # built from the reference's message_id. - _reply_target = None - if _is_reply: - _resolved = getattr(message.reference, "resolved", None) - _resolved_id = getattr(_resolved, "id", None) if _resolved is not None else None - if _resolved_id is not None: - _reply_target = _resolved - else: - _ref_mid = getattr(message.reference, "message_id", None) - if _ref_mid is not None: - with suppress(ValueError, TypeError): - _reply_target = _Snowflake(int(_ref_mid)) - if (_has_mention_gap or is_thread or _is_reply) and auto_threaded_channel is None: _backfill_text = await self._fetch_channel_context( - message.channel, before=message, reply_target=_reply_target, + message.channel, before=message, + reply_target=self._reply_target(message.reference) if _is_reply else None, ) if _backfill_text: _channel_context = _backfill_text - - # Defense-in-depth: prevent empty user messages from entering session - # (can happen when user sends @mention-only with no other text). - # When channel_context is present, a bare mention means "catch me up" - # — the context IS the message, so skip the placeholder. + # Keep empty user messages out of the session; with channel_context a bare mention = "catch me up". if (not event_text or not event_text.strip()) and not _channel_context: - # Bare mention-only ping (e.g. "@Bot" with nothing else, including - # raw <@!ID> forms) with no media, no injected text, and no backfill - # context: drop it instead of spawning a fake empty-text turn. - # mention_prefix was computed (and message.content stripped) above, - # so reuse it rather than re-reading the now-stripped content. - if ( - mention_prefix - and not media_urls - and not pending_text_injection - ): + # Bare mention-only ping with no media/text/backfill: drop rather than spawn an empty turn. + if (mention_prefix and not media_urls and not pending_text_injection): logger.info( - "[%s] Ignoring mention-only message from %s in %s", - self.name, + "[%s] Ignoring mention-only message from %s in %s", self.name, getattr(message.author, "display_name", getattr(message.author, "name", "unknown")), getattr(message.channel, "id", "unknown"), ) return False event_text = "(The user sent a message with no text content)" - _chan = message.channel _parent_id = str(getattr(_chan, "parent_id", "") or "") _chan_id = str(getattr(_chan, "id", "")) _skills = self._resolve_channel_skills(_chan_id, _parent_id or None) _channel_prompt = self._resolve_channel_prompt(_chan_id, _parent_id or None) - reply_to_id = None reply_to_text = None if message.reference: reply_to_id = str(message.reference.message_id) if message.reference.resolved: reply_to_text = getattr(message.reference.resolved, "content", None) or None - event = MessageEvent( - text=event_text, - message_type=msg_type, - source=source, - raw_message=message, - message_id=str(message.id), - media_urls=media_urls, - media_types=media_types, - reply_to_message_id=reply_to_id, - reply_to_text=reply_to_text, - timestamp=message.created_at, - auto_skill=_skills, - channel_prompt=_channel_prompt, + text=event_text, message_type=msg_type, source=source, raw_message=message, + message_id=str(message.id), media_urls=media_urls, media_types=media_types, + reply_to_message_id=reply_to_id, reply_to_text=reply_to_text, + timestamp=message.created_at, auto_skill=_skills, channel_prompt=_channel_prompt, channel_context=_channel_context, ) - - # Track thread participation so the bot won't require @mention for - # follow-up messages in threads it has already engaged in. + # Track participation so follow-ups in this thread don't need @mention. if thread_id: self._threads.mark(thread_id) - - # Only live plain text messages use split-message batching. Recovery - # candidates are already complete historical messages; coalescing them - # would lose constituent IDs and make later restarts replay them. - if ( - not recovered - and msg_type == MessageType.TEXT - and self._text_batch_delay_seconds > 0 - ): + # Only live plain text is batched: recovery candidates are complete; coalescing would replay IDs. + if (not recovered and msg_type == MessageType.TEXT and self._text_batch_delay_seconds > 0): self._enqueue_text_event(event) else: await self.handle_message(event) @@ -8665,57 +6023,9 @@ class DiscordAdapter(BasePlatformAdapter): # Text message aggregation (handles Discord client-side splits) # ------------------------------------------------------------------ - def _text_batch_key(self, event: MessageEvent) -> str: - """Session-scoped key for text message batching. - - Passes ``event.source.profile`` through so routed messages batch - under the same namespace the agent run will use (e.g. - ``agent:crypto-trader`` instead of ``agent:main``). Without this, - the batch key would always land in ``agent:main`` even when the - routed profile differs. - """ - from gateway.session import build_session_key - return build_session_key( - event.source, - group_sessions_per_user=self.config.extra.get("group_sessions_per_user", True), - thread_sessions_per_user=self.config.extra.get("thread_sessions_per_user", False), - profile=self._session_key_profile(event.source), - ) - - def _enqueue_text_event(self, event: MessageEvent) -> None: - """Buffer a text event and reset the flush timer. - - When Discord splits a long user message at 2000 chars, the chunks - arrive within a few hundred milliseconds. This merges them into - a single event before dispatching. - """ - key = self._text_batch_key(event) - existing = self._pending_text_batches.get(key) - chunk_len = len(event.text or "") - if existing is None: - event._last_chunk_len = chunk_len # type: ignore[attr-defined] - self._pending_text_batches[key] = event - else: - if event.text: - existing.text = f"{existing.text}\n{event.text}" if existing.text else event.text - existing._last_chunk_len = chunk_len # type: ignore[attr-defined] - if event.media_urls: - existing.media_urls.extend(event.media_urls) - existing.media_types.extend(event.media_types) - - prior_task = self._pending_text_batch_tasks.get(key) - if prior_task and not prior_task.done(): - prior_task.cancel() - self._pending_text_batch_tasks[key] = asyncio.create_task( - self._flush_text_batch(key) - ) - async def _flush_text_batch(self, key: str) -> None: - """Wait for the quiet period then dispatch the aggregated text. - - Uses a longer delay when the latest chunk is near Discord's 2000-char - split point, since a continuation chunk is almost certain. - """ + """Wait for the quiet period then dispatch; longer delay when the chunk is + near Discord's 2000-char split point (continuation almost certain).""" current_task = asyncio.current_task() try: pending = self._pending_text_batches.get(key) @@ -8728,23 +6038,12 @@ class DiscordAdapter(BasePlatformAdapter): event = self._pending_text_batches.pop(key, None) if not event: return - logger.info( - "[Discord] Flushing text batch %s (%d chars)", - key, len(event.text or ""), - ) - # Shield the downstream dispatch so that a subsequent chunk - # arriving while handle_message is mid-flight cannot cancel - # the running agent turn. _enqueue_text_event always cancels - # the prior flush task when a new chunk lands; without this - # shield, CancelledError would propagate from our task down - # into handle_message → the agent's streaming request, - # aborting the response the user was waiting on. The new - # chunk is handled by the fresh flush task regardless. + logger.info("[Discord] Flushing text batch %s (%d chars)", key, len(event.text or "")) + # Shield the dispatch: _enqueue_text_event cancels the prior flush task on each new chunk; + # without the shield CancelledError would abort the in-flight agent turn. await asyncio.shield(self.handle_message(event)) except asyncio.CancelledError: - # Only reached if cancel landed before the pop — the shielded - # handle_message is unaffected either way. Let the task exit - # cleanly so the finally block cleans up. + # Cancel landed before the pop; shielded handle_message unaffected. pass finally: if self._pending_text_batch_tasks.get(key) is current_task: @@ -8757,42 +6056,21 @@ class DiscordAdapter(BasePlatformAdapter): def _component_check_auth( - interaction, - allowed_user_ids: Optional[set], - allowed_role_ids: Optional[set], + interaction, allowed_user_ids: Optional[set], allowed_role_ids: Optional[set], ) -> bool: - """Shared user-or-role OR semantics for component view button clicks. - - Mirrors the gateway's external-surface authorization model: component - button clicks must be explicitly authorized by a Discord user/role - allowlist, a global user allowlist, an explicit allow-all flag, or - the pairing store (``hermes pairing approve``). - - Behavior: - - - DISCORD_ALLOW_ALL_USERS or GATEWAY_ALLOW_ALL_USERS -> allow - - user is in DISCORD_ALLOWED_USERS or GATEWAY_ALLOWED_USERS -> allow - - role allowlist set + user has a role in it -> allow - - role allowlist set + interaction.user has no resolvable - ``roles`` attribute (e.g. DM context with a role policy active) - -> reject (fail closed) - - user is approved in the pairing store -> allow - - otherwise -> reject + """Shared user-or-role OR authorization for component button clicks. + Allow on: DISCORD/GATEWAY_ALLOW_ALL_USERS, user in DISCORD/GATEWAY_ALLOWED_USERS, a role in the + role allowlist, or pairing-store approval. Role allowlist with no ``roles`` (DM) rejects (fail closed). """ user = getattr(interaction, "user", None) if user is None or getattr(user, "id", None) is None: return False - - # Scope-aware reads (issue #72348): component interactions are dispatched - # from discord.py tasks descended from the task created inside the owning - # profile's runtime scope, so the profile's secret-scope contextvar is - # inherited here. Under multiplex a raw os.getenv could return ANOTHER - # profile's allow-all flag and authorize a click on this profile's bot. + # Scope-aware reads: interaction tasks inherit the owning profile's secret-scope contextvar; + # under multiplex a raw os.getenv could return ANOTHER profile's allow-all flag. if _scoped_gate_env("DISCORD_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"}: return True if _scoped_gate_env("GATEWAY_ALLOW_ALL_USERS").strip().lower() in {"true", "1", "yes"}: return True - user_set = {str(uid).strip() for uid in (allowed_user_ids or set()) if str(uid).strip()} global_allowed = { uid.strip() @@ -8803,24 +6081,17 @@ def _component_check_auth( role_set = set(allowed_role_ids or set()) has_users = bool(user_set) has_roles = bool(role_set) - - # Resolve user ID once for both allowlist and pairing checks. try: uid = str(user.id) except AttributeError: uid = "" - if has_users: if "*" in user_set or (uid and uid in user_set): return True - if has_roles: roles_attr = getattr(user, "roles", None) if roles_attr is None: - # Role policy is configured but the interaction doesn't - # carry role data (DM-context Member, raw User payload). - # Fail closed: a user without a resolvable role list cannot - # satisfy a role allowlist. + # Role policy configured but no role data (DM Member, raw User): fail closed. return False try: user_role_ids = {getattr(r, "id", None) for r in roles_attr} @@ -8828,10 +6099,7 @@ def _component_check_auth( return False if user_role_ids & role_set: return True - - # Check pairing store — mirrors ``authz_mixin._check_authorization`` - # so users approved via ``hermes pairing approve`` can interact with - # component buttons even without DISCORD_ALLOWED_USERS set. + # Pairing store (mirrors ``authz_mixin._check_authorization``): paired users click without allowlist. if uid: try: from gateway.pairing import PairingStore @@ -8840,32 +6108,13 @@ def _component_check_auth( return True except Exception: pass - return False -def _resolve_exec_approval_admin_gate( - config_extra: Optional[dict], -) -> Tuple[bool, set]: - """Resolve the exec-approval admin gate from a platform's ``extra`` config. - - Returns ``(require_admin, admin_user_ids)``. - - Behavior (default-OFF, opt-in): - - - ``require_admin_for_exec_approval`` absent/false -> ``(False, set())``; - exec-approval buttons stay user-scope (any admitted user can click), - which is the v0.16-restored behavior. This is the default so existing - installs are unaffected. - - toggle true -> ``(True, )``. Only - users in ``allow_admin_from`` (the same key the slash-access split - uses) may click exec-approval buttons. - - The admin id list reuses ``slash_access._coerce_id_list`` so a string, - list, or scalar all normalize identically to the slash-command gate. - Misconfiguration (toggle on, no admins listed) returns ``(True, set())`` - -> the view fails closed and logs once, rather than silently locking the - owner out without explanation. +def _resolve_exec_approval_admin_gate(config_extra: Optional[dict]) -> Tuple[bool, set]: + """Resolve the exec-approval admin gate from ``extra``; returns ``(require_admin, admin_user_ids)``. + Default OFF (user-scope buttons). When ``require_admin_for_exec_approval`` is true only + ``allow_admin_from`` ids may click; on with no admins -> ``(True, set())`` (fail closed, log once). """ extra = config_extra if isinstance(config_extra, dict) else {} raw_toggle = extra.get("require_admin_for_exec_approval", False) @@ -8882,49 +6131,82 @@ def _resolve_exec_approval_admin_gate( def _define_discord_view_classes() -> None: """Register Discord UI view classes as module globals. - - Called at module load (when discord.py is pre-installed) and also from - check_discord_requirements() after a lazy install, so view classes are - always defined whenever DISCORD_AVAILABLE is True. Without this, - ExecApprovalView and siblings are only defined at import time; a later - lazy install sets DISCORD_AVAILABLE=True but leaves the classes - undefined, causing NameError on the first button interaction. - """ + Called at module load and after a lazy install so the classes exist whenever DISCORD_AVAILABLE.""" global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView, ChoicePickerView - class ExecApprovalView(discord.ui.View): - """ - Interactive button view for exec approval of dangerous commands. + class _HermesView(discord.ui.View): + """Shared plumbing for Hermes component views: allowlist auth, single-use + ``resolved`` flag, ``_message`` handle for timeout edits.""" - Shows four buttons: Allow Once, Allow Session, Always Allow, Deny. - Clicking a button calls ``resolve_gateway_approval()`` to unblock the - waiting agent thread — the same mechanism as the text ``/approve`` flow. - Only users in the allowed list can click. Times out after 5 minutes. - """ - - def __init__( - self, - session_key: str, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, - require_admin: bool = False, - admin_user_ids: Optional[set] = None, - allow_permanent: bool = True, - allow_session: bool = True, - smart_denied: bool = False, - ): - super().__init__(timeout=_read_discord_prompt_timeout()) - self.session_key = session_key + def __init__(self, allowed_user_ids: set, allowed_role_ids: Optional[set], *, timeout): + super().__init__(timeout=timeout) self.allowed_user_ids = allowed_user_ids self.allowed_role_ids = allowed_role_ids or set() - # Opt-in admin gate for exec approval (default off → user-scope, - # the v0.16-restored behavior). When on, the clicker must be in - # ``admin_user_ids`` on top of passing the base admission check. - self.require_admin = require_admin - self.admin_user_ids = { - str(a).strip() for a in (admin_user_ids or set()) if str(a).strip() - } self.resolved = False + self._message = None + + def _check_auth(self, interaction: discord.Interaction) -> bool: + return _component_check_auth(interaction, self.allowed_user_ids, self.allowed_role_ids) + + async def _gate(self, interaction: discord.Interaction, *, resolved_msg: Optional[str], unauth_msg: str) -> bool: + """Reject (ephemerally) an already-resolved or unauthorized click; True when it may proceed.""" + if resolved_msg is not None and self.resolved: + await interaction.response.send_message(resolved_msg, ephemeral=True) + return False + if not self._check_auth(interaction): + await interaction.response.send_message(unauth_msg, ephemeral=True) + return False + return True + + def _disable_all(self) -> None: + for child in self.children: + child.disabled = True + + @staticmethod + def _first_embed(message): + return message.embeds[0] if message.embeds else None + + async def _expire_embed(self, footer: str) -> None: + """Grey out the original message's embed after a timeout (best effort).""" + msg = self._message + if msg: + try: + embed = self._first_embed(msg) + if embed: + embed.color = discord.Color.greyple() + embed.set_footer(text=footer) + await msg.edit(embed=embed, view=self) + except Exception: + pass # message deleted or too old to edit + + async def _finalize_embed(self, interaction: discord.Interaction, color, footer: str) -> None: + """Mark resolved, stamp the embed (color + footer), disable buttons, edit in place.""" + self.resolved = True + embed = self._first_embed(interaction.message) + if embed: + embed.color = color + embed.set_footer(text=footer) + self._disable_all() + await interaction.response.edit_message(embed=embed, view=self) + + async def on_timeout(self): + self.resolved = True + self._disable_all() + await self._expire_embed("⏱ Prompt expired — no action taken") + + class ExecApprovalView(_HermesView): + """Allow Once / Allow Session / Always Allow / Deny buttons for a dangerous command. + Clicks call ``resolve_gateway_approval()`` — the same mechanism as the text ``/approve`` flow.""" + + def __init__( + self, session_key: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, + require_admin: bool = False, admin_user_ids: Optional[set] = None, + allow_permanent: bool = True, allow_session: bool = True, smart_denied: bool = False, + ): + super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout()) + self.session_key = session_key + self.require_admin = require_admin + self.admin_user_ids = {str(a).strip() for a in (admin_user_ids or set()) if str(a).strip()} if smart_denied or not allow_session: self.remove_item(self.allow_session) self.remove_item(self.allow_always) @@ -8932,18 +6214,9 @@ def _define_discord_view_classes() -> None: self.remove_item(self.allow_always) def _check_auth(self, interaction: discord.Interaction) -> bool: - """Verify the user clicking is authorized. - - Base admission (allowlist / role / pairing) is always required. - When ``require_admin`` is on, the clicker must ALSO be an admin — - approving a dangerous command is gated to operators, while plain - chat and the lower-stakes component views stay user-scope. The - gate fails closed: if it's on but no admins are configured, nobody - can approve (logged once so the misconfiguration is visible). - """ - if not _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, - ): + """Base admission always required; with ``require_admin`` the clicker must + also be an admin. Fails closed (logged once) when no admins are configured.""" + if not super()._check_auth(interaction): return False if not self.require_admin: return True @@ -8964,28 +6237,16 @@ def _define_discord_view_classes() -> None: ) return False - async def _resolve( - self, interaction: discord.Interaction, choice: str, - color: discord.Color, label: str, - ): + async def _resolve(self, interaction: discord.Interaction, choice: str, color: discord.Color, label: str): """Resolve the approval via the gateway approval queue and update the embed.""" - if self.resolved: - await interaction.response.send_message( - "This approval has already been resolved~", ephemeral=True - ) + if not await self._gate( + interaction, resolved_msg="This approval has already been resolved~", + unauth_msg="You're not authorized to approve commands~", + ): 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 - - # Unblock the waiting agent thread FIRST, then render the outcome. - # A click that lands after the approval wait timed out (count == 0) - # must not claim "Approved" — the command was already denied. + # Unblock the waiting agent thread FIRST. A click after the approval + # wait timed out (count == 0) must not claim "Approved". try: from tools.approval import resolve_gateway_approval count = resolve_gateway_approval(self.session_key, choice) @@ -8996,136 +6257,48 @@ def _define_discord_view_classes() -> None: except Exception as exc: logger.error("Failed to resolve gateway approval from button: %s", exc) count = 0 - if not count: color = discord.Color.dark_grey() label = "⌛ Approval expired — command was not run (already timed out or resolved elsewhere)" - - # Update the embed with the decision - embed = interaction.message.embeds[0] if interaction.message.embeds else None - if embed: - embed.color = color - footer = f"{label} by {interaction.user.display_name}" if count else label - embed.set_footer(text=footer) - - # Disable all buttons - for child in self.children: - child.disabled = True - - await interaction.response.edit_message(embed=embed, view=self) + await self._finalize_embed( + interaction, color, f"{label} by {interaction.user.display_name}" if count else label) @discord.ui.button(label="Allow Once", style=discord.ButtonStyle.green) - async def allow_once( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def allow_once(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "once", discord.Color.green(), "Approved once") @discord.ui.button(label="Allow Session", style=discord.ButtonStyle.grey) - async def allow_session( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def allow_session(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "session", discord.Color.blue(), "Approved for session") @discord.ui.button(label="Always Allow", style=discord.ButtonStyle.blurple) - async def allow_always( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def allow_always(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "always", discord.Color.purple(), "Approved permanently") @discord.ui.button(label="Deny", style=discord.ButtonStyle.red) - async def deny( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def deny(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "deny", discord.Color.red(), "Denied") - 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 - # Visually update the Discord message so buttons appear disabled. - msg = getattr(self, '_message', None) - if msg: - try: - embed = msg.embeds[0] if msg.embeds else None - if embed: - embed.color = discord.Color.greyple() - embed.set_footer(text="⏱ Prompt expired — no action taken") - await msg.edit(embed=embed, view=self) - except Exception: - pass # message deleted or too old to edit + class SlashConfirmView(_HermesView): + """Approve Once / Always Approve / Cancel for slash-command confirmations (``/reload-mcp``, + ``GatewayRunner._request_slash_confirm``); clicks call ``tools.slash_confirm.resolve(...)``.""" - class SlashConfirmView(discord.ui.View): - """Three-button view for generic slash-command confirmations. - - Used by ``/reload-mcp`` and any future slash command routed through - ``GatewayRunner._request_slash_confirm``. Buttons map to the - gateway's three choices: - - * "Approve Once" → ``choice="once"`` - * "Always Approve" → ``choice="always"`` - * "Cancel" → ``choice="cancel"`` - - Clicking calls the module-level - ``tools.slash_confirm.resolve(session_key, confirm_id, choice)`` - which runs the handler the runner stored for this ``session_key``. - Only users in the adapter's allowlist can click. Times out after - 5 minutes (matches the gateway primitive's timeout). - """ - - def __init__( - self, - session_key: str, - confirm_id: str, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, - ): - super().__init__(timeout=_read_discord_prompt_timeout()) + def __init__(self, session_key: str, confirm_id: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None): + super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout()) self.session_key = session_key self.confirm_id = confirm_id - self.allowed_user_ids = allowed_user_ids - self.allowed_role_ids = allowed_role_ids or set() - self.resolved = False - def _check_auth(self, interaction: discord.Interaction) -> bool: - return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, - ) - - async def _resolve( - self, interaction: discord.Interaction, choice: str, - color: discord.Color, label: str, - ): - if self.resolved: - await interaction.response.send_message( - "This prompt has already been resolved~", ephemeral=True, - ) + async def _resolve(self, interaction: discord.Interaction, choice: str, color: discord.Color, label: str): + if not await self._gate( + interaction, resolved_msg="This prompt has already been resolved~", + unauth_msg="You're not authorized to answer this prompt~", + ): return - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized to answer this prompt~", ephemeral=True, - ) - return - - self.resolved = True - - embed = interaction.message.embeds[0] if interaction.message.embeds else None - if embed: - embed.color = color - embed.set_footer(text=f"{label} by {interaction.user.display_name}") - - for child in self.children: - child.disabled = True - - await interaction.response.edit_message(embed=embed, view=self) - - # Resolve via the module-level primitive. If the handler - # returns a follow-up message, post it in the same channel. + await self._finalize_embed(interaction, color, f"{label} by {interaction.user.display_name}") + # A returned follow-up message is posted in the same channel. try: from tools import slash_confirm as _slash_confirm_mod - result_text = await _slash_confirm_mod.resolve( - self.session_key, self.confirm_id, choice, - ) + result_text = await _slash_confirm_mod.resolve(self.session_key, self.confirm_id, choice) if result_text: await interaction.followup.send(result_text) logger.info( @@ -9137,170 +6310,79 @@ def _define_discord_view_classes() -> None: logger.error("Discord slash-confirm resolve failed: %s", exc, exc_info=True) @discord.ui.button(label="Approve Once", style=discord.ButtonStyle.green) - async def approve_once( - self, interaction: discord.Interaction, button: discord.ui.Button, - ): + async def approve_once(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "once", discord.Color.green(), "Approved once") @discord.ui.button(label="Always Approve", style=discord.ButtonStyle.blurple) - async def approve_always( - self, interaction: discord.Interaction, button: discord.ui.Button, - ): + async def approve_always(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "always", discord.Color.purple(), "Always approved") @discord.ui.button(label="Cancel", style=discord.ButtonStyle.red) - async def cancel( - self, interaction: discord.Interaction, button: discord.ui.Button, - ): + async def cancel(self, interaction: discord.Interaction, button: discord.ui.Button): await self._resolve(interaction, "cancel", discord.Color.greyple(), "Cancelled") - async def on_timeout(self): - self.resolved = True - for child in self.children: - child.disabled = True - # Visually update the Discord message so buttons appear disabled. - msg = getattr(self, '_message', None) - if msg: - try: - embed = msg.embeds[0] if msg.embeds else None - if embed: - embed.color = discord.Color.greyple() - embed.set_footer(text="⏱ Prompt expired — no action taken") - await msg.edit(embed=embed, view=self) - except Exception: - pass + class UpdatePromptView(_HermesView): + """Yes/No buttons for ``hermes update`` prompts; the answer is written to + ``.update_response`` for the detached update process to pick up.""" - class UpdatePromptView(discord.ui.View): - """Interactive Yes/No buttons for ``hermes update`` prompts. - - Clicking a button writes the answer to ``.update_response`` so the - detached update process can pick it up. Only authorized users can - click. Times out after 5 minutes (the update process also has a - 5-minute timeout on its side). - """ - - def __init__( - self, - session_key: str, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, - ): - super().__init__(timeout=_read_discord_prompt_timeout()) + def __init__(self, session_key: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None): + super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout()) self.session_key = session_key - self.allowed_user_ids = allowed_user_ids - self.allowed_role_ids = allowed_role_ids or set() - self.resolved = False - def _check_auth(self, interaction: discord.Interaction) -> bool: - return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, - ) - - async def _respond( - self, interaction: discord.Interaction, answer: str, - color: discord.Color, label: str, - ): - if self.resolved: - await interaction.response.send_message( - "Already answered~", ephemeral=True - ) + async def _respond(self, interaction: discord.Interaction, answer: str, color: discord.Color, label: str): + if not await self._gate(interaction, resolved_msg="Already answered~", unauth_msg="You're not authorized~"): return - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized~", ephemeral=True - ) - return - - self.resolved = True - - # Update embed - embed = interaction.message.embeds[0] if interaction.message.embeds else None - if embed: - embed.color = color - embed.set_footer(text=f"{label} by {interaction.user.display_name}") - - for child in self.children: - child.disabled = True - await interaction.response.edit_message(embed=embed, view=self) - - # Write response file + await self._finalize_embed(interaction, color, f"{label} by {interaction.user.display_name}") try: from hermes_constants import get_hermes_home - home = get_hermes_home() - response_path = home / ".update_response" + response_path = get_hermes_home() / ".update_response" tmp = response_path.with_suffix(".tmp") tmp.write_text(answer, encoding="utf-8") tmp.replace(response_path) - logger.info( - "Discord update prompt answered '%s' by %s", - answer, interaction.user.display_name, - ) + logger.info("Discord update prompt answered '%s' by %s", answer, interaction.user.display_name) except Exception as exc: logger.error("Failed to write update response: %s", exc) @discord.ui.button(label="Yes", style=discord.ButtonStyle.green, emoji="✓") - async def yes_btn( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def yes_btn(self, interaction: discord.Interaction, button: discord.ui.Button): await self._respond(interaction, "y", discord.Color.green(), "Yes") @discord.ui.button(label="No", style=discord.ButtonStyle.red, emoji="✗") - async def no_btn( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def no_btn(self, interaction: discord.Interaction, button: discord.ui.Button): await self._respond(interaction, "n", discord.Color.red(), "No") - async def on_timeout(self): - self.resolved = True - for child in self.children: - child.disabled = True - # Visually update the Discord message so buttons appear disabled. - msg = getattr(self, '_message', None) - if msg: - try: - embed = msg.embeds[0] if msg.embeds else None - if embed: - embed.color = discord.Color.greyple() - embed.set_footer(text="⏱ Prompt expired — no action taken") - await msg.edit(embed=embed, view=self) - except Exception: - pass - - class ModelPickerView(discord.ui.View): - """Interactive select-menu view for model switching. - - Two-step drill-down: provider dropdown → model dropdown. - Edits the original message in-place as the user navigates. - Times out after 2 minutes. - """ + class ModelPickerView(_HermesView): + """Two-step select-menu model picker: provider dropdown → model dropdown, + editing the original message in place. Times out after 2 minutes.""" def __init__( - self, - providers: list, - current_model: str, - current_provider: str, - session_key: str, - on_model_selected, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, + self, providers: list, current_model: str, current_provider: str, session_key: str, + on_model_selected, allowed_user_ids: set, allowed_role_ids: Optional[set] = None, ): - super().__init__(timeout=120) + super().__init__(allowed_user_ids, allowed_role_ids, timeout=120) self.providers = providers self.current_model = current_model self.current_provider = current_provider self.session_key = session_key self.on_model_selected = on_model_selected - self.allowed_user_ids = allowed_user_ids - self.allowed_role_ids = allowed_role_ids or set() - self.resolved = False self._selected_provider: str = "" self._pending_expensive_model: str = "" - self._build_provider_select() - def _check_auth(self, interaction: discord.Interaction) -> bool: - return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, + def _add_button(self, label: str, style, custom_id: str, callback) -> None: + btn = discord.ui.Button(label=label, style=style, custom_id=custom_id) + btn.callback = callback + self.add_item(btn) + + def _add_select(self, placeholder: str, options: list, custom_id: str, callback) -> None: + select = discord.ui.Select(placeholder=placeholder, options=options, custom_id=custom_id) + select.callback = callback + self.add_item(select) + + async def _edit(self, interaction: discord.Interaction, description: str, *, view=..., **embed_kw) -> None: + """Edit the picker message in place with a config embed (``view`` defaults to self).""" + await interaction.response.edit_message( + embed=self._config_embed(description, **embed_kw), view=self if view is ... else view, ) def _build_provider_select(self): @@ -9309,398 +6391,180 @@ def _define_discord_view_classes() -> None: options = [] for p in self.providers: count = p.get("total_models", len(p.get("models", []))) - label = f"{p['name']} ({count} models)" - desc = "current" if p.get("is_current") else None - options.append( - discord.SelectOption( - label=_truncate_discord_component_text( - label, - _DISCORD_SELECT_FIELD_LIMIT, - ), - value=p["slug"], - description=desc, - ) - ) + options.append(discord.SelectOption( + label=_truncate_discord_component_text(f"{p['name']} ({count} models)", _DISCORD_SELECT_FIELD_LIMIT), + value=p["slug"], description="current" if p.get("is_current") else None, + )) if not options: return - - select = discord.ui.Select( - placeholder="Choose a provider...", - options=options[:_DISCORD_SELECT_MAX_OPTIONS], - custom_id="model_provider_select", + self._add_select( + "Choose a provider...", options[:_DISCORD_SELECT_MAX_OPTIONS], "model_provider_select", + self._on_provider_selected, ) - select.callback = self._on_provider_selected - self.add_item(select) - - cancel_btn = discord.ui.Button( - label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel" - ) - cancel_btn.callback = self._on_cancel - self.add_item(cancel_btn) + self._add_button("Cancel", discord.ButtonStyle.red, "model_cancel", self._on_cancel) def _build_model_select(self, provider_slug: str): - """Build the model dropdown(s) for a specific provider. - - Discord caps each ``discord.ui.Select`` at 25 options and a View at - 5 action rows. We keep 2 rows for Back/Cancel, so partition the - model list across up to 3 select menus (75 slots) instead of - truncating at 25. This matters for providers like Nous whose - curated + Portal free-recommendation list exceeds 25 entries — the - tail (typically the ``:free`` Portal picks) was previously dropped - on Discord, so free-tier models never surfaced there. - """ + """Model dropdown(s) for one provider. + Select caps at 25 options and View at 5 rows (2 reserved for Back/Cancel), so models are + partitioned across up to 3 selects (75) rather than truncated (tail entries would vanish).""" self.clear_items() - provider = next( - (p for p in self.providers if p["slug"] == provider_slug), None - ) + provider = next((p for p in self.providers if p["slug"] == provider_slug), None) if not provider: return - models = provider.get("models", []) if not models: return - - # Slice the model list into <= 25-option chunks across (up to) 3 - # select rows: 3 selects + Back/Cancel = 5 rows, Discord's View cap. - # Providers past that would still clip, but none currently do. chunks = [ - models[ - i : i + _DISCORD_SELECT_MAX_OPTIONS - ] + models[i : i + _DISCORD_SELECT_MAX_OPTIONS] for i in range(0, len(models), _DISCORD_SELECT_MAX_OPTIONS) - ][ - : _DISCORD_SELECT_MAX_ROWS - 2 - ] # keep 2 rows for Back/Cancel - + ][: _DISCORD_SELECT_MAX_ROWS - 2] placeholder_base = f"Choose a model from {provider.get('name', provider_slug)}" for idx, chunk in enumerate(chunks): - options = [] - for model_id in chunk: - short = model_id.split("/")[-1] if "/" in model_id else model_id - options.append( - discord.SelectOption( - label=_truncate_discord_component_text( - short, - _DISCORD_SELECT_FIELD_LIMIT, - ), - value=_truncate_discord_component_text( - model_id, - _DISCORD_SELECT_FIELD_LIMIT, - ), - ) + options = [ + discord.SelectOption( + label=_truncate_discord_component_text(model_id.split("/")[-1], _DISCORD_SELECT_FIELD_LIMIT), + value=_truncate_discord_component_text(model_id, _DISCORD_SELECT_FIELD_LIMIT), ) + for model_id in chunk + ] suffix = f" ({idx + 1}/{len(chunks)})" if len(chunks) > 1 else "" - select = discord.ui.Select( - placeholder=f"{placeholder_base}{suffix}...", - options=options, - custom_id=f"model_model_select_{idx}", - ) - # All model selects resolve through the same handler — the - # selected value is the model id, identical across rows. - select.callback = self._on_model_selected - self.add_item(select) - - back_btn = discord.ui.Button( - label="◀ Back", style=discord.ButtonStyle.grey, custom_id="model_back" - ) - back_btn.callback = self._on_back - self.add_item(back_btn) - - cancel_btn = discord.ui.Button( - label="Cancel", style=discord.ButtonStyle.red, custom_id="model_cancel2" - ) - cancel_btn.callback = self._on_cancel - self.add_item(cancel_btn) + self._add_select( + f"{placeholder_base}{suffix}...", options, f"model_model_select_{idx}", self._on_model_selected) + self._add_button("◀ Back", discord.ButtonStyle.grey, "model_back", self._on_back) + self._add_button("Cancel", discord.ButtonStyle.red, "model_cancel2", self._on_cancel) def _build_expensive_confirm(self, model_id: str): """Build confirmation buttons for unusually expensive models.""" self.clear_items() self._pending_expensive_model = model_id - - confirm_btn = discord.ui.Button( - label="Switch anyway", - style=discord.ButtonStyle.red, - custom_id="model_expensive_confirm", - ) - confirm_btn.callback = self._on_expensive_confirm - self.add_item(confirm_btn) - - cancel_btn = discord.ui.Button( - label="Cancel", - style=discord.ButtonStyle.grey, - custom_id="model_expensive_cancel", - ) - cancel_btn.callback = self._on_cancel - self.add_item(cancel_btn) + self._add_button("Switch anyway", discord.ButtonStyle.red, "model_expensive_confirm", self._on_expensive_confirm) + self._add_button("Cancel", discord.ButtonStyle.grey, "model_expensive_cancel", self._on_cancel) async def _expensive_warning_for(self, model_id: str): try: from hermes_cli.model_selection_guards import combined_selection_warning - - # Pricing lookup can hit models.dev / a /models endpoint on a - # cache miss — keep it off the event loop. - return await asyncio.to_thread( - combined_selection_warning, - model_id, - provider=self._selected_provider, - ) + # Pricing lookup can hit models.dev on a cache miss — keep it off the event loop. + return await asyncio.to_thread(combined_selection_warning, model_id, provider=self._selected_provider) except Exception: return None - async def _on_provider_selected(self, interaction: discord.Interaction): - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized~", ephemeral=True - ) - return + def _config_embed(self, description: str, *, title: str = "⚙ Model Configuration", color=None): + return discord.Embed(title=title, description=description, color=discord.Color.blue() if color is None else color) + async def _on_provider_selected(self, interaction: discord.Interaction): + if not await self._gate(interaction, resolved_msg=None, unauth_msg="You're not authorized~"): + return provider_slug = interaction.data["values"][0] self._selected_provider = provider_slug - provider = next( - (p for p in self.providers if p["slug"] == provider_slug), None - ) + provider = next((p for p in self.providers if p["slug"] == provider_slug), None) pname = provider.get("name", provider_slug) if provider else provider_slug - self._build_model_select(provider_slug) - - # `shown` counts models actually rendered across the partitioned - # select menus (up to 3×25 = 75); the old code hard-capped at 25 - # and silently dropped the tail (e.g. Nous `:free` Portal picks). + # `shown` counts models actually rendered across the partitioned selects (≤ 75). total = provider.get("total_models", 0) if provider else 0 - shown = ( - min(len(provider.get("models", [])), _DISCORD_MODEL_SELECT_CAPACITY) - if provider - else 0 - ) + shown = min(len(provider.get("models", [])), _DISCORD_MODEL_SELECT_CAPACITY) if provider else 0 extra = f"\n*{total - shown} more available — type `/model ` directly*" if total > shown else "" + await self._edit(interaction, f"Provider: **{pname}**\nSelect a model:{extra}") - await interaction.response.edit_message( - embed=discord.Embed( - title="⚙ Model Configuration", - description=f"Provider: **{pname}**\nSelect a model:{extra}", - color=discord.Color.blue(), - ), - view=self, - ) - - async def _switch_selected_model( - self, - interaction: discord.Interaction, - model_id: str, - ): - if self.resolved: - await interaction.response.send_message( - "Already resolved~", ephemeral=True - ) + async def _switch_selected_model(self, interaction: discord.Interaction, model_id: str): + if not await self._gate(interaction, resolved_msg="Already resolved~", unauth_msg="You're not authorized~"): return - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized~", ephemeral=True - ) - return - self.resolved = True self.clear_items() - await interaction.response.edit_message( - embed=discord.Embed( - title="⚙ Switching Model", - description=f"Switching to `{model_id}`...", - color=discord.Color.blue(), - ), - view=None, - ) - + await self._edit(interaction, f"Switching to `{model_id}`...", title="⚙ Switching Model", view=None) try: - result_text = await self.on_model_selected( - str(interaction.channel_id), - model_id, - self._selected_provider, - ) + result_text = await self.on_model_selected(str(interaction.channel_id), model_id, self._selected_provider) except Exception as exc: result_text = f"Error switching model: {exc}" - await interaction.edit_original_response( - embed=discord.Embed( - title="⚙ Model Switched", - description=result_text, - color=discord.Color.green(), - ), + embed=self._config_embed(result_text, title="⚙ Model Switched", color=discord.Color.green()), view=None, ) async def _on_model_selected(self, interaction: discord.Interaction): - if self.resolved: - await interaction.response.send_message( - "Already resolved~", ephemeral=True - ) + if not await self._gate(interaction, resolved_msg="Already resolved~", unauth_msg="You're not authorized~"): return - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized~", ephemeral=True - ) - return - model_id = interaction.data["values"][0] warning = await self._expensive_warning_for(model_id) if warning is not None: self._build_expensive_confirm(model_id) - await interaction.response.edit_message( - embed=discord.Embed( - title=f"⚠ {warning.title}", - description=warning.message, - color=discord.Color.red(), - ), - view=self, - ) + await self._edit(interaction, warning.message, title=f"⚠ {warning.title}", color=discord.Color.red()) return - await self._switch_selected_model(interaction, model_id) async def _on_expensive_confirm(self, interaction: discord.Interaction): - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized~", ephemeral=True - ) + if not await self._gate(interaction, resolved_msg=None, unauth_msg="You're not authorized~"): return if not self._pending_expensive_model: - await interaction.response.send_message( - "Model selection expired.", ephemeral=True - ) + await interaction.response.send_message("Model selection expired.", ephemeral=True) return - await self._switch_selected_model( - interaction, - self._pending_expensive_model, - ) + await self._switch_selected_model(interaction, self._pending_expensive_model) async def _on_back(self, interaction: discord.Interaction): - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized~", ephemeral=True - ) + if not await self._gate(interaction, resolved_msg=None, unauth_msg="You're not authorized~"): return - self._build_provider_select() - try: from hermes_cli.providers import get_label provider_label = get_label(self.current_provider) except Exception: provider_label = self.current_provider - - await interaction.response.edit_message( - embed=discord.Embed( - title="⚙ Model Configuration", - description=( - f"Current model: `{self.current_model or 'unknown'}`\n" - f"Provider: {provider_label}\n\n" - f"Select a provider:" - ), - color=discord.Color.blue(), - ), - view=self, + await self._edit( + interaction, + f"Current model: `{self.current_model or 'unknown'}`\nProvider: {provider_label}\n\nSelect a provider:", ) async def _on_cancel(self, interaction: discord.Interaction): self.resolved = True self.clear_items() - await interaction.response.edit_message( - embed=discord.Embed( - title="⚙ Model Configuration", - description="Model selection cancelled.", - color=discord.Color.greyple(), - ), - view=self, - ) + await self._edit(interaction, "Model selection cancelled.", color=discord.Color.greyple()) async def on_timeout(self): self.resolved = True self.clear_items() - # Visually update the Discord message so it appears expired. - msg = getattr(self, '_message', None) + msg = self._message if msg: try: - embed = discord.Embed( - title="⚙ Model Configuration", - description="⏱ Selection expired — no model change.", - color=discord.Color.greyple(), - ) + embed = self._config_embed("⏱ Selection expired — no model change.", color=discord.Color.greyple()) await msg.edit(embed=embed, view=self) except Exception: pass + class ChoicePickerView(_HermesView): + """Flat single-select picker for finite-choice commands (/reasoning, /fast); 2-minute timeout.""" - class ChoicePickerView(discord.ui.View): - """Flat select-menu view for finite-choice commands (/reasoning, /fast). - - One dropdown, one selection, done — the generic single-level companion - to ``ModelPickerView``. Auth gating mirrors ``ExecApprovalView``. - Times out after 2 minutes. - """ - - def __init__( - self, - choices: list, - on_choice_selected, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, - ): - super().__init__(timeout=120) + def __init__(self, choices: list, on_choice_selected, allowed_user_ids: set, allowed_role_ids: Optional[set] = None): + super().__init__(allowed_user_ids, allowed_role_ids, timeout=120) self.choices = list(choices)[:_DISCORD_SELECT_MAX_OPTIONS] self.on_choice_selected = on_choice_selected - self.allowed_user_ids = allowed_user_ids - self.allowed_role_ids = allowed_role_ids or set() - self.resolved = False - self._message = None - options = [] for choice in self.choices: label = str(choice.get("label") or choice.get("value") or "") options.append( discord.SelectOption( - label=_truncate_discord_component_text( - label, _DISCORD_SELECT_FIELD_LIMIT - ), + label=_truncate_discord_component_text(label, _DISCORD_SELECT_FIELD_LIMIT), value=str(choice.get("value") or ""), description="current" if choice.get("is_current") else None, ) ) - select = discord.ui.Select( - placeholder="Choose an option...", - options=options, - ) + select = discord.ui.Select(placeholder="Choose an option...", options=options) select.callback = self._on_select self.add_item(select) - def _check_auth(self, interaction: discord.Interaction) -> bool: - return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, - ) - async def _on_select(self, interaction: discord.Interaction): if not self._check_auth(interaction): - await interaction.response.send_message( - "⛔ You are not authorized to change this setting.", - ephemeral=True, - ) + await interaction.response.send_message("⛔ You are not authorized to change this setting.", ephemeral=True) return if self.resolved: await interaction.response.defer() return self.resolved = True - value = interaction.data.get("values", [""])[0] try: - result_text = await self.on_choice_selected( - str(interaction.channel_id), value - ) + result_text = await self.on_choice_selected(str(interaction.channel_id), value) except Exception as exc: logger.error("Choice picker selection failed: %s", exc) result_text = f"Error applying selection: {exc}" - - embed = discord.Embed( - description=result_text, - color=discord.Color.green(), - ) + embed = discord.Embed(description=result_text, color=discord.Color.green()) self.clear_items() self.stop() await interaction.response.edit_message(embed=embed, view=self) @@ -9711,160 +6575,90 @@ def _define_discord_view_classes() -> None: msg = self._message if msg is not None: try: - embed = discord.Embed( - description="⏱ Selection expired — no change made.", - color=discord.Color.greyple(), - ) + embed = discord.Embed(description="⏱ Selection expired — no change made.", color=discord.Color.greyple()) self.clear_items() await msg.edit(embed=embed, view=self) except Exception: pass - class ClarifyChoiceView(discord.ui.View): - """Interactive button view for the clarify tool's multiple-choice prompts. + class ClarifyChoiceView(_HermesView): + """One button per clarify choice (max 24) plus ``✏️ Other``. A numeric click resolves the + gateway clarify entry immediately; ``Other`` flips to text-capture (next message answers). + Single-use: after the first valid click all buttons disable.""" - Renders one button per choice (max 24) plus a final ``✏️ Other`` button. - Picking a numeric choice resolves the gateway clarify entry immediately; - picking ``Other`` flips the entry into text-capture mode so the next - user message in the session becomes the response (the gateway's - text-intercept handles the resolution). - - Auth gating mirrors ``ExecApprovalView`` — only users/roles in the - Discord adapter's allowlist may answer. Single-use: after the first - valid click all buttons disable and the embed updates to show who - answered and what they chose. - """ - - def __init__( - self, - choices: List[str], - clarify_id: str, - allowed_user_ids: set, - allowed_role_ids: Optional[set] = None, - ): - super().__init__(timeout=_read_discord_prompt_timeout()) + def __init__(self, choices: List[str], clarify_id: str, allowed_user_ids: set, allowed_role_ids: Optional[set] = None): + super().__init__(allowed_user_ids, allowed_role_ids, timeout=_read_discord_prompt_timeout()) self.choices = list(choices)[:24] self.clarify_id = clarify_id - self.allowed_user_ids = allowed_user_ids - self.allowed_role_ids = allowed_role_ids or set() - self.resolved = False - for index, choice in enumerate(self.choices): - # Discord button labels are capped at 80 chars. On mobile the - # visible width is much narrower (often <40 chars before it - # wraps to 2 lines and the second line gets cut off), so we - # cap aggressively and cut at a word boundary when possible - # to keep the trailing text readable. - # - # Cut strategy (most-preferred to least-preferred): - # 1. Last space in the trailing half of the budget - # (cleanest word boundary) - # 2. Last soft boundary in the trailing half of the - # budget (hyphen, comma, period, paren) - # 3. Hard cut at the budget limit (last resort) - prefix = f"{index + 1}. " - budget = _DISCORD_BUTTON_LABEL_LIMIT - utf16_len(prefix) - if utf16_len(choice) <= budget: - label_body = choice - else: - truncated = _prefix_within_utf16_limit( - choice, - max(0, budget - utf16_len(_DISCORD_ELLIPSIS)), - ).rstrip() - cut_at = -1 - # 1. Last space in the trailing half of the budget. - space = truncated.rfind(" ") - if space >= len(truncated) // 2: - cut_at = space - # 2. Soft boundary — only if no word boundary found. - # Find the latest soft boundary in the trailing half - # of the budget; that maximizes preserved text length. - # Cut AT the soft boundary (inclusive) so the label - # ends on the soft char (e.g. "-" or ",") rather than - # on the alpha char that followed it. - if cut_at < 0: - latest_soft = max( - (truncated.rfind(s) for s in ("-", ",", ".", ")")), - default=-1, - ) - if latest_soft >= len(truncated) // 2: - cut_at = latest_soft + 1 - if cut_at > 0: - truncated = truncated[:cut_at] - label_body = truncated.rstrip() + _DISCORD_ELLIPSIS button = discord.ui.Button( - label=f"{prefix}{label_body}", - style=discord.ButtonStyle.primary, + label=self._button_label(index, choice), style=discord.ButtonStyle.primary, custom_id=f"clarify:{clarify_id}:{index}", ) button.callback = self._make_choice_callback(index, choice) self.add_item(button) - other_btn = discord.ui.Button( - label="✏️ Other (type answer)", - style=discord.ButtonStyle.secondary, + label="✏️ Other (type answer)", style=discord.ButtonStyle.secondary, custom_id=f"clarify:{clarify_id}:other", ) other_btn.callback = self._on_other self.add_item(other_btn) - def _check_auth(self, interaction: "discord.Interaction") -> bool: - return _component_check_auth( - interaction, self.allowed_user_ids, self.allowed_role_ids, - ) + @staticmethod + def _button_label(index: int, choice: str) -> str: + """``"N. "`` within Discord's 80-char (UTF-16) label cap. + Mobile wraps early, so long choices cut at a word boundary in the trailing half, else a + soft boundary (``- , . )``, inclusive), else hard.""" + prefix = f"{index + 1}. " + budget = _DISCORD_BUTTON_LABEL_LIMIT - utf16_len(prefix) + if utf16_len(choice) <= budget: + return f"{prefix}{choice}" + truncated = _prefix_within_utf16_limit(choice, max(0, budget - utf16_len(_DISCORD_ELLIPSIS))).rstrip() + cut_at = -1 + space = truncated.rfind(" ") + if space >= len(truncated) // 2: + cut_at = space + if cut_at < 0: + latest_soft = max((truncated.rfind(s) for s in ("-", ",", ".", ")")), default=-1) + if latest_soft >= len(truncated) // 2: + cut_at = latest_soft + 1 + if cut_at > 0: + truncated = truncated[:cut_at] + return f"{prefix}{truncated.rstrip() + _DISCORD_ELLIPSIS}" def _make_choice_callback(self, index: int, choice: str): async def _callback(interaction: "discord.Interaction"): await self._resolve_choice(interaction, index, choice) return _callback - async def _resolve_choice( - self, - interaction: "discord.Interaction", - index: int, - choice: str, - ) -> None: - """Resolve the clarify with a chosen option.""" - if self.resolved: - await interaction.response.send_message( - "This prompt has already been answered~", ephemeral=True, - ) - return - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized to answer this prompt~", ephemeral=True, - ) - return - + async def _finish(self, interaction: "discord.Interaction", color, footer: str, *, log_edit_failure: bool) -> None: + """Disable the buttons and stamp the embed; fall back to a bare defer.""" self.resolved = True - for child in self.children: - child.disabled = True - - embed = interaction.message.embeds[0] if ( - interaction.message and interaction.message.embeds - ) else None + self._disable_all() + embed = self._first_embed(interaction.message) if interaction.message else None if embed: - user = getattr(interaction, "user", None) - display_name = getattr(user, "display_name", "user") - embed.color = discord.Color.green() - embed.set_footer(text=f"Answered by {display_name}: {choice}") - + embed.color = color + embed.set_footer(text=footer) try: await interaction.response.edit_message(embed=embed, view=self) except Exception: - logger.debug( - "Discord clarify edit_message failed for %s", - self.clarify_id, - exc_info=True, - ) + if log_edit_failure: + logger.debug("Discord clarify edit_message failed for %s", self.clarify_id, exc_info=True) try: await interaction.response.defer() except Exception: pass - # Resolve via the gateway clarify primitive — same mechanism as - # Telegram. Look up the canonical choice text from the entry so - # we round-trip the original value, not a button-label variant. + async def _resolve_choice(self, interaction: "discord.Interaction", index: int, choice: str) -> None: + """Resolve the clarify with a chosen option.""" + if not await self._gate( + interaction, resolved_msg="This prompt has already been answered~", + unauth_msg="You're not authorized to answer this prompt~", + ): + return + display_name = getattr(getattr(interaction, "user", None), "display_name", "user") + await self._finish(interaction, discord.Color.green(), f"Answered by {display_name}: {choice}", log_edit_failure=True) + # Round-trip the canonical choice text from the entry, not the button label. resolved_text: Optional[str] = None try: from tools.clarify_gateway import _entries as _clarify_entries # type: ignore @@ -9875,102 +6669,42 @@ def _define_discord_view_classes() -> None: resolved_text = None if resolved_text is None: resolved_text = choice - try: from tools.clarify_gateway import resolve_gateway_clarify resolved = resolve_gateway_clarify(self.clarify_id, resolved_text) logger.info( "Discord clarify button resolved (id=%s, choice=%r, user=%s, ok=%s)", self.clarify_id, resolved_text, - getattr(getattr(interaction, "user", None), "display_name", "?"), - resolved, + getattr(getattr(interaction, "user", None), "display_name", "?"), resolved, ) except Exception as exc: - logger.error( - "Discord clarify resolve_gateway_clarify failed (id=%s): %s", - self.clarify_id, exc, - ) + logger.error("Discord clarify resolve_gateway_clarify failed (id=%s): %s", self.clarify_id, exc) async def _on_other(self, interaction: "discord.Interaction") -> None: """Flip the clarify entry into text-capture mode.""" - if self.resolved: - await interaction.response.send_message( - "This prompt has already been answered~", ephemeral=True, - ) + if not await self._gate( + interaction, resolved_msg="This prompt has already been answered~", + unauth_msg="You're not authorized to answer this prompt~", + ): return - if not self._check_auth(interaction): - await interaction.response.send_message( - "You're not authorized to answer this prompt~", ephemeral=True, - ) - return - - # Don't pop the entry — the gateway's text-intercept needs it - # until the user actually types. Just mark it as awaiting text - # and disable the buttons so the user can't double-click. + # Don't pop: the gateway text-intercept needs the entry until the user types. try: from tools.clarify_gateway import mark_awaiting_text mark_awaiting_text(self.clarify_id) except Exception as exc: - logger.warning( - "Discord clarify mark_awaiting_text failed (id=%s): %s", - self.clarify_id, exc, - ) + logger.warning("Discord clarify mark_awaiting_text failed (id=%s): %s", self.clarify_id, exc) + display_name = getattr(getattr(interaction, "user", None), "display_name", "user") + await self._finish(interaction, discord.Color.blue(), f"Awaiting typed response from {display_name}…", log_edit_failure=False) - self.resolved = True - for child in self.children: - child.disabled = True - - embed = interaction.message.embeds[0] if ( - interaction.message and interaction.message.embeds - ) else None - if embed: - user = getattr(interaction, "user", None) - display_name = getattr(user, "display_name", "user") - embed.color = discord.Color.blue() - embed.set_footer( - text=f"Awaiting typed response from {display_name}…", - ) - - try: - await interaction.response.edit_message(embed=embed, view=self) - except Exception: - try: - await interaction.response.defer() - except Exception: - pass - - async def on_timeout(self): - self.resolved = True - for child in self.children: - child.disabled = True - # Visually update the Discord message so buttons appear disabled. - msg = getattr(self, '_message', None) - if msg: - try: - embed = msg.embeds[0] if msg.embeds else None - if embed: - embed.color = discord.Color.greyple() - embed.set_footer(text="⏱ Prompt expired — no action taken") - await msg.edit(embed=embed, view=self) - except Exception: - pass if DISCORD_AVAILABLE: _define_discord_view_classes() # ── Standalone (out-of-process) sender ──────────────────────────────────────── -# Used by ``tools/send_message_tool._send_via_adapter`` when the gateway runner -# is not in this process (e.g. ``hermes cron`` running standalone) and no live -# DiscordAdapter instance is available. Implements the same forum/thread/ -# multipart logic the live adapter would use, via Discord's REST API directly. -# -# This block was previously hosted in ``tools/send_message_tool.py`` as -# ``_send_discord``. It moved into the plugin so all Discord-specific HTTP -# logic lives next to the adapter — same shape as Teams' ``_standalone_send``. +# Used by ``tools/send_message_tool._send_via_adapter`` when no live DiscordAdapter is in this +# process (e.g. standalone ``hermes cron``); same forum/thread/multipart logic via Discord REST. -# Process-local cache for Discord channel-type probes. Avoids re-probing the -# same channel on every send when the directory cache has no entry (e.g. fresh -# install, or channel created after the last directory build). +# Process-local channel-type probe cache: avoids re-probing every send when the directory cache misses. _DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {} _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES = 1 * 1024 * 1024 _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES = 8 * 1024 @@ -9987,7 +6721,6 @@ def _probe_is_forum_cached(chat_id: str) -> Optional[bool]: def _derive_forum_thread_name(message: str) -> str: """Derive a thread name from the first line of the message, capped at 100 chars.""" first_line = message.strip().split("\n", 1)[0].strip() - # Strip common markdown heading prefixes first_line = first_line.lstrip("#").strip() if not first_line: first_line = "New Post" @@ -9995,19 +6728,10 @@ def _derive_forum_thread_name(message: str) -> str: def _standalone_sanitize_error(text) -> str: - """Local copy of tools.send_message_tool._sanitize_error_text — strips bot - tokens from any error payload before bubbling it up. Inlined so the - plugin doesn't introduce a hard dependency on send_message_tool internals. - """ + """Local copy of tools.send_message_tool._sanitize_error_text (strips bot tokens); avoids hard dep.""" s = str(text) - # Mask anything that looks like a Bot token in an Authorization header. import re as _re_san - return _re_san.sub( - r"(Authorization:\s*Bot\s+)\S+", - r"\1***", - s, - flags=_re_san.IGNORECASE, - ) + return _re_san.sub(r"(Authorization:\s*Bot\s+)\S+", r"\1***", s, flags=_re_san.IGNORECASE) def _standalone_close_response(resp: Any) -> None: @@ -10021,21 +6745,14 @@ def _standalone_close_response(resp: Any) -> None: async def _standalone_read_response_bytes_limited( - resp: Any, - limit_bytes: int, + resp: Any, limit_bytes: int, ) -> Tuple[Optional[bytes], bool]: - """Read at most *limit_bytes* from an aiohttp-style response body. - - Returns ``(body, truncated)``. Returns ``(None, False)`` when the response - object does not expose a streaming ``content.read`` coroutine (e.g. a - proxy wrapper or test double) — callers fall back to the object's own - ``json()`` / ``text()`` in that case. - """ + """Read at most *limit_bytes*; returns ``(body, truncated)``. ``(None, False)`` when the object + has no streaming ``content.read`` coroutine (proxy/test double) — callers use ``json()``/``text()``.""" content = getattr(resp, "content", None) read = getattr(content, "read", None) if content is None or not inspect.iscoroutinefunction(read): return None, False - try: chunks: list[bytes] = [] total = 0 @@ -10052,8 +6769,7 @@ async def _standalone_read_response_bytes_limited( return b"".join(chunks)[:limit_bytes], True return b"".join(chunks), False except (TypeError, AttributeError): - # Object quacked like a stream but wasn't one — let the caller use - # its native json()/text() instead of failing the send. + # Quacked like a stream but wasn't — caller uses native json()/text(). return None, False @@ -10086,49 +6802,67 @@ async def _standalone_read_json_limited(resp: Any, limit_bytes: int) -> dict: return data if isinstance(data, dict) else {} +def _standalone_warn_missing_media(media_path: str) -> str: + warning = f"Media file not found, skipping: {media_path}" + logger.warning(warning) + return warning + + +async def _standalone_response_json_or_error(resp: Any, error_prefix: str): + """``(data, None)`` for a 200/201 JSON response, else ``(None, {"error": ...})`` + with the (size-capped) body text appended to ``error_prefix``.""" + if resp.status not in {200, 201}: + body = await _standalone_read_text_limited(resp, _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES) + return None, {"error": f"{error_prefix} ({resp.status}): {body}"} + return await _standalone_read_json_limited(resp, _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES), None + + +async def _standalone_is_forum(aiohttp, chat_id: str, json_headers: dict, sess_kw: dict, req_kw: dict) -> bool: + """Forum detection: channel directory → process-local probe cache → memoized ``GET /channels/{id}``.""" + _channel_type = None + try: + from gateway.channel_directory import lookup_channel_type + _channel_type = lookup_channel_type("discord", chat_id) + except Exception: + pass + if _channel_type is not None: + return _channel_type == "forum" + cached = _probe_is_forum_cached(chat_id) + if cached is not None: + return cached + is_forum = False + try: + info_url = f"https://discord.com/api/v10/channels/{chat_id}" + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **sess_kw) as info_sess: + async with info_sess.get(info_url, headers=json_headers, **req_kw) as info_resp: + if info_resp.status == 200: + info = await _standalone_read_json_limited(info_resp, _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES) + is_forum = info.get("type") == 15 + _remember_channel_is_forum(chat_id, is_forum) + except Exception: + logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True) + return is_forum + + async def _standalone_send( - pconfig, - chat_id: str, - message: str, - *, - thread_id: Optional[str] = None, - media_files: Optional[list] = None, - force_document: bool = False, - caption: Optional[str] = None, + pconfig, chat_id: str, message: str, *, thread_id: Optional[str] = None, + media_files: Optional[list] = None, force_document: bool = False, caption: Optional[str] = None, ) -> Dict[str, Any]: - """Send via Discord REST API without a live gateway adapter. - - Used by ``tools/send_message_tool._send_via_adapter`` when the gateway - runner is not in this process. Reads ``DISCORD_BOT_TOKEN`` from - ``pconfig.token`` (set by the gateway config loader from env) and falls - back to the ``DISCORD_BOT_TOKEN`` env var. - - Forum channels (type 15) reject ``POST /messages`` — a thread post is - created automatically via ``POST /channels/{id}/threads``. Media files - are uploaded as multipart attachments on the starter message of the new - thread. Channel type is resolved from the channel directory first, then - a process-local probe cache, and only as a last resort with a live - ``GET /channels/{id}`` probe (whose result is memoized). - - ``force_document`` is accepted for signature parity but unused — Discord - treats every uploaded file as a generic attachment. - """ + """Send via Discord REST without a live gateway adapter (token: ``pconfig.token`` then env var). + Forum channels (type 15) reject ``POST /messages``, so a thread post is created via + ``POST /channels/{id}/threads`` with media as multipart attachments. Channel type: directory + cache → process-local probe cache → memoized GET. ``force_document`` accepted but unused.""" try: import aiohttp except ImportError: return {"error": "aiohttp not installed. Run: pip install aiohttp"} - token = (getattr(pconfig, "token", None) or "").strip() if not token: - # Profile-scoped read: under multiplex the process env may hold a - # different profile's bot token, so honor the secret scope's verdict - # (scoped miss ⇒ no token; unscoped multiplex ⇒ UnscopedSecretError). + # Profile-scoped read: under multiplex the env may hold another profile's token. from agent.secret_scope import get_secret - token = (get_secret("DISCORD_BOT_TOKEN", "") or "").strip() if not token: return {"error": "Discord standalone send: DISCORD_BOT_TOKEN is not set"} - try: from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") @@ -10138,167 +6872,79 @@ async def _standalone_send( media_files = media_files or [] last_data = None warnings = [] - - # Thread endpoint: Discord threads are channels; send directly to the thread ID. if thread_id: url = f"https://discord.com/api/v10/channels/{thread_id}/messages" else: - # Check if the target channel is a forum channel (type 15). - # Forum channels reject POST /messages — create a thread post instead. - # Three-layer detection: directory cache → process-local probe - # cache → GET /channels/{id} probe (with result memoized). - _channel_type = None - try: - from gateway.channel_directory import lookup_channel_type - _channel_type = lookup_channel_type("discord", chat_id) - except Exception: - pass - - if _channel_type == "forum": - is_forum = True - elif _channel_type is not None: - is_forum = False - else: - cached = _probe_is_forum_cached(chat_id) - if cached is not None: - is_forum = cached - else: - is_forum = False - try: - info_url = f"https://discord.com/api/v10/channels/{chat_id}" - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess: - async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp: - if info_resp.status == 200: - info = await _standalone_read_json_limited( - info_resp, - _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, - ) - is_forum = info.get("type") == 15 - _remember_channel_is_forum(chat_id, is_forum) - except Exception: - logger.debug("Failed to probe channel type for %s", chat_id, exc_info=True) - - if is_forum: + # Forum channels (type 15) reject POST /messages — create a thread post. + if await _standalone_is_forum(aiohttp, chat_id, json_headers, _sess_kw, _req_kw): thread_name = _derive_forum_thread_name(message) thread_url = f"https://discord.com/api/v10/channels/{chat_id}/threads" - - # Filter to readable media files up front so we can pick the - # right code path (JSON vs multipart) before opening a session. + # Filter readable media first to pick JSON vs multipart before opening a session. valid_media = [] for media_path, _is_voice in media_files: if not os.path.exists(media_path): - warning = f"Media file not found, skipping: {media_path}" - logger.warning(warning) - warnings.append(warning) + warnings.append(_standalone_warn_missing_media(media_path)) continue valid_media.append(media_path) - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=60), **_sess_kw) as session: if valid_media: - # Multipart: payload_json + files[N] creates a forum - # thread with the starter message plus attachments in - # a single API call. + # Multipart payload_json + files[N]: thread + starter + attachments in one call. attachments_meta = [ {"id": str(idx), "filename": os.path.basename(path)} for idx, path in enumerate(valid_media) ] starter_message = {"content": (caption or message), "attachments": attachments_meta} payload_json = json.dumps({"name": thread_name, "message": starter_message}) - form = aiohttp.FormData() form.add_field("payload_json", payload_json, content_type="application/json") - try: for idx, media_path in enumerate(valid_media): with open(media_path, "rb") as fh: form.add_field( - f"files[{idx}]", - fh.read(), + f"files[{idx}]", fh.read(), filename=os.path.basename(media_path), ) async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in {200, 201}: - body = await _standalone_read_text_limited( - resp, - _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, - ) - return {"error": f"Discord forum thread creation error ({resp.status}): {body}"} - data = await _standalone_read_json_limited( - resp, - _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, - ) + data, err = await _standalone_response_json_or_error(resp, "Discord forum thread creation error") + if err: + return err except Exception as e: return {"error": _standalone_sanitize_error(f"Discord forum thread upload failed: {e}")} else: - # No media — simple JSON POST creates the thread with - # just the text starter. + # No media: JSON POST creates the thread with the text starter. async with session.post( - thread_url, - headers=json_headers, - json={ - "name": thread_name, - "message": {"content": message}, - }, - **_req_kw, + thread_url, headers=json_headers, + json={"name": thread_name, "message": {"content": message}}, **_req_kw, ) as resp: - if resp.status not in {200, 201}: - body = await _standalone_read_text_limited( - resp, - _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, - ) - return {"error": f"Discord forum thread creation error ({resp.status}): {body}"} - data = await _standalone_read_json_limited( - resp, - _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, - ) - + data, err = await _standalone_response_json_or_error(resp, "Discord forum thread creation error") + if err: + return err thread_id_created = data.get("id") starter_msg_id = (data.get("message") or {}).get("id", thread_id_created) result = { - "success": True, - "platform": "discord", - "chat_id": chat_id, - "thread_id": thread_id_created, - "message_id": starter_msg_id, + "success": True, "platform": "discord", "chat_id": chat_id, + "thread_id": thread_id_created, "message_id": starter_msg_id, } if warnings: result["warnings"] = warnings return result - url = f"https://discord.com/api/v10/channels/{chat_id}/messages" - async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: - # Send text message (skip if empty and media is present) if message.strip() or not media_files: async with session.post(url, headers=json_headers, json={"content": message}, **_req_kw) as resp: - if resp.status not in {200, 201}: - body = await _standalone_read_text_limited( - resp, - _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, - ) - return {"error": f"Discord API error ({resp.status}): {body}"} - last_data = await _standalone_read_json_limited( - resp, - _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, - ) - - # Send each media file as a separate multipart upload. When a - # MEDIA: caption was supplied, ride it as the message content - # on the attachment so it appears under the media bubble instead of - # as a separate message. caption_pending tracks whether the caption - # still needs delivering, so a missing file falls back to a plain - # message rather than silently dropping the text. + last_data, err = await _standalone_response_json_or_error(resp, "Discord API error") + if err: + return err + # One multipart upload per file; a MEDIA: caption rides as the attachment message's + # content, and caption_pending makes a missing file fall back to a plain message. caption_pending = bool(caption) for media_path, _is_voice in media_files: if not os.path.exists(media_path): - warning = f"Media file not found, skipping: {media_path}" - logger.warning(warning) - warnings.append(warning) + warnings.append(_standalone_warn_missing_media(media_path)) if caption_pending: try: async with session.post( - url, headers=json_headers, - json={"content": caption}, **_req_kw, + url, headers=json_headers, json={"content": caption}, **_req_kw, ) as resp: if resp.status in {200, 201}: last_data = await _standalone_read_json_limited( @@ -10313,45 +6959,35 @@ async def _standalone_send( filename = os.path.basename(media_path) if caption_pending: form.add_field( - "payload_json", - json.dumps({"content": caption}), + "payload_json", json.dumps({"content": caption}), content_type="application/json", ) caption_pending = False with open(media_path, "rb") as f: form.add_field("files[0]", f, filename=filename) async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp: - if resp.status not in {200, 201}: - body = await _standalone_read_text_limited( - resp, - _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES, - ) - warning = _standalone_sanitize_error(f"Failed to send media {media_path}: Discord API error ({resp.status}): {body}") + data, err = await _standalone_response_json_or_error(resp, "Discord API error") + if err: + warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {err['error']}") logger.error(warning) warnings.append(warning) continue - last_data = await _standalone_read_json_limited( - resp, - _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES, - ) + last_data = data except Exception as e: warning = _standalone_sanitize_error(f"Failed to send media {media_path}: {e}") logger.error(warning) warnings.append(warning) - if last_data is None: error = "No deliverable text or media remained after processing" if warnings: return {"error": error, "warnings": warnings} return {"error": error} - result = {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": last_data.get("id")} if warnings: result["warnings"] = warnings return result except Exception as e: - # Include the exception type: TimeoutError().str() is empty, so - # "Discord send failed: " alone gave no diagnostic signal. + # Include the exception type: str(TimeoutError()) is empty. logger.error("Discord standalone send failed", exc_info=True) return {"error": _standalone_sanitize_error(f"Discord send failed: {type(e).__name__}: {e}")} @@ -10374,20 +7010,18 @@ def _clean_discord_user_ids(raw: str) -> list: def interactive_setup() -> None: - """Guide the user through Discord bot setup. - - Mirrors Teams' ``interactive_setup`` shape: lazy-imports CLI helpers so - the plugin's import surface stays small, prompts for the bot token, - captures an allowlist, and offers to set a home channel. - """ + """Guide the user through Discord bot setup: token, allowlist, home channel (lazy CLI imports).""" from hermes_cli.config import get_env_value, remove_env_value, save_env_value from hermes_cli.cli_output import ( - prompt, - prompt_yes_no, - print_header, - print_info, - print_success, + prompt, prompt_yes_no, print_header, print_info, print_success, ) + def _info_lines(*lines: str) -> None: + for line in lines: + print_info(line) + + def _save_allowlist(allowed_users: str) -> None: + save_env_value("DISCORD_ALLOWED_USERS", ",".join(_clean_discord_user_ids(allowed_users))) + print_success("Discord allowlist configured") print_header("Discord") existing = get_env_value("DISCORD_BOT_TOKEN") @@ -10404,93 +7038,82 @@ def interactive_setup() -> None: print_info(" To find Discord ID: Enable Developer Mode, right-click name → Copy ID") allowed_users = prompt("Allowed user IDs (comma-separated)") if allowed_users: - cleaned_ids = _clean_discord_user_ids(allowed_users) - save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) - print_success("Discord allowlist configured") + _save_allowlist(allowed_users) return - - print_info("Create a bot at https://discord.com/developers/applications") - print_info("On Bot → Privileged Gateway Intents, enable:") - print_info(" - Message Content Intent (required — without it Discord rejects the connection)") - print_info(" - Server Members Intent (required if you use usernames or role allowlists)") - print_info("Save Changes in the Developer Portal before starting the gateway.") - print_info( - "Docs: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord" + _info_lines( + "Create a bot at https://discord.com/developers/applications", + "On Bot → Privileged Gateway Intents, enable:", + " - Message Content Intent (required — without it Discord rejects the connection)", + " - Server Members Intent (required if you use usernames or role allowlists)", + "Save Changes in the Developer Portal before starting the gateway.", + "Docs: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/discord", ) token = prompt("Discord bot token", password=True) if not token: return save_env_value("DISCORD_BOT_TOKEN", token) print_success("Discord token saved") - print() - print_info("🔒 Security: Restrict who can use your bot") - print_info(" To find your Discord user ID:") - print_info(" 1. Enable Developer Mode in Discord settings") - print_info(" 2. Right-click your name → Copy ID") + _info_lines( + "🔒 Security: Restrict who can use your bot", " To find your Discord user ID:", + " 1. Enable Developer Mode in Discord settings", " 2. Right-click your name → Copy ID", + ) print() print_info(" You can also use Discord usernames (resolved on gateway start).") print() - allowed_users = prompt( - "Allowed user IDs or usernames (comma-separated, leave empty for open access)" - ) + allowed_users = prompt("Allowed user IDs or usernames (comma-separated, leave empty for open access)") if allowed_users: - cleaned_ids = _clean_discord_user_ids(allowed_users) - save_env_value("DISCORD_ALLOWED_USERS", ",".join(cleaned_ids)) - print_success("Discord allowlist configured") + _save_allowlist(allowed_users) else: print_info( "⚠️ No allowlist set. Discord will deny messages until you set " "DISCORD_ALLOWED_USERS, DISCORD_ALLOWED_ROLES, DISCORD_ALLOWED_CHANNELS, " "or DISCORD_ALLOW_ALL_USERS=true for open access." ) - print() - print_info("📬 Home Channel: where Hermes delivers cron job results,") - print_info(" cross-platform messages, and notifications.") - print_info(" To get a channel ID: right-click a channel → Copy Channel ID") - print_info(" (requires Developer Mode in Discord settings)") - print_info(" You can also set this later by typing /set-home in a Discord channel.") + _info_lines( + "📬 Home Channel: where Hermes delivers cron job results,", + " cross-platform messages, and notifications.", + " To get a channel ID: right-click a channel → Copy Channel ID", + " (requires Developer Mode in Discord settings)", + " You can also set this later by typing /set-home in a Discord channel.", + ) home_channel = prompt("Home channel ID (leave empty to set later with /set-home)").strip() if home_channel: save_env_value("DISCORD_HOME_CHANNEL", home_channel) - else: - if remove_env_value("DISCORD_HOME_CHANNEL"): - print_info("Home channel cleared.") + elif remove_env_value("DISCORD_HOME_CHANNEL"): + print_info("Home channel cleared.") + + +_YAML_BOOL_ENV_KEYS = ( + ("require_mention", "DISCORD_REQUIRE_MENTION"), + ("thread_require_mention", "DISCORD_THREAD_REQUIRE_MENTION"), + ("bots_require_inline_mention", "DISCORD_BOTS_REQUIRE_INLINE_MENTION"), +) +# (public websocket_* key, legacy liveness_* alias, env bridge var) +_YAML_WEBSOCKET_LIVENESS_KEYS = ( + ("websocket_liveness_interval_seconds", "liveness_interval_seconds", "HERMES_DISCORD_LIVENESS_INTERVAL_SECONDS"), + ("websocket_liveness_failure_threshold", "liveness_failure_threshold", "HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD"), + ("websocket_heartbeat_ack_max_age_seconds", None, None), + ("websocket_max_latency_seconds", None, None), +) def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: - """Translate ``config.yaml`` ``discord:`` keys into env vars. + """Translate ``config.yaml`` ``discord:`` keys into env vars (``apply_yaml_config_fn``). + The adapter reads ``DISCORD_*`` via ``os.getenv()`` at ~50 sites, so this hook owns YAML→env; + ``extra`` stays the per-adapter truth for liveness (multiplex isolation). Returns liveness settings.""" + def _env_default(env_key: str, value) -> None: + # First-writer-wins: an explicit env var always beats the YAML value. + if not os.getenv(env_key): + os.environ[env_key] = value - Implements the ``apply_yaml_config_fn`` contract (#24836). Mirrors the - legacy ``discord_cfg`` block that used to live in - ``gateway/config.py::load_gateway_config()`` before this migration. + def _csv(value) -> str: + return ",".join(str(v) for v in value) if isinstance(value, list) else str(value) - The DiscordAdapter reads its runtime configuration via ``os.getenv()`` - throughout the connect / handle code paths (``DISCORD_ALLOWED_USERS``, - ``DISCORD_REQUIRE_MENTION``, ``DISCORD_FREE_RESPONSE_CHANNELS``, - ``DISCORD_AUTO_THREAD``, ``DISCORD_REACTIONS``, - ``DISCORD_IGNORED_CHANNELS``, ``DISCORD_ALLOWED_CHANNELS``, - ``DISCORD_NO_THREAD_CHANNELS``, ``DISCORD_HISTORY_BACKFILL``, - ``DISCORD_HISTORY_BACKFILL_LIMIT``, ``DISCORD_ALLOW_MENTION_*``, - ``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``, - ``DISCORD_BOTS_REQUIRE_INLINE_MENTION``). - Rather than rewrite ~50 call sites inside the adapter to read from - ``PlatformConfig.extra`` instead, this hook keeps the existing - env-driven model and merely owns the YAML→env translation here, next to - the adapter that consumes it. - - ``PlatformConfig.extra`` is the per-adapter source of truth for liveness - settings, which keeps multiplexed profiles isolated. The legacy env bridge - remains only for existing callers that construct adapters without config - extras. Returns canonical WebSocket liveness settings to seed that extra. - """ - if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"): - os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower() - if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"): - os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower() - if "bots_require_inline_mention" in discord_cfg and not os.getenv("DISCORD_BOTS_REQUIRE_INLINE_MENTION"): - os.environ["DISCORD_BOTS_REQUIRE_INLINE_MENTION"] = str(discord_cfg["bots_require_inline_mention"]).lower() + for key, env_key in _YAML_BOOL_ENV_KEYS: + if key in discord_cfg: + _env_default(env_key, str(discord_cfg[key]).lower()) platforms_cfg = yaml_cfg.get("platforms") platform_extra_cfg = {} if isinstance(platforms_cfg, dict): @@ -10500,144 +7123,59 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: if isinstance(candidate_extra, dict): platform_extra_cfg = candidate_extra seeded_extra = {} - # Authorization gate keys are ALWAYS seeded into PlatformConfig.extra so - # every adapter carries its own profile's allow/deny lists (issue #72348). - # The os.environ writes below remain first-writer-wins for legacy env-only - # consumers, but are skipped for profile-scoped loads under multiplex — - # a secondary profile's gates must never land in process-global env where - # they'd become another profile's policy. + # Gate keys are ALWAYS seeded into PlatformConfig.extra (per-profile lists); the os.environ writes + # below are first-writer-wins for legacy consumers and skipped for profile-scoped multiplex loads. _skip_env_bridge = _profile_scoped_config_load() - allowed_users_cfg = ( - discord_cfg["allow_from"] if "allow_from" in discord_cfg - else platform_extra_cfg.get("allow_from") - ) - if allowed_users_cfg is not None: - if isinstance(allowed_users_cfg, list): - allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg) - seeded_extra["allow_from"] = str(allowed_users_cfg) - if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_USERS"): - os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg) - allowed_roles_cfg = ( - discord_cfg["allowed_roles"] if "allowed_roles" in discord_cfg - else platform_extra_cfg.get("allowed_roles") - ) - if allowed_roles_cfg is not None: - if isinstance(allowed_roles_cfg, list): - allowed_roles_cfg = ",".join(str(v) for v in allowed_roles_cfg) - seeded_extra["allowed_roles"] = str(allowed_roles_cfg) - if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_ROLES"): - os.environ["DISCORD_ALLOWED_ROLES"] = str(allowed_roles_cfg) - allow_all_cfg = ( - discord_cfg["allow_all_users"] if "allow_all_users" in discord_cfg - else platform_extra_cfg.get("allow_all_users") - ) - if allow_all_cfg is not None: - seeded_extra["allow_all_users"] = str(allow_all_cfg).lower() - if not _skip_env_bridge and not os.getenv("DISCORD_ALLOW_ALL_USERS"): - os.environ["DISCORD_ALLOW_ALL_USERS"] = str(allow_all_cfg).lower() + + def _gate(key: str, env_key: str, *, from_platform_extra: bool, lower: bool = False) -> None: + value = discord_cfg[key] if key in discord_cfg else (platform_extra_cfg.get(key) if from_platform_extra else None) + if value is None: + return + text = str(value).lower() if lower else _csv(value) + seeded_extra[key] = text + if not _skip_env_bridge: + _env_default(env_key, text) + + _gate("allow_from", "DISCORD_ALLOWED_USERS", from_platform_extra=True) + _gate("allowed_roles", "DISCORD_ALLOWED_ROLES", from_platform_extra=True) + _gate("allow_all_users", "DISCORD_ALLOW_ALL_USERS", from_platform_extra=True, lower=True) approval_mentions_cfg = ( discord_cfg["approval_mentions"] if "approval_mentions" in discord_cfg else platform_extra_cfg.get("approval_mentions") ) - if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"): - os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower() - frc = discord_cfg.get("free_response_channels") - if frc is not None: - if isinstance(frc, list): - frc = ",".join(str(v) for v in frc) - seeded_extra["free_response_channels"] = str(frc) - if not _skip_env_bridge and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"): - os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) - if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): - os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() - if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): - os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() + if approval_mentions_cfg is not None: + _env_default("DISCORD_APPROVAL_MENTIONS", str(approval_mentions_cfg).lower()) + _gate("free_response_channels", "DISCORD_FREE_RESPONSE_CHANNELS", from_platform_extra=False) + for key, env_key in (("auto_thread", "DISCORD_AUTO_THREAD"), ("reactions", "DISCORD_REACTIONS")): + if key in discord_cfg: + _env_default(env_key, str(discord_cfg[key]).lower()) backfill_cfg = discord_cfg.get("missed_message_backfill") if isinstance(backfill_cfg, dict): seeded_extra["missed_message_backfill"] = dict(backfill_cfg) - # ignored_channels: channels where bot never responds (even when mentioned) - ic = discord_cfg.get("ignored_channels") - if ic is not None: - if isinstance(ic, list): - ic = ",".join(str(v) for v in ic) - seeded_extra["ignored_channels"] = str(ic) - if not _skip_env_bridge and not os.getenv("DISCORD_IGNORED_CHANNELS"): - os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic) - # allowed_channels: if set, bot ONLY responds in these channels (whitelist) - ac = discord_cfg.get("allowed_channels") - if ac is not None: - if isinstance(ac, list): - ac = ",".join(str(v) for v in ac) - seeded_extra["allowed_channels"] = str(ac) - if not _skip_env_bridge and not os.getenv("DISCORD_ALLOWED_CHANNELS"): - os.environ["DISCORD_ALLOWED_CHANNELS"] = str(ac) - # no_thread_channels: channels where bot responds directly without creating thread - ntc = discord_cfg.get("no_thread_channels") - if ntc is not None: - if isinstance(ntc, list): - ntc = ",".join(str(v) for v in ntc) - seeded_extra["no_thread_channels"] = str(ntc) - if not _skip_env_bridge and not os.getenv("DISCORD_NO_THREAD_CHANNELS"): - os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc) - # history_backfill: recover missed channel messages for shared sessions - # when require_mention is active. Fetches messages between bot turns - # and prepends them to the user message for context. - if "history_backfill" in discord_cfg and not os.getenv("DISCORD_HISTORY_BACKFILL"): - os.environ["DISCORD_HISTORY_BACKFILL"] = str(discord_cfg["history_backfill"]).lower() + _gate("ignored_channels", "DISCORD_IGNORED_CHANNELS", from_platform_extra=False) + _gate("allowed_channels", "DISCORD_ALLOWED_CHANNELS", from_platform_extra=False) + _gate("no_thread_channels", "DISCORD_NO_THREAD_CHANNELS", from_platform_extra=False) + # history_backfill: recover mention-gated channel messages between bot turns. + if "history_backfill" in discord_cfg: + _env_default("DISCORD_HISTORY_BACKFILL", str(discord_cfg["history_backfill"]).lower()) hbl = discord_cfg.get("history_backfill_limit") - if hbl is not None and not os.getenv("DISCORD_HISTORY_BACKFILL_LIMIT"): - os.environ["DISCORD_HISTORY_BACKFILL_LIMIT"] = str(hbl) - # allow_mentions: granular control over what the bot can ping. - # Safe defaults (no @everyone/roles) are applied in the adapter; - # these YAML keys only override when set and let users opt back - # into unsafe modes (e.g. roles=true) if they actually want it. + if hbl is not None: + _env_default("DISCORD_HISTORY_BACKFILL_LIMIT", str(hbl)) + # allow_mentions: safe defaults live in the adapter; these keys only override when set. allow_mentions_cfg = discord_cfg.get("allow_mentions") if isinstance(allow_mentions_cfg, dict): - for yaml_key, env_key in ( - ("everyone", "DISCORD_ALLOW_MENTION_EVERYONE"), - ("roles", "DISCORD_ALLOW_MENTION_ROLES"), - ("users", "DISCORD_ALLOW_MENTION_USERS"), - ("replied_user", "DISCORD_ALLOW_MENTION_REPLIED_USER"), - ): - if yaml_key in allow_mentions_cfg and not os.getenv(env_key): - os.environ[env_key] = str(allow_mentions_cfg[yaml_key]).lower() - # reply_to_mode: top-level preferred, falls back to extra.reply_to_mode. - # YAML 1.1 parses bare 'off' as boolean False — coerce to string "off". + for yaml_key in ("everyone", "roles", "users", "replied_user"): + if yaml_key in allow_mentions_cfg: + _env_default(f"DISCORD_ALLOW_MENTION_{yaml_key.upper()}", str(allow_mentions_cfg[yaml_key]).lower()) + # reply_to_mode: top-level preferred, falls back to extra; YAML 1.1 parses bare 'off' as False. _discord_extra = discord_cfg.get("extra") if isinstance(discord_cfg.get("extra"), dict) else {} - _discord_rtm = ( - discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg - else _discord_extra.get("reply_to_mode") - ) - if _discord_rtm is not None and not os.getenv("DISCORD_REPLY_TO_MODE"): - _rtm_str = "off" if _discord_rtm is False else str(_discord_rtm).lower() - os.environ["DISCORD_REPLY_TO_MODE"] = _rtm_str - _websocket_extra_cfg = discord_cfg.get("extra") - if not isinstance(_websocket_extra_cfg, dict): - _websocket_extra_cfg = {} - # Public config keys win over the generic ``extra`` form used by nested - # platform configuration. - _websocket_liveness_cfg = { - **_websocket_extra_cfg, - **discord_cfg, - } - # WebSocket health knobs: REST 200 is deliberately not used as Gateway - # health. Accept legacy liveness_* aliases for compatibility during the - # migration; the websocket_* spelling is the public config surface. - _websocket_liveness_keys = ( - ( - "websocket_liveness_interval_seconds", - "liveness_interval_seconds", - "HERMES_DISCORD_LIVENESS_INTERVAL_SECONDS", - ), - ( - "websocket_liveness_failure_threshold", - "liveness_failure_threshold", - "HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD", - ), - ("websocket_heartbeat_ack_max_age_seconds", None, None), - ("websocket_max_latency_seconds", None, None), - ) - for primary_key, legacy_key, env_key in _websocket_liveness_keys: + _discord_rtm = discord_cfg["reply_to_mode"] if "reply_to_mode" in discord_cfg else _discord_extra.get("reply_to_mode") + if _discord_rtm is not None: + _env_default("DISCORD_REPLY_TO_MODE", "off" if _discord_rtm is False else str(_discord_rtm).lower()) + # Public config keys win over the generic ``extra`` form. + _websocket_liveness_cfg = {**_discord_extra, **discord_cfg} + # WebSocket health knobs (REST 200 is not Gateway health); legacy liveness_* aliases accepted. + for primary_key, legacy_key, env_key in _YAML_WEBSOCKET_LIVENESS_KEYS: value = _websocket_liveness_cfg.get(primary_key) if value is None and legacy_key: value = _websocket_liveness_cfg.get(legacy_key) @@ -10649,14 +7187,8 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: def _is_connected(config) -> bool: - """Discord is considered connected when DISCORD_BOT_TOKEN is set. - - Looks up via ``hermes_cli.gateway.get_env_value`` at call time (not via - the plugin's own bound import) so tests that patch ``gateway_mod.get_env_value`` - — including ``test_setup_openclaw_migration`` — can suppress ambient - ``DISCORD_BOT_TOKEN`` env vars. Matches what the legacy - ``_PLATFORMS["discord"]`` dispatch did before this migration. - """ + """Connected when DISCORD_BOT_TOKEN is set. + Looks up ``hermes_cli.gateway.get_env_value`` at call time so tests can patch it (ambient env).""" import hermes_cli.gateway as gateway_mod return bool((gateway_mod.get_env_value("DISCORD_BOT_TOKEN") or "").strip()) @@ -10677,29 +7209,15 @@ def register(ctx) -> None: is_connected=_is_connected, required_env=["DISCORD_BOT_TOKEN"], install_hint="Run `hermes setup` to install Discord support.", - # Interactive setup wizard — replaces the central - # hermes_cli/setup.py::_setup_discord function. Same shape as Teams. setup_fn=interactive_setup, - # YAML→env config bridge — owns the translation of ``config.yaml`` - # ``discord:`` keys (require_mention, free_response_channels, - # auto_thread, reactions, ignored_channels, allowed_channels, - # no_thread_channels, allow_mentions.*, reply_to_mode, - # thread_require_mention) into ``DISCORD_*`` env vars that the - # adapter reads via ``os.getenv()``. Replaces the hardcoded block - # that used to live in ``gateway/config.py``. Hook contract: #24836. + # YAML→env bridge: ``discord:`` config keys → ``DISCORD_*`` env vars read via os.getenv(). apply_yaml_config_fn=_apply_yaml_config, - # Auth env vars for _is_user_authorized() integration allowed_users_env="DISCORD_ALLOWED_USERS", allow_all_env="DISCORD_ALLOW_ALL_USERS", - # Cron home-channel delivery cron_deliver_env_var="DISCORD_HOME_CHANNEL", - # Out-of-process cron delivery via Discord REST API. Without this - # hook, ``deliver=discord`` cron jobs fail with "No live adapter" - # when cron runs separately from the gateway. Mirrors Teams pattern. + # Out-of-process cron delivery via REST, else ``deliver=discord`` jobs fail with "No live adapter". standalone_sender_fn=_standalone_send, - # Discord hard limit per message max_message_length=2000, - # Display emoji="🎮", allow_update_command=True, ) diff --git a/plugins/platforms/discord/ffmpeg_utils.py b/plugins/platforms/discord/ffmpeg_utils.py index d1fdbdc98d..a22e1d10ce 100644 --- a/plugins/platforms/discord/ffmpeg_utils.py +++ b/plugins/platforms/discord/ffmpeg_utils.py @@ -28,16 +28,13 @@ def resolve_ffmpeg_executable() -> str: explicit = os.getenv("FFMPEG_PATH") if explicit and explicit.strip(): return os.path.expandvars(os.path.expanduser(explicit.strip())) - discovered = _shared_find_ffmpeg() if discovered: return discovered - local_appdata = os.getenv("LOCALAPPDATA") if local_appdata: packages_dir = Path(local_appdata) / "Microsoft" / "WinGet" / "Packages" candidates = sorted(packages_dir.glob("Gyan.FFmpeg_*/*/bin/ffmpeg.exe")) if candidates: return str(candidates[-1]) - return "ffmpeg" diff --git a/plugins/platforms/discord/recovery.py b/plugins/platforms/discord/recovery.py index 97217d7680..bed52b5962 100644 --- a/plugins/platforms/discord/recovery.py +++ b/plugins/platforms/discord/recovery.py @@ -54,7 +54,6 @@ class DiscordRecoveryStore: def _initialize(self, conn: sqlite3.Connection) -> None: from hermes_state import apply_wal_with_fallback - apply_wal_with_fallback(conn, db_label="discord_recovery.db") conn.execute(""" CREATE TABLE IF NOT EXISTS discord_messages ( @@ -97,16 +96,11 @@ class DiscordRecoveryStore: updated_at TEXT NOT NULL ) """) - cutoff = ( - dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=_RETENTION_DAYS) - ).isoformat() + cutoff = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=_RETENTION_DAYS)).isoformat() conn.execute("DELETE FROM discord_messages WHERE updated_at < ?", (cutoff,)) conn.execute( "DELETE FROM discord_recovery_scans " "WHERE COALESCE(completed_at, started_at) < ?", (cutoff,), ) - conn.execute( - "DELETE FROM discord_recovery_cursors WHERE updated_at < ?", - (cutoff,), - ) + conn.execute("DELETE FROM discord_recovery_cursors WHERE updated_at < ?", (cutoff,)) diff --git a/plugins/platforms/discord/voice_mixer.py b/plugins/platforms/discord/voice_mixer.py index f498eb6ef1..912fec6f3f 100644 --- a/plugins/platforms/discord/voice_mixer.py +++ b/plugins/platforms/discord/voice_mixer.py @@ -95,14 +95,8 @@ class MixerChild: ) def __init__( - self, - name: str, - pcm: bytes, - *, - loop: bool = False, - gain: float = 1.0, - is_speech: bool = False, - fade_in_ms: int = 0, + self, name: str, pcm: bytes, *, loop: bool = False, gain: float = 1.0, + is_speech: bool = False, fade_in_ms: int = 0, ): # Pad to a whole number of frames so looping is seamless and the final # partial frame doesn't click. @@ -134,20 +128,16 @@ class MixerChild: else: self._finished = True return None - np = _require_numpy() chunk = self._pcm[self._pos:self._pos + FRAME_SIZE] self._pos += FRAME_SIZE if len(chunk) < FRAME_SIZE: chunk = chunk + b"\x00" * (FRAME_SIZE - len(chunk)) - samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) - gain = self.gain if self.fade_frames and self._fade_done < self.fade_frames: self._fade_done += 1 gain *= self._fade_done / self.fade_frames - if gain != 1.0: samples = samples * gain return samples @@ -167,11 +157,7 @@ class VoiceMixer(discord.AudioSource): return False def __init__( - self, - *, - ambient_gain: float = 0.18, - duck_gain: float = 0.06, - speech_gain: float = 1.0, + self, *, ambient_gain: float = 0.18, duck_gain: float = 0.06, speech_gain: float = 1.0, duck_release_ms: int = 400, ): self._lock = threading.Lock() @@ -202,8 +188,7 @@ class VoiceMixer(discord.AudioSource): self._ambient = None return self._ambient = MixerChild( - "ambient", pcm, loop=True, - gain=self._effective_ambient_gain(), fade_in_ms=200, + "ambient", pcm, loop=True, gain=self._effective_ambient_gain(), fade_in_ms=200, ) def _effective_ambient_gain(self) -> float: @@ -220,8 +205,7 @@ class VoiceMixer(discord.AudioSource): return with self._lock: child = MixerChild( - "speech", pcm, loop=False, - gain=self._speech_gain if gain is None else float(gain), + "speech", pcm, loop=False, gain=self._speech_gain if gain is None else float(gain), is_speech=True, fade_in_ms=fade_in_ms, ) self._speech.append(child) @@ -259,10 +243,8 @@ class VoiceMixer(discord.AudioSource): with self._lock: if self._closed: return SILENCE_FRAME - np = _require_numpy() acc: "Optional[np.ndarray]" = None - # Speech children (drop exhausted ones; release duck when last ends) if self._speech: still_live: List[MixerChild] = [] @@ -275,7 +257,6 @@ class VoiceMixer(discord.AudioSource): self._speech = still_live if not self._speech and self._speech_active: self._begin_duck_release_locked() - # Ambient bed — ramp gain back up during duck-release. if self._ambient is not None: if self._duck_release_left > 0 and not self._speech_active: @@ -290,10 +271,8 @@ class VoiceMixer(discord.AudioSource): amb = self._ambient.read_frame() if amb is not None: acc = amb if acc is None else acc + amb - if acc is None: return SILENCE_FRAME - np.clip(acc, -32768, 32767, out=acc) return acc.astype(np.int16).tobytes() @@ -315,16 +294,11 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]: requirement of the voice path (see ``VoiceReceiver.pcm_to_wav``). """ import subprocess - try: proc = subprocess.run( [ - resolve_ffmpeg_executable(), "-y", "-loglevel", "error", - "-i", path, - "-f", "s16le", - "-ar", str(SAMPLE_RATE), - "-ac", str(CHANNELS), - "pipe:1", + resolve_ffmpeg_executable(), "-y", "-loglevel", "error", "-i", path, "-f", "s16le", + "-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "pipe:1", ], capture_output=True, timeout=timeout, @@ -353,35 +327,26 @@ def synth_ambient_pcm(seconds: float = 4.0) -> bytes: np = _require_numpy() n = int(SAMPLE_RATE * seconds) t = np.arange(n, dtype=np.float64) / SAMPLE_RATE - # Choose base frequencies that complete whole cycles over the loop so the # wrap point is click-free. def _whole_cycle_freq(target: float) -> float: cycles = max(1, round(target * seconds)) return cycles / seconds - f1 = _whole_cycle_freq(110.0) f2 = _whole_cycle_freq(110.5) trem = _whole_cycle_freq(0.5) # ~0.5 Hz tremolo - - pad = ( - 0.55 * np.sin(2 * np.pi * f1 * t) - + 0.45 * np.sin(2 * np.pi * f2 * t) - ) + pad = (0.55 * np.sin(2 * np.pi * f1 * t) + 0.45 * np.sin(2 * np.pi * f2 * t)) tremolo = 0.6 + 0.4 * (0.5 * (1 + np.sin(2 * np.pi * trem * t))) signal = pad * tremolo - # Smooth filtered noise for air, kept very low. rng = np.random.default_rng(7) noise = rng.standard_normal(n) kernel = np.ones(64) / 64.0 noise = np.convolve(noise, kernel, mode="same") signal = signal + 0.08 * noise - # Normalise to a modest peak (mixer applies the real ambient gain on top). peak = float(np.max(np.abs(signal))) or 1.0 signal = (signal / peak) * 0.5 - mono16 = (signal * 32767.0).astype(np.int16) stereo16 = np.repeat(mono16[:, None], CHANNELS, axis=1).reshape(-1) return stereo16.tobytes() diff --git a/tests/gateway/test_discord_opus.py b/tests/gateway/test_discord_opus.py index ae098473e2..cb90f3fcf5 100644 --- a/tests/gateway/test_discord_opus.py +++ b/tests/gateway/test_discord_opus.py @@ -9,8 +9,8 @@ class TestOpusFindLibrary: def test_uses_find_library_first(self): """find_library must be the primary lookup strategy.""" - from plugins.platforms.discord.adapter import DiscordAdapter - source = inspect.getsource(DiscordAdapter.connect) + from plugins.platforms.discord.adapter import DiscordAdapter, _load_opus_codec + source = inspect.getsource(DiscordAdapter.connect) + inspect.getsource(_load_opus_codec) assert "find_library" in source, \ "Opus loading must use ctypes.util.find_library" diff --git a/tests/gateway/test_discord_view_base_parity.py b/tests/gateway/test_discord_view_base_parity.py new file mode 100644 index 0000000000..ffcd26e2ed --- /dev/null +++ b/tests/gateway/test_discord_view_base_parity.py @@ -0,0 +1,113 @@ +"""Parity tests for the shared ``_HermesView`` base behind the Discord component views. + +Every view must keep its own user-visible rejection strings and the shared +timeout behaviour (buttons disabled, embed greyed with the expiry footer). +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from plugins.platforms.discord.adapter import ( # noqa: E402 + ChoicePickerView, + ClarifyChoiceView, + ExecApprovalView, + ModelPickerView, + SlashConfirmView, + UpdatePromptView, +) + + +def _interaction(user_id=1): + return SimpleNamespace( + user=SimpleNamespace(id=user_id, display_name="alice", roles=[]), + response=SimpleNamespace(send_message=AsyncMock(), edit_message=AsyncMock(), defer=AsyncMock()), + message=SimpleNamespace(embeds=[]), + data={"values": ["x"]}, + channel_id=5, + ) + + +async def _noop(*_a, **_k): + return "" + + +def _views(): + return { + "exec": ExecApprovalView(session_key="s", allowed_user_ids=set()), + "slash": SlashConfirmView(session_key="s", confirm_id="c", allowed_user_ids=set()), + "update": UpdatePromptView(session_key="s", allowed_user_ids=set()), + "clarify": ClarifyChoiceView(choices=["a"], clarify_id="c", allowed_user_ids=set()), + "model": ModelPickerView( + providers=[], current_model="m", current_provider="p", session_key="s", + on_model_selected=_noop, allowed_user_ids=set(), + ), + "choice": ChoicePickerView(choices=[{"value": "v"}], on_choice_selected=_noop, allowed_user_ids=set()), + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "name,call,expected", + [ + ("exec", lambda v, i: v._resolve(i, "once", None, "x"), "You're not authorized to approve commands~"), + ("slash", lambda v, i: v._resolve(i, "once", None, "x"), "You're not authorized to answer this prompt~"), + ("update", lambda v, i: v._respond(i, "y", None, "x"), "You're not authorized~"), + ("clarify", lambda v, i: v._resolve_choice(i, 0, "a"), "You're not authorized to answer this prompt~"), + ("clarify", lambda v, i: v._on_other(i), "You're not authorized to answer this prompt~"), + ("model", lambda v, i: v._on_provider_selected(i), "You're not authorized~"), + ("model", lambda v, i: v._on_back(i), "You're not authorized~"), + ("choice", lambda v, i: v._on_select(i), "⛔ You are not authorized to change this setting."), + ], +) +async def test_unauthorized_click_strings_preserved(monkeypatch, name, call, expected): + monkeypatch.delenv("DISCORD_ALLOW_ALL_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOW_ALL_USERS", raising=False) + monkeypatch.delenv("GATEWAY_ALLOWED_USERS", raising=False) + view = _views()[name] + interaction = _interaction() + await call(view, interaction) + interaction.response.send_message.assert_awaited_once_with(expected, ephemeral=True) + interaction.response.edit_message.assert_not_called() + assert view.resolved is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "name,call,expected", + [ + ("exec", lambda v, i: v._resolve(i, "once", None, "x"), "This approval has already been resolved~"), + ("slash", lambda v, i: v._resolve(i, "once", None, "x"), "This prompt has already been resolved~"), + ("update", lambda v, i: v._respond(i, "y", None, "x"), "Already answered~"), + ("clarify", lambda v, i: v._resolve_choice(i, 0, "a"), "This prompt has already been answered~"), + ("model", lambda v, i: v._on_model_selected(i), "Already resolved~"), + ], +) +async def test_already_resolved_strings_preserved(monkeypatch, name, call, expected): + monkeypatch.setenv("DISCORD_ALLOW_ALL_USERS", "true") + view = _views()[name] + view.resolved = True + interaction = _interaction() + await call(view, interaction) + interaction.response.send_message.assert_awaited_once_with(expected, ephemeral=True) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name", ["exec", "slash", "update", "clarify"]) +async def test_on_timeout_disables_and_greys_embed(name): + view = _views()[name] + embed = SimpleNamespace(color=None, set_footer=lambda *, text: setattr(embed, "footer", text)) + msg = SimpleNamespace(embeds=[embed], edit=AsyncMock()) + view._message = msg + await view.on_timeout() + assert view.resolved is True + assert all(child.disabled for child in view.children) + assert embed.footer == "⏱ Prompt expired — no action taken" + msg.edit.assert_awaited_once_with(embed=embed, view=view) + + +@pytest.mark.asyncio +async def test_on_timeout_without_message_is_safe(): + for view in _views().values(): + await view.on_timeout()