diff --git a/gateway/config.py b/gateway/config.py index ab0d7c11860..047ad542f5a 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -712,6 +712,13 @@ def _apply_env_overrides(config: GatewayConfig) -> None: name=os.getenv("DISCORD_HOME_CHANNEL_NAME", "Home"), ) + # Reply threading mode for Discord (off/first/all) + discord_reply_mode = os.getenv("DISCORD_REPLY_TO_MODE", "").lower() + if discord_reply_mode in ("off", "first", "all"): + if Platform.DISCORD not in config.platforms: + config.platforms[Platform.DISCORD] = PlatformConfig() + config.platforms[Platform.DISCORD].reply_to_mode = discord_reply_mode + # WhatsApp (typically uses different auth mechanism) whatsapp_enabled = os.getenv("WHATSAPP_ENABLED", "").lower() in ("true", "1", "yes") if whatsapp_enabled: diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 703c7549232..b802f5712cb 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -455,6 +455,9 @@ class DiscordAdapter(BasePlatformAdapter): self._seen_messages: Dict[str, float] = {} self._SEEN_TTL = 300 # 5 minutes self._SEEN_MAX = 2000 # prune threshold + # Reply threading mode: "off" (no replies), "first" (reply on first + # chunk only, default), "all" (reply-reference on every chunk). + self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first' async def connect(self) -> bool: """Connect to Discord and start receiving events.""" @@ -774,7 +777,7 @@ class DiscordAdapter(BasePlatformAdapter): message_ids = [] reference = None - if reply_to: + if reply_to and self._reply_to_mode != "off": try: ref_msg = await channel.fetch_message(int(reply_to)) reference = ref_msg @@ -782,7 +785,10 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("Could not fetch reply-to message: %s", e) for i, chunk in enumerate(chunks): - chunk_reference = reference if i == 0 else None + if self._reply_to_mode == "all": + chunk_reference = reference + else: # "first" (default) or "off" + chunk_reference = reference if i == 0 else None try: msg = await channel.send( content=chunk, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 6e86886be98..7c860f15936 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -999,6 +999,13 @@ OPTIONAL_ENV_VARS = { "password": False, "category": "messaging", }, + "DISCORD_REPLY_TO_MODE": { + "description": "Discord reply threading mode: 'off' (no reply references), 'first' (reply on first message only, default), 'all' (reply on every chunk)", + "prompt": "Discord reply mode (off/first/all)", + "url": None, + "password": False, + "category": "messaging", + }, "SLACK_BOT_TOKEN": { "description": "Slack bot token (xoxb-). Get from OAuth & Permissions after installing your app. " "Required scopes: chat:write, app_mentions:read, channels:history, groups:history, " diff --git a/tests/gateway/test_discord_reply_mode.py b/tests/gateway/test_discord_reply_mode.py new file mode 100644 index 00000000000..5a9bb9cd1d8 --- /dev/null +++ b/tests/gateway/test_discord_reply_mode.py @@ -0,0 +1,277 @@ +"""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 +""" +import os +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from gateway.config import PlatformConfig, GatewayConfig, Platform, _apply_env_overrides + + +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 gateway.platforms.discord 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 + mock_channel = AsyncMock() + ref_message = MagicMock() + 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 adapter, mock_channel, ref_message + + +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: ["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: ["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: ["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: ["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: ["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: ["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: ["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" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index beacb8c117c..00b428697fc 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -175,6 +175,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) | | `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds | | `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading | +| `DISCORD_REPLY_TO_MODE` | Reply-reference behavior: `off`, `first` (default), or `all` | | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) | | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) | | `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs | diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index a015dbb9d68..111bea596d5 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -282,6 +282,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_REACTIONS` | No | `true` | When `true`, the bot adds emoji reactions to messages during processing (👀 when starting, ✅ on success, ❌ on error). Set to `false` to disable reactions entirely. | | `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. | | `DISCORD_NO_THREAD_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds directly in the channel instead of creating a thread. Only relevant when `DISCORD_AUTO_THREAD` is `true`. | +| `DISCORD_REPLY_TO_MODE` | No | `"first"` | Controls reply-reference behavior: `"off"` — never reply to the original message, `"first"` — reply-reference on the first message chunk only (default), `"all"` — reply-reference on every chunk. | ### Config File (`config.yaml`)