fix(discord): wire voice input callback at adapter connect time

- Wire adapter._voice_input_callback at connect and reconnect so voice
  transcription is forwarded without requiring /voice join (#60623).
- Add optional text_channel_id and source params to DiscordAdapter
  .join_voice_channel() so automatic/programmatic voice joins can
  establish the text-channel binding needed by _handle_voice_channel_input.
- Add TestVoiceInputCallbackWiring: asserts callback wiring on startup
  and reconnect for Discord adapters with voice attributes.
This commit is contained in:
isheng-eqi 2026-07-09 18:15:53 +08:00 committed by Teknium
parent 79da6adfe9
commit 31301c1af7
3 changed files with 123 additions and 6 deletions

View file

@ -9455,9 +9455,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if success:
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
# Wire voice input callback on reconnect as well (#60623).
if hasattr(adapter, "_voice_input_callback"):
adapter._voice_input_callback = self._handle_voice_channel_input
# Wire voice input callback on reconnect as well (#60623).
if hasattr(adapter, "_voice_input_callback"):
adapter._voice_input_callback = self._handle_voice_channel_input
self.delivery_router.adapters = self.adapters
del self._failed_platforms[platform]
self._update_platform_runtime_status(

View file

@ -3820,8 +3820,15 @@ class DiscordAdapter(BasePlatformAdapter):
mixers = getattr(self, "_voice_mixers", None)
return bool(mixers) and mixers.get(guild_id) is not None
async def join_voice_channel(self, channel) -> bool:
"""Join a Discord voice channel. Returns True on success."""
async def join_voice_channel(self, channel, *, text_channel_id: int = None, source: dict = None) -> bool:
"""Join a Discord voice channel. Returns True on success.
When ``text_channel_id`` is provided, the binding is stored so
voice transcriptions are routed to the correct text channel
(``_voice_text_channels``) without requiring `/voice join`.
This supports automatic/programmatic voice joins where the
command flow that normally establishes the binding is absent.
"""
if not self._client or not DISCORD_AVAILABLE:
return False
guild_id = channel.guild.id
@ -3841,6 +3848,13 @@ class DiscordAdapter(BasePlatformAdapter):
self._voice_clients[guild_id] = vc
self._reset_voice_timeout(guild_id)
# Store text-channel binding for automatic/programmatic joins
# so voice transcriptions can be routed without /voice join.
if text_channel_id is not None:
self._voice_text_channels[guild_id] = text_channel_id
if source is not None:
self._voice_sources[guild_id] = source
# Start voice receiver (Phase 2: listen to users)
try:
receiver = VoiceReceiver(vc, allowed_user_ids=self._allowed_user_ids)

View file

@ -64,7 +64,6 @@ def _make_runner():
runner._failed_platforms = {}
runner.adapters = {}
runner.delivery_router = MagicMock()
runner._handle_voice_channel_input = MagicMock()
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
@ -1583,3 +1582,107 @@ async def _instant_sleep(delay, *a, **k):
_REAL_ASYNCIO_SLEEP = asyncio.sleep
# --- Voice input callback wiring ---
class TestVoiceInputCallbackWiring:
"""Startup and reconnect must wire _voice_input_callback on Discord."""
@staticmethod
def _make_discord_voice_adapter():
"""A minimal Discord adapter stub with voice attributes."""
adapter = MagicMock()
adapter._voice_input_callback = None
adapter._voice_text_channels = {}
adapter._voice_sources = {}
adapter.connect = AsyncMock(return_value=True)
adapter.disconnect = AsyncMock()
return adapter
def _make_runner_with_discord(self):
runner = _make_runner()
runner.config = GatewayConfig(
platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="test")}
)
runner._update_runtime_status = MagicMock()
runner._update_platform_runtime_status = MagicMock()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._send_update_notification = AsyncMock(return_value=True)
runner._send_restart_notification = AsyncMock()
runner._suspend_stuck_loop_sessions = MagicMock(return_value=0)
runner.hooks = MagicMock()
runner.hooks.loaded_hooks = []
runner.hooks.emit = AsyncMock()
return runner
@pytest.mark.asyncio
async def test_startup_wires_voice_input_callback(self, tmp_path):
"""Cold-start connect must wire _voice_input_callback on Discord adapter."""
runner = self._make_runner_with_discord()
adapter = self._make_discord_voice_adapter()
runner.config.sessions_dir = tmp_path
def fake_create_task(coro):
coro.close()
return MagicMock()
with patch.object(runner, "_create_adapter", return_value=adapter):
with patch("gateway.status.write_runtime_status"):
with patch("hermes_cli.plugins.discover_plugins"):
with patch("hermes_cli.config.load_config", return_value={}):
with patch("agent.shell_hooks.register_from_config"):
with patch(
"tools.process_registry.process_registry.recover_from_checkpoint",
return_value=0,
):
with patch(
"gateway.channel_directory.build_channel_directory",
new=AsyncMock(return_value={"platforms": {}}),
):
with patch(
"gateway.run.asyncio.create_task",
side_effect=fake_create_task,
):
assert await runner.start() is True
assert adapter._voice_input_callback is not None, (
"startup must wire _voice_input_callback"
)
@pytest.mark.asyncio
async def test_reconnect_wires_voice_input_callback(self):
"""Reconnect watcher must re-wire _voice_input_callback after reconnect."""
import time as _time
runner = self._make_runner_with_discord()
runner._sync_voice_mode_state_to_adapter = MagicMock()
runner._failed_platforms[Platform.DISCORD] = {
"config": PlatformConfig(enabled=True, token="test"),
"attempts": 1,
"next_retry": _time.monotonic() - 1, # past retry time
}
adapter = self._make_discord_voice_adapter()
real_sleep = asyncio.sleep
with patch.object(runner, "_create_adapter", return_value=adapter):
with patch("gateway.run.build_channel_directory", create=True):
runner._running = True
call_count = 0
async def fake_sleep(n):
nonlocal call_count
call_count += 1
if call_count > 1:
runner._running = False
await real_sleep(0)
with patch("asyncio.sleep", side_effect=fake_sleep):
await runner._platform_reconnect_watcher()
assert adapter._voice_input_callback is not None, (
"reconnect must re-wire _voice_input_callback"
)
assert Platform.DISCORD not in runner._failed_platforms