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.
This commit is contained in:
unsupportedpastels
2026-10-05 19:07:48 +00:00
committed by Mark S.
parent fb49ef18f5
commit d5d234b377
3 changed files with 64 additions and 59 deletions

View File

@@ -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(

View File

@@ -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)

View File

@@ -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"}