diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index d9ca627c4..8e97b506f 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -12,7 +12,7 @@ No LLM, no real platform connections. import asyncio import sys import uuid -from datetime import datetime +from datetime import datetime, timezone, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -22,6 +22,7 @@ from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent, SendResult from gateway.session import SessionEntry, SessionSource, build_session_key +E2E_MESSAGE_SETTLE_DELAY = 0.3 # Platform library mocks @@ -113,8 +114,9 @@ _ensure_telegram_mock() _ensure_discord_mock() _ensure_slack_mock() -from gateway.platforms.discord import DiscordAdapter # noqa: E402 +import discord # noqa: E402 — mocked above from gateway.platforms.telegram import TelegramAdapter # noqa: E402 +from gateway.platforms.discord import DiscordAdapter # noqa: E402 import gateway.platforms.slack as _slack_mod # noqa: E402 _slack_mod.SLACK_AVAILABLE = True @@ -264,3 +266,140 @@ def runner(platform, session_entry): @pytest.fixture() def adapter(platform, runner): return make_adapter(platform, runner) + + +# ═══════════════════════════════════════════════════════════════════════════ +# Discord helpers and fixtures +# ═══════════════════════════════════════════════════════════════════════════ + +BOT_USER_ID = 99999 +BOT_USER_NAME = "HermesBot" +CHANNEL_ID = 22222 +GUILD_ID = 44444 +THREAD_ID = 33333 +MESSAGE_ID_COUNTER = 0 + + +def _next_message_id() -> int: + global MESSAGE_ID_COUNTER + MESSAGE_ID_COUNTER += 1 + return 70000 + MESSAGE_ID_COUNTER + + +def make_fake_bot_user(): + return SimpleNamespace( + id=BOT_USER_ID, name=BOT_USER_NAME, + display_name=BOT_USER_NAME, bot=True, + ) + + +def make_fake_guild(guild_id: int = GUILD_ID, name: str = "Test Server"): + return SimpleNamespace(id=guild_id, name=name) + + +def make_fake_text_channel(channel_id: int = CHANNEL_ID, name: str = "general", guild=None): + return SimpleNamespace( + id=channel_id, name=name, + guild=guild or make_fake_guild(), + topic=None, type=0, + ) + + +def make_fake_dm_channel(channel_id: int = 55555): + ch = MagicMock(spec=[]) + ch.id = channel_id + ch.name = "DM" + ch.topic = None + ch.__class__ = discord.DMChannel + return ch + + +def make_fake_thread(thread_id: int = THREAD_ID, name: str = "test-thread", parent=None): + th = MagicMock(spec=[]) + th.id = thread_id + th.name = name + th.parent = parent or make_fake_text_channel() + th.parent_id = th.parent.id + th.guild = th.parent.guild + th.topic = None + th.type = 11 + th.__class__ = discord.Thread + return th + + +def make_discord_message( + *, content: str = "hello", author=None, channel=None, mentions=None, + attachments=None, message_id: int = None, +): + if message_id is None: + message_id = _next_message_id() + if author is None: + author = SimpleNamespace( + id=11111, name="testuser", display_name="testuser", bot=False, + ) + if channel is None: + channel = make_fake_text_channel() + if mentions is None: + mentions = [] + if attachments is None: + attachments = [] + + return SimpleNamespace( + id=message_id, content=content, author=author, channel=channel, + mentions=mentions, attachments=attachments, + type=getattr(discord, "MessageType", SimpleNamespace()).default, + reference=None, created_at=datetime.now(timezone.utc), + create_thread=AsyncMock(), + ) + + +def get_response_text(adapter) -> str | None: + """Extract the response text from adapter.send() call args, or None if not called.""" + if not adapter.send.called: + return None + return adapter.send.call_args[1].get("content") or adapter.send.call_args[0][1] + + +def _make_discord_adapter_wired(runner=None): + """Create a DiscordAdapter wired to a GatewayRunner for e2e tests.""" + if runner is None: + runner = make_runner(Platform.DISCORD) + + config = PlatformConfig(enabled=True, token="e2e-test-token") + from gateway.platforms.helpers import ThreadParticipationTracker + with patch.object(ThreadParticipationTracker, "_load", return_value=set()): + adapter = DiscordAdapter(config) + + bot_user = make_fake_bot_user() + adapter._client = SimpleNamespace( + user=bot_user, + get_channel=lambda _id: None, + fetch_channel=AsyncMock(), + ) + + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="e2e-resp-1")) + adapter.send_typing = AsyncMock() + adapter.set_message_handler(runner._handle_message) + runner.adapters[Platform.DISCORD] = adapter + + return adapter, runner + + +@pytest.fixture() +def discord_setup(): + return _make_discord_adapter_wired() + + +@pytest.fixture() +def discord_adapter(discord_setup): + return discord_setup[0] + + +@pytest.fixture() +def discord_runner(discord_setup): + return discord_setup[1] + + +@pytest.fixture() +def bot_user(): + return make_fake_bot_user() diff --git a/tests/e2e/test_discord_adapter.py b/tests/e2e/test_discord_adapter.py new file mode 100644 index 000000000..97c806f82 --- /dev/null +++ b/tests/e2e/test_discord_adapter.py @@ -0,0 +1,106 @@ +"""Minimal e2e tests for Discord mention stripping + /command detection. + +Covers the fix for slash commands not being recognized when sent via +@mention in a channel, especially after auto-threading. +""" + +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from tests.e2e.conftest import ( + BOT_USER_ID, + E2E_MESSAGE_SETTLE_DELAY, + get_response_text, + make_discord_message, + make_fake_dm_channel, + make_fake_thread, +) + +pytestmark = pytest.mark.asyncio + + +async def dispatch(adapter, msg): + await adapter._handle_message(msg) + await asyncio.sleep(E2E_MESSAGE_SETTLE_DELAY) + + +class TestMentionStrippedCommandDispatch: + async def test_mention_then_command(self, discord_adapter, bot_user): + """<@BOT> /help → mention stripped, /help dispatched.""" + msg = make_discord_message( + content=f"<@{BOT_USER_ID}> /help", + mentions=[bot_user], + ) + await dispatch(discord_adapter, msg) + response = get_response_text(discord_adapter) + assert response is not None + assert "/new" in response + + async def test_nickname_mention_then_command(self, discord_adapter, bot_user): + """<@!BOT> /help → nickname mention also stripped, /help works.""" + msg = make_discord_message( + content=f"<@!{BOT_USER_ID}> /help", + mentions=[bot_user], + ) + await dispatch(discord_adapter, msg) + response = get_response_text(discord_adapter) + assert response is not None + assert "/new" in response + + async def test_text_before_command_not_detected(self, discord_adapter, bot_user): + """'<@BOT> something else /help' → mention stripped, but 'something else /help' + doesn't start with / so it's treated as text, not a command.""" + msg = make_discord_message( + content=f"<@{BOT_USER_ID}> something else /help", + mentions=[bot_user], + ) + await dispatch(discord_adapter, msg) + # Message is accepted (not dropped), but not dispatched as a command + discord_adapter.send.assert_awaited() + response = get_response_text(discord_adapter) + # /help command output lists /new — if it went through as text, it won't + assert response is None or "/new" not in response + + async def test_no_mention_in_channel_dropped(self, discord_adapter): + """Message without @mention in server channel → silently dropped.""" + msg = make_discord_message(content="/help", mentions=[]) + await dispatch(discord_adapter, msg) + assert get_response_text(discord_adapter) is None + + async def test_dm_no_mention_needed(self, discord_adapter): + """DMs don't require @mention — /help works directly.""" + dm = make_fake_dm_channel() + msg = make_discord_message(content="/help", channel=dm, mentions=[]) + await dispatch(discord_adapter, msg) + response = get_response_text(discord_adapter) + assert response is not None + assert "/new" in response + + +class TestAutoThreadingPreservesCommand: + async def test_command_detected_after_auto_thread(self, discord_adapter, bot_user, monkeypatch): + """@mention /help in channel with auto-thread → thread created AND command dispatched.""" + monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") + fake_thread = make_fake_thread(thread_id=90001, name="help") + msg = make_discord_message( + content=f"<@{BOT_USER_ID}> /help", + mentions=[bot_user], + ) + + # Simulate discord.py restoring the original raw content (with mention) + # after create_thread(), which undoes any prior mention stripping. + original_content = msg.content + + async def clobber_content(**kwargs): + msg.content = original_content + return fake_thread + + msg.create_thread = AsyncMock(side_effect=clobber_content) + await dispatch(discord_adapter, msg) + + msg.create_thread.assert_awaited_once() + response = get_response_text(discord_adapter) + assert response is not None + assert "/new" in response