{"repo":"NousResearch/hermes-agent","public_id":"1336","display_label":"Discord voice and hermes update extras fixes","summary":"These issues and PRs relate to Discord voice channel reliability problems and fixes to the hermes update process to correctly reinstall editable packages with extras, including PyNaCl dependencies.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1429},"resolution":{"state":"resolved","by":"pr-1728"},"freshness":{"state":"stale","last_verified_at":"2026-03-17T11:22:37+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":1336,"pr_number":1429,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":1336,"pr_number":1728,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":1336,"title":"[Bug]: Discord voice fails because of missing PyNaCl after `hermes update`","url":null,"state":"closed"},{"kind":"pr","number":1342,"title":"fix(update): reinstall editable package with extras ","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1728","number":1728}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":1418,"title":"fix(voice): Discord voice channel reliability fixes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1429","number":1429}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1429,"title":"fix(voice): Discord voice channel reliability fixes","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","gateway/run.py","scripts/discord-voice-doctor.py","tests/gateway/test_voice_command.py","tests/integration/test_voice_channel_flow.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -87,8 +87,9 @@ class VoiceReceiver:\n     SAMPLE_RATE = 48000        # Discord native rate\n     CHANNELS = 2               # Discord sends stereo\n \n-    def __init__(self, voice_client):\n+    def __init__(self, voice_client, allowed_user_ids: set = None):\n         self._vc = voice_client\n+        self._allowed_user_ids = allowed_user_ids or set()\n         self._running = False\n \n         # Decryption","@@ -274,19 +275,21 @@ def _on_packet(self, data: bytes):\n         if self._dave_session:\n             with self._lock:\n                 user_id = self._ssrc_to_user.get(ssrc, 0)\n-            if user_id == 0:\n-                if self._packet_debug_count <= 10:\n-                    logger.warning(\"DAVE skip: unknown user for ssrc=%d\", ssrc)\n-                return  # unknown user, can't DAVE-decrypt\n-            try:\n-                import davey\n-                decrypted = self._dave_session.decrypt(\n-                    user_id, davey.MediaType.audio, decrypted\n-                )\n-            except Exception as e:\n-                if self._packet_debug_count <= 10:\n-                    logger.warning(\"DAVE decrypt failed for ssrc=%d: %s\", ssrc, e)\n-                return\n+            if user_id:\n+                try:\n+                    import davey\n+                    decrypted = self._dave_session.decrypt(\n+                        user_id, davey.MediaType.audio, decrypted\n+                    )\n+                except Exception as e:\n+                    # Unencrypted passthrough — use NaCl-decrypted data as-is\n+                    if \"Unencrypted\" not in str(e):\n+                        if self._packet_debug_count <= 10:\n+                            logger.warning(\"DAVE decrypt failed for ssrc=%d: %s\", ssrc, e)\n+                        return\n+            # If SSRC unknown (no SPEAKING event yet), skip DAVE and try\n+            # Opus decode directly — audio may be in passthrough mode.\n+            # Buffer will get a user_id when SPEAKING event arrives later.\n \n         # --- Opus decode -> PCM ---\n         try:","@@ -304,6 +307,32 @@ def _on_packet(self, data: bytes):\n     # Silence detection\n     # ------------------------------------------------------------------\n \n+    def _infer_user_for_ssrc(self, ssrc: int) -> int:\n+        \"\"\"Try to infer user_id for an unmapped SSRC.\n+\n+        When the bot rejoins a voice channel, Discord may not resend\n+        SPEAKING events for users already speaking.  If exactly one\n+        allowed user is in the channel, map the SSRC to them.\n+        \"\"\"\n+        try:\n+            channel = self._vc.channel\n+            if not channel:\n+                return 0\n+            bot_id = self._vc.user.id if self._vc.user else 0\n+            allowed = self._allowed_user_ids\n+            candidates = [\n+                m.id for m in channel.members\n+                if m.id != bot_id and (not allowed or str(m.id) in allowed)\n+            ]\n+            if len(candidates) == 1:\n+                uid = candidates[0]\n+                self._ssrc_to_user[ssrc] = uid\n+                logger.info(\"Auto-mapped ssrc=%d -> user=%d (sole allowed member)\", ssrc, uid)\n+                return uid\n+        except Exception:\n+            pass\n+        return 0\n+\n     def check_silence(self) -> list:\n         \"\"\"Return list of (user_id, pcm_bytes) for completed utterances.\"\"\"\n         now = time.monotonic()","@@ -322,6 +351,10 @@ def check_silence(self) -> list:\n \n                 if silence_duration >= self.SILENCE_THRESHOLD and buf_duration >= self.MIN_SPEECH_DURATION:\n                     user_id = ssrc_user_map.get(ssrc, 0)\n+                    if not user_id:\n+                        # SSRC not mapped (SPEAKING event missing after bot rejoin).\n+                        # Infer from allowed users in the voice channel.\n+                        user_id = self._infer_user_for_ssrc(ssrc)\n                     if user_id:\n                         completed.append((user_id, bytes(buf)))\n                     self._buffers[ssrc] = bytearray()","@@ -695,13 +728,14 @@ async def play_tts(\n     ) -> SendResult:\n         \"\"\"Play auto-TTS audio.\n \n-        When the bot is in a voice channel for this chat's guild, skip the\n-        file attachment — the gateway runner plays audio in the VC instead.\n+        When the bot is in a voice channel for this chat's guild, play\n+        directly in the VC instead of sending as a file attachment.\n         \"\"\"\n         for gid, text_ch_id in self._voice_text_channels.items():\n             if str(text_ch_id) == str(chat_id) and self.is_in_voice_channel(gid):\n-                logger.debug(\"[%s] Skipping play_tts for %s — VC playback handled by runner\", self.name, chat_id)\n-                return SendResult(success=True)\n+                logger.info(\"[%s] Playing TTS in voice channel (guild=%d)\", self.name, gid)\n+                success = await self.play_in_voice_channel(gid, audio_path)\n+                return SendResult(success=success)\n         return await self.send_voice(chat_id=chat_id, audio_path=audio_path, **kwargs)\n \n     async def send_voice(","@@ -805,7 +839,7 @@ async def join_voice_channel(self, channel) -> bool:\n \n         # Start voice receiver (Phase 2: listen to users)\n         try:\n-            receiver = VoiceReceiver(vc)\n+            receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids)\n             receiver.start()\n             self._voice_receivers[guild_id] = receiver\n             self._voice_listen_tasks[guild_id] = asyncio.ensure_future(","@@ -1001,14 +1035,32 @@ def get_voice_channel_context(self, guild_id: int) -> str:\n     # Voice listening (Phase 2)\n     # ------------------------------------------------------------------\n \n+    # UDP keepalive interval in seconds — prevents Discord from dropping\n+    # the UDP route after ~60s of silence.\n+    _KEEPALIVE_INTERVAL = 15\n+\n     async def _voice_listen_loop(self, guild_id: int):\n         \"\"\"Periodically check for completed utterances and process them.\"\"\"\n         receiver = self._voice_receivers.get(guild_id)\n         if not receiver:\n             return\n+        last_keepalive = time.monotonic()\n         try:\n             while receiver._running:\n                 await asyncio.sleep(0.2)\n+\n+                # Send periodic UDP keepalive to prevent Discord from\n+                # dropping the UDP session after ~60s of silence.\n+                now = time.monotonic()\n+                if now - last_keepalive >= self._KEEPALIVE_INTERVAL:\n+                    last_keepalive = now\n+                    try:\n+                        vc = self._voice_clients.get(guild_id)\n+                        if vc and vc.is_connected():\n+                            vc._connection.send_packet(b'\\xf8\\xff\\xfe')\n+                    except Exception:\n+                        pass\n+\n                 completed = receiver.check_silence()\n                 for user_id, pcm_data in completed:\n                     if not self._is_allowed_user(str(user_id)):"]},{"file":"gateway/run.py","hunks":["@@ -2435,6 +2435,13 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:\n         except Exception as e:\n             logger.warning(\"Failed to join voice channel: %s\", e)\n             adapter._voice_input_callback = None\n+            err_lower = str(e).lower()\n+            if \"pynacl\" in err_lower or \"nacl\" in err_lower or \"davey\" in err_lower:\n+                return (\n+                    \"Voice dependencies are missing (PyNaCl / davey). \"\n+                    \"Install or reinstall Hermes with the messaging extra, e.g. \"\n+                    \"`pip install hermes-agent[messaging]`.\"\n+                )\n             return f\"Failed to join voice channel: {e}\"\n \n         if success:","@@ -2575,18 +2582,9 @@ def _should_send_voice_reply(\n         if has_agent_tts:\n             return False\n \n-        # Dedup: base adapter auto-TTS already handles voice input.\n-        # Exception: Discord voice channel — play_tts override is a no-op,\n-        # so the runner must handle VC playback.\n-        skip_double = is_voice_input\n-        if skip_double:\n-            adapter = self.adapters.get(event.source.platform)\n-            guild_id = self._get_guild_id(event)\n-            if (guild_id and adapter\n-                    and hasattr(adapter, \"is_in_voice_channel\")\n-                    and adapter.is_in_voice_channel(guild_id)):\n-                skip_double = False\n-        if skip_double:\n+        # Dedup: base adapter auto-TTS already handles voice input\n+        # (play_tts plays in VC when connected, so runner can skip).\n+        if is_voice_input:\n             return False\n \n         return True"]},{"file":"scripts/discord-voice-doctor.py","hunks":["@@ -0,0 +1,389 @@\n+#!/usr/bin/env python3\n+\"\"\"Discord Voice Doctor — diagnostic tool for voice channel support.\n+\n+Checks all dependencies, configuration, and bot permissions needed\n+for Discord voice mode to work correctly.\n+\n+Usage:\n+    python scripts/discord-voice-doctor.py\n+    .venv/bin/python scripts/discord-voice-doctor.py\n+\"\"\"\n+\n+import os\n+import sys\n+import shutil\n+from pathlib import Path\n+\n+# Resolve project root\n+SCRIPT_DIR = Path(__file__).resolve().parent\n+PROJECT_ROOT = SCRIPT_DIR.parent\n+sys.path.insert(0, str(PROJECT_ROOT))\n+\n+HERMES_HOME = Path(os.getenv(\"HERMES_HOME\", Path.home() / \".hermes\"))\n+ENV_FILE = HERMES_HOME / \".env\"\n+\n+OK = \"\\033[92m\\u2713\\033[0m\"\n+FAIL = \"\\033[91m\\u2717\\033[0m\"\n+WARN = \"\\033[93m!\\033[0m\"\n+\n+# Track whether discord.py is available for later sections\n+_discord_available = False\n+\n+\n+def mask(value):\n+    \"\"\"Mask sensitive value: show only first 4 chars.\"\"\"\n+    if not value or len(value) < 8:\n+        return \"****\"\n+    return f\"{value[:4]}{'*' * (len(value) - 4)}\"\n+\n+\n+def check(label, ok, detail=\"\"):\n+    symbol = OK if ok else FAIL\n+    msg = f\"  {symbol} {label}\"\n+    if detail:\n+        msg += f\"  ({detail})\"\n+    print(msg)\n+    return ok\n+\n+\n+def warn(label, detail=\"\"):\n+    msg = f\"  {WARN} {label}\"\n+    if detail:\n+        msg += f\"  ({detail})\"\n+    print(msg)\n+\n+\n+def section(title):\n+    print(f\"\\n\\033[1m{title}\\033[0m\")\n+\n+\n+def check_packages():\n+    \"\"\"Check Python package dependencies. Returns True if all critical deps OK.\"\"\"\n+    global _discord_available\n+    section(\"Python Packages\")\n+    ok = True\n+\n+    # discord.py\n+    try:\n+        import discord\n+        _discord_available = True\n+        check(\"discord.py\", True, f\"v{discord.__version__}\")\n+    except ImportError:\n+        check(\"discord.py\", False, \"pip install discord.py[voice]\")\n+        ok = False\n+\n+    # PyNaCl\n+    try:\n+        import nacl\n+        ver = getattr(nacl, \"__version__\", \"unknown\")\n+        try:\n+            import nacl.secret\n+            nacl.secret.Aead(bytes(32))\n+            check(\"PyNaCl\", True, f\"v{ver}\")\n+        except (AttributeError, Exception):\n+            check(\"PyNaCl (Aead)\", False, f\"v{ver} — need >=1.5.0\")\n+            ok = False\n+    except ImportError:\n+        check(\"PyNaCl\", False, \"pip install PyNaCl>=1.5.0\")\n+        ok = False\n+\n+    # davey (DAVE E2EE)\n+    try:\n+        import davey\n+        check(\"davey (DAVE E2EE)\", True, f\"v{getattr(davey, '__version__', '?')}\")\n+    except ImportError:\n+        check(\"davey (DAVE E2EE)\", False, \"pip install davey\")\n+        ok = False\n+\n+    # Optional: local STT\n+    try:\n+        import faster_whisper\n+        check(\"faster-whisper (local STT)\", True)\n+    except ImportError:\n+        warn(\"faster-whisper (local STT)\", \"not installed — local STT unavailable\")\n+\n+    # Optional: TTS providers\n+    try:\n+        import edge_tts\n+        check(\"edge-tts\", True)\n+    except ImportError:\n+        warn(\"edge-tts\", \"not installed — edge TTS unavailable\")\n+\n+    try:\n+        import elevenlabs\n+        check(\"elevenlabs SDK\", True)\n+    except ImportError:\n+        warn(\"elevenlabs SDK\", \"not installed — premium TTS unavailable\")\n+\n+    return ok\n+\n+\n+def check_system_tools():\n+    \"\"\"Check system-level tools (opus, ffmpeg). Returns True if all OK.\"\"\"\n+    section(\"System Tools\")\n+    ok = True\n+\n+    # Opus codec\n+    if _discord_available:\n+        try:\n+            import discord\n+            opus_loaded = discord.opus.is_loaded()\n+            if not opus_loaded:\n+                import ctypes.util\n+                opus_path = ctypes.util.find_library(\"opus\")\n+                if not opus_path:\n+                    # Platform-specific fallback paths\n+                    candidates = [\n+                        \"/opt/homebrew/lib/libopus.dylib\",   # macOS Apple Silicon\n+                        \"/usr/local/lib/libopus.dylib\",      # macOS Intel\n+                        \"/usr/lib/x86_64-linux-gnu/libopus.so.0\",  # Debian/Ubuntu x86\n+                        \"/usr/lib/aarch64-linux-gnu/libopus.so.0\", # Debian/Ubuntu ARM\n+                        \"/usr/lib/libopus.so\",               # Arch Linux\n+                        \"/usr/lib64/libopus.so\",             # RHEL/Fedora\n+                    ]\n+                    for p in candidates:\n+                        if os.path.isfile(p):\n+                            opus_path = p\n+                            break\n+                if opus_path:\n+                    discord.opus.load_opus(opus_path)\n+                    opus_loaded = discord.opus.is_loaded()\n+            if opus_loaded:\n+                check(\"Opus codec\", True)\n+            else:\n+                check(\"Opus codec\", False, \"brew install opus / apt install libopus0\")\n+                ok = False\n+        except Exception as e:\n+            check(\"Opus codec\", False, str(e))\n+            ok = False\n+    else:\n+        warn(\"Opus codec\", \"skipped — discord.py not installed\")\n+\n+    # ffmpeg\n+    ffmpeg_path = shutil.which(\"ffmpeg\")\n+    if ffmpeg_path:\n+        check(\"ffmpeg\", True, ffmpeg_path)\n+    else:\n+        check(\"ffmpeg\", False, \"brew install ffmpeg / apt install ffmpeg\")\n+        ok = False\n+\n+    return ok\n+\n+\n+def check_env_vars():\n+    \"\"\"Check environment variables. Returns (ok, token, groq_key, eleven_key).\"\"\"\n+    section(\"Environment Variables\")\n+\n+    # Load .env\n+    try:\n+        from dotenv import load_dotenv\n+        if ENV_FILE.exists():\n+            load_dotenv(ENV_FILE)\n+    except ImportError:\n+        pass\n+\n+    ok = True\n+\n+    token = [redacted:secret-assignment]\"DISCORD_BOT_TOKEN\", \"\")\n+    if token:\n+        check(\"DISCORD_BOT_TOKEN\", True, mask(token))\n+    else:\n+        check(\"DISCORD_BOT_TOKEN\", False, \"not set\")\n+        ok = False\n+\n+    # Allowed users — resolve usernames if possible\n+    allowed = os.getenv(\"DISCORD_ALLOWED_USERS\", \"\")\n+    if allowed:\n+        users = [u.strip() for u in allowed.split(\",\") if u.strip()]\n+        user_labels = []\n+        for uid in users:\n+            label = mask(uid)\n+            if token and uid.isdigit():\n+                try:\n+                    import requests\n+                    r = requests.get(\n+                        f\"https://discord.com/api/v10/users/{uid}\",\n+                        headers={\"Authorization\": f\"Bot {token}\"},\n+                        timeout=3,\n+                    )\n+                    if r.status_code == 200:\n+                        label = f\"{r.json().get('username', '?')} ({mask(uid)})\"\n+                except Exception:\n+                    pass\n+            user_labels.append(label)\n+        check(\"DISCORD_ALLOWED_USERS\", True, f\"{len(users)} user(s): {', '.join(user_labels)}\")\n+    else:\n+        warn(\"DISCORD_ALLOWED_USERS\", \"not set — all users can use voice\")\n+\n+    groq_key = os.getenv(\"GROQ_API_KEY\", \"\")\n+    eleven_key = os.getenv(\"ELEVENLABS_API_KEY\", \"\")\n+\n+    if groq_key:\n+        check(\"GROQ_API_KEY (STT)\", True, mask(groq_key))\n+    else:\n+        warn(\"GROQ_API_KEY\", \"not set — Groq STT unavailable\")\n+\n+    if eleven_key:\n+        check(\"ELEVENLABS_API_KEY (TTS)\", True, mask(eleven_key))\n+    else:\n+        warn(\"ELEVENLABS_API_KEY\", \"not set — ElevenLabs TTS unavailable\")\n+\n+    return ok, token, groq_key, eleven_key\n+\n+\n+def check_config(groq_key, eleven_key):\n+    \"\"\"Check hermes config.yaml.\"\"\"\n+    section(\"Configuration\")\n+\n+    config_path = HERMES_HOME / \"config.yaml\"\n+    if config_path.exists():\n+        try:\n+            import yaml\n+            with open(config_path) as f:\n+                cfg = yaml.safe_load(f) or {}\n+\n+            stt_provider = cfg.get(\"stt\", {}).get(\"provider\", \"local\")\n+            tts_provider = cfg.get(\"tts\", {}).get(\"provider\", \"edge\")\n+            check(\"STT provider\", True, stt_provider)\n+            check(\"TTS provider\", True, tts_provider)\n+\n+            if stt_provider == \"groq\" and not groq_key:\n+                warn(\"STT config says groq but GROQ_API_KEY is missing\")\n+            if tts_provider == \"elevenlabs\" and not eleven_key:\n+                warn(\"TTS config says elevenlabs but ELEVENLABS_API_KEY is missing\")\n+        except Exception as e:\n+            warn(\"config.yaml\", f\"parse error: {e}\")\n+    else:\n+        warn(\"config.yaml\", \"not found — using defaults\")\n+\n+    # Voice mode state\n+    voice_mode_path = HERMES_HOME / \"gateway_voice_mode.json\"\n+    if voice_mode_path.exists():\n+        try:\n+            import json\n+            modes = json.loads(voice_mode_path.read_text())\n+            off_count = sum(1 for v in modes.values() if v == \"off\")\n+            all_count = sum(1 for v in modes.values() if v == \"all\")\n+            check(\"Voice mode state\", True, f\"{all_count} on, {off_count} off, {len(modes)} total\")\n+        except Exception:\n+            warn(\"Voice mode state\", \"parse error\")\n+    else:\n+        check(\"Voice mode state\", True, \"no saved state (fresh)\")\n+\n+\n+def check_bot_permissions(token):\n+    \"\"\"Check bot permissions via Discord API. Returns True if all OK.\"\"\"\n+    section(\"Bot Permissions\")\n+\n+    if not token:\n+        warn(\"Bot permissions\", \"no token — skipping\")\n+        return True\n+\n+    try:\n+        import requests\n+    except ImportError:\n+        warn(\"Bot permissions\", \"requests not installed — skipping\")\n+        return True\n+\n+    VOICE_PERMS = {\n+        \"Priority Speaker\":      8,\n+        \"Stream\":                9,\n+        \"View Channel\":         10,\n+        \"Send Messages\":        11,\n+        \"Embed Links\":          14,\n+        \"Attach Files\":         15,\n+        \"Read Message History\": 16,\n+        \"Connect\":              20,\n+        \"Speak\":                21,\n+        \"Mute Members\":         22,\n+        \"Deafen Members\":       23,\n+        \"Move Members\":         24,\n+        \"Use VAD\":              25,\n+        \"Send Voice Messages\":  46,\n+    }\n+    REQUIRED_PERMS = {\"Connect\", \"Speak\", \"View Channel\", \"Send Messages\"}\n+    ok = True\n+\n+    try:\n+        headers = {\"Authorization\": f\"Bot {token}\"}\n+        r = requests.get(\"https://discord.com/api/v10/users/@me\", headers=headers, timeout=5)\n+\n+        if r.status_code == 401:\n+            check(\"Bot login\", False, \"invalid token (401)\")\n+            return False\n+        if r.status_code != 200:\n+            check(\"Bot login\", False, f\"HTTP {r.status_code}\")\n+            return False\n+\n+        bot = r.json()\n+        bot_name = bot.get(\"username\", \"?\")\n+        check(\"Bot login\", True, f\"{bot_name[:3]}{'*' * (len(bot_name) - 3)}\")\n+\n+        # Check guilds\n+        r2 = requests.get(\"https://discord.com/api/v10/users/@me/guilds\", headers=headers, timeout=5)\n+        if r2.status_code != 200:\n+            warn(\"Guilds\", f\"HTTP {r2.status_code}\")\n+            return ok\n+\n+        guilds = r2.json()\n+        check(\"Guilds\", True, f\"{len(guilds)} guild(s)\")\n+\n+        for g in guilds[:5]:\n+            perms = int(g.get(\"permissions\", 0))\n+            is_admin = bool(perms & (1 << 3))\n+\n+            if is_admin:\n+                print(f\"    {OK} {g['name']}: Administrator (all permissions)\")\n+                continue\n+\n+            has = []\n+            missing = []\n+            for name, bit in sorted(VOICE_PERMS.items(), key=lambda x: x[1]):\n+                if perms & (1 << bit):\n+                    has.append(name)\n+                elif name in REQUIRED_PERMS:\n+                    missing.append(name)\n+\n+            if missing:\n+                print(f\"    {FAIL} {g['name']}: missing {', '.join(missing)}\")\n+                ok = False\n+            else:\n+                print(f\"    {OK} {g['name']}: {', '.join(has)}\")\n+\n+    except requests.exceptions.Timeout:\n+        warn(\"Bot permissions\", \"Discord API timeout\")\n+    except requests.exceptions.ConnectionError:\n+        warn(\"Bot permissions\", \"cannot reach Discord API\")\n+    except Exception as e:\n+        warn(\"Bot permissions\", f\"check failed: {e}\")\n+\n+    return ok\n+\n+\n+def main():\n+    print()\n+    print(\"\\033[1m\" + \"=\" * 50 + \"\\033[0m\")\n+    print(\"\\033[1m  Discord Voice Doctor\\033[0m\")\n+    print(\"\\033[1m\" + \"=\" * 50 + \"\\033[0m\")\n+\n+    all_ok = True\n+\n+    all_ok &= check_packages()\n+    all_ok &= check_system_tools()\n+    env_ok, token, groq_key, eleven_key = check_env_vars()\n+    all_ok &= env_ok\n+    check_config(groq_key, eleven_key)\n+    all_ok &= check_bot_permissions(token)\n+\n+    # Summary\n+    print()\n+    print(\"\\033[1m\" + \"-\" * 50 + \"\\033[0m\")\n+    if all_ok:\n+        print(f\"  {OK} \\033[92mAll checks passed — voice mode ready!\\033[0m\")\n+    else:\n+        print(f\"  {FAIL} \\033[91mSome checks failed — fix issues above.\\033[0m\")\n+    print()\n+\n+\n+if __name__ == \"__main__\":\n+    main()"]},{"file":"tests/gateway/test_voice_command.py","hunks":["@@ -1,5 +1,6 @@\n \"\"\"Tests for the /voice command and auto voice reply in the gateway.\"\"\"\n \n+import importlib.util\n import json\n import os\n import queue","@@ -206,9 +207,11 @@ class TestAutoVoiceReply:\n       2. gateway _send_voice_reply: fires based on voice_mode setting\n \n     To prevent double audio, _send_voice_reply is skipped when voice input\n-    already triggered base adapter auto-TTS (skip_double = is_voice_input).\n-    Exception: Discord voice channel — both auto-TTS and Discord play_tts\n-    override skip, so the runner must handle it via play_in_voice_channel.\n+    already triggered base adapter auto-TTS.\n+\n+    For Discord voice channels, the base adapter now routes play_tts directly\n+    into VC playback, so the runner should still skip voice-input follow-ups to\n+    avoid double playback.\n     \"\"\"\n \n     @pytest.fixture","@@ -292,14 +295,14 @@ def test_off_mode_text(self, runner):\n \n     # -- Discord VC exception: runner must handle --------------------------\n \n-    def test_discord_vc_voice_input_runner_fires(self, runner):\n-        \"\"\"Discord VC + voice input: base play_tts skips (VC override),\n-        so runner must handle via play_in_voice_channel.\"\"\"\n-        assert self._call(runner, \"all\", MessageType.VOICE, in_voice_channel=True) is True\n+    def test_discord_vc_voice_input_base_handles(self, runner):\n+        \"\"\"Discord VC + voice input: base adapter play_tts plays in VC,\n+        so runner skips to avoid double playback.\"\"\"\n+        assert self._call(runner, \"all\", MessageType.VOICE, in_voice_channel=True) is False\n \n-    def test_discord_vc_voice_only_runner_fires(self, runner):\n-        \"\"\"Discord VC + voice_only + voice: runner must handle.\"\"\"\n-        assert self._call(runner, \"voice_only\", MessageType.VOICE, in_voice_channel=True) is True\n+    def test_discord_vc_voice_only_base_handles(self, runner):\n+        \"\"\"Discord VC + voice_only + voice: base adapter handles.\"\"\"\n+        assert self._call(runner, \"voice_only\", MessageType.VOICE, in_voice_channel=True) is False\n \n     # -- Edge cases --------------------------------------------------------\n ","@@ -422,17 +425,23 @@ def _make_discord_adapter(self):\n         return adapter\n \n     @pytest.mark.asyncio\n-    async def test_play_tts_skipped_when_in_vc(self):\n+    async def test_play_tts_plays_in_vc_when_connected(self):\n         adapter = self._make_discord_adapter()\n         # Simulate bot in voice channel for guild 111, text channel 123\n         mock_vc = MagicMock()\n         mock_vc.is_connected.return_value = True\n+        mock_vc.is_playing.return_value = False\n         adapter._voice_clients[111] = mock_vc\n         adapter._voice_text_channels[111] = 123\n \n+        # Mock play_in_voice_channel to avoid actual ffmpeg call\n+        async def fake_play(gid, path):\n+            return True\n+        adapter.play_in_voice_channel = fake_play\n+\n         result = await adapter.play_tts(chat_id=\"123\", audio_path=\"/tmp/test.ogg\")\n+        # play_tts now plays in VC instead of being a no-op\n         assert result.success is True\n-        # send_voice should NOT have been called (no client, would fail)\n \n     @pytest.mark.asyncio\n     async def test_play_tts_not_skipped_when_not_in_vc(self):","@@ -728,6 +737,24 @@ async def test_join_exception(self, runner):\n         result = await runner._handle_voice_channel_join(event)\n         assert \"failed\" in result.lower()\n \n+    @pytest.mark.asyncio\n+    async def test_join_missing_voice_dependencies(self, runner):\n+        \"\"\"Missing PyNaCl/davey should return a user-actionable install hint.\"\"\"\n+        mock_channel = MagicMock()\n+        mock_channel.name = \"General\"\n+        mock_adapter = AsyncMock()\n+        mock_adapter.join_voice_channel = AsyncMock(\n+            side_effect=RuntimeError(\"PyNaCl library needed in order to use voice\")\n+        )\n+        mock_adapter.get_user_voice_channel = AsyncMock(return_value=mock_channel)\n+        event = self._make_discord_event()\n+        runner.adapters[event.source.platform] = mock_adapter\n+\n+        result = await runner._handle_voice_channel_join(event)\n+\n+        assert \"voice dependencies are missing\" in result.lower()\n+        assert \"hermes-agent[messaging]\" in result\n+\n     # -- _handle_voice_channel_leave --\n \n     @pytest.mark.asyncio","@@ -2031,3 +2058,534 @@ async def mock_leave(guild_id):\n         assert len(adapter._voice_receivers) == 0\n         assert len(adapter._voice_listen_tasks) == 0\n         assert len(adapter._voice_timeout_tasks) == 0\n+\n+\n+# =====================================================================\n+# Discord Voice Channel Flow Tests\n+# =====================================================================\n+\n+\n[redacted:email](\n+    importlib.util.find_spec(\"nacl\") is None,\n+    reason=\"PyNaCl not installed\",\n+)\n+class TestVoiceReception:\n+    \"\"\"Audio reception: SSRC mapping, DAVE passthrough, buffer lifecycle.\"\"\"\n+\n+    @staticmethod\n+    def _make_receiver(allowed_ids=None, members=None, dave=False, bot_id=9999):\n+        from gateway.platforms.discord import VoiceReceiver\n+        vc = MagicMock()\n+        vc._connection.secret_key = [0] * 32\n+        vc._connection.dave_session = MagicMock() if dave else None\n+        vc._connection.ssrc = bot_id\n+        vc._connection.add_socket_listener = MagicMock()\n+        vc._connection.remove_socket_listener = MagicMock()\n+        vc._connection.hook = None\n+        vc.user = SimpleNamespace(id=bot_id)\n+        vc.channel = MagicMock()\n+        vc.channel.members = members or []\n+        receiver = VoiceReceiver(vc, allowed_user_ids=allowed_ids)\n+        return receiver\n+\n+    @staticmethod\n+    def _fill_buffer(receiver, ssrc, duration_s=1.0, age_s=3.0):\n+        \"\"\"Add PCM data to buffer. 48kHz stereo 16-bit = 192000 bytes/sec.\"\"\"\n+        size = int(192000 * duration_s)\n+        receiver._buffers[ssrc] = bytearray(b\"\\x00\" * size)\n+        receiver._last_packet_time[ssrc] = time.monotonic() - age_s\n+\n+    # -- Known SSRC (normal flow) --\n+\n+    def test_known_ssrc_returns_completed(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.map_ssrc(100, 42)\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+        assert len(receiver._buffers[100]) == 0  # cleared\n+\n+    def test_known_ssrc_short_buffer_ignored(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.map_ssrc(100, 42)\n+        self._fill_buffer(receiver, 100, duration_s=0.1)  # too short\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_known_ssrc_recent_audio_waits(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.map_ssrc(100, 42)\n+        self._fill_buffer(receiver, 100, age_s=0.0)  # just arrived\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    # -- Unknown SSRC + DAVE passthrough --\n+\n+    def test_unknown_ssrc_no_automap_no_completed(self):\n+        \"\"\"Unknown SSRC, no members to infer — buffer cleared, not returned.\"\"\"\n+        receiver = self._make_receiver(dave=True, members=[])\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+        assert len(receiver._buffers[100]) == 0\n+\n+    def test_unknown_ssrc_late_speaking_event(self):\n+        \"\"\"Audio buffered before SPEAKING → SPEAKING maps → next check returns it.\"\"\"\n+        receiver = self._make_receiver(dave=True)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100, age_s=0.0)  # still receiving\n+        # No user yet\n+        assert receiver.check_silence() == []\n+        # SPEAKING event arrives\n+        receiver.map_ssrc(100, 42)\n+        # Silence kicks in\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+    # -- SSRC auto-mapping --\n+\n+    def test_automap_single_allowed_user(self):\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = self._make_receiver(allowed_ids={\"42\"}, members=members)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+        assert receiver._ssrc_to_user[100] == 42\n+\n+    def test_automap_multiple_allowed_users_no_map(self):\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+            SimpleNamespace(id=43, name=\"Bob\"),\n+        ]\n+        receiver = self._make_receiver(allowed_ids={\"42\", \"43\"}, members=members)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_automap_no_allowlist_single_member(self):\n+        \"\"\"No allowed_user_ids → sole non-bot member inferred.\"\"\"\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = self._make_receiver(allowed_ids=None, members=members)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+    def test_automap_unallowed_user_rejected(self):\n+        \"\"\"User in channel but not in allowed list — not mapped.\"\"\"\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = self._make_receiver(allowed_ids={\"99\"}, members=members)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_automap_only_bot_in_channel(self):\n+        \"\"\"Only bot in channel — no one to map to.\"\"\"\n+        members = [SimpleNamespace(id=9999, name=\"Bot\")]\n+        receiver = self._make_receiver(allowed_ids=None, members=members)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_automap_persists_across_calls(self):\n+        \"\"\"Auto-mapped SSRC stays mapped for subsequent checks.\"\"\"\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = self._make_receiver(allowed_ids={\"42\"}, members=members)\n+        receiver.start()\n+        self._fill_buffer(receiver, 100)\n+        receiver.check_silence()\n+        assert receiver._ssrc_to_user[100] == 42\n+        # Second utterance — should use cached mapping\n+        self._fill_buffer(receiver, 100)\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+    # -- Stale buffer cleanup --\n+\n+    def test_stale_unknown_buffer_discarded(self):\n+        \"\"\"Buffer with no user and very old timestamp is discarded.\"\"\"\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver._buffers[200] = bytearray(b\"\\x00\" * 100)\n+        receiver._last_packet_time[200] = time.monotonic() - 10.0\n+        receiver.check_silence()\n+        assert 200 not in receiver._buffers\n+\n+    # -- Pause / resume (echo prevention) --\n+\n+    def test_paused_receiver_ignores_packets(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.pause()\n+        receiver._on_packet(b\"\\x00\" * 100)\n+        assert len(receiver._buffers) == 0\n+\n+    def test_resumed_receiver_accepts_packets(self):\n+        receiver = self._make_receiver()\n+        receiver.start()\n+        receiver.pause()\n+        receiver.resume()\n+        assert receiver._paused is False\n+\n+    # -- _on_packet DAVE passthrough behavior --\n+\n+    def _make_receiver_with_nacl(self, dave_session=None, mapped_ssrcs=None):\n+        \"\"\"Create a receiver that can process _on_packet with mocked NaCl + Opus.\"\"\"\n+        from gateway.platforms.discord import VoiceReceiver\n+        vc = MagicMock()\n+        vc._connection.secret_key = [0] * 32\n+        vc._connection.dave_session = dave_session\n+        vc._connection.ssrc = 9999\n+        vc._connection.add_socket_listener = MagicMock()\n+        vc._connection.remove_socket_listener = MagicMock()\n+        vc._connection.hook = None\n+        vc.user = SimpleNamespace(id=9999)\n+        vc.channel = MagicMock()\n+        vc.channel.members = []\n+        receiver = VoiceReceiver(vc)\n+        receiver.start()\n+        # Pre-map SSRCs if provided\n+        if mapped_ssrcs:\n+            for ssrc, uid in mapped_ssrcs.items():\n+                receiver.map_ssrc(ssrc, uid)\n+        return receiver\n+\n+    @staticmethod\n+    def _build_rtp_packet(ssrc=100, seq=1, timestamp=960):\n+        \"\"\"Build a minimal valid RTP packet for _on_packet.\n+\n+        We need: RTP header (12 bytes) + encrypted payload + 4-byte nonce.\n+        NaCl decrypt is mocked so payload content doesn't matter.\n+        \"\"\"\n+        import struct\n+        # RTP header: version=2, payload_type=0x78, no extension, no CSRC\n+        header = struct.pack(\">BBHII\", 0x80, 0x78, seq, timestamp, ssrc)\n+        # Fake encrypted payload (NaCl will be mocked) + 4 byte nonce\n+        payload = b\"\\x00\" * 20 + b\"\\x00\\x00\\x00\\x01\"\n+        return header + payload\n+\n+    def _inject_mock_decoder(self, receiver, ssrc):\n+        \"\"\"Pre-inject a mock Opus decoder for the given SSRC.\"\"\"\n+        mock_decoder = MagicMock()\n+        mock_decoder.decode.return_value = b\"\\x00\" * 3840\n+        receiver._decoders[ssrc] = mock_decoder\n+        return mock_decoder\n+\n+    def test_on_packet_dave_known_user_decrypt_ok(self):\n+        \"\"\"Known SSRC + DAVE decrypt success → audio buffered.\"\"\"\n+        dave = MagicMock()\n+        dave.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+        receiver = self._make_receiver_with_nacl(\n+            dave_session=dave, mapped_ssrcs={100: 42}\n+        )\n+        self._inject_mock_decoder(receiver, 100)\n+\n+        with patch(\"nacl.secret.Aead\") as mock_aead:\n+            mock_aead.return_value.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+            receiver._on_packet(self._build_rtp_packet(ssrc=100))\n+\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+        dave.decrypt.assert_called_once()\n+\n+    def test_on_packet_dave_unknown_ssrc_passthrough(self):\n+        \"\"\"Unknown SSRC + DAVE → skip DAVE, attempt Opus decode (passthrough).\"\"\"\n+        dave = MagicMock()\n+        receiver = self._make_receiver_with_nacl(dave_session=dave)\n+        self._inject_mock_decoder(receiver, 100)\n+\n+        with patch(\"nacl.secret.Aead\") as mock_aead:\n+            mock_aead.return_value.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+            receiver._on_packet(self._build_rtp_packet(ssrc=100))\n+\n+        dave.decrypt.assert_not_called()\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_on_packet_dave_unencrypted_error_passthrough(self):\n+        \"\"\"DAVE decrypt 'Unencrypted' error → use data as-is, don't drop.\"\"\"\n+        dave = MagicMock()\n+        dave.decrypt.side_effect = Exception(\n+            \"Failed to decrypt: DecryptionFailed(UnencryptedWhenPassthroughDisabled)\"\n+        )\n+        receiver = self._make_receiver_with_nacl(\n+            dave_session=dave, mapped_ssrcs={100: 42}\n+        )\n+        self._inject_mock_decoder(receiver, 100)\n+\n+        with patch(\"nacl.secret.Aead\") as mock_aead:\n+            mock_aead.return_value.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+            receiver._on_packet(self._build_rtp_packet(ssrc=100))\n+\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_on_packet_dave_other_error_drops(self):\n+        \"\"\"DAVE decrypt non-Unencrypted error → packet dropped.\"\"\"\n+        dave = MagicMock()\n+        dave.decrypt.side_effect = Exception(\"KeyRotationFailed\")\n+        receiver = self._make_receiver_with_nacl(\n+            dave_session=dave, mapped_ssrcs={100: 42}\n+        )\n+\n+        with patch(\"nacl.secret.Aead\") as mock_aead:\n+            mock_aead.return_value.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+            receiver._on_packet(self._build_rtp_packet(ssrc=100))\n+\n+        assert len(receiver._buffers.get(100, b\"\")) == 0\n+\n+    def test_on_packet_no_dave_direct_decode(self):\n+        \"\"\"No DAVE session → decode directly.\"\"\"\n+        receiver = self._make_receiver_with_nacl(dave_session=None)\n+        self._inject_mock_decoder(receiver, 100)\n+\n+        with patch(\"nacl.secret.Aead\") as mock_aead:\n+            mock_aead.return_value.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+            receiver._on_packet(self._build_rtp_packet(ssrc=100))\n+\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_on_packet_bot_own_ssrc_ignored(self):\n+        \"\"\"Bot's own SSRC → dropped (echo prevention).\"\"\"\n+        receiver = self._make_receiver_with_nacl()\n+        with patch(\"nacl.secret.Aead\"):\n+            receiver._on_packet(self._build_rtp_packet(ssrc=9999))\n+        assert len(receiver._buffers) == 0\n+\n+    def test_on_packet_multiple_ssrcs_separate_buffers(self):\n+        \"\"\"Different SSRCs → separate buffers.\"\"\"\n+        receiver = self._make_receiver_with_nacl(dave_session=None)\n+        self._inject_mock_decoder(receiver, 100)\n+        self._inject_mock_decoder(receiver, 200)\n+\n+        with patch(\"nacl.secret.Aead\") as mock_aead:\n+            mock_aead.return_value.decrypt.return_value = b\"\\xf8\\xff\\xfe\"\n+            receiver._on_packet(self._build_rtp_packet(ssrc=100))\n+            receiver._on_packet(self._build_rtp_packet(ssrc=200))\n+\n+        assert 100 in receiver._buffers\n+        assert 200 in receiver._buffers\n+\n+\n+class TestVoiceTTSPlayback:\n+    \"\"\"TTS playback: play_tts in VC, dedup, fallback.\"\"\"\n+\n+    @staticmethod\n+    def _make_discord_adapter():\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import PlatformConfig, Platform\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"[redacted:secret-assignment]\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter.platform = Platform.DISCORD\n+        adapter.config = config\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_receivers = {}\n+        return adapter\n+\n+    # -- play_tts behavior --\n+\n+    @pytest.mark.asyncio\n+    async def test_play_tts_plays_in_vc(self):\n+        \"\"\"play_tts calls play_in_voice_channel when bot is in VC.\"\"\"\n+        adapter = self._make_discord_adapter()\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 123\n+\n+        played = []\n+        async def fake_play(gid, path):\n+            played.append((gid, path))\n+            return True\n+        adapter.play_in_voice_channel = fake_play\n+\n+        result = await adapter.play_tts(chat_id=\"123\", audio_path=\"/tmp/tts.ogg\")\n+        assert result.success is True\n+        assert played == [(111, \"/tmp/tts.ogg\")]\n+\n+    @pytest.mark.asyncio\n+    async def test_play_tts_fallback_when_not_in_vc(self):\n+        \"\"\"play_tts sends as file attachment when bot is not in VC.\"\"\"\n+        adapter = self._make_discord_adapter()\n+        from gateway.platforms.base import SendResult\n+        adapter.send_voice = AsyncMock(return_value=SendResult(success=False, error=\"no client\"))\n+        result = await adapter.play_tts(chat_id=\"123\", audio_path=\"/tmp/tts.ogg\")\n+        assert result.success is False\n+        adapter.send_voice.assert_called_once()\n+\n+    @pytest.mark.asyncio\n+    async def test_play_tts_wrong_channel_no_match(self):\n+        \"\"\"play_tts doesn't match if chat_id is for a different channel.\"\"\"\n+        adapter = self._make_discord_adapter()\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        adapter._voice_clients[111] = mock_vc\n+        adapter._voice_text_channels[111] = 123\n+\n+        from gateway.platforms.base import SendResult\n+        adapter.send_voice = AsyncMock(return_value=SendResult(success=True))\n+        # Different chat_id — shouldn't match VC\n+        result = await adapter.play_tts(chat_id=\"999\", audio_path=\"/tmp/tts.ogg\")\n+        adapter.send_voice.assert_called_once()\n+\n+    # -- Runner dedup --\n+\n+    @staticmethod\n+    def _make_runner():\n+        from gateway.run import GatewayRunner\n+        runner = object.__new__(GatewayRunner)\n+        runner._voice_mode = {}\n+        runner.adapters = {}\n+        return runner\n+\n+    def _call_should_reply(self, runner, voice_mode, msg_type, response=\"Hello\", agent_msgs=None):\n+        from gateway.platforms.base import MessageType, MessageEvent, SessionSource\n+        from gateway.config import Platform\n+        runner._voice_mode[\"ch1\"] = voice_mode\n+        source = SessionSource(\n+            platform=Platform.DISCORD, chat_id=\"ch1\",\n+            user_id=\"1\", user_name=\"test\", chat_type=\"channel\",\n+        )\n+        event = MessageEvent(source=source, text=\"test\", message_type=msg_type)\n+        return runner._should_send_voice_reply(event, response, agent_msgs or [])\n+\n+    def test_voice_input_runner_skips(self):\n+        \"\"\"Voice input: runner skips — base adapter handles via play_tts.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        assert self._call_should_reply(runner, \"all\", MessageType.VOICE) is False\n+\n+    def test_text_input_voice_all_runner_fires(self):\n+        \"\"\"Text input + voice_mode=all: runner generates TTS.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        assert self._call_should_reply(runner, \"all\", MessageType.TEXT) is True\n+\n+    def test_text_input_voice_off_no_tts(self):\n+        \"\"\"Text input + voice_mode=off: no TTS.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        assert self._call_should_reply(runner, \"off\", MessageType.TEXT) is False\n+\n+    def test_text_input_voice_only_no_tts(self):\n+        \"\"\"Text input + voice_mode=voice_only: no TTS for text.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        assert self._call_should_reply(runner, \"voice_only\", MessageType.TEXT) is False\n+\n+    def test_error_response_no_tts(self):\n+        \"\"\"Error response: no TTS regardless of voice_mode.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        assert self._call_should_reply(runner, \"all\", MessageType.TEXT, response=\"Error: boom\") is False\n+\n+    def test_empty_response_no_tts(self):\n+        \"\"\"Empty response: no TTS.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        assert self._call_should_reply(runner, \"all\", MessageType.TEXT, response=\"\") is False\n+\n+    def test_agent_tts_tool_dedup(self):\n+        \"\"\"Agent already called text_to_speech tool: runner skips.\"\"\"\n+        from gateway.platforms.base import MessageType\n+        runner = self._make_runner()\n+        agent_msgs = [{\"role\": \"assistant\", \"tool_calls\": [\n+            {\"id\": \"1\", \"type\": \"function\", \"function\": {\"name\": \"text_to_speech\", \"arguments\": \"{}\"}}\n+        ]}]\n+        assert self._call_should_reply(runner, \"all\", MessageType.TEXT, agent_msgs=agent_msgs) is False\n+\n+\n+class TestUDPKeepalive:\n+    \"\"\"UDP keepalive prevents Discord from dropping the voice session.\"\"\"\n+\n+    def test_keepalive_interval_is_reasonable(self):\n+        from gateway.platforms.discord import DiscordAdapter\n+        interval = DiscordAdapter._KEEPALIVE_INTERVAL\n+        assert 5 <= interval <= 30, f\"Keepalive interval {interval}s should be between 5-30s\"\n+\n+    @pytest.mark.asyncio\n+    async def test_keepalive_sends_silence_frame(self):\n+        \"\"\"Listen loop sends silence frame via send_packet after interval.\"\"\"\n+        from gateway.platforms.discord import DiscordAdapter\n+        from gateway.config import PlatformConfig, Platform\n+\n+        config = PlatformConfig(enabled=True, extra={})\n+        config.token = \"fake\"\n+        adapter = object.__new__(DiscordAdapter)\n+        adapter.platform = Platform.DISCORD\n+        adapter.config = config\n+        adapter._voice_clients = {}\n+        adapter._voice_text_channels = {}\n+        adapter._voice_receivers = {}\n+        adapter._voice_listen_tasks = {}\n+\n+        # Mock VC and receiver\n+        mock_vc = MagicMock()\n+        mock_vc.is_connected.return_value = True\n+        mock_conn = MagicMock()\n+        adapter._voice_clients[111] = mock_vc\n+        mock_vc._connection = mock_conn\n+\n+        from gateway.platforms.discord import VoiceReceiver\n+        mock_receiver_vc = MagicMock()\n+        mock_receiver_vc._connection.secret_key = [0] * 32\n+        mock_receiver_vc._connection.dave_session = None\n+        mock_receiver_vc._connection.ssrc = 9999\n+        mock_receiver_vc._connection.add_socket_listener = MagicMock()\n+        mock_receiver_vc._connection.remove_socket_listener = MagicMock()\n+        mock_receiver_vc._connection.hook = None\n+        receiver = VoiceReceiver(mock_receiver_vc)\n+        receiver.start()\n+        adapter._voice_receivers[111] = receiver\n+\n+        # Set keepalive interval very short for test\n+        original_interval = DiscordAdapter._KEEPALIVE_INTERVAL\n+        DiscordAdapter._KEEPALIVE_INTERVAL = 0.1\n+\n+        try:\n+            # Run listen loop briefly\n+            import asyncio\n+            loop_task = asyncio.create_task(adapter._voice_listen_loop(111))\n+            await asyncio.sleep(0.3)\n+            receiver._running = False  # stop loop\n+            await asyncio.sleep(0.1)\n+            loop_task.cancel()\n+            try:\n+                await loop_task\n+            except asyncio.CancelledError:\n+                pass\n+\n+            # send_packet should have been called with silence frame\n+            mock_conn.send_packet.assert_called_with(b'\\xf8\\xff\\xfe')\n+        finally:\n+            DiscordAdapter._KEEPALIVE_INTERVAL = original_interval"]},{"file":"tests/integration/test_voice_channel_flow.py","hunks":["@@ -0,0 +1,611 @@\n+\"\"\"Integration tests for Discord voice channel audio flow.\n+\n+Uses real NaCl encryption and Opus codec (no mocks for crypto/codec).\n+Does NOT require a Discord connection — tests the VoiceReceiver\n+packet processing pipeline end-to-end.\n+\n+Requires: PyNaCl>=1.5.0, discord.py[voice] (opus codec)\n+\"\"\"\n+\n+import struct\n+import time\n+import pytest\n+\n+pytestmark = pytest.mark.integration\n+\n+# Skip entire module if voice deps are missing\n+pytest.importorskip(\"nacl.secret\", reason=\"PyNaCl required for voice integration tests\")\n+discord = pytest.importorskip(\"discord\", reason=\"discord.py required for voice integration tests\")\n+\n+import nacl.secret\n+\n+try:\n+    if not discord.opus.is_loaded():\n+        import ctypes.util\n+        opus_path = ctypes.util.find_library(\"opus\")\n+        if not opus_path:\n+            import sys\n+            for p in (\"/opt/homebrew/lib/libopus.dylib\", \"/usr/local/lib/libopus.dylib\"):\n+                import os\n+                if os.path.isfile(p):\n+                    opus_path = p\n+                    break\n+        if opus_path:\n+            discord.opus.load_opus(opus_path)\n+    OPUS_AVAILABLE = discord.opus.is_loaded()\n+except Exception:\n+    OPUS_AVAILABLE = False\n+\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock\n+from gateway.platforms.discord import VoiceReceiver\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def _make_secret_key():\n+    \"\"\"Generate a random 32-byte key.\"\"\"\n+    import os\n+    return os.urandom(32)\n+\n+\n+def _build_encrypted_rtp_packet(secret_key, opus_payload, ssrc=100, seq=1, timestamp=960):\n+    \"\"\"Build a real NaCl-encrypted RTP packet matching Discord's format.\n+\n+    Format: RTP header (12 bytes) + encrypted(opus) + 4-byte nonce\n+    Encryption: aead_xchacha20_poly1305 with RTP header as AAD.\n+    \"\"\"\n+    # RTP header: version=2, payload_type=0x78, no extension, no CSRC\n+    header = struct.pack(\">BBHII\", 0x80, 0x78, seq, timestamp, ssrc)\n+\n+    # Encrypt with NaCl AEAD\n+    box = nacl.secret.Aead(secret_key)\n+    nonce_counter = struct.pack(\">I\", seq)  # 4-byte counter as nonce seed\n+    # Full 24-byte nonce: counter in first 4 bytes, rest zeros\n+    full_nonce = nonce_counter + b'\\x00' * 20\n+\n+    enc_msg = box.encrypt(opus_payload, header, full_nonce)\n+    ciphertext = enc_msg.ciphertext  # without nonce prefix\n+\n+    # Discord format: header + ciphertext + 4-byte nonce\n+    return header + ciphertext + nonce_counter\n+\n+\n+def _make_voice_receiver(secret_key, dave_session=None, bot_ssrc=9999,\n+                         allowed_user_ids=None, members=None):\n+    \"\"\"Create a VoiceReceiver with real secret key.\"\"\"\n+    vc = MagicMock()\n+    vc._connection.secret_key = [redacted:secret-assignment]\n+    vc._connection.dave_session = dave_session\n+    vc._connection.ssrc = bot_ssrc\n+    vc._connection.add_socket_listener = MagicMock()\n+    vc._connection.remove_socket_listener = MagicMock()\n+    vc._connection.hook = None\n+    vc.user = SimpleNamespace(id=bot_ssrc)\n+    vc.channel = MagicMock()\n+    vc.channel.members = members or []\n+    receiver = VoiceReceiver(vc, allowed_user_ids=allowed_user_ids)\n+    receiver.start()\n+    return receiver\n+\n+\n+# ---------------------------------------------------------------------------\n+# Tests\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestRealNaClDecrypt:\n+    \"\"\"End-to-end: real NaCl encrypt → _on_packet decrypt → buffer.\"\"\"\n+\n+    def test_valid_encrypted_packet_buffered(self):\n+        \"\"\"Real NaCl encrypted packet → decrypted → buffered.\"\"\"\n+        key = _make_secret_key()\n+        opus_silence = b'\\xf8\\xff\\xfe'\n+        receiver = _make_voice_receiver(key)\n+\n+        packet = _build_encrypted_rtp_packet(key, opus_silence, ssrc=100)\n+        receiver._on_packet(packet)\n+\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_wrong_key_packet_dropped(self):\n+        \"\"\"Packet encrypted with wrong key → NaCl fails → not buffered.\"\"\"\n+        real_key = _make_secret_key()\n+        wrong_key = _make_secret_key()\n+        opus_silence = b'\\xf8\\xff\\xfe'\n+        receiver = _make_voice_receiver(real_key)\n+\n+        packet = _build_encrypted_rtp_packet(wrong_key, opus_silence, ssrc=100)\n+        receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers.get(100, b\"\")) == 0\n+\n+    def test_bot_ssrc_ignored(self):\n+        \"\"\"Packet from bot's own SSRC → ignored.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key, bot_ssrc=9999)\n+\n+        packet = _build_encrypted_rtp_packet(key, b'\\xf8\\xff\\xfe', ssrc=9999)\n+        receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers) == 0\n+\n+    def test_multiple_packets_accumulate(self):\n+        \"\"\"Multiple valid packets → buffer grows.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+\n+        for seq in range(1, 6):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        assert 100 in receiver._buffers\n+        buf_size = len(receiver._buffers[100])\n+        assert buf_size > 0, \"Multiple packets should accumulate in buffer\"\n+\n+    def test_different_ssrcs_separate_buffers(self):\n+        \"\"\"Packets from different SSRCs → separate buffers.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+\n+        for ssrc in [100, 200, 300]:\n+            packet = _build_encrypted_rtp_packet(key, b'\\xf8\\xff\\xfe', ssrc=ssrc)\n+            receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers) == 3\n+        for ssrc in [100, 200, 300]:\n+            assert ssrc in receiver._buffers\n+\n+\n+class TestRealNaClWithDAVE:\n+    \"\"\"NaCl decrypt + DAVE passthrough scenarios with real crypto.\"\"\"\n+\n+    def test_dave_unknown_ssrc_passthrough(self):\n+        \"\"\"DAVE enabled but SSRC unknown → skip DAVE, buffer audio.\"\"\"\n+        key = _make_secret_key()\n+        dave = MagicMock()  # DAVE session present but SSRC not mapped\n+        receiver = _make_voice_receiver(key, dave_session=dave)\n+\n+        packet = _build_encrypted_rtp_packet(key, b'\\xf8\\xff\\xfe', ssrc=100)\n+        receiver._on_packet(packet)\n+\n+        # DAVE decrypt not called (SSRC unknown)\n+        dave.decrypt.assert_not_called()\n+        # Audio still buffered via passthrough\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_dave_unencrypted_error_passthrough(self):\n+        \"\"\"DAVE raises 'Unencrypted' → use NaCl-decrypted data as-is.\"\"\"\n+        key = _make_secret_key()\n+        dave = MagicMock()\n+        dave.decrypt.side_effect = Exception(\n+            \"DecryptionFailed(UnencryptedWhenPassthroughDisabled)\"\n+        )\n+        receiver = _make_voice_receiver(key, dave_session=dave)\n+        receiver.map_ssrc(100, 42)\n+\n+        packet = _build_encrypted_rtp_packet(key, b'\\xf8\\xff\\xfe', ssrc=100)\n+        receiver._on_packet(packet)\n+\n+        # DAVE was called but failed → passthrough\n+        dave.decrypt.assert_called_once()\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_dave_real_error_drops(self):\n+        \"\"\"DAVE raises non-Unencrypted error → packet dropped.\"\"\"\n+        key = _make_secret_key()\n+        dave = MagicMock()\n+        dave.decrypt.side_effect = Exception(\"KeyRotationFailed\")\n+        receiver = _make_voice_receiver(key, dave_session=dave)\n+        receiver.map_ssrc(100, 42)\n+\n+        packet = _build_encrypted_rtp_packet(key, b'\\xf8\\xff\\xfe', ssrc=100)\n+        receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers.get(100, b\"\")) == 0\n+\n+\n+class TestFullVoiceFlow:\n+    \"\"\"End-to-end: encrypt → receive → buffer → silence detect → complete.\"\"\"\n+\n+    def test_single_utterance_flow(self):\n+        \"\"\"Encrypt packets → buffer → silence → check_silence returns utterance.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(100, 42)\n+\n+        # Send enough packets to exceed MIN_SPEECH_DURATION (0.5s)\n+        # At 48kHz stereo 16-bit, each Opus silence frame decodes to ~3840 bytes\n+        # Need 96000 bytes = ~25 frames\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        # Simulate silence by setting last_packet_time in the past\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        user_id, pcm_data = completed[0]\n+        assert user_id == 42\n+        assert len(pcm_data) > 0\n+\n+    def test_utterance_with_ssrc_automap(self):\n+        \"\"\"No SPEAKING event → auto-map sole allowed user → utterance processed.\"\"\"\n+        key = _make_secret_key()\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = _make_voice_receiver(\n+            key, allowed_user_ids={\"42\"}, members=members\n+        )\n+        # No map_ssrc call — simulating missing SPEAKING event\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42  # auto-mapped to sole allowed user\n+\n+    def test_pause_blocks_during_playback(self):\n+        \"\"\"Pause receiver → packets ignored → resume → packets accepted.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+\n+        # Pause (echo prevention during TTS playback)\n+        receiver.pause()\n+        packet = _build_encrypted_rtp_packet(key, b'\\xf8\\xff\\xfe', ssrc=100)\n+        receiver._on_packet(packet)\n+        assert len(receiver._buffers.get(100, b\"\")) == 0\n+\n+        # Resume\n+        receiver.resume()\n+        receiver._on_packet(packet)\n+        assert 100 in receiver._buffers\n+        assert len(receiver._buffers[100]) > 0\n+\n+    def test_corrupted_packet_ignored(self):\n+        \"\"\"Corrupted/truncated packet → silently ignored.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+\n+        # Too short\n+        receiver._on_packet(b\"\\x00\" * 5)\n+        assert len(receiver._buffers) == 0\n+\n+        # Wrong RTP version\n+        bad_header = struct.pack(\">BBHII\", 0x00, 0x78, 1, 960, 100)\n+        receiver._on_packet(bad_header + b\"\\x00\" * 20)\n+        assert len(receiver._buffers) == 0\n+\n+        # Wrong payload type\n+        bad_pt = struct.pack(\">BBHII\", 0x80, 0x00, 1, 960, 100)\n+        receiver._on_packet(bad_pt + b\"\\x00\" * 20)\n+        assert len(receiver._buffers) == 0\n+\n+    def test_stop_cleans_everything(self):\n+        \"\"\"stop() clears all state cleanly.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(100, 42)\n+\n+        for seq in range(1, 10):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers[100]) > 0\n+\n+        receiver.stop()\n+        assert receiver._running is False\n+        assert len(receiver._buffers) == 0\n+        assert len(receiver._ssrc_to_user) == 0\n+        assert len(receiver._decoders) == 0\n+\n+\n+class TestSPEAKINGHook:\n+    \"\"\"SPEAKING event hook correctly maps SSRC to user_id.\"\"\"\n+\n+    def test_speaking_hook_installed(self):\n+        \"\"\"start() installs speaking hook on connection.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        conn = receiver._vc._connection\n+        # hook should be set (wrapped)\n+        assert conn.hook is not None\n+\n+    def test_map_ssrc_via_speaking(self):\n+        \"\"\"SPEAKING op 5 event maps SSRC to user_id.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(500, 12345)\n+        assert receiver._ssrc_to_user[500] == 12345\n+\n+    def test_map_ssrc_overwrites(self):\n+        \"\"\"New SPEAKING event for same SSRC overwrites old mapping.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(500, 111)\n+        receiver.map_ssrc(500, 222)\n+        assert receiver._ssrc_to_user[500] == 222\n+\n+    def test_speaking_mapped_audio_processed(self):\n+        \"\"\"After SSRC is mapped, audio from that SSRC gets correct user_id.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(100, 42)\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+\n+class TestAuthFiltering:\n+    \"\"\"Only allowed users' audio should be processed.\"\"\"\n+\n+    def test_allowed_user_audio_processed(self):\n+        \"\"\"Allowed user's utterance is returned by check_silence.\"\"\"\n+        key = _make_secret_key()\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = _make_voice_receiver(\n+            key, allowed_user_ids={\"42\"}, members=members,\n+        )\n+        receiver.map_ssrc(100, 42)\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+    def test_automap_rejects_unallowed_user(self):\n+        \"\"\"Auto-map refuses to map SSRC to user not in allowed list.\"\"\"\n+        key = _make_secret_key()\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = _make_voice_receiver(\n+            key, allowed_user_ids={\"99\"},  # Alice not allowed\n+            members=members,\n+        )\n+        # No map_ssrc — SSRC unknown, auto-map should reject\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 0\n+\n+    def test_empty_allowlist_allows_all(self):\n+        \"\"\"Empty allowed_user_ids means no restriction.\"\"\"\n+        key = _make_secret_key()\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+        receiver = _make_voice_receiver(\n+            key, allowed_user_ids=None, members=members,\n+        )\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        # Auto-mapped to sole non-bot member\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+\n+class TestRejoinFlow:\n+    \"\"\"Leave and rejoin: state cleanup and fresh receiver.\"\"\"\n+\n+    def test_stop_then_new_receiver_clean_state(self):\n+        \"\"\"After stop(), a new receiver starts with empty state.\"\"\"\n+        key = _make_secret_key()\n+        receiver1 = _make_voice_receiver(key)\n+        receiver1.map_ssrc(100, 42)\n+\n+        for seq in range(1, 10):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver1._on_packet(packet)\n+\n+        assert len(receiver1._buffers[100]) > 0\n+        receiver1.stop()\n+\n+        # New receiver (simulates rejoin)\n+        receiver2 = _make_voice_receiver(key)\n+        assert len(receiver2._buffers) == 0\n+        assert len(receiver2._ssrc_to_user) == 0\n+        assert len(receiver2._decoders) == 0\n+\n+    def test_rejoin_new_ssrc_works(self):\n+        \"\"\"After rejoin, user may get new SSRC — still works.\"\"\"\n+        key = _make_secret_key()\n+        receiver1 = _make_voice_receiver(key)\n+        receiver1.map_ssrc(100, 42)  # old SSRC\n+        receiver1.stop()\n+\n+        receiver2 = _make_voice_receiver(key)\n+        receiver2.map_ssrc(200, 42)  # new SSRC after rejoin\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=200, seq=seq, timestamp=960 * seq\n+            )\n+            receiver2._on_packet(packet)\n+\n+        receiver2._last_packet_time[200] = time.monotonic() - 3.0\n+        completed = receiver2.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+    def test_rejoin_without_speaking_event_automap(self):\n+        \"\"\"Rejoin without SPEAKING event — auto-map sole allowed user.\"\"\"\n+        key = _make_secret_key()\n+        members = [\n+            SimpleNamespace(id=9999, name=\"Bot\"),\n+            SimpleNamespace(id=42, name=\"Alice\"),\n+        ]\n+\n+        # First session\n+        receiver1 = _make_voice_receiver(\n+            key, allowed_user_ids={\"42\"}, members=members,\n+        )\n+        receiver1.stop()\n+\n+        # Rejoin — new key (Discord may assign new secret_key)\n+        new_key = _make_secret_key()\n+        receiver2 = _make_voice_receiver(\n+            new_key, allowed_user_ids={\"42\"}, members=members,\n+        )\n+        # No map_ssrc — simulating missing SPEAKING event\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                new_key, b'\\xf8\\xff\\xfe', ssrc=300, seq=seq, timestamp=960 * seq\n+            )\n+            receiver2._on_packet(packet)\n+\n+        receiver2._last_packet_time[300] = time.monotonic() - 3.0\n+        completed = receiver2.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n+\n+\n+class TestMultiGuildIsolation:\n+    \"\"\"Each guild has independent voice state.\"\"\"\n+\n+    def test_separate_receivers_independent(self):\n+        \"\"\"Two receivers (different guilds) don't interfere.\"\"\"\n+        key1 = _make_secret_key()\n+        key2 = _make_secret_key()\n+\n+        receiver1 = _make_voice_receiver(key1, bot_ssrc=1111)\n+        receiver2 = _make_voice_receiver(key2, bot_ssrc=2222)\n+\n+        receiver1.map_ssrc(100, 42)\n+        receiver2.map_ssrc(200, 99)\n+\n+        # Send to receiver1\n+        for seq in range(1, 10):\n+            packet = _build_encrypted_rtp_packet(\n+                key1, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver1._on_packet(packet)\n+\n+        # receiver2 should be empty\n+        assert len(receiver2._buffers) == 0\n+        assert 100 in receiver1._buffers\n+\n+    def test_stop_one_doesnt_affect_other(self):\n+        \"\"\"Stopping one receiver doesn't affect another.\"\"\"\n+        key1 = _make_secret_key()\n+        key2 = _make_secret_key()\n+\n+        receiver1 = _make_voice_receiver(key1)\n+        receiver2 = _make_voice_receiver(key2)\n+\n+        receiver1.map_ssrc(100, 42)\n+        receiver2.map_ssrc(200, 99)\n+\n+        for seq in range(1, 10):\n+            packet = _build_encrypted_rtp_packet(\n+                key2, b'\\xf8\\xff\\xfe', ssrc=200, seq=seq, timestamp=960 * seq\n+            )\n+            receiver2._on_packet(packet)\n+\n+        receiver1.stop()\n+\n+        # receiver2 still has data\n+        assert receiver2._running is True\n+        assert len(receiver2._buffers[200]) > 0\n+\n+\n+class TestEchoPreventionFlow:\n+    \"\"\"Receiver pause/resume during TTS playback prevents echo.\"\"\"\n+\n+    def test_audio_during_pause_ignored(self):\n+        \"\"\"Audio arriving while paused is completely ignored.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(100, 42)\n+        receiver.pause()\n+\n+        for seq in range(1, 30):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers.get(100, b\"\")) == 0\n+\n+    def test_audio_after_resume_processed(self):\n+        \"\"\"Audio arriving after resume is processed normally.\"\"\"\n+        key = _make_secret_key()\n+        receiver = _make_voice_receiver(key)\n+        receiver.map_ssrc(100, 42)\n+\n+        # Pause → send packets → resume → send more packets\n+        receiver.pause()\n+        for seq in range(1, 5):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+        assert len(receiver._buffers.get(100, b\"\")) == 0\n+\n+        receiver.resume()\n+        for seq in range(5, 35):\n+            packet = _build_encrypted_rtp_packet(\n+                key, b'\\xf8\\xff\\xfe', ssrc=100, seq=seq, timestamp=960 * seq\n+            )\n+            receiver._on_packet(packet)\n+\n+        assert len(receiver._buffers[100]) > 0\n+        receiver._last_packet_time[100] = time.monotonic() - 3.0\n+        completed = receiver.check_silence()\n+        assert len(completed) == 1\n+        assert completed[0][0] == 42\n"]}]},{"kind":"pr","number":1728,"title":"fix(update): use .[all] extras with fallback in hermes update","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/hooks.py","gateway/run.py","hermes_cli/main.py","tests/hermes_cli/test_update_autostash.py"],"fix_hunks":[{"file":"gateway/hooks.py","hunks":["@@ -8,8 +8,9 @@\n \n Events:\n   - gateway:startup     -- Gateway process starts\n-  - session:start       -- New session created\n-  - session:reset       -- User ran /new or /reset\n+  - session:start       -- New session created (first message of a new session)\n+  - session:end         -- Session ends (user ran /new or /reset)\n+  - session:reset       -- Session reset completed (new session entry created)\n   - agent:start         -- Agent begins processing a message\n   - agent:step          -- Each turn in the tool-calling loop\n   - agent:end           -- Agent finishes processing"]},{"file":"gateway/run.py","hunks":["@@ -2178,7 +2178,14 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:\n         \n         # Reset the session\n         new_entry = self.session_store.reset_session(session_key)\n-        \n+\n+        # Emit session:end hook (session is ending)\n+        await self.hooks.emit(\"session:end\", {\n+            \"platform\": source.platform.value if source.platform else \"\",\n+            \"user_id\": source.user_id,\n+            \"session_key\": session_key,\n+        })\n+\n         # Emit session:reset hook\n         await self.hooks.emit(\"session:reset\", {\n             \"platform\": source.platform.value if source.platform else \"\","]},{"file":"hermes_cli/main.py","hunks":["@@ -1996,20 +1996,32 @@ def _update_via_zip(args):\n         print(f\"✗ ZIP update failed: {e}\")\n         sys.exit(1)\n     \n-    # Reinstall Python dependencies\n+    # Reinstall Python dependencies (try .[all] first for optional extras,\n+    # fall back to . if extras fail — mirrors the install script behavior)\n     print(\"→ Updating Python dependencies...\")\n     import subprocess\n     uv_bin = shutil.which(\"uv\")\n     if uv_bin:\n-        subprocess.run(\n-            [uv_bin, \"pip\", \"install\", \"-e\", \".\", \"--quiet\"],\n-            cwd=PROJECT_ROOT, check=True,\n-            env={**os.environ, \"VIRTUAL_ENV\": str(PROJECT_ROOT / \"venv\")}\n-        )\n+        uv_env = {**os.environ, \"VIRTUAL_ENV\": str(PROJECT_ROOT / \"venv\")}\n+        try:\n+            subprocess.run(\n+                [uv_bin, \"pip\", \"install\", \"-e\", \".[all]\", \"--quiet\"],\n+                cwd=PROJECT_ROOT, check=True, env=uv_env,\n+            )\n+        except subprocess.CalledProcessError:\n+            print(\"  ⚠ Optional extras failed, installing base dependencies...\")\n+            subprocess.run(\n+                [uv_bin, \"pip\", \"install\", \"-e\", \".\", \"--quiet\"],\n+                cwd=PROJECT_ROOT, check=True, env=uv_env,\n+            )\n     else:\n         venv_pip = PROJECT_ROOT / \"venv\" / (\"Scripts\" if sys.platform == \"win32\" else \"bin\") / \"pip\"\n-        if venv_pip.exists():\n-            subprocess.run([str(venv_pip), \"install\", \"-e\", \".\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n+        pip_cmd = [str(venv_pip)] if venv_pip.exists() else [\"pip\"]\n+        try:\n+            subprocess.run(pip_cmd + [\"install\", \"-e\", \".[all]\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n+        except subprocess.CalledProcessError:\n+            print(\"  ⚠ Optional extras failed, installing base dependencies...\")\n+            subprocess.run(pip_cmd + [\"install\", \"-e\", \".\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n     \n     # Sync skills\n     try:","@@ -2257,21 +2269,31 @@ def cmd_update(args):\n         \n         _invalidate_update_cache()\n         \n-        # Reinstall Python dependencies (prefer uv for speed, fall back to pip)\n+        # Reinstall Python dependencies (try .[all] first for optional extras,\n+        # fall back to . if extras fail — mirrors the install script behavior)\n         print(\"→ Updating Python dependencies...\")\n         uv_bin = shutil.which(\"uv\")\n         if uv_bin:\n-            subprocess.run(\n-                [uv_bin, \"pip\", \"install\", \"-e\", \".\", \"--quiet\"],\n-                cwd=PROJECT_ROOT, check=True,\n-                env={**os.environ, \"VIRTUAL_ENV\": str(PROJECT_ROOT / \"venv\")}\n-            )\n+            uv_env = {**os.environ, \"VIRTUAL_ENV\": str(PROJECT_ROOT / \"venv\")}\n+            try:\n+                subprocess.run(\n+                    [uv_bin, \"pip\", \"install\", \"-e\", \".[all]\", \"--quiet\"],\n+                    cwd=PROJECT_ROOT, check=True, env=uv_env,\n+                )\n+            except subprocess.CalledProcessError:\n+                print(\"  ⚠ Optional extras failed, installing base dependencies...\")\n+                subprocess.run(\n+                    [uv_bin, \"pip\", \"install\", \"-e\", \".\", \"--quiet\"],\n+                    cwd=PROJECT_ROOT, check=True, env=uv_env,\n+                )\n         else:\n             venv_pip = PROJECT_ROOT / \"venv\" / (\"Scripts\" if sys.platform == \"win32\" else \"bin\") / \"pip\"\n-            if venv_pip.exists():\n-                subprocess.run([str(venv_pip), \"install\", \"-e\", \".\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n-            else:\n-                subprocess.run([\"pip\", \"install\", \"-e\", \".\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n+            pip_cmd = [str(venv_pip)] if venv_pip.exists() else [\"pip\"]\n+            try:\n+                subprocess.run(pip_cmd + [\"install\", \"-e\", \".[all]\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n+            except subprocess.CalledProcessError:\n+                print(\"  ⚠ Optional extras failed, installing base dependencies...\")\n+                subprocess.run(pip_cmd + [\"install\", \"-e\", \".\", \"--quiet\"], cwd=PROJECT_ROOT, check=True)\n         \n         # Check for Node.js deps\n         if (PROJECT_ROOT / \"package.json\").exists():"]},{"file":"tests/hermes_cli/test_update_autostash.py","hunks":["@@ -4,6 +4,7 @@\n \n import pytest\n \n+from hermes_cli import config as hermes_config\n from hermes_cli import main as hermes_main\n \n ","@@ -235,3 +236,82 @@ def fake_run(cmd, **kwargs):\n \n     with pytest.raises(CalledProcessError):\n         hermes_main._stash_local_changes_if_needed([\"git\"], Path(tmp_path))\n+\n+\n+# ---------------------------------------------------------------------------\n+# Update uses .[all] with fallback to .\n+# ---------------------------------------------------------------------------\n+\n+def _setup_update_mocks(monkeypatch, tmp_path):\n+    \"\"\"Common setup for cmd_update tests.\"\"\"\n+    (tmp_path / \".git\").mkdir()\n+    monkeypatch.setattr(hermes_main, \"PROJECT_ROOT\", tmp_path)\n+    monkeypatch.setattr(hermes_main, \"_stash_local_changes_if_needed\", lambda *a, **kw: None)\n+    monkeypatch.setattr(hermes_main, \"_restore_stashed_changes\", lambda *a, **kw: True)\n+    monkeypatch.setattr(hermes_config, \"get_missing_env_vars\", lambda required_only=True: [])\n+    monkeypatch.setattr(hermes_config, \"get_missing_config_fields\", lambda: [])\n+    monkeypatch.setattr(hermes_config, \"check_config_version\", lambda: (5, 5))\n+    monkeypatch.setattr(hermes_config, \"migrate_config\", lambda **kw: {\"env_added\": [], \"config_added\": []})\n+\n+\n+def test_cmd_update_tries_extras_first_then_falls_back(monkeypatch, tmp_path):\n+    \"\"\"When .[all] fails, update should fall back to . instead of aborting.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    recorded = []\n+\n+    def fake_run(cmd, **kwargs):\n+        recorded.append(cmd)\n+        if cmd == [\"git\", \"fetch\", \"origin\"]:\n+            return SimpleNamespace(stdout=\"\", stderr=\"\", returncode=0)\n+        if cmd == [\"git\", \"rev-parse\", \"--abbrev-ref\", \"HEAD\"]:\n+            return SimpleNamespace(stdout=\"main\\n\", stderr=\"\", returncode=0)\n+        if cmd == [\"git\", \"rev-list\", \"HEAD..origin/main\", \"--count\"]:\n+            return SimpleNamespace(stdout=\"1\\n\", stderr=\"\", returncode=0)\n+        if cmd == [\"git\", \"pull\", \"origin\", \"main\"]:\n+            return SimpleNamespace(stdout=\"Updating\\n\", stderr=\"\", returncode=0)\n+        # .[all] fails\n+        if \".[all]\" in cmd:\n+            raise CalledProcessError(returncode=1, cmd=cmd)\n+        # bare . succeeds\n+        if cmd == [\"/usr/bin/uv\", \"pip\", \"install\", \"-e\", \".\", \"--quiet\"]:\n+            return SimpleNamespace(returncode=0)\n+        return SimpleNamespace(returncode=0)\n+\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", fake_run)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    install_cmds = [c for c in recorded if \"pip\" in c and \"install\" in c]\n+    assert len(install_cmds) == 2\n+    assert \".[all]\" in install_cmds[0]\n+    assert \".\" in install_cmds[1] and \".[all]\" not in install_cmds[1]\n+\n+\n+def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path):\n+    \"\"\"When .[all] succeeds, no fallback should be attempted.\"\"\"\n+    _setup_update_mocks(monkeypatch, tmp_path)\n+    monkeypatch.setattr(\"shutil.which\", lambda name: \"/usr/bin/uv\" if name == \"uv\" else None)\n+\n+    recorded = []\n+\n+    def fake_run(cmd, **kwargs):\n+        recorded.append(cmd)\n+        if cmd == [\"git\", \"fetch\", \"origin\"]:\n+            return SimpleNamespace(stdout=\"\", stderr=\"\", returncode=0)\n+        if cmd == [\"git\", \"rev-parse\", \"--abbrev-ref\", \"HEAD\"]:\n+            return SimpleNamespace(stdout=\"main\\n\", stderr=\"\", returncode=0)\n+        if cmd == [\"git\", \"rev-list\", \"HEAD..origin/main\", \"--count\"]:\n+            return SimpleNamespace(stdout=\"1\\n\", stderr=\"\", returncode=0)\n+        if cmd == [\"git\", \"pull\", \"origin\", \"main\"]:\n+            return SimpleNamespace(stdout=\"Updating\\n\", stderr=\"\", returncode=0)\n+        return SimpleNamespace(returncode=0)\n+\n+    monkeypatch.setattr(hermes_main.subprocess, \"run\", fake_run)\n+\n+    hermes_main.cmd_update(SimpleNamespace())\n+\n+    install_cmds = [c for c in recorded if \"pip\" in c and \"install\" in c]\n+    assert len(install_cmds) == 1\n+    assert \".[all]\" in install_cmds[0]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}