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
- 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
Open PRs0
Fixes on record
-
#5975 feat(gateway): Discord channel controls + Telegram reactions
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.mdShow 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.00means 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 LabAt 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.