fix(gateway): namespace voice mode state by platform to prevent cross-platform collision (#12542)

This commit is contained in:
Tranquil-Flow 2026-04-20 01:42:53 +00:00 committed by Teknium
parent be3bec55be
commit 52a972e927
2 changed files with 281 additions and 18 deletions

View file

@ -786,6 +786,10 @@ class GatewayRunner:
_VOICE_MODE_PATH = _hermes_home / "gateway_voice_mode.json"
def _voice_key(self, platform: Platform, chat_id: str) -> str:
"""Return a platform-namespaced key for voice mode state."""
return f"{platform.value}:{chat_id}"
def _load_voice_modes(self) -> Dict[str, str]:
try:
data = json.loads(self._VOICE_MODE_PATH.read_text())
@ -796,11 +800,21 @@ class GatewayRunner:
return {}
valid_modes = {"off", "voice_only", "all"}
return {
str(chat_id): mode
for chat_id, mode in data.items()
if mode in valid_modes
}
result = {}
for chat_id, mode in data.items():
if mode not in valid_modes:
continue
key = str(chat_id)
# Skip legacy unprefixed keys (warn and skip)
if ":" not in key:
logger.warning(
"Skipping legacy unprefixed voice mode key %r during migration. "
"Re-enable voice mode on that chat to rebuild the prefixed key.",
key,
)
continue
result[key] = mode
return result
def _save_voice_modes(self) -> None:
try:
@ -826,9 +840,14 @@ class GatewayRunner:
disabled_chats = getattr(adapter, "_auto_tts_disabled_chats", None)
if not isinstance(disabled_chats, set):
return
platform = getattr(adapter, "platform", None)
if not isinstance(platform, Platform):
return
disabled_chats.clear()
prefix = f"{platform.value}:"
disabled_chats.update(
chat_id for chat_id, mode in self._voice_mode.items() if mode == "off"
key[len(prefix):] for key, mode in self._voice_mode.items()
if mode == "off" and key.startswith(prefix)
)
async def _safe_adapter_disconnect(self, adapter, platform) -> None:
@ -5830,11 +5849,13 @@ class GatewayRunner:
"""Handle /voice [on|off|tts|channel|leave|status] command."""
args = event.get_command_args().strip().lower()
chat_id = event.source.chat_id
platform = event.source.platform
voice_key = self._voice_key(platform, chat_id)
adapter = self.adapters.get(event.source.platform)
adapter = self.adapters.get(platform)
if args in ("on", "enable"):
self._voice_mode[chat_id] = "voice_only"
self._voice_mode[voice_key] = "voice_only"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
@ -5844,13 +5865,13 @@ class GatewayRunner:
"Use /voice tts to get voice replies for all messages."
)
elif args in ("off", "disable"):
self._voice_mode[chat_id] = "off"
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
return "Voice mode disabled. Text-only replies."
elif args == "tts":
self._voice_mode[chat_id] = "all"
self._voice_mode[voice_key] = "all"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
@ -5863,7 +5884,7 @@ class GatewayRunner:
elif args == "leave":
return await self._handle_voice_channel_leave(event)
elif args == "status":
mode = self._voice_mode.get(chat_id, "off")
mode = self._voice_mode.get(voice_key, "off")
labels = {
"off": "Off (text only)",
"voice_only": "On (voice reply to voice messages)",
@ -5887,15 +5908,15 @@ class GatewayRunner:
return f"Voice mode: {labels.get(mode, mode)}"
else:
# Toggle: off → on, on/all → off
current = self._voice_mode.get(chat_id, "off")
current = self._voice_mode.get(voice_key, "off")
if current == "off":
self._voice_mode[chat_id] = "voice_only"
self._voice_mode[voice_key] = "voice_only"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=False)
return "Voice mode enabled."
else:
self._voice_mode[chat_id] = "off"
self._voice_mode[voice_key] = "off"
self._save_voice_modes()
if adapter:
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
@ -5941,7 +5962,7 @@ class GatewayRunner:
adapter._voice_text_channels[guild_id] = int(event.source.chat_id)
if hasattr(adapter, "_voice_sources"):
adapter._voice_sources[guild_id] = event.source.to_dict()
self._voice_mode[event.source.chat_id] = "all"
self._voice_mode[self._voice_key(Platform.DISCORD, event.source.chat_id)] = "all"
self._save_voice_modes()
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=False)
return (
@ -5968,7 +5989,7 @@ class GatewayRunner:
except Exception as e:
logger.warning("Error leaving voice channel: %s", e)
# Always clean up state even if leave raised an exception
self._voice_mode[event.source.chat_id] = "off"
self._voice_mode[self._voice_key(Platform.DISCORD, event.source.chat_id)] = "off"
self._save_voice_modes()
self._set_adapter_auto_tts_disabled(adapter, event.source.chat_id, disabled=True)
if hasattr(adapter, "_voice_input_callback"):
@ -5980,7 +6001,7 @@ class GatewayRunner:
Cleans up runner-side voice_mode state that the adapter cannot reach.
"""
self._voice_mode[chat_id] = "off"
self._voice_mode[self._voice_key(Platform.DISCORD, chat_id)] = "off"
self._save_voice_modes()
adapter = self.adapters.get(Platform.DISCORD)
self._set_adapter_auto_tts_disabled(adapter, chat_id, disabled=True)
@ -6066,7 +6087,7 @@ class GatewayRunner:
return False
chat_id = event.source.chat_id
voice_mode = self._voice_mode.get(chat_id, "off")
voice_mode = self._voice_mode.get(self._voice_key(event.source.platform, chat_id), "off")
is_voice_input = (event.message_type == MessageType.VOICE)
should = (