NousResearch/hermes-agent

Discord channel filtering and Telegram reaction features

Cluster 5881 · gateway. Data as of

Nothing open on GitHub. Fix merged in #5975 on 8 Apr 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #5975. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #5975. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #5975 feat(gateway): Discord channel controls + Telegram reactions

    merged to main

    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

    Show the diff
    • gateway/config.py
      @@ -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", {})
      @@ -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
      @@ -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
      @@ -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)
       
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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 |
      @@ -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
      @@ -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`)
       
      @@ -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
      @@ -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
      @@ -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 |

1 PR closed without merging

From the close comments: merged via another change 1

  • #5882 feat(discord): add ignored_channels and no_thread_channels config · merged via another change → #5975
Closed issues · 1
  • Closed#5881Discord: Add ignored_channels and no_thread_channels config options
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#5975
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
8 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.