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
40 changes: 30 additions & 10 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,12 +497,26 @@ async def connect(self) -> bool:
self._set_fatal_error('discord_token_lock', message, retryable=False)
return False

# Set up intents -- members intent needed for username-to-ID resolution
# Parse allowed user entries (may contain usernames or IDs)
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
if allowed_env:
self._allowed_user_ids = {
_clean_discord_id(uid) for uid in allowed_env.split(",")
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 = True
intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids)
intents.voice_states = True

# Create bot
Expand All @@ -511,14 +525,6 @@ async def connect(self) -> bool:
intents=intents,
)

# Parse allowed user entries (may contain usernames or IDs)
allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "")
if allowed_env:
self._allowed_user_ids = {
_clean_discord_id(uid) for uid in allowed_env.split(",")
if uid.strip()
}

adapter_self = self # capture for closure

# Register event handlers
Expand Down Expand Up @@ -626,9 +632,23 @@ async def on_voice_state_update(member, before, after):

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:
Expand Down
140 changes: 140 additions & 0 deletions tests/gateway/test_discord_connect.py
Original file line number Diff line number Diff line change
@@ -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