# NousResearch/hermes-agent · Discord channel filtering and Telegram reaction features

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #5975. Verify it addresses your case.
- **Recommended:** read PR #5975 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-5975`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #5881: Discord: Add ignored_channels and no_thread_channels config options

## Prior & competing PRs

- **#5975: feat(gateway): Discord channel controls + Telegram reactions**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `gateway/config.py`

    ```diff
    @@ -556,6 +556,18 @@ def load_gateway_config() -> GatewayConfig:
                         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()
    +                # ignored_channels: channels where bot never responds (even when mentioned)
    +                ic = discord_cfg.get("ignored_channels")
    +                if ic is not None and not os.getenv("DISCORD_IGNORED_CHANNELS"):
    +                    if isinstance(ic, list):
    +                        ic = ",".join(str(v) for v in ic)
    +                    os.environ["DISCORD_IGNORED_CHANNELS"] = str(ic)
    +                # no_thread_channels: channels where bot responds directly without creating thread
    +                ntc = discord_cfg.get("no_thread_channels")
    +                if ntc is not None and not os.getenv("DISCORD_NO_THREAD_CHANNELS"):
    +                    if isinstance(ntc, list):
    +                        ntc = ",".join(str(v) for v in ntc)
    +                    os.environ["DISCORD_NO_THREAD_CHANNELS"] = str(ntc)
     
                 # Telegram settings → env vars (env vars take precedence)
                 telegram_cfg = yaml_cfg.get("telegram", {})
    ```

    ```diff
    @@ -570,6 +582,8 @@ def load_gateway_config() -> GatewayConfig:
                         if isinstance(frc, list):
                             frc = ",".join(str(v) for v in frc)
                         os.environ["TELEGRAM_FREE_RESPONSE_CHATS"] = str(frc)
    +                if "reactions" in telegram_cfg and not os.getenv("TELEGRAM_REACTIONS"):
    +                    os.environ["TELEGRAM_REACTIONS"] = str(telegram_cfg["reactions"]).lower()
     
                 whatsapp_cfg = yaml_cfg.get("whatsapp", {})
                 if isinstance(whatsapp_cfg, dict):
    ```
  - `gateway/platforms/discord.py`

    ```diff
    @@ -2193,9 +2193,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:
             # UNLESS the channel is in the free-response list or the message is
             # in a thread where the bot has already participated.
             #
    -        # Config (all settable via discord.* in config.yaml):
    +        # Config (all settable via discord.* in config.yaml or DISCORD_* env vars):
             #   discord.require_mention: Require @mention in server channels (default: true)
             #   discord.free_response_channels: Channel IDs where bot responds without mention
    +        #   discord.ignored_channels: Channel IDs where bot NEVER responds (even when mentioned)
    +        #   discord.no_thread_channels: Channel IDs where bot responds directly without creating thread
             #   discord.auto_thread: Auto-create thread on @mention in channels (default: true)
     
             thread_id = None
    ```

    ```diff
    @@ -2206,9 +2208,18 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                 parent_channel_id = self._get_parent_channel_id(message.channel)
     
             if not isinstance(message.channel, discord.DMChannel):
    +            # Check ignored channels first - never respond even when mentioned
    +            ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
    +            ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
    +            channel_ids = {str(message.channel.id)}
    +            if parent_channel_id:
    +                channel_ids.add(parent_channel_id)
    +            if channel_ids & ignored_channels:
    +                logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
    +                return
    +
                 free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
                 free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()}
    -            channel_ids = {str(message.channel.id)}
                 if parent_channel_id:
                     channel_ids.add(parent_channel_id)
     
    ```

    ```diff
    @@ -2230,10 +2241,14 @@ async def _handle_message(self, message: DiscordMessage) -> None:
             # Auto-thread: when enabled, automatically create a thread for every
             # @mention in a text channel so each conversation is isolated (like Slack).
             # Messages already inside threads or DMs are unaffected.
    +        # no_thread_channels: channels where bot responds directly without thread.
             auto_threaded_channel = None
             if not is_thread and not isinstance(message.channel, discord.DMChannel):
    +            no_thread_channels_raw = os.getenv("DISCORD_NO_THREAD_CHANNELS", "")
    +            no_thread_channels = {ch.strip() for ch in no_thread_channels_raw.split(",") if ch.strip()}
    +            skip_thread = bool(channel_ids & no_thread_channels)
                 auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in ("true", "1", "yes")
    -            if auto_thread:
    +            if auto_thread and not skip_thread:
                     thread = await self._auto_create_thread(message)
                     if thread:
                         is_thread = True
    ```
  - `gateway/platforms/telegram.py`

    ```diff
    @@ -2673,3 +2673,46 @@ def _build_message_event(self, message: Message, msg_type: MessageType) -> Messa
                 auto_skill=topic_skill,
                 timestamp=message.date,
             )
    +
    +    # ── Message reactions (processing lifecycle) ──────────────────────────
    +
    +    def _reactions_enabled(self) -> bool:
    +        """Check if message reactions are enabled via config/env."""
    +        return os.getenv("TELEGRAM_REACTIONS", "false").lower() not in ("false", "0", "no")
    +
    +    async def _set_reaction(self, chat_id: str, message_id: str, emoji: str) -> bool:
    +        """Set a single emoji reaction on a Telegram message."""
    +        if not self._bot:
    +            return False
    +        try:
    +            await self._bot.set_message_reaction(
    +                chat_id=int(chat_id),
    +                message_id=int(message_id),
    +                reaction=emoji,
    +            )
    +            return True
    +        except Exception as e:
    +            logger.debug("[%s] set_message_reaction failed (%s): %s", self.name, emoji, e)
    +            return False
    +
    +    async def on_processing_start(self, event: MessageEvent) -> None:
    +        """Add an in-progress reaction when message processing begins."""
    +        if not self._reactions_enabled():
    +            return
    +        chat_id = getattr(event.source, "chat_id", None)
    +        message_id = getattr(event, "message_id", None)
    +        if chat_id and message_id:
    +            await self._set_reaction(chat_id, message_id, "\U0001f440")
    +
    +    async def on_processing_complete(self, event: MessageEvent, success: bool) -> None:
    +        """Swap the in-progress reaction for a final success/failure reaction.
    +
    +        Unlike Discord (additive reactions), Telegram's set_message_reaction
    +        replaces all existing reactions in one call — no remove step needed.
    +        """
    +        if not self._reactions_enabled():
    +            return
    +        chat_id = getattr(event.source, "chat_id", None)
    +        message_id = getattr(event, "message_id", None)
    +        if chat_id and message_id:
    +            await self._set_reaction(chat_id, message_id, "\u2705" if success else "\u274c")
    ```
  - `tests/gateway/test_discord_channel_controls.py`

    ```diff
    @@ -0,0 +1,343 @@
    +"""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
    +
    +
    [redacted:email]
    +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="[redacted:secret-assignment]")
    +    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 ─────────────────────────────────────────────────
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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 ───────────────────────────────────────────────
    +
    +
    [redacted:email]
    +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"
    +
    +
    [redacted:email]
    +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"
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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"
    ```
  - `tests/gateway/test_telegram_reactions.py`

    ```diff
    @@ -0,0 +1,260 @@
    +"""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="[redacted:secret-assignment]")
    +    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 ────────────────────────────────────────────────────
    +
    +
    [redacted:email]
    +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",
    +    )
    +
    +
    [redacted:email]
    +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
    +
    +
    [redacted:email]
    +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 ──────────────────────────────────────────────
    +
    +
    [redacted:email]
    +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",
    +    )
    +
    +
    [redacted:email]
    +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()
    +
    +
    [redacted:email]
    +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 ───────────────────────────────────────────
    +
    +
    [redacted:email]
    +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",
    +    )
    +
    +
    [redacted:email]
    +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",
    +    )
    +
    +
    [redacted:email]
    +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))
    +    # Use setenv (not delenv) so monkeypatch registers cleanup even when
    +    # the var doesn't exist yet — load_gateway_config will overwrite it.
    +    monkeypatch.setenv("TELEGRAM_REACTIONS", "")
    +
    +    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"
    ```
  - `website/docs/reference/environment-variables.md`

    ```diff
    @@ -164,6 +164,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
     | `TELEGRAM_WEBHOOK_URL` | Public HTTPS URL for webhook mode (enables webhook instead of polling) |
     | `TELEGRAM_WEBHOOK_PORT` | Local listen port for webhook server (default: `8443`) |
     | `TELEGRAM_WEBHOOK_SECRET` | Secret token for verifying updates come from Telegram |
    +| `TELEGRAM_REACTIONS` | Enable emoji reactions on messages during processing (default: `false`) |
     | `DISCORD_BOT_TOKEN` | Discord bot token |
     | `DISCORD_ALLOWED_USERS` | Comma-separated Discord user IDs allowed to use the bot |
     | `DISCORD_HOME_CHANNEL` | Default Discord channel for cron delivery |
    ```

    ```diff
    @@ -171,6 +172,9 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI
     | `DISCORD_REQUIRE_MENTION` | Require an @mention before responding in server channels |
     | `DISCORD_FREE_RESPONSE_CHANNELS` | Comma-separated channel IDs where mention is not required |
     | `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported |
    +| `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 |
     | `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 |
    ```
  - `website/docs/user-guide/messaging/discord.md`

    ```diff
    @@ -280,6 +280,8 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede
     | `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. |
     | `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. |
     | `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`. |
     
     ### Config File (`config.yaml`)
     
    ```

    ```diff
    @@ -292,6 +294,8 @@ discord:
       free_response_channels: ""      # Comma-separated channel IDs (or YAML list)
       auto_thread: true               # Auto-create threads on @mention
       reactions: true                 # Add emoji reactions during processing
    +  ignored_channels: []            # Channel IDs where bot never responds
    +  no_thread_channels: []          # Channel IDs where bot responds without threading
     
     # Session isolation (applies to all gateway platforms, not just Discord)
     group_sessions_per_user: true     # Isolate sessions per user in shared channels
    ```

    ```diff
    @@ -342,6 +346,40 @@ Controls whether the bot adds emoji reactions to messages as visual feedback:
     
     Disable this if you find the reactions distracting or if the bot's role doesn't have the **Add Reactions** permission.
     
    +#### `discord.ignored_channels`
    +
    +**Type:** string or list — **Default:** `[]`
    +
    +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.
    +
    +```yaml
    +# String format
    +discord:
    +  ignored_channels: "1234567890,9876543210"
    +
    +# List format
    +discord:
    +  ignored_channels:
    +    - 1234567890
    +    - 9876543210
    +```
    +
    +If a thread's parent channel is in this list, messages in that thread are also ignored.
    +
    +#### `discord.no_thread_channels`
    +
    +**Type:** string or list — **Default:** `[]`
    +
    +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.
    +
    +```yaml
    +discord:
    +  no_thread_channels:
    +    - 1234567890  # Bot responds inline here
    +```
    +
    +Useful for channels dedicated to bot interaction where threads would add unnecessary noise.
    +
     #### `group_sessions_per_user`
     
     **Type:** boolean — **Default:** `true`
    ```
  - `website/docs/user-guide/messaging/telegram.md`

    ```diff
    @@ -463,6 +463,35 @@ platforms:
     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.
     :::
     
    +## Message Reactions
    +
    +The bot can add emoji reactions to messages as visual processing feedback:
    +
    +- 👀 when the bot starts processing your message
    +- ✅ when the response is delivered successfully
    +- ❌ if an error occurs during processing
    +
    +Reactions are **disabled by default**. Enable them in `config.yaml`:
    +
    +```yaml
    +telegram:
    +  reactions: true
    +```
    +
    +Or via environment variable:
    +
    +```bash
    +TELEGRAM_REACTIONS=true
    +```
    +
    +:::note
    +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.
    +:::
    +
    +:::tip
    +If the bot doesn't have permission to add reactions in a group, the reaction calls fail silently and message processing continues normally.
    +:::
    +
     ## Troubleshooting
     
     | Problem | Solution |
    ```
- **#5882: feat(discord): add ignored_channels and no_thread_channels config**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#5975`

## Fix status

- Merged to main in PR #5975 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-08`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
