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:
committed by
Mark S.
parent
fb49ef18f5
commit
d5d234b377
@@ -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(
|
||||
|
||||
53
plugins/platforms/discord/adapter_voice_info.py
Normal file
53
plugins/platforms/discord/adapter_voice_info.py
Normal 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)
|
||||
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user