Skip to content
Merged
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
6 changes: 6 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -9367,6 +9367,12 @@ async def _handle_voice_channel_join(self, event: MessageEvent) -> str:
adapter._voice_input_callback = self._handle_voice_channel_input
if hasattr(adapter, "_on_voice_disconnect"):
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
# Let the adapter's inactivity timer see the live voice-reply mode so it
# doesn't disconnect a deliberately text-only (/voice off) session.
if hasattr(adapter, "_voice_mode_getter"):
adapter._voice_mode_getter = lambda chat_id: self._voice_mode.get(
self._voice_key(Platform.DISCORD, str(chat_id)), "off"
)

try:
success = await adapter.join_voice_channel(voice_channel)
Expand Down
24 changes: 24 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,11 @@ def __init__(self, config: PlatformConfig):
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
self._voice_input_callback: Optional[Callable] = None # set by run.py
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
# linked text-channel id; set by run.py. Lets the inactivity timer leave
# the bot in the channel when the user deliberately picked text-only
# (/voice off) instead of leaving (/voice leave).
self._voice_mode_getter: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
# Installed once per guild on join; lets acks / TTS / the "thinking"
# loop overlap in one outgoing stream instead of stop-and-swap.
Expand Down Expand Up @@ -2265,6 +2270,20 @@ async def _voice_timeout_handler(self, guild_id: int) -> None:
except asyncio.CancelledError:
return
text_ch_id = self._voice_text_channels.get(guild_id)
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
# the channel (leaving is ``/voice leave``). The inactivity timer only
# counts the bot's OWN audio as activity, so under voice-off mode it
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
# text channel with "Left voice channel (inactivity timeout)." Honor the
# user's choice: skip the auto-disconnect while voice replies are off.
# (The timer re-arms when the bot next speaks or hears a user.)
_mode_getter = getattr(self, "_voice_mode_getter", None)
if text_ch_id is not None and _mode_getter is not None:
try:
if _mode_getter(str(text_ch_id)) == "off":
return
except Exception:
pass
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 @@ -2395,6 +2414,11 @@ async def _voice_listen_loop(self, guild_id: int):
is_dm=False,
):
continue
# A user speaking to the bot is activity too — not just the
# bot's own playback. Reset the inactivity timer so an active
# listener isn't disconnected mid-conversation (this also
# covers voice-on text-only sessions that never play audio).
self._reset_voice_timeout(guild_id)
await self._process_voice_input(guild_id, user_id, pcm_data)
except asyncio.CancelledError:
pass
Expand Down
43 changes: 43 additions & 0 deletions tests/gateway/test_voice_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -1929,6 +1929,49 @@ async def test_timeout_without_callback_does_not_crash(self, adapter):

assert 111 not in adapter._voice_clients

@pytest.mark.asyncio
async def test_timeout_skips_disconnect_when_voice_mode_off(self, adapter):
"""Voice-off is deliberate text-only mode, not idle neglect — the
inactivity timer must NOT disconnect or spam the channel (#PanBartosz)."""
disconnect_calls = []
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
adapter._voice_mode_getter = lambda chat_id: "off"

mock_vc = MagicMock()
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 999
adapter._voice_timeout_tasks[111] = MagicMock()

with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._voice_timeout_handler(111)

# Still connected, no disconnect callback, no "inactivity timeout" spam.
assert 111 in adapter._voice_clients
assert disconnect_calls == []
mock_vc.disconnect.assert_not_called()

@pytest.mark.asyncio
async def test_timeout_still_disconnects_when_voice_mode_active(self, adapter):
"""A non-off mode still auto-disconnects on genuine inactivity."""
disconnect_calls = []
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
adapter._voice_mode_getter = lambda chat_id: "all"

mock_vc = MagicMock()
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 999
adapter._voice_timeout_tasks[111] = MagicMock()

with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._voice_timeout_handler(111)

assert 111 not in adapter._voice_clients
assert disconnect_calls == ["999"]


# =====================================================================
# Bug 6: play_in_voice_channel has playback timeout
Expand Down
Loading