Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -6896,6 +6896,10 @@ async def start(self) -> bool:
if success:
self.adapters[platform] = adapter
self._sync_voice_mode_state_to_adapter(adapter)
# Wire voice input callback at connect time so voice
# transcription is forwarded without requiring /voice join.
if hasattr(adapter, "_voice_input_callback"):
adapter._voice_input_callback = self._handle_voice_channel_input
connected_count += 1
self._update_platform_runtime_status(
platform.value,
Expand Down Expand Up @@ -7726,6 +7730,9 @@ async def _platform_reconnect_watcher(self) -> None:
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
self.delivery_router.adapters = self.adapters
del self._failed_platforms[platform]
self._update_platform_runtime_status(
Expand Down
18 changes: 16 additions & 2 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2762,8 +2762,15 @@ def voice_mixer_active(self, guild_id: int) -> bool:
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
Expand All @@ -2783,6 +2790,13 @@ async def join_voice_channel(self, channel) -> bool:
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)
Expand Down
105 changes: 105 additions & 0 deletions tests/gateway/test_platform_reconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -840,3 +840,108 @@ async def test_bare_platform_shows_usage_with_list(self):
runner = _make_runner()
out = await runner._handle_platform_command(self._make_event("/platform"))
assert "Gateway platforms" in out


# --- 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
Loading