From 311900842e88e640dc9d82fd700e0911a0d5d171 Mon Sep 17 00:00:00 2001 From: xxxigm Date: Wed, 10 Jun 2026 07:04:28 +0700 Subject: [PATCH] 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 54de48e66b5..16623e25385 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -9367,6 +9367,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew 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 450c9767c8f..c0dd7221718 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -602,6 +602,11 @@ class DiscordAdapter(BasePlatformAdapter): 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 @@ class DiscordAdapter(BasePlatformAdapter): 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 @@ class DiscordAdapter(BasePlatformAdapter): 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