mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
- Discord: ignored_channels, no_thread_channels config reference + examples - Telegram: message reactions section with config, behavior notes - Environment variables reference updated for all new vars
344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""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)
|
|
|
|
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))
|
|
# 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"
|