tests(e2e): test command stripping behavior in Discord
This commit is contained in:
committed by
Teknium
parent
2008e997dc
commit
e640ea736c
@@ -12,7 +12,7 @@ No LLM, no real platform connections.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
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.platforms.base import MessageEvent, SendResult
|
||||||
from gateway.session import SessionEntry, SessionSource, build_session_key
|
from gateway.session import SessionEntry, SessionSource, build_session_key
|
||||||
|
|
||||||
|
E2E_MESSAGE_SETTLE_DELAY = 0.3
|
||||||
|
|
||||||
# Platform library mocks
|
# Platform library mocks
|
||||||
|
|
||||||
@@ -113,8 +114,9 @@ _ensure_telegram_mock()
|
|||||||
_ensure_discord_mock()
|
_ensure_discord_mock()
|
||||||
_ensure_slack_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.telegram import TelegramAdapter # noqa: E402
|
||||||
|
from gateway.platforms.discord import DiscordAdapter # noqa: E402
|
||||||
|
|
||||||
import gateway.platforms.slack as _slack_mod # noqa: E402
|
import gateway.platforms.slack as _slack_mod # noqa: E402
|
||||||
_slack_mod.SLACK_AVAILABLE = True
|
_slack_mod.SLACK_AVAILABLE = True
|
||||||
@@ -264,3 +266,140 @@ def runner(platform, session_entry):
|
|||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def adapter(platform, runner):
|
def adapter(platform, runner):
|
||||||
return make_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()
|
||||||
|
|||||||
106
tests/e2e/test_discord_adapter.py
Normal file
106
tests/e2e/test_discord_adapter.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user