From 32b4c455bd3aec6b776eb2007934d2ec753cefa6 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 07:04:28 +0700 Subject: [PATCH 1/2] fix(discord): don't auto-disconnect voice when reply mode is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The voice inactivity timer (VOICE_TIMEOUT) only counted the bot's OWN audio playback as activity. Under /voice off (text-only replies, but still in the channel — leaving is /voice leave) nothing ever reset it, so every 300s the bot disconnected and spammed "Left voice channel (inactivity timeout)." The adapter now learns the live voice-reply mode via a getter wired from run.py and skips the auto-disconnect while mode is off. It also resets the timer when a user actually speaks to the bot, so an active listener (incl. voice-on text-only sessions that never play audio) isn't dropped mid-conversation. --- gateway/run.py | 6 ++++++ plugins/platforms/discord/adapter.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 57f86d7ab3198..ccaa40dd44124 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9325,6 +9325,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) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 450c9767c8f19..c0dd72217185f 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -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. @@ -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: @@ -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 From 46da6341a9d22280f4c1873b541c586372974a68 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 07:04:28 +0700 Subject: [PATCH 2/2] test(discord): cover voice timeout under voice-off mode Assert the inactivity handler skips disconnect (and the channel spam) when the voice-mode getter reports "off", and still disconnects on genuine inactivity when the mode is active. --- tests/gateway/test_voice_command.py | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/gateway/test_voice_command.py b/tests/gateway/test_voice_command.py index 5066f4952f658..d285e31fe3a77 100644 --- a/tests/gateway/test_voice_command.py +++ b/tests/gateway/test_voice_command.py @@ -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