From d5d234b377cecb1a64d499c61fa196ff7166e408 Mon Sep 17 00:00:00 2001 From: unsupportedpastels Date: Mon, 5 Oct 2026 19:07:48 +0000 Subject: [PATCH] refactor(discord): move voice-channel info helpers to a sibling mixin; annotate the stale-binding test get_voice_channel_info and get_voice_channel_context move unchanged into adapter_voice_info.py, and the new comments and docstring are folded, so adapter.py stays within its code-health file cap. Add type hints to the new test. --- plugins/platforms/discord/adapter.py | 61 ++----------------- .../platforms/discord/adapter_voice_info.py | 53 ++++++++++++++++ .../test_discord_voice_stale_binding.py | 9 +-- 3 files changed, 64 insertions(+), 59 deletions(-) create mode 100644 plugins/platforms/discord/adapter_voice_info.py diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 152f352871..224d8c08aa 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -1253,9 +1253,10 @@ def _read_discord_prompt_timeout() -> int: from plugins.platforms.discord.adapter_media import DiscordMediaMixin from plugins.platforms.discord.adapter_thread_titles import DiscordThreadTitlesMixin, SemanticThreadRenames +from plugins.platforms.discord.adapter_voice_info import DiscordVoiceInfoMixin -class DiscordAdapter(DiscordMediaMixin, DiscordThreadTitlesMixin, BasePlatformAdapter): +class DiscordAdapter(DiscordMediaMixin, DiscordThreadTitlesMixin, DiscordVoiceInfoMixin, BasePlatformAdapter): """Discord bot adapter: guild/DM messages, threads, slash commands, button approvals, reactions.""" MAX_MESSAGE_LENGTH = 2000 @@ -4034,48 +4035,6 @@ class DiscordAdapter(DiscordMediaMixin, DiscordThreadTitlesMixin, BasePlatformAd vc = self._voice_clients.get(guild_id) return vc is not None and vc.is_connected() - def get_voice_channel_info(self, guild_id: int) -> Optional[Dict[str, Any]]: - """Return voice channel info (name, members, count, speaking user IDs) or None if not connected.""" - vc = self._voice_clients.get(guild_id) - if not vc or not vc.is_connected(): - return None - channel = vc.channel - if not channel: - return None - members_info = [] - bot_user = self._client.user if self._client else None - for m in channel.members: - if bot_user and m.id == bot_user.id: - continue # skip the bot itself - members_info.append({"user_id": m.id, "display_name": m.display_name, "is_bot": m.bot}) - speaking_user_ids: set = set() - receiver = self._voice_receivers.get(guild_id) - if receiver: - now = time.monotonic() - with receiver._lock: - for ssrc, last_t in receiver._last_packet_time.items(): - if now - last_t < 2.0: - uid = receiver._ssrc_to_user.get(ssrc) - if uid: - speaking_user_ids.add(uid) - for info in members_info: - info["is_speaking"] = info["user_id"] in speaking_user_ids - return { - "channel_name": channel.name, "member_count": len(members_info), - "members": members_info, "speaking_count": len(speaking_user_ids), - } - - def get_voice_channel_context(self, guild_id: int) -> str: - """Return a human-readable voice channel context string for prompt injection.""" - info = self.get_voice_channel_info(guild_id) - if not info: - return "" - parts = [f"[Voice channel: #{info['channel_name']} — {info['member_count']} participant(s)]"] - for m in info["members"]: - status = " (speaking)" if m["is_speaking"] else "" - parts.append(f" - {m['display_name']}{status}") - return "\n".join(parts) - # --- Voice listening (Phase 2) --- # UDP keepalive interval; Discord drops the UDP route after ~60s of silence. @@ -4100,8 +4059,7 @@ class DiscordAdapter(DiscordMediaMixin, DiscordThreadTitlesMixin, BasePlatformAd except Exception: pass completed = receiver.check_silence() - # Utterances in one batch are transcribed serially; each must keep the binding it was - # spoken under, not one a /voice join set during an earlier utterance's STT. + # Each utterance keeps the binding it was collected under, not one set during an earlier STT. captured_for = self._voice_text_channels.get(guild_id) # Pass guild so role checks stay guild-scoped. _vc_guild = self._client.get_guild(guild_id) if self._client is not None else None @@ -4116,14 +4074,8 @@ class DiscordAdapter(DiscordMediaMixin, DiscordThreadTitlesMixin, BasePlatformAd except Exception as e: logger.error("Voice listen loop error: %s", e, exc_info=True) - async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes, - captured_for: int | None): - """Convert PCM -> WAV -> STT -> callback. - - ``captured_for`` is the text-channel binding when the utterance was collected. The callback - routes by the binding as it is when it runs; a /voice join elsewhere in between would hand - this utterance to that other conversation, so stale work is dropped. - """ + async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: bytes, captured_for: int | None): + """Convert PCM -> WAV -> STT -> callback; dropped if the binding moved off *captured_for* during STT.""" from tools.voice_mode_transcript import is_whisper_hallucination tmp_f = tempfile.NamedTemporaryFile(suffix=".wav", prefix="vc_listen_", delete=False) wav_path = tmp_f.name @@ -4139,8 +4091,7 @@ class DiscordAdapter(DiscordMediaMixin, DiscordThreadTitlesMixin, BasePlatformAd return logger.info("Voice input from user %d: %s", user_id, transcript[:100]) if getattr(self, "_voice_text_channels", {}).get(guild_id) != captured_for: - logger.info("Dropping voice input from user %d: the voice binding moved during transcription", - user_id) + logger.info("Dropping voice input from user %d: binding moved during transcription", user_id) return if self._voice_input_callback: await self._voice_input_callback( diff --git a/plugins/platforms/discord/adapter_voice_info.py b/plugins/platforms/discord/adapter_voice_info.py new file mode 100644 index 0000000000..284cdfe6e8 --- /dev/null +++ b/plugins/platforms/discord/adapter_voice_info.py @@ -0,0 +1,53 @@ +"""Who is in the bot's Discord voice channel and who is speaking, for status and prompt context.""" +from __future__ import annotations + +import time +from typing import Any, Dict, Optional + + +class DiscordVoiceInfoMixin: + _voice_clients: dict[int, Any] + _voice_receivers: dict[int, Any] + _client: Any + + def get_voice_channel_info(self, guild_id: int) -> Optional[Dict[str, Any]]: + """Return voice channel info (name, members, count, speaking user IDs) or None if not connected.""" + vc = self._voice_clients.get(guild_id) + if not vc or not vc.is_connected(): + return None + channel = vc.channel + if not channel: + return None + members_info = [] + bot_user = self._client.user if self._client else None + for m in channel.members: + if bot_user and m.id == bot_user.id: + continue # skip the bot itself + members_info.append({"user_id": m.id, "display_name": m.display_name, "is_bot": m.bot}) + speaking_user_ids: set = set() + receiver = self._voice_receivers.get(guild_id) + if receiver: + now = time.monotonic() + with receiver._lock: + for ssrc, last_t in receiver._last_packet_time.items(): + if now - last_t < 2.0: + uid = receiver._ssrc_to_user.get(ssrc) + if uid: + speaking_user_ids.add(uid) + for info in members_info: + info["is_speaking"] = info["user_id"] in speaking_user_ids + return { + "channel_name": channel.name, "member_count": len(members_info), + "members": members_info, "speaking_count": len(speaking_user_ids), + } + + def get_voice_channel_context(self, guild_id: int) -> str: + """Return a human-readable voice channel context string for prompt injection.""" + info = self.get_voice_channel_info(guild_id) + if not info: + return "" + parts = [f"[Voice channel: #{info['channel_name']} — {info['member_count']} participant(s)]"] + for m in info["members"]: + status = " (speaking)" if m["is_speaking"] else "" + parts.append(f" - {m['display_name']}{status}") + return "\n".join(parts) diff --git a/tests/gateway/test_discord_voice_stale_binding.py b/tests/gateway/test_discord_voice_stale_binding.py index 917e6c0026..0063ad5ece 100644 --- a/tests/gateway/test_discord_voice_stale_binding.py +++ b/tests/gateway/test_discord_voice_stale_binding.py @@ -6,6 +6,7 @@ rewrote the binding, and the old utterance became a turn in the new conversation skills and replies. The listen loop transcribes a poll batch serially, so a later utterance of the same batch must keep the binding of the batch, not one set during an earlier utterance's STT. """ +from __future__ import annotations import asyncio import threading @@ -22,10 +23,10 @@ _GUILD, _USER = 1, 42 class _OneBatchReceiver: """One check_silence() batch of two utterances, both completed while bound to channel 700.""" - def __init__(self): + def __init__(self) -> None: self._running = True - def check_silence(self): + def check_silence(self) -> list[tuple[int, bytes]]: self._running = False return [(_USER, b"\x00" * 9600), (_USER + 1, b"\x00" * 9600)] @@ -35,7 +36,7 @@ class _OneBatchReceiver: (700, 2), # unchanged binding (also leave + rejoin from the same channel) (800, 0), # /voice join from another text channel during the first utterance's STT ]) -async def test_transcribed_utterance_keeps_its_captured_binding(bound_after, dispatched): +async def test_transcribed_utterance_keeps_its_captured_binding(bound_after: int, dispatched: int) -> None: adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake")) adapter._voice_text_channels = {_GUILD: 700} adapter._voice_receivers[_GUILD] = _OneBatchReceiver() @@ -44,7 +45,7 @@ async def test_transcribed_utterance_keeps_its_captured_binding(bound_after, dis adapter._reset_voice_timeout = lambda *a: None started, release = threading.Event(), threading.Event() - def transcribe(_path): + def transcribe(_path: str) -> dict[str, object]: started.set() release.wait(5) return {"success": True, "transcript": "what broke on the ingest box"}