From a6547f399f8d7edaef385f9d878a3fdb9175f4fd Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 7 Apr 2026 17:35:31 -0700 Subject: [PATCH] test: add tests for Discord channel controls and Telegram reactions - 14 tests for ignored_channels, no_thread_channels, and config bridging - 17 tests for reaction enable/disable, API calls, error handling, and config --- .../gateway/test_discord_channel_controls.py | 342 ++++++++++++++++++ tests/gateway/test_telegram_reactions.py | 258 +++++++++++++ 2 files changed, 600 insertions(+) create mode 100644 tests/gateway/test_discord_channel_controls.py create mode 100644 tests/gateway/test_telegram_reactions.py diff --git a/tests/gateway/test_discord_channel_controls.py b/tests/gateway/test_discord_channel_controls.py new file mode 100644 index 000000000..9b8e7cbb8 --- /dev/null +++ b/tests/gateway/test_discord_channel_controls.py @@ -0,0 +1,342 @@ +"""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 gateway.platforms.discord as discord_platform # noqa: E402 +from gateway.platforms.discord 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) + monkeypatch.setattr(discord_platform.discord, "ForumChannel", type("ForumChannel", (), {}), raising=False) + + config = PlatformConfig(enabled=True, token="fake-token") + adapter = DiscordAdapter(config) + adapter._client = SimpleNamespace(user=SimpleNamespace(id=999)) + 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) + + 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) + + 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() + + +# ── 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)) + monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False) + + 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.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False) + + 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" diff --git a/tests/gateway/test_telegram_reactions.py b/tests/gateway/test_telegram_reactions.py new file mode 100644 index 000000000..c232a7601 --- /dev/null +++ b/tests/gateway/test_telegram_reactions.py @@ -0,0 +1,258 @@ +"""Tests for Telegram message reactions tied to processing lifecycle hooks.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from gateway.config import Platform, PlatformConfig +from gateway.platforms.base import MessageEvent, MessageType +from gateway.session import SessionSource + + +def _make_adapter(**extra_env): + from gateway.platforms.telegram import TelegramAdapter + + adapter = object.__new__(TelegramAdapter) + adapter.platform = Platform.TELEGRAM + adapter.config = PlatformConfig(enabled=True, token="fake-token") + adapter._bot = AsyncMock() + adapter._bot.set_message_reaction = AsyncMock() + return adapter + + +def _make_event(chat_id: str = "123", message_id: str = "456") -> MessageEvent: + return MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=SessionSource( + platform=Platform.TELEGRAM, + chat_id=chat_id, + chat_type="private", + user_id="42", + user_name="TestUser", + ), + message_id=message_id, + ) + + +# ── _reactions_enabled ─────────────────────────────────────────────── + + +def test_reactions_disabled_by_default(monkeypatch): + """Telegram reactions should be disabled by default.""" + monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False) + adapter = _make_adapter() + assert adapter._reactions_enabled() is False + + +def test_reactions_enabled_when_set_true(monkeypatch): + """Setting TELEGRAM_REACTIONS=true enables reactions.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + assert adapter._reactions_enabled() is True + + +def test_reactions_enabled_with_1(monkeypatch): + """TELEGRAM_REACTIONS=1 enables reactions.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "1") + adapter = _make_adapter() + assert adapter._reactions_enabled() is True + + +def test_reactions_disabled_with_false(monkeypatch): + """TELEGRAM_REACTIONS=false disables reactions.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "false") + adapter = _make_adapter() + assert adapter._reactions_enabled() is False + + +def test_reactions_disabled_with_0(monkeypatch): + """TELEGRAM_REACTIONS=0 disables reactions.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "0") + adapter = _make_adapter() + assert adapter._reactions_enabled() is False + + +def test_reactions_disabled_with_no(monkeypatch): + """TELEGRAM_REACTIONS=no disables reactions.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "no") + adapter = _make_adapter() + assert adapter._reactions_enabled() is False + + +# ── _set_reaction ──────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_set_reaction_calls_bot_api(monkeypatch): + """_set_reaction should call bot.set_message_reaction with correct args.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + + result = await adapter._set_reaction("123", "456", "\U0001f440") + + assert result is True + adapter._bot.set_message_reaction.assert_awaited_once_with( + chat_id=123, + message_id=456, + reaction="\U0001f440", + ) + + +@pytest.mark.asyncio +async def test_set_reaction_returns_false_without_bot(monkeypatch): + """_set_reaction should return False when bot is not available.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + adapter._bot = None + + result = await adapter._set_reaction("123", "456", "\U0001f440") + assert result is False + + +@pytest.mark.asyncio +async def test_set_reaction_handles_api_error_gracefully(monkeypatch): + """API errors during reaction should not propagate.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + adapter._bot.set_message_reaction = AsyncMock(side_effect=RuntimeError("no perms")) + + result = await adapter._set_reaction("123", "456", "\U0001f440") + assert result is False + + +# ── on_processing_start ────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_on_processing_start_adds_eyes_reaction(monkeypatch): + """Processing start should add eyes reaction when enabled.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + event = _make_event() + + await adapter.on_processing_start(event) + + adapter._bot.set_message_reaction.assert_awaited_once_with( + chat_id=123, + message_id=456, + reaction="\U0001f440", + ) + + +@pytest.mark.asyncio +async def test_on_processing_start_skipped_when_disabled(monkeypatch): + """Processing start should not react when reactions are disabled.""" + monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False) + adapter = _make_adapter() + event = _make_event() + + await adapter.on_processing_start(event) + + adapter._bot.set_message_reaction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_processing_start_handles_missing_ids(monkeypatch): + """Should handle events without chat_id or message_id gracefully.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + event = MessageEvent( + text="hello", + message_type=MessageType.TEXT, + source=SimpleNamespace(chat_id=None), + message_id=None, + ) + + await adapter.on_processing_start(event) + + adapter._bot.set_message_reaction.assert_not_awaited() + + +# ── on_processing_complete ─────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_on_processing_complete_success(monkeypatch): + """Successful processing should set check mark reaction.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + event = _make_event() + + await adapter.on_processing_complete(event, success=True) + + adapter._bot.set_message_reaction.assert_awaited_once_with( + chat_id=123, + message_id=456, + reaction="\u2705", + ) + + +@pytest.mark.asyncio +async def test_on_processing_complete_failure(monkeypatch): + """Failed processing should set cross mark reaction.""" + monkeypatch.setenv("TELEGRAM_REACTIONS", "true") + adapter = _make_adapter() + event = _make_event() + + await adapter.on_processing_complete(event, success=False) + + adapter._bot.set_message_reaction.assert_awaited_once_with( + chat_id=123, + message_id=456, + reaction="\u274c", + ) + + +@pytest.mark.asyncio +async def test_on_processing_complete_skipped_when_disabled(monkeypatch): + """Processing complete should not react when reactions are disabled.""" + monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False) + adapter = _make_adapter() + event = _make_event() + + await adapter.on_processing_complete(event, success=True) + + adapter._bot.set_message_reaction.assert_not_awaited() + + +# ── config.py bridging ─────────────────────────────────────────────── + + +def test_config_bridges_telegram_reactions(monkeypatch, tmp_path): + """gateway/config.py bridges telegram.reactions to TELEGRAM_REACTIONS env var.""" + import yaml + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump({ + "telegram": { + "reactions": True, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("TELEGRAM_REACTIONS", raising=False) + + from gateway.config import load_gateway_config + load_gateway_config() + + import os + assert os.getenv("TELEGRAM_REACTIONS") == "true" + + +def test_config_reactions_env_takes_precedence(monkeypatch, tmp_path): + """Env var should take precedence over config.yaml for reactions.""" + import yaml + config_file = tmp_path / "config.yaml" + config_file.write_text(yaml.dump({ + "telegram": { + "reactions": True, + }, + })) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TELEGRAM_REACTIONS", "false") + + from gateway.config import load_gateway_config + load_gateway_config() + + import os + assert os.getenv("TELEGRAM_REACTIONS") == "false"