mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
79da6adfe9
commit
31301c1af7
3 changed files with 123 additions and 6 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue