"""Tests for Discord ignored_channels and no_thread_channels config.""" from types import SimpleNamespace from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock import sys import pytest from gateway.config import PlatformConfig 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() import plugins.platforms.discord.adapter as discord_platform # noqa: E402 from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402 class FakeDMChannel: def __init__(self, channel_id: int = 1, name: str = "dm"): self.id = channel_id self.name = name class FakeTextChannel: def __init__(self, channel_id: int = 1, name: str = "general", guild_name: str = "Hermes Server"): self.id = channel_id self.name = name self.guild = SimpleNamespace(name=guild_name) self.topic = None class FakeThread: def __init__(self, channel_id: int = 1, name: str = "thread", parent=None, guild_name: str = "Hermes Server"): self.id = channel_id self.name = name self.parent = parent self.parent_id = getattr(parent, "id", None) self.guild = getattr(parent, "guild", None) or SimpleNamespace(name=guild_name) self.topic = None @pytest.fixture def adapter(monkeypatch): monkeypatch.setattr(discord_platform.discord, "DMChannel", FakeDMChannel, raising=False) monkeypatch.setattr(discord_platform.discord, "Thread", FakeThread, 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 # disable batching for tests adapter.handle_message = AsyncMock() return adapter def make_message(*, channel, content: str, mentions=None): author = SimpleNamespace(id=42, display_name="TestUser", name="TestUser") return SimpleNamespace( id=123, content=content, mentions=list(mentions or []), attachments=[], reference=None, created_at=datetime.now(timezone.utc), channel=channel, author=author, ) # ── ignored_channels ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_ignored_channel_blocks_message(adapter, monkeypatch): """Messages in ignored channels are silently dropped.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) message = make_message(channel=FakeTextChannel(channel_id=500), content="hello") await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() @pytest.mark.asyncio async def test_ignored_channel_blocks_even_with_mention(adapter, monkeypatch): """Ignored channels take priority — even @mentions are dropped.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500") bot_user = adapter._client.user message = make_message( channel=FakeTextChannel(channel_id=500), content=f"<@{bot_user.id}> hello", mentions=[bot_user], ) await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() @pytest.mark.asyncio async def test_non_ignored_channel_processes_normally(adapter, monkeypatch): """Channels not in the ignored list process normally.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500,600") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) # Stub auto-thread creation so this test focuses on ignored-channel # routing only — auto-thread failures now correctly skip agent invocation # (#20243), which would otherwise mask the assertion below. adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999)) message = make_message(channel=FakeTextChannel(channel_id=700), content="hello") await adapter._handle_message(message) adapter.handle_message.assert_awaited_once() @pytest.mark.asyncio async def test_ignored_channels_csv_parsing(adapter, monkeypatch): """Multiple channel IDs are parsed correctly from CSV.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500, 600 , 700") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) for ch_id in (500, 600, 700): adapter.handle_message.reset_mock() message = make_message(channel=FakeTextChannel(channel_id=ch_id), content="hello") await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() @pytest.mark.asyncio async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatch): """Empty DISCORD_IGNORED_CHANNELS means nothing is ignored.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) # Stub auto-thread creation so this test focuses on ignored-channel # routing only — auto-thread failures now correctly skip agent invocation # (#20243), which would otherwise mask the assertion below. adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999)) message = make_message(channel=FakeTextChannel(channel_id=500), content="hello") await adapter._handle_message(message) adapter.handle_message.assert_awaited_once() @pytest.mark.asyncio async def test_ignored_channel_thread_parent_match(adapter, monkeypatch): """Thread whose parent channel is ignored should also be ignored.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) parent = FakeTextChannel(channel_id=500, name="ignored-channel") thread = FakeThread(channel_id=501, name="thread-in-ignored", parent=parent) message = make_message(channel=thread, content="hello from thread") await adapter._handle_message(message) adapter.handle_message.assert_not_awaited() @pytest.mark.asyncio async def test_dms_unaffected_by_ignored_channels(adapter, monkeypatch): """DMs should never be affected by ignored_channels.""" monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500") monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) message = make_message(channel=FakeDMChannel(channel_id=500), content="dm hello") await adapter._handle_message(message) adapter.handle_message.assert_awaited_once() # ── no_thread_channels ─────────────────────────────────────────────── @pytest.mark.asyncio async def test_no_thread_channel_skips_auto_thread(adapter, monkeypatch): """Channels in no_thread_channels should not auto-create threads.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800") monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999)) message = make_message(channel=FakeTextChannel(channel_id=800), content="hello") await adapter._handle_message(message) adapter._auto_create_thread.assert_not_awaited() adapter.handle_message.assert_awaited_once() event = adapter.handle_message.await_args.args[0] assert event.source.chat_type == "group" @pytest.mark.asyncio async def test_normal_channel_still_auto_threads(adapter, monkeypatch): """Channels NOT in no_thread_channels still get auto-threading.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800") monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) fake_thread = FakeThread(channel_id=999, name="auto-thread") adapter._auto_create_thread = AsyncMock(return_value=fake_thread) message = make_message(channel=FakeTextChannel(channel_id=900), content="hello") await adapter._handle_message(message) adapter._auto_create_thread.assert_awaited_once() adapter.handle_message.assert_awaited_once() event = adapter.handle_message.await_args.args[0] assert event.source.chat_type == "thread" @pytest.mark.asyncio async def test_no_thread_channels_csv_parsing(adapter, monkeypatch): """Multiple no_thread channel IDs parsed from CSV.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800, 900") monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False) monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999)) for ch_id in (800, 900): adapter._auto_create_thread.reset_mock() adapter.handle_message.reset_mock() message = make_message(channel=FakeTextChannel(channel_id=ch_id), content="hello") await adapter._handle_message(message) adapter._auto_create_thread.assert_not_awaited() @pytest.mark.asyncio async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch): """no_thread_channels is a no-op when auto_thread is globally disabled.""" monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_AUTO_THREAD", "false") monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "800") monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) adapter._auto_create_thread = AsyncMock() message = make_message(channel=FakeTextChannel(channel_id=800), content="hello") await adapter._handle_message(message) adapter._auto_create_thread.assert_not_awaited() adapter.handle_message.assert_awaited_once() # ── auto-thread failure must not silently fall back to inline (#20243) ── @pytest.mark.asyncio async def test_auto_thread_failure_skips_agent_and_notifies_user(adapter, monkeypatch): """Auto-thread creation failure must not trigger an inline parent-channel reply. Before #20243, ``effective_channel = auto_threaded_channel or message.channel`` silently routed the response back to the parent channel when thread creation failed, breaking thread-first Discord workflows. The fix surfaces a short visible error to the parent channel and skips agent invocation entirely so the user can retry. """ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) adapter._auto_create_thread = AsyncMock(return_value=None) channel = FakeTextChannel(channel_id=800) channel.send = AsyncMock() message = make_message(channel=channel, content="hello") await adapter._handle_message(message) adapter._auto_create_thread.assert_awaited_once() # Agent must NOT be invoked when the routing target failed. adapter.handle_message.assert_not_awaited() # User gets a visible explanation in the parent channel instead of a silent # inline reply. channel.send.assert_awaited_once() sent_text = channel.send.await_args.args[0] assert "could not create" in sent_text.lower() assert "thread" in sent_text.lower() @pytest.mark.asyncio async def test_auto_thread_failure_notify_error_does_not_crash(adapter, monkeypatch): """If even the failure-notification send raises, we still skip the agent. ``message.channel.send`` itself can fail (the same connect issue that killed thread creation often kills plain sends too). The handler should swallow the secondary error and still avoid invoking the agent. """ monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false") monkeypatch.setenv("DISCORD_AUTO_THREAD", "true") monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False) adapter._auto_create_thread = AsyncMock(return_value=None) channel = FakeTextChannel(channel_id=800) channel.send = AsyncMock(side_effect=RuntimeError("Cannot connect to host discord.com:443")) message = make_message(channel=channel, content="hello") # No exception must propagate. await adapter._handle_message(message) adapter._auto_create_thread.assert_awaited_once() adapter.handle_message.assert_not_awaited() channel.send.assert_awaited_once() # ── config.py bridging ─────────────────────────────────────────────── def test_config_bridges_ignored_channels(monkeypatch, tmp_path): """gateway/config.py bridges discord.ignored_channels to env var.""" import yaml config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump({ "discord": { "ignored_channels": ["111", "222"], }, })) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Use setenv (not delenv) so monkeypatch registers cleanup even when # the var doesn't exist yet — load_gateway_config will overwrite it. monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "") from gateway.config import load_gateway_config load_gateway_config() import os assert os.getenv("DISCORD_IGNORED_CHANNELS") == "111,222" def test_config_bridges_no_thread_channels(monkeypatch, tmp_path): """gateway/config.py bridges discord.no_thread_channels to env var.""" import yaml config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump({ "discord": { "no_thread_channels": ["333"], }, })) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "") from gateway.config import load_gateway_config load_gateway_config() import os assert os.getenv("DISCORD_NO_THREAD_CHANNELS") == "333" def test_config_env_var_takes_precedence(monkeypatch, tmp_path): """Env vars should take precedence over config.yaml values.""" import yaml config_file = tmp_path / "config.yaml" config_file.write_text(yaml.dump({ "discord": { "ignored_channels": ["111"], }, })) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "999") from gateway.config import load_gateway_config load_gateway_config() import os # Env var should NOT be overwritten assert os.getenv("DISCORD_IGNORED_CHANNELS") == "999"