From 8ce4e86cd62043e745a3645710571c25001ffe03 Mon Sep 17 00:00:00 2001 From: "BMO (Hermes Agent)" Date: Tue, 7 Jul 2026 17:46:31 -0500 Subject: [PATCH 1/2] feat(discord): configure voice inactivity timeout --- plugins/platforms/discord/adapter.py | 99 +++++++++++++++++--- tests/gateway/test_config.py | 20 ++++ tests/gateway/test_voice_command.py | 77 +++++++++++++++ website/docs/user-guide/configuration.md | 2 + website/docs/user-guide/messaging/discord.md | 2 +- 5 files changed, 185 insertions(+), 15 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 8a83f8805ed9a..3af4cde3bafb2 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -812,7 +812,9 @@ class DiscordAdapter(BasePlatformAdapter): supports_code_blocks = True # Discord markdown renders fenced code blocks natively splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) - # Auto-disconnect from voice channel after this many seconds of inactivity + # Auto-disconnect from voice channel after this many seconds of inactivity. + # ``VOICE_TIMEOUT`` is retained as the historical/default value for tests + # and external code that may have referenced the class constant. VOICE_TIMEOUT = 300 def __init__(self, config: PlatformConfig): @@ -833,6 +835,7 @@ def __init__(self, config: PlatformConfig): self._voice_text_channels: Dict[int, int] = {} # guild_id -> text_channel_id self._voice_sources: Dict[int, Dict[str, Any]] = {} # guild_id -> linked text channel source metadata self._voice_timeout_tasks: Dict[int, asyncio.Task] = {} # guild_id -> timeout task + self._voice_inactivity_timeout_seconds = self._resolve_voice_inactivity_timeout_seconds() # 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 @@ -901,6 +904,28 @@ def __init__(self, config: PlatformConfig): # Mirrors the Telegram #58563 fix. Entries are dropped on finalize. self._last_overflow_preview: Dict[tuple, str] = {} + def _resolve_voice_inactivity_timeout_seconds(self) -> int: + """Return Discord voice auto-leave timeout from config. + + ``discord.voice_inactivity_timeout_seconds`` is the user-facing knob. + Missing or malformed values fall back to the historical five-minute + default. ``0`` disables auto-leave for always-on coworking voice + channels; negative values are treated as invalid and fall back safely. + """ + raw = getattr(self.config, "extra", {}).get( + "voice_inactivity_timeout_seconds", + self.VOICE_TIMEOUT, + ) + if raw is None or raw == "": + return self.VOICE_TIMEOUT + try: + seconds = int(raw) + except (TypeError, ValueError): + return self.VOICE_TIMEOUT + if seconds < 0: + return self.VOICE_TIMEOUT + return seconds + def _handle_bot_task_done(self, task: asyncio.Task) -> None: """Surface post-startup discord.py task exits to the gateway supervisor. @@ -2933,6 +2958,7 @@ async def play_in_voice_channel(self, guild_id: int, audio_path: str) -> bool: vc = self._voice_clients.get(guild_id) if not vc or not vc.is_connected(): return False + self._cancel_voice_timeout(guild_id) # ── Mixer path (overlap + ducking) ────────────────────────────── mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None @@ -3009,19 +3035,32 @@ async def get_user_voice_channel(self, guild_id: int, user_id: str): return None return member.voice.channel - def _reset_voice_timeout(self, guild_id: int) -> None: - """Reset the auto-disconnect inactivity timer.""" + def _cancel_voice_timeout(self, guild_id: int) -> None: + """Cancel any pending auto-disconnect timer for this guild.""" task = self._voice_timeout_tasks.pop(guild_id, None) if task: task.cancel() + + def _reset_voice_timeout(self, guild_id: int) -> None: + """Reset the auto-disconnect inactivity timer.""" + self._cancel_voice_timeout(guild_id) + if getattr(self, "_voice_inactivity_timeout_seconds", self.VOICE_TIMEOUT) <= 0: + return self._voice_timeout_tasks[guild_id] = asyncio.ensure_future( self._voice_timeout_handler(guild_id) ) async def _voice_timeout_handler(self, guild_id: int) -> None: - """Auto-disconnect after VOICE_TIMEOUT seconds of inactivity.""" + """Auto-disconnect after configured seconds of inactivity.""" + timeout_seconds = getattr( + self, + "_voice_inactivity_timeout_seconds", + self.VOICE_TIMEOUT, + ) + if timeout_seconds <= 0: + return try: - await asyncio.sleep(self.VOICE_TIMEOUT) + await asyncio.sleep(timeout_seconds) except asyncio.CancelledError: return text_ch_id = self._voice_text_channels.get(guild_id) @@ -3039,6 +3078,28 @@ async def _voice_timeout_handler(self, guild_id: int) -> None: return except Exception: pass + vc = self._voice_clients.get(guild_id) + mixer = ( + getattr(self, "_voice_mixers", {}).get(guild_id) + if getattr(self, "_voice_mixers", None) + else None + ) + try: + vc_is_playing = ( + vc is not None + and vc.is_connected() + and vc.is_playing() is True + ) + mixer_is_speaking = ( + mixer is not None + and getattr(mixer, "speech_active", False) is True + ) + active_playback = vc_is_playing or mixer_is_speaking + except Exception: + active_playback = False + if active_playback: + self._reset_voice_timeout(guild_id) + return await self.leave_voice_channel(guild_id) # Notify the runner so it can clean up voice_mode state if self._on_voice_disconnect and text_ch_id: @@ -3170,11 +3231,14 @@ async def _voice_listen_loop(self, guild_id: int): ): 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). - self._reset_voice_timeout(guild_id) - await self._process_voice_input(guild_id, user_id, pcm_data) + # bot's own playback. Suspend the inactivity timer while + # STT and the resulting agent turn run so active voice + # conversation is not disconnected mid-turn. + self._cancel_voice_timeout(guild_id) + try: + await self._process_voice_input(guild_id, user_id, pcm_data) + finally: + self._reset_voice_timeout(guild_id) except asyncio.CancelledError: pass except Exception as e: @@ -8162,7 +8226,10 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: ``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``). + ``DISCORD_BOTS_REQUIRE_INLINE_MENTION``). New settings that are only + consumed by ``DiscordAdapter`` may be returned into ``PlatformConfig.extra`` + instead; ``discord.voice_inactivity_timeout_seconds`` uses that path so it + stays a normal config.yaml setting instead of becoming a user-facing env var. 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 @@ -8170,9 +8237,9 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: Env vars take precedence over YAML — every assignment is guarded by ``not os.getenv(...)`` so explicit env vars survive a config.yaml - update. Returns ``None`` because no extras are seeded into - ``PlatformConfig.extra`` directly (everything flows through env). + update. """ + seeded_extra: dict[str, Any] = {} 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"): @@ -8270,7 +8337,11 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None: lft = discord_cfg.get("liveness_failure_threshold") if lft is not None and not os.getenv("HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD"): os.environ["HERMES_DISCORD_LIVENESS_FAILURE_THRESHOLD"] = str(lft) - return None # all settings flow through env; nothing to merge into extras + if "voice_inactivity_timeout_seconds" in discord_cfg: + seeded_extra["voice_inactivity_timeout_seconds"] = discord_cfg[ + "voice_inactivity_timeout_seconds" + ] + return seeded_extra or None def _is_connected(config) -> bool: diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index b9d4993b5f060..96a2aa06fa480 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -561,6 +561,26 @@ def test_top_level_max_concurrent_sessions_overrides_nested_config_yaml(self, tm assert config.max_concurrent_sessions == 2 + def test_bridges_discord_voice_inactivity_timeout_from_config_yaml(self, tmp_path, monkeypatch): + """discord.voice_inactivity_timeout_seconds reaches PlatformConfig.extra.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " voice_inactivity_timeout_seconds: 0\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert ( + config.platforms[Platform.DISCORD].extra["voice_inactivity_timeout_seconds"] + == 0 + ) + def test_bridges_discord_thread_require_mention_from_config_yaml(self, tmp_path, monkeypatch): """discord.thread_require_mention in config.yaml should reach the runtime env var.""" hermes_home = tmp_path / ".hermes" diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 539648886dafd..e8732da90d30b 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -1930,6 +1930,83 @@ def test_adapter_has_on_voice_disconnect_attr(self, adapter): assert hasattr(adapter, "_on_voice_disconnect") assert adapter._on_voice_disconnect is None + def test_default_voice_inactivity_timeout_is_historical_value(self): + """Missing config keeps the existing 5-minute auto-leave default.""" + from plugins.platforms.discord.adapter import DiscordAdapter + from gateway.config import PlatformConfig + + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake-token", extra={})) + + assert adapter._voice_inactivity_timeout_seconds == 300 + + def test_invalid_voice_inactivity_timeout_falls_back_to_default(self): + """Malformed or negative config must not disable or shrink the guard.""" + from plugins.platforms.discord.adapter import DiscordAdapter + from gateway.config import PlatformConfig + + for raw in ("not-a-number", -1, None): + adapter = DiscordAdapter( + PlatformConfig( + enabled=True, + token="fake-token", + extra={"voice_inactivity_timeout_seconds": raw}, + ) + ) + + assert adapter._voice_inactivity_timeout_seconds == 300 + + def test_zero_voice_inactivity_timeout_disables_timer(self, adapter): + """0 is the documented opt-out for always-on Discord VC sessions.""" + adapter._voice_inactivity_timeout_seconds = 0 + stale_task = MagicMock() + adapter._voice_timeout_tasks[111] = stale_task + + adapter._reset_voice_timeout(111) + + stale_task.cancel.assert_called_once() + assert 111 not in adapter._voice_timeout_tasks + + @pytest.mark.asyncio + async def test_timeout_handler_skips_disconnect_when_disabled(self, adapter): + adapter._voice_inactivity_timeout_seconds = 0 + mock_vc = MagicMock() + mock_vc.is_connected.return_value = True + mock_vc.disconnect = AsyncMock() + adapter._voice_clients[111] = mock_vc + + await adapter._voice_timeout_handler(111) + + assert 111 in adapter._voice_clients + mock_vc.disconnect.assert_not_called() + + @pytest.mark.asyncio + async def test_playback_suspends_inactivity_timer(self, adapter): + """Long active playback should not be cut off by the idle timer.""" + adapter._voice_inactivity_timeout_seconds = 300 + existing_timeout = MagicMock() + adapter._voice_timeout_tasks[111] = existing_timeout + reset_calls = [] + adapter._reset_voice_timeout = lambda guild_id: reset_calls.append(guild_id) + + mock_vc = MagicMock() + mock_vc.is_connected.return_value = True + mock_vc.is_playing.return_value = False + adapter._voice_clients[111] = mock_vc + + def play(_source, after=None): + if after: + after(None) + + mock_vc.play.side_effect = play + + with patch("discord.FFmpegPCMAudio"), \ + patch("discord.PCMVolumeTransformer", side_effect=lambda s, **kw: s): + result = await adapter.play_in_voice_channel(111, "/tmp/test.mp3") + + assert result is True + existing_timeout.cancel.assert_called_once() + assert reset_calls == [111] + @pytest.mark.asyncio async def test_timeout_calls_disconnect_callback(self, adapter): """_voice_timeout_handler calls _on_voice_disconnect with chat_id.""" diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 46afc11bcfd25..02aefb92f895e 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1817,11 +1817,13 @@ discord: require_mention: true # Require @mention to respond in server channels free_response_channels: "" # Comma-separated channel IDs where bot responds without @mention auto_thread: true # Auto-create threads on @mention in channels + voice_inactivity_timeout_seconds: 300 # Auto-leave Discord voice channels after idle; 0 disables ``` - `require_mention` — when `true` (default), the bot only responds in server channels when mentioned with `@BotName`. DMs always work without mention. - `free_response_channels` — comma-separated list of channel IDs where the bot responds to every message without requiring a mention. - `auto_thread` — when `true` (default), mentions in channels automatically create a thread for the conversation, keeping channels clean (similar to Slack threading). +- `voice_inactivity_timeout_seconds` — how long Hermes stays connected to a Discord voice channel after the last voice-channel activity before auto-leaving. The default is `300` seconds. Set to `0` to disable auto-leave for always-on coworking voice channels. Invalid or negative values fall back to the default. ## Security diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 030e69681e5e6..afc2e3dae589b 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -705,7 +705,7 @@ Hermes Agent supports Discord voice messages: - **Incoming voice messages** are automatically transcribed using the configured STT provider: local `faster-whisper` (no key), Groq Whisper (`GROQ_API_KEY`), or OpenAI Whisper (`VOICE_TOOLS_OPENAI_KEY`). - **Text-to-speech**: Use `/voice tts` to have the bot send spoken audio responses alongside text replies. -- **Discord voice channels**: Hermes can also join a voice channel, listen to users speaking, and talk back in the channel. +- **Discord voice channels**: Hermes can also join a voice channel, listen to users speaking, and talk back in the channel. By default it auto-leaves after 300 seconds of voice-channel inactivity; set `discord.voice_inactivity_timeout_seconds: 0` in `config.yaml` to disable auto-leave for always-on coworking channels. For the full setup and operational guide, see: - [Voice Mode](/user-guide/features/voice-mode) From f0951b31045b559abcf4e567919a1c6d569e8943 Mon Sep 17 00:00:00 2001 From: "BMO (Hermes Agent)" Date: Tue, 7 Jul 2026 18:50:25 -0500 Subject: [PATCH 2/2] test(discord): cover voice input timeout suspension --- tests/gateway/test_voice_command.py | 33 +++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index e8732da90d30b..a243cf5dcc944 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -2007,6 +2007,39 @@ def play(_source, after=None): existing_timeout.cancel.assert_called_once() assert reset_calls == [111] + @pytest.mark.asyncio + async def test_voice_input_suspends_inactivity_timer(self, adapter): + """STT + agent turn work from VC input should not race the idle timer.""" + events = [] + + class FakeReceiver: + def __init__(self): + self._running = True + + def check_silence(self): + self._running = False + return [(222, b"pcm")] + + adapter._voice_receivers[111] = FakeReceiver() + adapter._client = MagicMock() + adapter._client.get_guild.return_value = None + adapter._is_allowed_user = lambda *args, **kwargs: True + adapter._cancel_voice_timeout = lambda guild_id: events.append(("cancel", guild_id)) + adapter._reset_voice_timeout = lambda guild_id: events.append(("reset", guild_id)) + + async def process_voice_input(guild_id, user_id, pcm_data): + events.append(("process", guild_id, user_id, pcm_data)) + + adapter._process_voice_input = process_voice_input + + await adapter._voice_listen_loop(111) + + assert events == [ + ("cancel", 111), + ("process", 111, 222, b"pcm"), + ("reset", 111), + ] + @pytest.mark.asyncio async def test_timeout_calls_disconnect_callback(self, adapter): """_voice_timeout_handler calls _on_voice_disconnect with chat_id."""