diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 51a8780aa..ca7e9e416 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2474,6 +2474,14 @@ class DiscordAdapter(BasePlatformAdapter): _parent_id = str(getattr(_chan, "parent_id", "") or "") _chan_id = str(getattr(_chan, "id", "")) _skills = self._resolve_channel_skills(_chan_id, _parent_id or None) + + reply_to_id = None + reply_to_text = None + if message.reference: + reply_to_id = str(message.reference.message_id) + if message.reference.resolved: + reply_to_text = message.reference.resolved.content or None + event = MessageEvent( text=event_text, message_type=msg_type, @@ -2482,7 +2490,8 @@ class DiscordAdapter(BasePlatformAdapter): message_id=str(message.id), media_urls=media_urls, media_types=media_types, - reply_to_message_id=str(message.reference.message_id) if message.reference else None, + reply_to_message_id=reply_to_id, + reply_to_text=reply_to_text, timestamp=message.created_at, auto_skill=_skills, ) diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py index 2346d086f..47cc93c5c 100644 --- a/tests/gateway/test_discord_reply_mode.py +++ b/tests/gateway/test_discord_reply_mode.py @@ -4,9 +4,12 @@ Covers the threading behavior control for multi-chunk replies: - "off": Never reply-reference to original message - "first": Only first chunk uses reply reference (default) - "all": All chunks reply-reference the original message + +Also covers reply_to_text extraction from incoming messages. """ import os import sys +from datetime import datetime, timezone from types import SimpleNamespace from unittest.mock import MagicMock, AsyncMock, patch @@ -275,3 +278,94 @@ class TestEnvVarOverride: _apply_env_overrides(config) assert Platform.DISCORD in config.platforms assert config.platforms[Platform.DISCORD].reply_to_mode == "off" + + +# ------------------------------------------------------------------ +# Tests for reply_to_text extraction in _handle_message +# ------------------------------------------------------------------ + +class FakeDMChannel: + """Minimal DM channel stub (skips mention / channel-allow checks).""" + def __init__(self, channel_id: int = 100, name: str = "dm"): + self.id = channel_id + self.name = name + + +def _make_message(*, content: str = "hi", reference=None): + """Build a mock Discord message for _handle_message tests.""" + author = SimpleNamespace(id=42, display_name="TestUser", name="TestUser") + return SimpleNamespace( + id=999, + content=content, + mentions=[], + attachments=[], + reference=reference, + created_at=datetime.now(timezone.utc), + channel=FakeDMChannel(), + author=author, + ) + + +@pytest.fixture +def reply_text_adapter(monkeypatch): + """DiscordAdapter wired for _handle_message → handle_message capture.""" + import gateway.platforms.discord as discord_platform + + monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False) + + config = PlatformConfig(enabled=True, token="fake-token") + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + adapter._text_batch_delay_seconds = 0 + adapter.handle_message = AsyncMock() + return adapter + + +class TestReplyToText: + """Tests for reply_to_text populated by _handle_message.""" + + @pytest.mark.asyncio + async def test_no_reference_both_none(self, reply_text_adapter): + message = _make_message(reference=None) + + await reply_text_adapter._handle_message(message) + + event = reply_text_adapter.handle_message.await_args.args[0] + assert event.reply_to_message_id is None + assert event.reply_to_text is None + + @pytest.mark.asyncio + async def test_reference_without_resolved(self, reply_text_adapter): + ref = SimpleNamespace(message_id=555, resolved=None) + message = _make_message(reference=ref) + + await reply_text_adapter._handle_message(message) + + event = reply_text_adapter.handle_message.await_args.args[0] + assert event.reply_to_message_id == "555" + assert event.reply_to_text is None + + @pytest.mark.asyncio + async def test_reference_with_resolved_content(self, reply_text_adapter): + resolved_msg = SimpleNamespace(content="original message text") + ref = SimpleNamespace(message_id=555, resolved=resolved_msg) + message = _make_message(reference=ref) + + await reply_text_adapter._handle_message(message) + + event = reply_text_adapter.handle_message.await_args.args[0] + assert event.reply_to_message_id == "555" + assert event.reply_to_text == "original message text" + + @pytest.mark.asyncio + async def test_reference_with_empty_resolved_content(self, reply_text_adapter): + """Empty string content should become None, not leak as empty string.""" + resolved_msg = SimpleNamespace(content="") + ref = SimpleNamespace(message_id=555, resolved=resolved_msg) + message = _make_message(reference=ref) + + await reply_text_adapter._handle_message(message) + + event = reply_text_adapter.handle_message.await_args.args[0] + assert event.reply_to_message_id == "555" + assert event.reply_to_text is None