First migration of an existing built-in platform adapter to the plugin system established by IRC / Teams / LINE / Google Chat. Closes #24325; advances the umbrella refactor in #3823. Matches Teams' shape exactly — adapter under ``plugins/platforms/discord/`` with the standard ``__init__.py`` / ``adapter.py`` / ``plugin.yaml`` shell, ``register(ctx)`` entry point, **no back-compat shim** at the old import path, and full parity for the four hooks Teams uses plus the ``apply_yaml_config_fn`` hook that landed in #25443 (the Discord plugin is the first consumer of that hook): * ``standalone_sender_fn`` — out-of-process cron delivery via REST API * ``setup_fn`` — interactive ``hermes setup gateway`` wizard * ``apply_yaml_config_fn`` — translate ``config.yaml`` ``discord:`` keys into ``DISCORD_*`` env vars (replaces the hardcoded block in ``gateway/config.py``) * ``is_connected`` — declares connection state from ``DISCORD_BOT_TOKEN`` * ``check_fn`` — lazy-installs ``discord.py`` on demand * plus ``allowed_users_env``, ``allow_all_env``, ``cron_deliver_env_var``, ``max_message_length``, ``emoji``, ``required_env``, ``install_hint`` * ``gateway/platforms/discord.py`` (5,101 LOC) → ``plugins/platforms/discord/adapter.py`` (git rename, R090). * New ``plugins/platforms/discord/{__init__.py, plugin.yaml}`` with ``requires_env`` / ``optional_env`` declarations. * Append ``register(ctx)`` block + new hook implementations (``_standalone_send``, ``interactive_setup``, ``_apply_yaml_config``, ``_clean_discord_user_ids``, ``_is_connected``, ``_build_adapter``, plus helpers ``_DISCORD_CHANNEL_TYPE_PROBE_CACHE`` etc.) to the adapter. * Replace the ``Platform.DISCORD elif`` branch in ``GatewayRunner._create_adapter()`` (−9 LOC) with a generic post-creation hook (+6 LOC) in the registry path: any plugin adapter that declares a ``gateway_runner`` attribute now gets it auto-injected. Webhook's built-in branch is unchanged (it doesn't go through the registry path). * Move ``_send_discord`` (190 LOC) and helpers (``_DISCORD_CHANNEL_TYPE_PROBE_CACHE``, ``_remember_channel_is_forum``, ``_probe_is_forum_cached``, ``_derive_forum_thread_name``) from ``tools/send_message_tool.py`` into the plugin as ``_standalone_send``. * Wire via ``standalone_sender_fn=_standalone_send`` (Teams pattern; same gap fixed in #21804 for other plugin platforms). * Replace the Discord ``elif`` in ``tools/send_message_tool.py`` ``_send_to_platform`` with a 10-line registry-hook dispatch. * Drop the ``DiscordAdapter`` import and the ``Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH`` ``_MAX_LENGTHS`` entry — the registry's ``max_message_length=2000`` covers it. * Move ``_setup_discord`` and ``_clean_discord_user_ids`` (68 LOC) from ``hermes_cli/setup.py`` into the plugin as ``interactive_setup``. * Wire via ``setup_fn=interactive_setup``. CLI helpers (``prompt``, ``print_info``, etc.) are lazy-imported so the plugin's module-load surface stays minimal. * Remove ``"discord": _s._setup_discord`` from ``hermes_cli/gateway.py::_builtin_setup_fn``. * Remove the entire 32-line ``_PLATFORMS["discord"]`` static dict entry — Discord's setup metadata is now discovered dynamically via ``_all_platforms()`` from the registry entry. * Move the 59-line ``discord_cfg`` YAML→env bridge from ``gateway/config.py::load_gateway_config()`` into the plugin as ``_apply_yaml_config``. Covers ``require_mention``, ``thread_require_mention``, ``free_response_channels``, ``auto_thread``, ``reactions``, ``ignored_channels``, ``allowed_channels``, ``no_thread_channels``, ``allow_mentions.{everyone,roles,users, replied_user}``, and ``reply_to_mode`` (including the YAML 1.1 ``off``-as-False coercion and the ``extra.reply_to_mode`` fallback). * Wire via ``apply_yaml_config_fn=_apply_yaml_config``. * The hook runs BEFORE ``_apply_env_overrides`` and after the generic shared-key loop, exactly as documented in ``website/docs/developer-guide/adding-platform-adapters.md``. * Behavior is preserved exactly — every assignment still uses ``not os.getenv(...)`` guards so env vars take precedence over YAML. All 78 references to the old import path are rewritten — no back-compat shim: * 51 ``from gateway.platforms.discord import X`` → ``from plugins.platforms.discord.adapter import X`` * 5 ``import gateway.platforms.discord as discord_platform`` → ``import plugins.platforms.discord.adapter as discord_platform`` * 1 ``from gateway.platforms import discord as discord_mod`` → ``from plugins.platforms.discord import adapter as discord_mod`` * 21 ``mock.patch("gateway.platforms.discord.X")`` strings → ``mock.patch("plugins.platforms.discord.adapter.X")`` * 1 docstring reference in ``hermes_cli/commands.py`` * 1 import in ``tools/send_message_tool.py`` (now removed entirely) The import-safety test in ``tests/gateway/test_discord_imports.py`` is updated to purge the new canonical module name from ``sys.modules``. **38 files changed, +621 / −473** — net positive due to the YAML hook implementation (89 new LOC in the plugin trading for 59 deleted in core), but every line moved has a clear plugin home now. The git rename is detected at R090 because the adapter gained ~340 LOC of moved-in hook implementations (``_standalone_send`` + ``interactive_setup`` + ``_apply_yaml_config`` + helpers). * All 568 Discord-specific tests pass across 25 ``test_discord_*.py`` files plus voice/send/text-batching/reload-skills/stream-consumer/ integration tests. * All 147 tests in the YAML-touching subset (``test_discord_reply_mode``, ``test_discord_free_response``, ``test_discord_allowed_channels``, ``test_discord_allowed_mentions``, ``test_discord_channel_controls``, ``test_discord_reactions``, ``test_discord_thread_persistence``, ``test_runtime_footer``) pass — this is the strongest signal that the YAML→env hook behaves identically to the legacy block. * Broader gateway/cron/integration sweep (1297 tests) introduces zero new failures vs ``main``. Pre-existing failures in ``tests/gateway/test_tts_media_routing.py`` and ``tests/e2e/test_platform_commands.py`` reproduce identically on the unchanged ``main`` revision. * Plugin discovery sanity check confirms Discord registers alongside the other four platform plugins: Registered platforms: ['discord', 'google_chat', 'irc', 'line', 'teams'] These Discord-shaped tendrils in core were **deliberately not moved** — they are generic platform-registry concerns affecting every platform, not Discord-specific: * ``gateway/config.py:1205`` ``DISCORD_BOT_TOKEN → config.token`` env enablement — same shape Telegram has. The existing ``env_enablement_fn`` registry hook only seeds ``extra``, not ``.token``, so it can't replace this without an adapter refactor to read from ``extra["bot_token"]``. * ``gateway/run.py`` voice-mode hooks (``self.adapters.get(Platform.DISCORD)`` for ``start_voice_mode``/``stop_voice_mode``), role-based auth, ``DISCORD_ALLOW_BOTS`` branch in ``_is_user_authorized``, ``_UPDATE_ALLOWED_PLATFORMS`` frozenset, and the per-platform allowlist maps — generic platform-registry concerns. * ``Platform.DISCORD`` enum literal — stable identifier used as dict keys throughout the codebase; removing it is a separate refactor with no real benefit. * ``tools/discord_tool.py`` and ``tools/environments/local.py`` — first-class agent tools and env-passthrough config, neither is the gateway adapter. Each of these is worth its own scoping issue when the time comes.
463 lines
18 KiB
Python
463 lines
18 KiB
Python
"""Tests for Discord reply_to_mode functionality.
|
|
|
|
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
|
|
|
|
import pytest
|
|
|
|
from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides, load_gateway_config
|
|
|
|
|
|
def _ensure_discord_mock():
|
|
"""Install a mock discord module when discord.py isn't available."""
|
|
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, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)
|
|
discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)
|
|
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),
|
|
)
|
|
|
|
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()
|
|
|
|
from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
|
|
|
|
|
|
@pytest.fixture()
|
|
def adapter_factory():
|
|
"""Factory to create DiscordAdapter with custom reply_to_mode."""
|
|
def create(reply_to_mode: str = "first"):
|
|
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
|
|
return DiscordAdapter(config)
|
|
return create
|
|
|
|
|
|
class TestReplyToModeConfig:
|
|
"""Tests for reply_to_mode configuration loading."""
|
|
|
|
def test_default_mode_is_first(self, adapter_factory):
|
|
adapter = adapter_factory()
|
|
assert adapter._reply_to_mode == "first"
|
|
|
|
def test_off_mode(self, adapter_factory):
|
|
adapter = adapter_factory(reply_to_mode="off")
|
|
assert adapter._reply_to_mode == "off"
|
|
|
|
def test_first_mode(self, adapter_factory):
|
|
adapter = adapter_factory(reply_to_mode="first")
|
|
assert adapter._reply_to_mode == "first"
|
|
|
|
def test_all_mode(self, adapter_factory):
|
|
adapter = adapter_factory(reply_to_mode="all")
|
|
assert adapter._reply_to_mode == "all"
|
|
|
|
def test_invalid_mode_stored_as_is(self, adapter_factory):
|
|
"""Invalid modes are stored but send() handles them gracefully."""
|
|
adapter = adapter_factory(reply_to_mode="invalid")
|
|
assert adapter._reply_to_mode == "invalid"
|
|
|
|
def test_none_mode_defaults_to_first(self):
|
|
config = PlatformConfig(enabled=True, token="test-token")
|
|
adapter = DiscordAdapter(config)
|
|
assert adapter._reply_to_mode == "first"
|
|
|
|
def test_empty_string_mode_defaults_to_first(self):
|
|
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode="")
|
|
adapter = DiscordAdapter(config)
|
|
assert adapter._reply_to_mode == "first"
|
|
|
|
|
|
def _make_discord_adapter(reply_to_mode: str = "first"):
|
|
"""Create a DiscordAdapter with mocked client and channel for send() tests."""
|
|
config = PlatformConfig(enabled=True, token="test-token", reply_to_mode=reply_to_mode)
|
|
adapter = DiscordAdapter(config)
|
|
|
|
# Mock the Discord client and channel.
|
|
# ref_message.to_reference() → a distinct sentinel: the adapter now wraps
|
|
# the fetched Message via to_reference(fail_if_not_exists=False) so a
|
|
# deleted target degrades to "send without reply chip" instead of a 400.
|
|
mock_channel = AsyncMock()
|
|
ref_message = MagicMock()
|
|
ref_reference = MagicMock(name="MessageReference")
|
|
ref_message.to_reference = MagicMock(return_value=ref_reference)
|
|
mock_channel.fetch_message = AsyncMock(return_value=ref_message)
|
|
|
|
sent_msg = MagicMock()
|
|
sent_msg.id = 42
|
|
mock_channel.send = AsyncMock(return_value=sent_msg)
|
|
|
|
mock_client = MagicMock()
|
|
mock_client.get_channel = MagicMock(return_value=mock_channel)
|
|
|
|
adapter._client = mock_client
|
|
# Return the reference sentinel alongside so tests can assert identity.
|
|
adapter._test_expected_reference = ref_reference
|
|
return adapter, mock_channel, ref_reference
|
|
|
|
|
|
class TestSendWithReplyToMode:
|
|
"""Tests for send() method respecting reply_to_mode."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_off_mode_no_reply_reference(self):
|
|
adapter, channel, ref_msg = _make_discord_adapter("off")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
|
|
|
await adapter.send("12345", "test content", reply_to="999")
|
|
|
|
# Should never try to fetch the reference message
|
|
channel.fetch_message.assert_not_called()
|
|
# All chunks sent without reference
|
|
for call in channel.send.call_args_list:
|
|
assert call.kwargs.get("reference") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_mode_only_first_chunk_references(self):
|
|
adapter, channel, ref_msg = _make_discord_adapter("first")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
|
|
|
await adapter.send("12345", "test content", reply_to="999")
|
|
|
|
# Should fetch the reference message
|
|
channel.fetch_message.assert_called_once_with(999)
|
|
calls = channel.send.call_args_list
|
|
assert len(calls) == 3
|
|
assert calls[0].kwargs.get("reference") is ref_msg
|
|
assert calls[1].kwargs.get("reference") is None
|
|
assert calls[2].kwargs.get("reference") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_mode_all_chunks_reference(self):
|
|
adapter, channel, ref_msg = _make_discord_adapter("all")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2", "chunk3"]
|
|
|
|
await adapter.send("12345", "test content", reply_to="999")
|
|
|
|
channel.fetch_message.assert_called_once_with(999)
|
|
calls = channel.send.call_args_list
|
|
assert len(calls) == 3
|
|
for call in calls:
|
|
assert call.kwargs.get("reference") is ref_msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_reply_to_param_no_reference(self):
|
|
adapter, channel, ref_msg = _make_discord_adapter("all")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
|
|
|
|
await adapter.send("12345", "test content", reply_to=None)
|
|
|
|
channel.fetch_message.assert_not_called()
|
|
for call in channel.send.call_args_list:
|
|
assert call.kwargs.get("reference") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_chunk_respects_first_mode(self):
|
|
adapter, channel, ref_msg = _make_discord_adapter("first")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
|
|
|
|
await adapter.send("12345", "test", reply_to="999")
|
|
|
|
calls = channel.send.call_args_list
|
|
assert len(calls) == 1
|
|
assert calls[0].kwargs.get("reference") is ref_msg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_chunk_off_mode(self):
|
|
adapter, channel, ref_msg = _make_discord_adapter("off")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["single chunk"]
|
|
|
|
await adapter.send("12345", "test", reply_to="999")
|
|
|
|
channel.fetch_message.assert_not_called()
|
|
calls = channel.send.call_args_list
|
|
assert len(calls) == 1
|
|
assert calls[0].kwargs.get("reference") is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_mode_falls_back_to_first_behavior(self):
|
|
"""Invalid mode behaves like 'first' — only first chunk gets reference."""
|
|
adapter, channel, ref_msg = _make_discord_adapter("banana")
|
|
adapter.truncate_message = lambda content, max_len, **kw: ["chunk1", "chunk2"]
|
|
|
|
await adapter.send("12345", "test", reply_to="999")
|
|
|
|
calls = channel.send.call_args_list
|
|
assert len(calls) == 2
|
|
assert calls[0].kwargs.get("reference") is ref_msg
|
|
assert calls[1].kwargs.get("reference") is None
|
|
|
|
|
|
class TestConfigSerialization:
|
|
"""Tests for reply_to_mode serialization (shared with Telegram)."""
|
|
|
|
def test_to_dict_includes_reply_to_mode(self):
|
|
config = PlatformConfig(enabled=True, token="test", reply_to_mode="all")
|
|
result = config.to_dict()
|
|
assert result["reply_to_mode"] == "all"
|
|
|
|
def test_from_dict_loads_reply_to_mode(self):
|
|
data = {"enabled": True, "token": "***", "reply_to_mode": "off"}
|
|
config = PlatformConfig.from_dict(data)
|
|
assert config.reply_to_mode == "off"
|
|
|
|
def test_from_dict_defaults_to_first(self):
|
|
data = {"enabled": True, "token": "***"}
|
|
config = PlatformConfig.from_dict(data)
|
|
assert config.reply_to_mode == "first"
|
|
|
|
|
|
class TestEnvVarOverride:
|
|
"""Tests for DISCORD_REPLY_TO_MODE environment variable override."""
|
|
|
|
def _make_config(self):
|
|
config = GatewayConfig()
|
|
config.platforms[Platform.DISCORD] = PlatformConfig(enabled=True, token="test")
|
|
return config
|
|
|
|
def test_env_var_sets_off_mode(self):
|
|
config = self._make_config()
|
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "off"}, clear=False):
|
|
_apply_env_overrides(config)
|
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "off"
|
|
|
|
def test_env_var_sets_all_mode(self):
|
|
config = self._make_config()
|
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "all"}, clear=False):
|
|
_apply_env_overrides(config)
|
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "all"
|
|
|
|
def test_env_var_case_insensitive(self):
|
|
config = self._make_config()
|
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "ALL"}, clear=False):
|
|
_apply_env_overrides(config)
|
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "all"
|
|
|
|
def test_env_var_invalid_value_ignored(self):
|
|
config = self._make_config()
|
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "banana"}, clear=False):
|
|
_apply_env_overrides(config)
|
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "first"
|
|
|
|
def test_env_var_empty_value_ignored(self):
|
|
config = self._make_config()
|
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": ""}, clear=False):
|
|
_apply_env_overrides(config)
|
|
assert config.platforms[Platform.DISCORD].reply_to_mode == "first"
|
|
|
|
def test_env_var_creates_platform_config_if_missing(self):
|
|
"""DISCORD_REPLY_TO_MODE creates PlatformConfig even without DISCORD_BOT_TOKEN."""
|
|
config = GatewayConfig()
|
|
assert Platform.DISCORD not in config.platforms
|
|
with patch.dict(os.environ, {"DISCORD_REPLY_TO_MODE": "off"}, clear=False):
|
|
_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
|
|
# ------------------------------------------------------------------
|
|
|
|
# Build FakeDMChannel as a subclass of the real discord.DMChannel when the
|
|
# library is installed — this guarantees isinstance() checks pass in
|
|
# production code regardless of test ordering or monkeypatch state.
|
|
try:
|
|
import discord as _discord_lib
|
|
_DMChannelBase = _discord_lib.DMChannel
|
|
except (ImportError, AttributeError):
|
|
_DMChannelBase = object
|
|
|
|
|
|
class FakeDMChannel(_DMChannelBase):
|
|
"""Minimal DM channel stub (skips mention / channel-allow checks)."""
|
|
def __init__(self, channel_id: int = 100, name: str = "dm"):
|
|
# Do NOT call super().__init__() — real DMChannel requires State
|
|
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."""
|
|
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
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reference_with_deleted_message(self, reply_text_adapter):
|
|
"""Deleted messages lack .content — getattr guard should return None."""
|
|
resolved_deleted = SimpleNamespace(id=555)
|
|
ref = SimpleNamespace(message_id=555, resolved=resolved_deleted)
|
|
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
|
|
|
|
|
|
class TestYamlConfigLoading:
|
|
"""Tests for reply_to_mode loaded from config.yaml discord section."""
|
|
|
|
def _write_config(self, tmp_path, content: str):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
(hermes_home / "config.yaml").write_text(content, encoding="utf-8")
|
|
return hermes_home
|
|
|
|
def test_top_level_reply_to_mode_off(self, tmp_path, monkeypatch):
|
|
"""YAML 1.1 parses bare 'off' as boolean False — must map back to 'off'."""
|
|
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: off\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
|
|
|
load_gateway_config()
|
|
|
|
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off"
|
|
|
|
def test_top_level_reply_to_mode_all(self, tmp_path, monkeypatch):
|
|
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
|
|
|
load_gateway_config()
|
|
|
|
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"
|
|
|
|
def test_extra_reply_to_mode_off(self, tmp_path, monkeypatch):
|
|
"""discord.extra.reply_to_mode is also honoured."""
|
|
hermes_home = self._write_config(
|
|
tmp_path, "discord:\n extra:\n reply_to_mode: \"off\"\n"
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
|
|
|
load_gateway_config()
|
|
|
|
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "off"
|
|
|
|
def test_env_var_takes_precedence_over_yaml(self, tmp_path, monkeypatch):
|
|
"""Existing DISCORD_REPLY_TO_MODE env var is not overwritten by YAML."""
|
|
hermes_home = self._write_config(tmp_path, "discord:\n reply_to_mode: all\n")
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setenv("DISCORD_REPLY_TO_MODE", "first")
|
|
|
|
load_gateway_config()
|
|
|
|
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "first"
|
|
|
|
def test_top_level_takes_precedence_over_extra(self, tmp_path, monkeypatch):
|
|
"""discord.reply_to_mode wins over discord.extra.reply_to_mode."""
|
|
hermes_home = self._write_config(
|
|
tmp_path,
|
|
"discord:\n reply_to_mode: all\n extra:\n reply_to_mode: \"off\"\n",
|
|
)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.delenv("DISCORD_REPLY_TO_MODE", raising=False)
|
|
|
|
load_gateway_config()
|
|
|
|
assert os.environ.get("DISCORD_REPLY_TO_MODE") == "all"
|