From ff78ad4c811cdd7a74cf077d569e6571e91caa6a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 01:24:48 -0700 Subject: [PATCH] feat: add discord.reactions config option to disable message reactions (#4199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'reactions' key under the discord config section (default: true). When set to false, the bot no longer adds 👀/✅/❌ reactions to messages during processing. The config maps to DISCORD_REACTIONS env var following the same pattern as require_mention and auto_thread. Files changed: - hermes_cli/config.py: Add reactions default to DEFAULT_CONFIG - gateway/config.py: Map discord.reactions to DISCORD_REACTIONS env var - gateway/platforms/discord.py: Gate on_processing_start/complete hooks - tests/gateway/test_discord_reactions.py: 3 new tests for config gate --- gateway/config.py | 2 + gateway/platforms/discord.py | 8 ++++ hermes_cli/config.py | 1 + tests/gateway/test_discord_reactions.py | 64 +++++++++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/gateway/config.py b/gateway/config.py index 8c7843780..c660bb48e 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -550,6 +550,8 @@ def load_gateway_config() -> GatewayConfig: os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc) if "auto_thread" in discord_cfg and not os.getenv("DISCORD_AUTO_THREAD"): os.environ["DISCORD_AUTO_THREAD"] = str(discord_cfg["auto_thread"]).lower() + if "reactions" in discord_cfg and not os.getenv("DISCORD_REACTIONS"): + os.environ["DISCORD_REACTIONS"] = str(discord_cfg["reactions"]).lower() # Telegram settings → env vars (env vars take precedence) telegram_cfg = yaml_cfg.get("telegram", {}) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 9e0c9c123..168919b09 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -683,14 +683,22 @@ class DiscordAdapter(BasePlatformAdapter): logger.debug("[%s] remove_reaction failed (%s): %s", self.name, emoji, e) return False + def _reactions_enabled(self) -> bool: + """Check if message reactions are enabled via config/env.""" + return os.getenv("DISCORD_REACTIONS", "true").lower() not in ("false", "0", "no") + async def on_processing_start(self, event: MessageEvent) -> None: """Add an in-progress reaction for normal Discord message events.""" + if not self._reactions_enabled(): + return message = event.raw_message if hasattr(message, "add_reaction"): await self._add_reaction(message, "👀") async def on_processing_complete(self, event: MessageEvent, success: bool) -> None: """Swap the in-progress reaction for a final success/failure reaction.""" + if not self._reactions_enabled(): + return message = event.raw_message if hasattr(message, "add_reaction"): await self._remove_reaction(message, "👀") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f7ae4239d..97df597d5 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -452,6 +452,7 @@ DEFAULT_CONFIG = { "require_mention": True, # Require @mention to respond in server channels "free_response_channels": "", # Comma-separated channel IDs where bot responds without mention "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) + "reactions": True, # Add 👀/✅/❌ reactions to messages during processing }, # WhatsApp platform settings (gateway mode) diff --git a/tests/gateway/test_discord_reactions.py b/tests/gateway/test_discord_reactions.py index c19913a4c..3988c67b5 100644 --- a/tests/gateway/test_discord_reactions.py +++ b/tests/gateway/test_discord_reactions.py @@ -168,3 +168,67 @@ async def test_reaction_helper_failures_do_not_break_message_flow(adapter): await adapter._process_message_background(event, build_session_key(event.source)) adapter.send.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reactions_disabled_via_env(adapter, monkeypatch): + """When DISCORD_REACTIONS=false, no reactions should be added.""" + monkeypatch.setenv("DISCORD_REACTIONS", "false") + + raw_message = SimpleNamespace( + add_reaction=AsyncMock(), + remove_reaction=AsyncMock(), + ) + + async def handler(_event): + await asyncio.sleep(0) + return "ack" + + async def hold_typing(_chat_id, interval=2.0, metadata=None): + await asyncio.Event().wait() + + adapter.set_message_handler(handler) + adapter.send = AsyncMock(return_value=SendResult(success=True, message_id="999")) + adapter._keep_typing = hold_typing + + event = _make_event("4", raw_message) + await adapter._process_message_background(event, build_session_key(event.source)) + + raw_message.add_reaction.assert_not_awaited() + raw_message.remove_reaction.assert_not_awaited() + # Response should still be sent + adapter.send.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reactions_disabled_via_env_zero(adapter, monkeypatch): + """DISCORD_REACTIONS=0 should also disable reactions.""" + monkeypatch.setenv("DISCORD_REACTIONS", "0") + + raw_message = SimpleNamespace( + add_reaction=AsyncMock(), + remove_reaction=AsyncMock(), + ) + + event = _make_event("5", raw_message) + await adapter.on_processing_start(event) + await adapter.on_processing_complete(event, success=True) + + raw_message.add_reaction.assert_not_awaited() + raw_message.remove_reaction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_reactions_enabled_by_default(adapter, monkeypatch): + """When DISCORD_REACTIONS is unset, reactions should still work (default: true).""" + monkeypatch.delenv("DISCORD_REACTIONS", raising=False) + + raw_message = SimpleNamespace( + add_reaction=AsyncMock(), + remove_reaction=AsyncMock(), + ) + + event = _make_event("6", raw_message) + await adapter.on_processing_start(event) + + raw_message.add_reaction.assert_awaited_once_with("👀")