Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 85 additions & 14 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please re-arm the timer in a finally around the remaining playback attempt. decode_to_pcm, FFmpegPCMAudio, or vc.play can raise after this cancellation; the current success-only resets then leave a connected guild with no inactivity timer.


# ── Mixer path (overlap + ducking) ──────────────────────────────
mixer = getattr(self, "_voice_mixers", {}).get(guild_id) if getattr(self, "_voice_mixers", None) else None
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -8162,17 +8226,20 @@ 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
the adapter that consumes it.

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"):
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
110 changes: 110 additions & 0 deletions tests/gateway/test_voice_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -1930,6 +1930,116 @@ 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_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."""
Expand Down
2 changes: 2 additions & 0 deletions website/docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion website/docs/user-guide/messaging/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down