{"repo":"NousResearch/hermes-agent","public_id":"5881","display_label":"Discord channel filtering and Telegram reaction features","summary":"These items introduce configuration options for ignoring specific Discord channels and controlling thread creation, alongside a feature adding Telegram reactions and Discord channel controls to the gateway.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":5975,"merged_at":"2026-04-08T00:55:56+00:00"},"resolution":{"state":"resolved","by":"pr-5975"},"freshness":{"state":"stale","last_verified_at":"2026-04-08T00:55:56+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":5881,"pr_number":5975,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-08T00:55:56+00:00"}],"members":[{"kind":"issue","number":5881,"title":"Discord: Add ignored_channels and no_thread_channels config options","url":null,"state":"closed"},{"kind":"pr","number":5882,"title":"feat(discord): add ignored_channels and no_thread_channels config","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#5975","number":5975}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":5975,"title":"feat(gateway): Discord channel controls + Telegram reactions","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/platforms/discord.py","gateway/platforms/telegram.py","tests/gateway/test_discord_channel_controls.py","tests/gateway/test_telegram_reactions.py","website/docs/reference/environment-variables.md","website/docs/user-guide/messaging/discord.md","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -556,6 +556,18 @@ def load_gateway_config() -> GatewayConfig:\n                     os.environ[\"DISCORD_AUTO_THREAD\"] = str(discord_cfg[\"auto_thread\"]).lower()\n                 if \"reactions\" in discord_cfg and not os.getenv(\"DISCORD_REACTIONS\"):\n                     os.environ[\"DISCORD_REACTIONS\"] = str(discord_cfg[\"reactions\"]).lower()\n+                # ignored_channels: channels where bot never responds (even when mentioned)\n+                ic = discord_cfg.get(\"ignored_channels\")\n+                if ic is not None and not os.getenv(\"DISCORD_IGNORED_CHANNELS\"):\n+                    if isinstance(ic, list):\n+                        ic = \",\".join(str(v) for v in ic)\n+                    os.environ[\"DISCORD_IGNORED_CHANNELS\"] = str(ic)\n+                # no_thread_channels: channels where bot responds directly without creating thread\n+                ntc = discord_cfg.get(\"no_thread_channels\")\n+                if ntc is not None and not os.getenv(\"DISCORD_NO_THREAD_CHANNELS\"):\n+                    if isinstance(ntc, list):\n+                        ntc = \",\".join(str(v) for v in ntc)\n+                    os.environ[\"DISCORD_NO_THREAD_CHANNELS\"] = str(ntc)\n \n             # Telegram settings → env vars (env vars take precedence)\n             telegram_cfg = yaml_cfg.get(\"telegram\", {})","@@ -570,6 +582,8 @@ def load_gateway_config() -> GatewayConfig:\n                     if isinstance(frc, list):\n                         frc = \",\".join(str(v) for v in frc)\n                     os.environ[\"TELEGRAM_FREE_RESPONSE_CHATS\"] = str(frc)\n+                if \"reactions\" in telegram_cfg and not os.getenv(\"TELEGRAM_REACTIONS\"):\n+                    os.environ[\"TELEGRAM_REACTIONS\"] = str(telegram_cfg[\"reactions\"]).lower()\n \n             whatsapp_cfg = yaml_cfg.get(\"whatsapp\", {})\n             if isinstance(whatsapp_cfg, dict):"]},{"file":"gateway/platforms/discord.py","hunks":["@@ -2193,9 +2193,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n         # UNLESS the channel is in the free-response list or the message is\n         # in a thread where the bot has already participated.\n         #\n-        # Config (all settable via discord.* in config.yaml):\n+        # Config (all settable via discord.* in config.yaml or DISCORD_* env vars):\n         #   discord.require_mention: Require @mention in server channels (default: true)\n         #   discord.free_response_channels: Channel IDs where bot responds without mention\n+        #   discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned)\n+        #   discord.no_thread_channels: Channel IDs where bot responds directly without creating thread\n         #   discord.auto_thread: Auto-create thread on @mention in channels (default: true)\n \n         thread_id = None","@@ -2206,9 +2208,18 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n             parent_channel_id = self._get_parent_channel_id(message.channel)\n \n         if not isinstance(message.channel, discord.DMChannel):\n+            # Check ignored channels first - never respond even when mentioned\n+            ignored_channels_raw = os.getenv(\"DISCORD_IGNORED_CHANNELS\", \"\")\n+            ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(\",\") if ch.strip()}\n+            channel_ids = {str(message.channel.id)}\n+            if parent_channel_id:\n+                channel_ids.add(parent_channel_id)\n+            if channel_ids & ignored_channels:\n+                logger.debug(\"[%s] Ignoring message in ignored channel: %s\", self.name, channel_ids)\n+                return\n+\n             free_channels_raw = os.getenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", \"\")\n             free_channels = {ch.strip() for ch in free_channels_raw.split(\",\") if ch.strip()}\n-            channel_ids = {str(message.channel.id)}\n             if parent_channel_id:\n                 channel_ids.add(parent_channel_id)\n ","@@ -2230,10 +2241,14 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n         # Auto-thread: when enabled, automatically create a thread for every\n         # @mention in a text channel so each conversation is isolated (like Slack).\n         # Messages already inside threads or DMs are unaffected.\n+        # no_thread_channels: channels where bot responds directly without thread.\n         auto_threaded_channel = None\n         if not is_thread and not isinstance(message.channel, discord.DMChannel):\n+            no_thread_channels_raw = os.getenv(\"DISCORD_NO_THREAD_CHANNELS\", \"\")\n+            no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(\",\") if ch.strip()}\n+            skip_thread = bool(channel_ids & no_thread_channels)\n             auto_thread = os.getenv(\"DISCORD_AUTO_THREAD\", \"true\").lower() in (\"true\", \"1\", \"yes\")\n-            if auto_thread:\n+            if auto_thread and not skip_thread:\n                 thread = await self._auto_create_thread(message)\n                 if thread:\n                     is_thread = True"]},{"file":"gateway/platforms/telegram.py","hunks":["@@ -2673,3 +2673,46 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa\n             auto_skill=topic_skill,\n             timestamp=message.date,\n         )\n+\n+    # ── Message reactions (processing lifecycle) ──────────────────────────\n+\n+    def _reactions_enabled(self) -> bool:\n+        \"\"\"Check if message reactions are enabled via config/env.\"\"\"\n+        return os.getenv(\"TELEGRAM_REACTIONS\", \"false\").lower() not in (\"false\", \"0\", \"no\")\n+\n+    async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:\n+        \"\"\"Set a single emoji reaction on a Telegram message.\"\"\"\n+        if not self._bot:\n+            return False\n+        try:\n+            await self._bot.set_message_reaction(\n+                chat_id=int(chat_id),\n+                message_id=int(message_id),\n+                reaction=emoji,\n+            )\n+            return True\n+        except Exception as e:\n+            logger.debug(\"[%s] set_message_reaction failed (%s): %s\", self.name, emoji, e)\n+            return False\n+\n+    async def on_processing_start(self, event: MessageEvent) -> None:\n+        \"\"\"Add an in-progress reaction when message processing begins.\"\"\"\n+        if not self._reactions_enabled():\n+            return\n+        chat_id = getattr(event.source, \"chat_id\", None)\n+        message_id = getattr(event, \"message_id\", None)\n+        if chat_id and message_id:\n+            await self._set_reaction(chat_id, message_id, \"\\U0001f440\")\n+\n+    async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:\n+        \"\"\"Swap the in-progress reaction for a final success/failure reaction.\n+\n+        Unlike Discord (additive reactions), Telegram's set_message_reaction\n+        replaces all existing reactions in one call — no remove step needed.\n+        \"\"\"\n+        if not self._reactions_enabled():\n+            return\n+        chat_id = getattr(event.source, \"chat_id\", None)\n+        message_id = getattr(event, \"message_id\", None)\n+        if chat_id and message_id:\n+            await self._set_reaction(chat_id, message_id, \"\\u2705\" if success else \"\\u274c\")"]},{"file":"tests/gateway/test_discord_channel_controls.py","hunks":["@@ -0,0 +1,343 @@\n+\"\"\"Tests for Discord ignored_channels and no_thread_channels config.\"\"\"\n+\n+from types import SimpleNamespace\n+from datetime import datetime, timezone\n+from unittest.mock import AsyncMock, MagicMock\n+import sys\n+\n+import pytest\n+\n+from gateway.config import PlatformConfig\n+\n+\n+def _ensure_discord_mock():\n+    \"\"\"Install a mock discord module when discord.py isn't available.\"\"\"\n+    if \"discord\" in sys.modules and hasattr(sys.modules[\"discord\"], \"__file__\"):\n+        return\n+\n+    discord_mod = MagicMock()\n+    discord_mod.Intents.default.return_value = MagicMock()\n+    discord_mod.Client = MagicMock\n+    discord_mod.File = MagicMock\n+    discord_mod.DMChannel = type(\"DMChannel\", (), {})\n+    discord_mod.Thread = type(\"Thread\", (), {})\n+    discord_mod.ForumChannel = type(\"ForumChannel\", (), {})\n+    discord_mod.ui = SimpleNamespace(View=object, button=lambda *a, **k: (lambda fn: fn), Button=object)\n+    discord_mod.ButtonStyle = SimpleNamespace(success=1, primary=2, secondary=2, danger=3, green=1, grey=2, blurple=2, red=3)\n+    discord_mod.Color = SimpleNamespace(orange=lambda: 1, green=lambda: 2, blue=lambda: 3, red=lambda: 4, purple=lambda: 5)\n+    discord_mod.Interaction = object\n+    discord_mod.Embed = MagicMock\n+    discord_mod.app_commands = SimpleNamespace(\n+        describe=lambda **kwargs: (lambda fn: fn),\n+        choices=lambda **kwargs: (lambda fn: fn),\n+        Choice=lambda **kwargs: SimpleNamespace(**kwargs),\n+    )\n+\n+    ext_mod = MagicMock()\n+    commands_mod = MagicMock()\n+    commands_mod.Bot = MagicMock\n+    ext_mod.commands = commands_mod\n+\n+    sys.modules.setdefault(\"discord\", discord_mod)\n+    sys.modules.setdefault(\"discord.ext\", ext_mod)\n+    sys.modules.setdefault(\"discord.ext.commands\", commands_mod)\n+\n+\n+_ensure_discord_mock()\n+\n+import gateway.platforms.discord as discord_platform  # noqa: E402\n+from gateway.platforms.discord import DiscordAdapter  # noqa: E402\n+\n+\n+class FakeDMChannel:\n+    def __init__(self, channel_id: int = 1, name: str = \"dm\"):\n+        self.id = channel_id\n+        self.name = name\n+\n+\n+class FakeTextChannel:\n+    def __init__(self, channel_id: int = 1, name: str = \"general\", guild_name: str = \"Hermes Server\"):\n+        self.id = channel_id\n+        self.name = name\n+        self.guild = SimpleNamespace(name=guild_name)\n+        self.topic = None\n+\n+\n+class FakeThread:\n+    def __init__(self, channel_id: int = 1, name: str = \"thread\", parent=None, guild_name: str = \"Hermes Server\"):\n+        self.id = channel_id\n+        self.name = name\n+        self.parent = parent\n+        self.parent_id = getattr(parent, \"id\", None)\n+        self.guild = getattr(parent, \"guild\", None) or SimpleNamespace(name=guild_name)\n+        self.topic = None\n+\n+\n[redacted:email]\n+def adapter(monkeypatch):\n+    monkeypatch.setattr(discord_platform.discord, \"DMChannel\", FakeDMChannel, raising=False)\n+    monkeypatch.setattr(discord_platform.discord, \"Thread\", FakeThread, raising=False)\n+\n+    config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter = DiscordAdapter(config)\n+    adapter._client = SimpleNamespace(user=SimpleNamespace(id=999))\n+    adapter.handle_message = AsyncMock()\n+    return adapter\n+\n+\n+def make_message(*, channel, content: str, mentions=None):\n+    author = SimpleNamespace(id=42, display_name=\"TestUser\", name=\"TestUser\")\n+    return SimpleNamespace(\n+        id=123,\n+        content=content,\n+        mentions=list(mentions or []),\n+        attachments=[],\n+        reference=None,\n+        created_at=datetime.now(timezone.utc),\n+        channel=channel,\n+        author=author,\n+    )\n+\n+\n+# ── ignored_channels ─────────────────────────────────────────────────\n+\n+\n[redacted:email]\n+async def test_ignored_channel_blocks_message(adapter, monkeypatch):\n+    \"\"\"Messages in ignored channels are silently dropped.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500\")\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=500), content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_ignored_channel_blocks_even_with_mention(adapter, monkeypatch):\n+    \"\"\"Ignored channels take priority — even @mentions are dropped.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"true\")\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500\")\n+\n+    bot_user = adapter._client.user\n+    message = make_message(\n+        channel=FakeTextChannel(channel_id=500),\n+        content=f\"<@{bot_user.id}> hello\",\n+        mentions=[bot_user],\n+    )\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_non_ignored_channel_processes_normally(adapter, monkeypatch):\n+    \"\"\"Channels not in the ignored list process normally.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500,600\")\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=700), content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_ignored_channels_csv_parsing(adapter, monkeypatch):\n+    \"\"\"Multiple channel IDs are parsed correctly from CSV.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500, 600 , 700\")\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    for ch_id in (500, 600, 700):\n+        adapter.handle_message.reset_mock()\n+        message = make_message(channel=FakeTextChannel(channel_id=ch_id), content=\"hello\")\n+        await adapter._handle_message(message)\n+        adapter.handle_message.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatch):\n+    \"\"\"Empty DISCORD_IGNORED_CHANNELS means nothing is ignored.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"\")\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=500), content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_ignored_channel_thread_parent_match(adapter, monkeypatch):\n+    \"\"\"Thread whose parent channel is ignored should also be ignored.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500\")\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    parent = FakeTextChannel(channel_id=500, name=\"ignored-channel\")\n+    thread = FakeThread(channel_id=501, name=\"thread-in-ignored\", parent=parent)\n+    message = make_message(channel=thread, content=\"hello from thread\")\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_dms_unaffected_by_ignored_channels(adapter, monkeypatch):\n+    \"\"\"DMs should never be affected by ignored_channels.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"500\")\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    message = make_message(channel=FakeDMChannel(channel_id=500), content=\"dm hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n+# ── no_thread_channels ───────────────────────────────────────────────\n+\n+\n[redacted:email]\n+async def test_no_thread_channel_skips_auto_thread(adapter, monkeypatch):\n+    \"\"\"Channels in no_thread_channels should not auto-create threads.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_NO_THREAD_CHANNELS\", \"800\")\n+    monkeypatch.delenv(\"DISCORD_AUTO_THREAD\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_IGNORED_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=800), content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_not_awaited()\n+    adapter.handle_message.assert_awaited_once()\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.source.chat_type == \"group\"\n+\n+\n[redacted:email]\n+async def test_normal_channel_still_auto_threads(adapter, monkeypatch):\n+    \"\"\"Channels NOT in no_thread_channels still get auto-threading.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_NO_THREAD_CHANNELS\", \"800\")\n+    monkeypatch.delenv(\"DISCORD_AUTO_THREAD\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_IGNORED_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    fake_thread = FakeThread(channel_id=999, name=\"auto-thread\")\n+    adapter._auto_create_thread = AsyncMock(return_value=fake_thread)\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=900), content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_awaited_once()\n+    adapter.handle_message.assert_awaited_once()\n+    event = adapter.handle_message.await_args.args[0]\n+    assert event.source.chat_type == \"thread\"\n+\n+\n[redacted:email]\n+async def test_no_thread_channels_csv_parsing(adapter, monkeypatch):\n+    \"\"\"Multiple no_thread channel IDs parsed from CSV.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_NO_THREAD_CHANNELS\", \"800, 900\")\n+    monkeypatch.delenv(\"DISCORD_AUTO_THREAD\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_IGNORED_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))\n+\n+    for ch_id in (800, 900):\n+        adapter._auto_create_thread.reset_mock()\n+        adapter.handle_message.reset_mock()\n+        message = make_message(channel=FakeTextChannel(channel_id=ch_id), content=\"hello\")\n+        await adapter._handle_message(message)\n+        adapter._auto_create_thread.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch):\n+    \"\"\"no_thread_channels is a no-op when auto_thread is globally disabled.\"\"\"\n+    monkeypatch.setenv(\"DISCORD_REQUIRE_MENTION\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_AUTO_THREAD\", \"false\")\n+    monkeypatch.setenv(\"DISCORD_NO_THREAD_CHANNELS\", \"800\")\n+    monkeypatch.delenv(\"DISCORD_IGNORED_CHANNELS\", raising=False)\n+    monkeypatch.delenv(\"DISCORD_FREE_RESPONSE_CHANNELS\", raising=False)\n+\n+    adapter._auto_create_thread = AsyncMock()\n+\n+    message = make_message(channel=FakeTextChannel(channel_id=800), content=\"hello\")\n+    await adapter._handle_message(message)\n+\n+    adapter._auto_create_thread.assert_not_awaited()\n+    adapter.handle_message.assert_awaited_once()\n+\n+\n+# ── config.py bridging ───────────────────────────────────────────────\n+\n+\n+def test_config_bridges_ignored_channels(monkeypatch, tmp_path):\n+    \"\"\"gateway/config.py bridges discord.ignored_channels to env var.\"\"\"\n+    import yaml\n+    config_file = tmp_path / \"config.yaml\"\n+    config_file.write_text(yaml.dump({\n+        \"discord\": {\n+            \"ignored_channels\": [\"111\", \"222\"],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    # Use setenv (not delenv) so monkeypatch registers cleanup even when\n+    # the var doesn't exist yet — load_gateway_config will overwrite it.\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"\")\n+\n+    from gateway.config import load_gateway_config\n+    load_gateway_config()\n+\n+    import os\n+    assert os.getenv(\"DISCORD_IGNORED_CHANNELS\") == \"111,222\"\n+\n+\n+def test_config_bridges_no_thread_channels(monkeypatch, tmp_path):\n+    \"\"\"gateway/config.py bridges discord.no_thread_channels to env var.\"\"\"\n+    import yaml\n+    config_file = tmp_path / \"config.yaml\"\n+    config_file.write_text(yaml.dump({\n+        \"discord\": {\n+            \"no_thread_channels\": [\"333\"],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setenv(\"DISCORD_NO_THREAD_CHANNELS\", \"\")\n+\n+    from gateway.config import load_gateway_config\n+    load_gateway_config()\n+\n+    import os\n+    assert os.getenv(\"DISCORD_NO_THREAD_CHANNELS\") == \"333\"\n+\n+\n+def test_config_env_var_takes_precedence(monkeypatch, tmp_path):\n+    \"\"\"Env vars should take precedence over config.yaml values.\"\"\"\n+    import yaml\n+    config_file = tmp_path / \"config.yaml\"\n+    config_file.write_text(yaml.dump({\n+        \"discord\": {\n+            \"ignored_channels\": [\"111\"],\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setenv(\"DISCORD_IGNORED_CHANNELS\", \"999\")\n+\n+    from gateway.config import load_gateway_config\n+    load_gateway_config()\n+\n+    import os\n+    # Env var should NOT be overwritten\n+    assert os.getenv(\"DISCORD_IGNORED_CHANNELS\") == \"999\""]},{"file":"tests/gateway/test_telegram_reactions.py","hunks":["@@ -0,0 +1,260 @@\n+\"\"\"Tests for Telegram message reactions tied to processing lifecycle hooks.\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock\n+\n+import pytest\n+\n+from gateway.config import Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent, MessageType\n+from gateway.session import SessionSource\n+\n+\n+def _make_adapter(**extra_env):\n+    from gateway.platforms.telegram import TelegramAdapter\n+\n+    adapter = object.__new__(TelegramAdapter)\n+    adapter.platform = Platform.TELEGRAM\n+    adapter.config = PlatformConfig(enabled=True, token=\"[redacted:secret-assignment]\")\n+    adapter._bot = AsyncMock()\n+    adapter._bot.set_message_reaction = AsyncMock()\n+    return adapter\n+\n+\n+def _make_event(chat_id: str = \"123\", message_id: str = \"456\") -> MessageEvent:\n+    return MessageEvent(\n+        text=\"hello\",\n+        message_type=MessageType.TEXT,\n+        source=SessionSource(\n+            platform=Platform.TELEGRAM,\n+            chat_id=chat_id,\n+            chat_type=\"private\",\n+            user_id=\"42\",\n+            user_name=\"TestUser\",\n+        ),\n+        message_id=message_id,\n+    )\n+\n+\n+# ── _reactions_enabled ───────────────────────────────────────────────\n+\n+\n+def test_reactions_disabled_by_default(monkeypatch):\n+    \"\"\"Telegram reactions should be disabled by default.\"\"\"\n+    monkeypatch.delenv(\"TELEGRAM_REACTIONS\", raising=False)\n+    adapter = _make_adapter()\n+    assert adapter._reactions_enabled() is False\n+\n+\n+def test_reactions_enabled_when_set_true(monkeypatch):\n+    \"\"\"Setting TELEGRAM_REACTIONS=true enables reactions.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    assert adapter._reactions_enabled() is True\n+\n+\n+def test_reactions_enabled_with_1(monkeypatch):\n+    \"\"\"TELEGRAM_REACTIONS=1 enables reactions.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"1\")\n+    adapter = _make_adapter()\n+    assert adapter._reactions_enabled() is True\n+\n+\n+def test_reactions_disabled_with_false(monkeypatch):\n+    \"\"\"TELEGRAM_REACTIONS=false disables reactions.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"false\")\n+    adapter = _make_adapter()\n+    assert adapter._reactions_enabled() is False\n+\n+\n+def test_reactions_disabled_with_0(monkeypatch):\n+    \"\"\"TELEGRAM_REACTIONS=0 disables reactions.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"0\")\n+    adapter = _make_adapter()\n+    assert adapter._reactions_enabled() is False\n+\n+\n+def test_reactions_disabled_with_no(monkeypatch):\n+    \"\"\"TELEGRAM_REACTIONS=no disables reactions.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"no\")\n+    adapter = _make_adapter()\n+    assert adapter._reactions_enabled() is False\n+\n+\n+# ── _set_reaction ────────────────────────────────────────────────────\n+\n+\n[redacted:email]\n+async def test_set_reaction_calls_bot_api(monkeypatch):\n+    \"\"\"_set_reaction should call bot.set_message_reaction with correct args.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+\n+    result = await adapter._set_reaction(\"123\", \"456\", \"\\U0001f440\")\n+\n+    assert result is True\n+    adapter._bot.set_message_reaction.assert_awaited_once_with(\n+        chat_id=123,\n+        message_id=456,\n+        reaction=\"\\U0001f440\",\n+    )\n+\n+\n[redacted:email]\n+async def test_set_reaction_returns_false_without_bot(monkeypatch):\n+    \"\"\"_set_reaction should return False when bot is not available.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    adapter._bot = None\n+\n+    result = await adapter._set_reaction(\"123\", \"456\", \"\\U0001f440\")\n+    assert result is False\n+\n+\n[redacted:email]\n+async def test_set_reaction_handles_api_error_gracefully(monkeypatch):\n+    \"\"\"API errors during reaction should not propagate.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    adapter._bot.set_message_reaction = AsyncMock(side_effect=RuntimeError(\"no perms\"))\n+\n+    result = await adapter._set_reaction(\"123\", \"456\", \"\\U0001f440\")\n+    assert result is False\n+\n+\n+# ── on_processing_start ──────────────────────────────────────────────\n+\n+\n[redacted:email]\n+async def test_on_processing_start_adds_eyes_reaction(monkeypatch):\n+    \"\"\"Processing start should add eyes reaction when enabled.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    event = _make_event()\n+\n+    await adapter.on_processing_start(event)\n+\n+    adapter._bot.set_message_reaction.assert_awaited_once_with(\n+        chat_id=123,\n+        message_id=456,\n+        reaction=\"\\U0001f440\",\n+    )\n+\n+\n[redacted:email]\n+async def test_on_processing_start_skipped_when_disabled(monkeypatch):\n+    \"\"\"Processing start should not react when reactions are disabled.\"\"\"\n+    monkeypatch.delenv(\"TELEGRAM_REACTIONS\", raising=False)\n+    adapter = _make_adapter()\n+    event = _make_event()\n+\n+    await adapter.on_processing_start(event)\n+\n+    adapter._bot.set_message_reaction.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_on_processing_start_handles_missing_ids(monkeypatch):\n+    \"\"\"Should handle events without chat_id or message_id gracefully.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    event = MessageEvent(\n+        text=\"hello\",\n+        message_type=MessageType.TEXT,\n+        source=SimpleNamespace(chat_id=None),\n+        message_id=None,\n+    )\n+\n+    await adapter.on_processing_start(event)\n+\n+    adapter._bot.set_message_reaction.assert_not_awaited()\n+\n+\n+# ── on_processing_complete ───────────────────────────────────────────\n+\n+\n[redacted:email]\n+async def test_on_processing_complete_success(monkeypatch):\n+    \"\"\"Successful processing should set check mark reaction.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    event = _make_event()\n+\n+    await adapter.on_processing_complete(event, success=True)\n+\n+    adapter._bot.set_message_reaction.assert_awaited_once_with(\n+        chat_id=123,\n+        message_id=456,\n+        reaction=\"\\u2705\",\n+    )\n+\n+\n[redacted:email]\n+async def test_on_processing_complete_failure(monkeypatch):\n+    \"\"\"Failed processing should set cross mark reaction.\"\"\"\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"true\")\n+    adapter = _make_adapter()\n+    event = _make_event()\n+\n+    await adapter.on_processing_complete(event, success=False)\n+\n+    adapter._bot.set_message_reaction.assert_awaited_once_with(\n+        chat_id=123,\n+        message_id=456,\n+        reaction=\"\\u274c\",\n+    )\n+\n+\n[redacted:email]\n+async def test_on_processing_complete_skipped_when_disabled(monkeypatch):\n+    \"\"\"Processing complete should not react when reactions are disabled.\"\"\"\n+    monkeypatch.delenv(\"TELEGRAM_REACTIONS\", raising=False)\n+    adapter = _make_adapter()\n+    event = _make_event()\n+\n+    await adapter.on_processing_complete(event, success=True)\n+\n+    adapter._bot.set_message_reaction.assert_not_awaited()\n+\n+\n+# ── config.py bridging ───────────────────────────────────────────────\n+\n+\n+def test_config_bridges_telegram_reactions(monkeypatch, tmp_path):\n+    \"\"\"gateway/config.py bridges telegram.reactions to TELEGRAM_REACTIONS env var.\"\"\"\n+    import yaml\n+    config_file = tmp_path / \"config.yaml\"\n+    config_file.write_text(yaml.dump({\n+        \"telegram\": {\n+            \"reactions\": True,\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    # Use setenv (not delenv) so monkeypatch registers cleanup even when\n+    # the var doesn't exist yet — load_gateway_config will overwrite it.\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"\")\n+\n+    from gateway.config import load_gateway_config\n+    load_gateway_config()\n+\n+    import os\n+    assert os.getenv(\"TELEGRAM_REACTIONS\") == \"true\"\n+\n+\n+def test_config_reactions_env_takes_precedence(monkeypatch, tmp_path):\n+    \"\"\"Env var should take precedence over config.yaml for reactions.\"\"\"\n+    import yaml\n+    config_file = tmp_path / \"config.yaml\"\n+    config_file.write_text(yaml.dump({\n+        \"telegram\": {\n+            \"reactions\": True,\n+        },\n+    }))\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    monkeypatch.setenv(\"TELEGRAM_REACTIONS\", \"false\")\n+\n+    from gateway.config import load_gateway_config\n+    load_gateway_config()\n+\n+    import os\n+    assert os.getenv(\"TELEGRAM_REACTIONS\") == \"false\""]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -164,6 +164,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI\n | `TELEGRAM_WEBHOOK_URL` | Public HTTPS URL for webhook mode (enables webhook instead of polling) |\n | `TELEGRAM_WEBHOOK_PORT` | Local listen port for webhook server (default: `8443`) |\n | `TELEGRAM_WEBHOOK_SECRET` | Secret token for verifying updates come from Telegram |\n+| `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) |\n | `DISCORD_BOT_TOKEN` | Discord bot token |\n | `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot |\n | `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery |","@@ -171,6 +172,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI\n | `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |\n | `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |\n | `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |\n+| `DISCORD_REACTIONS` | Enable emoji reactions on messages during processing (default: `true`) |\n+| `DISCORD_IGNORED_CHANNELS` | Comma-separated channel IDs where the bot never responds |\n+| `DISCORD_NO_THREAD_CHANNELS` | Comma-separated channel IDs where bot responds without auto-threading |\n | `SLACK_BOT_TOKEN` | Slack bot token (`xoxb-...`) |\n | `SLACK_APP_TOKEN` | Slack app-level token (`xapp-...`, required for Socket Mode) |\n | `SLACK_ALLOWED_USERS` | Comma-separated Slack user IDs |"]},{"file":"website/docs/user-guide/messaging/discord.md","hunks":["@@ -280,6 +280,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede\n | `DISCORD_AUTO_THREAD` | No | `true` | When `true`, automatically creates a new thread for every `@mention` in a text channel, so each conversation is isolated (similar to Slack behavior). Messages already inside threads or DMs are unaffected. |\n | `DISCORD_ALLOW_BOTS` | No | `\"none\"` | Controls how the bot handles messages from other Discord bots. `\"none\"` — ignore all other bots. `\"mentions\"` — only accept bot messages that `@mention` Hermes. `\"all\"` — accept all bot messages. |\n | `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. |\n+| `DISCORD_IGNORED_CHANNELS` | No | — | Comma-separated channel IDs where the bot **never** responds, even when `@mentioned`. Takes priority over all other channel settings. |\n+| `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`. |\n \n ### Config File (`config.yaml`)\n ","@@ -292,6 +294,8 @@ discord:\n   free_response_channels: \"\"      # Comma-separated channel IDs (or YAML list)\n   auto_thread: true               # Auto-create threads on @mention\n   reactions: true                 # Add emoji reactions during processing\n+  ignored_channels: []            # Channel IDs where bot never responds\n+  no_thread_channels: []          # Channel IDs where bot responds without threading\n \n # Session isolation (applies to all gateway platforms, not just Discord)\n group_sessions_per_user: true     # Isolate sessions per user in shared channels","@@ -342,6 +346,40 @@ Controls whether the bot adds emoji reactions to messages as visual feedback:\n \n Disable this if you find the reactions distracting or if the bot's role doesn't have the **Add Reactions** permission.\n \n+#### `discord.ignored_channels`\n+\n+**Type:** string or list — **Default:** `[]`\n+\n+Channel IDs where the bot **never** responds, even when directly `@mentioned`. This takes the highest priority — if a channel is in this list, the bot silently ignores all messages there, regardless of `require_mention`, `free_response_channels`, or any other setting.\n+\n+```yaml\n+# String format\n+discord:\n+  ignored_channels: \"1234567890,9876543210\"\n+\n+# List format\n+discord:\n+  ignored_channels:\n+    - 1234567890\n+    - 9876543210\n+```\n+\n+If a thread's parent channel is in this list, messages in that thread are also ignored.\n+\n+#### `discord.no_thread_channels`\n+\n+**Type:** string or list — **Default:** `[]`\n+\n+Channel IDs where the bot responds directly in the channel instead of auto-creating a thread. This only has an effect when `auto_thread` is `true` (the default). In these channels, the bot responds inline like a normal message rather than spawning a new thread.\n+\n+```yaml\n+discord:\n+  no_thread_channels:\n+    - 1234567890  # Bot responds inline here\n+```\n+\n+Useful for channels dedicated to bot interaction where threads would add unnecessary noise.\n+\n #### `group_sessions_per_user`\n \n **Type:** boolean — **Default:** `true`"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -463,6 +463,35 @@ platforms:\n You usually don't need to configure this manually. The auto-discovery via DoH handles most restricted-network scenarios. The `TELEGRAM_FALLBACK_IPS` env var is only needed if DoH is also blocked on your network.\n :::\n \n+## Message Reactions\n+\n+The bot can add emoji reactions to messages as visual processing feedback:\n+\n+- 👀 when the bot starts processing your message\n+- ✅ when the response is delivered successfully\n+- ❌ if an error occurs during processing\n+\n+Reactions are **disabled by default**. Enable them in `config.yaml`:\n+\n+```yaml\n+telegram:\n+  reactions: true\n+```\n+\n+Or via environment variable:\n+\n+```bash\n+TELEGRAM_REACTIONS=true\n+```\n+\n+:::note\n+Unlike Discord (where reactions are additive), Telegram's Bot API replaces all bot reactions in a single call. The transition from 👀 to ✅/❌ happens atomically — you won't see both at once.\n+:::\n+\n+:::tip\n+If the bot doesn't have permission to add reactions in a group, the reaction calls fail silently and message processing continues normally.\n+:::\n+\n ## Troubleshooting\n \n | Problem | Solution |\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}