diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 6e828ed8e..847c2bb9d 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -502,19 +502,6 @@ class DiscordAdapter(BasePlatformAdapter): self._set_fatal_error('discord_token_lock', message, retryable=False) return False - # Set up intents -- members intent needed for username-to-ID resolution - intents = Intents.default() - intents.message_content = True - intents.dm_messages = True - intents.guild_messages = True - intents.members = True - intents.voice_states = True - - # Create bot - self._client = commands.Bot( - command_prefix="!", # Not really used, we handle raw messages - intents=intents, - ) # Parse allowed user entries (may contain usernames or IDs) allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "") @@ -524,6 +511,25 @@ class DiscordAdapter(BasePlatformAdapter): if uid.strip() } + # Set up intents. + # Message Content is required for normal text replies. + # Server Members is only needed when the allowlist contains usernames + # that must be resolved to numeric IDs. Requesting privileged intents + # that aren't enabled in the Discord Developer Portal can prevent the + # bot from coming online at all, so avoid requesting members intent + # unless it is actually necessary. + intents = Intents.default() + intents.message_content = True + intents.dm_messages = True + intents.guild_messages = True + intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids) + intents.voice_states = True + + # Create bot + self._client = commands.Bot( + command_prefix="!", # Not really used, we handle raw messages + intents=intents, + ) adapter_self = self # capture for closure # Register event handlers @@ -648,9 +654,23 @@ class DiscordAdapter(BasePlatformAdapter): except asyncio.TimeoutError: logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True) + try: + from gateway.status import release_scoped_lock + if getattr(self, '_token_lock_identity', None): + release_scoped_lock('discord-bot-token', self._token_lock_identity) + self._token_lock_identity = None + except Exception: + pass return False except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True) + try: + from gateway.status import release_scoped_lock + if getattr(self, '_token_lock_identity', None): + release_scoped_lock('discord-bot-token', self._token_lock_identity) + self._token_lock_identity = None + except Exception: + pass return False async def disconnect(self) -> None: diff --git a/tests/gateway/test_discord_connect.py b/tests/gateway/test_discord_connect.py new file mode 100644 index 000000000..61f1cba3d --- /dev/null +++ b/tests/gateway/test_discord_connect.py @@ -0,0 +1,140 @@ +import asyncio +import sys +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_discord_mock(): + if "discord" in sys.modules and hasattr(sys.modules["discord"], "__file__"): + return + + discord_mod = MagicMock() + discord_mod.Intents.default.return_value = MagicMock() + discord_mod.Client = MagicMock + discord_mod.File = MagicMock + discord_mod.DMChannel = type("DMChannel", (), {}) + discord_mod.Thread = type("Thread", (), {}) + discord_mod.ForumChannel = type("ForumChannel", (), {}) + discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object) + discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, danger=3, green=1, blurple=2, red=3) + discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4) + discord_mod.Interaction = object + discord_mod.Embed = MagicMock + discord_mod.app_commands = SimpleNamespace( + describe=lambda **kwargs: (lambda fn: fn), + choices=lambda **kwargs: (lambda fn: fn), + Choice=lambda **kwargs: SimpleNamespace(**kwargs), + ) + discord_mod.opus = SimpleNamespace(is_loaded=lambda: True) + + ext_mod = MagicMock() + commands_mod = MagicMock() + commands_mod.Bot = MagicMock + ext_mod.commands = commands_mod + + sys.modules.setdefault("discord", discord_mod) + sys.modules.setdefault("discord.ext", ext_mod) + sys.modules.setdefault("discord.ext.commands", commands_mod) + + +_ensure_discord_mock() + +import gateway.platforms.discord as discord_platform # noqa: E402 +from gateway.platforms.discord import DiscordAdapter # noqa: E402 + + +class FakeTree: + def __init__(self): + self.sync = AsyncMock(return_value=[]) + + def command(self, *args, **kwargs): + return lambda fn: fn + + +class FakeBot: + def __init__(self, *, intents): + self.intents = intents + self.user = SimpleNamespace(id=999, name="Hermes") + self._events = {} + self.tree = FakeTree() + + def event(self, fn): + self._events[fn.__name__] = fn + return fn + + async def start(self, token): + if "on_ready" in self._events: + await self._events["on_ready"]() + + async def close(self): + return None + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("allowed_users", "expected_members_intent"), + [ + ("769524422783664158", False), + ("abhey-gupta", True), + ("769524422783664158,abhey-gupta", True), + ], +) +async def test_connect_only_requests_members_intent_when_needed(monkeypatch, allowed_users, expected_members_intent): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + + monkeypatch.setenv("DISCORD_ALLOWED_USERS", allowed_users) + 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) + + created = {} + + def fake_bot_factory(*, command_prefix, intents): + created["bot"] = FakeBot(intents=intents) + return created["bot"] + + monkeypatch.setattr(discord_platform.commands, "Bot", fake_bot_factory) + monkeypatch.setattr(adapter, "_resolve_allowed_usernames", AsyncMock()) + + ok = await adapter.connect() + + assert ok is True + assert created["bot"].intents.members is expected_members_intent + + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_connect_releases_token_lock_on_timeout(monkeypatch): + adapter = DiscordAdapter(PlatformConfig(enabled=True, token="test-token")) + + monkeypatch.setattr("gateway.status.acquire_scoped_lock", lambda scope, identity, metadata=None: (True, None)) + released = [] + monkeypatch.setattr("gateway.status.release_scoped_lock", lambda scope, identity: released.append((scope, identity))) + + 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"]), + ) + + async def fake_wait_for(awaitable, timeout): + awaitable.close() + raise asyncio.TimeoutError() + + monkeypatch.setattr(discord_platform.asyncio, "wait_for", fake_wait_for) + + ok = await adapter.connect() + + assert ok is False + assert released == [("discord-bot-token", "test-token")] + assert adapter._token_lock_identity is None