diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index f509e0044..dc450ebf1 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -125,7 +125,7 @@ from tools.url_safety import is_safe_url async def _wait_for_ready_or_bot_exit( ready_event: asyncio.Event, bot_task: asyncio.Task, - timeout: float, + timeout: Optional[float], ) -> None: """Wait until Discord is ready, or surface early bot startup failure. @@ -328,6 +328,20 @@ def _build_allowed_mentions(): ) +def _discord_ready_timeout_seconds() -> float: + """Return the Discord ready wait timeout during gateway startup.""" + raw = os.getenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "").strip() + if raw: + try: + return max(0.0, float(raw)) + except ValueError: + logger.warning( + "Ignoring invalid HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT=%r", + raw, + ) + return 30.0 + + class VoiceReceiver: """Captures and decodes voice audio from a Discord voice channel. @@ -1152,9 +1166,14 @@ class DiscordAdapter(BasePlatformAdapter): self._bot_task = asyncio.create_task(self._client.start(self.config.token)) self._bot_task.add_done_callback(self._handle_bot_task_done) + ready_timeout = _discord_ready_timeout_seconds() # Wait for ready, but fail fast if discord.py's background startup # task dies first (for example on SOCKS/proxy connect errors). - await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30) + await _wait_for_ready_or_bot_exit( + self._ready_event, + self._bot_task, + timeout=None if ready_timeout <= 0 else ready_timeout, + ) self._running = True self._start_liveness_probe() diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py index 32dc3d27f..657b9d244 100644 --- a/tests/gateway/test_discord_connect.py +++ b/tests/gateway/test_discord_connect.py @@ -382,7 +382,6 @@ async def test_connect_timeout_cancels_bot_task(monkeypatch): "leaving it alive creates a zombie Discord client that produces duplicate threads" ) - @pytest.mark.asyncio async def test_disconnect_cancels_running_bot_task(monkeypatch): """Regression: disconnect() must cancel _bot_task even when connect() timed out. @@ -415,6 +414,42 @@ async def test_disconnect_cancels_running_bot_task(monkeypatch): assert zombie_task.cancelled(), "disconnect() must cancel the zombie bot task" +@pytest.mark.asyncio +async def test_connect_ready_wait_uses_gateway_platform_connect_timeout(monkeypatch): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + + monkeypatch.setenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "90") + monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None)) + monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: None) + + intents = SimpleNamespace(message_content=False, dm_messages=False, guild_messages=False, members=False, voice_states=False) + monkeypatch.setattr(discord_platform.Intents, "default", lambda: intents) + monkeypatch.setattr( + discord_platform.commands, + "Bot", + lambda **kwargs: FakeBot( + intents=kwargs["intents"], + proxy=kwargs.get("proxy"), + allowed_mentions=kwargs.get("allowed_mentions"), + ), + ) + + seen_timeouts = [] + + async def fake_wait_for_ready(ready_event, bot_task, timeout): + seen_timeouts.append(timeout) + raise asyncio.TimeoutError() + + monkeypatch.setattr( + discord_platform, "_wait_for_ready_or_bot_exit", fake_wait_for_ready + ) + + ok = await adapter.connect() + + assert ok is False + assert seen_timeouts == [90.0] + + @pytest.mark.asyncio async def test_connect_does_not_wait_for_slash_sync(monkeypatch): adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token"))