From 7e69f68fc2b53526b63b256d5c49bafade73647c Mon Sep 17 00:00:00 2001 From: voidberg-bot <263353778+voidberg-bot@users.noreply.github.com> Date: Sun, 6 Sep 2026 22:37:56 +0000 Subject: [PATCH] fix(discord): opt threaded free-response channels in --- plugins/platforms/discord/adapter.py | 26 +++++-- tests/conftest.py | 1 + tests/gateway/test_discord_free_response.py | 67 ++++++++++++++++++- .../platforms/test_discord_gate_isolation.py | 9 +++ .../docs/reference/environment-variables.md | 1 + website/docs/user-guide/messaging/discord.md | 22 +++++- 6 files changed, 119 insertions(+), 7 deletions(-) diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 88a18180b8aa9..fb76cab218502 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -542,6 +542,7 @@ def _clean_discord_id(entry: str) -> str: _GATE_ENV_KEYS = ( "DISCORD_ALLOWED_USERS", "DISCORD_ALLOWED_ROLES", "DISCORD_ALLOWED_CHANNELS", "DISCORD_IGNORED_CHANNELS", "DISCORD_NO_THREAD_CHANNELS", "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_FORCE_THREAD_CHANNELS", "DISCORD_MISSED_MESSAGE_BACKFILL_CHANNELS", "DISCORD_ALLOW_ALL_USERS", "DISCORD_ALLOW_BOTS", "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_ALLOWED_USERS", ) @@ -4718,6 +4719,12 @@ def _discord_free_response_channels(self) -> set: return {part.strip() for part in s.split(",") if part.strip()} return set() + def _discord_force_thread_channels(self) -> set: + """Free-response channels that should still auto-thread (per-profile).""" + return self._gate_csv_set( + self._gate_raw("force_thread_channels", "DISCORD_FORCE_THREAD_CHANNELS") + ) + def _raw_mentioned_user_ids(self, message: Any) -> set: """Extract user-mention IDs (``<@ID>`` and legacy ``<@!ID>``) from raw content, since ``message.mentions`` isn't always populated (mobile/edited/relayed).""" @@ -5728,11 +5735,20 @@ async def _handle_message( if require_mention and not is_free_channel and not in_bot_thread: if not self._self_is_explicitly_mentioned(message) and not mention_prefix: return False - # Auto-thread: isolate each @mention in a text channel into its own thread (Slack-style). + # Free-response channels stay inline unless explicitly opted back into + # top-level auto-threading. no_thread_channels remains the stronger opt-out. auto_threaded_channel = None if not is_thread and not isinstance(message.channel, discord.DMChannel): no_thread_channels = self._get_no_thread_channels() - skip_thread = bool(channel_keys & no_thread_channels) or is_free_channel + force_thread_channels = self._discord_force_thread_channels() + force_thread = ( + "*" in force_thread_channels + or bool(channel_keys & force_thread_channels) + ) + skip_thread = ( + bool(channel_keys & no_thread_channels) + or (is_free_channel and not force_thread) + ) auto_thread = self._extra_or_env_flag("auto_thread", "DISCORD_AUTO_THREAD", "true", truthy=True) is_reply_message = getattr(message, "type", None) == discord.MessageType.reply if auto_thread and not skip_thread and not is_voice_linked_channel and not is_reply_message: @@ -7003,6 +7019,7 @@ def _gate(key: str, env_key: str, *, from_platform_extra: bool, lower: bool = Fa seeded_extra["approval_mentions"] = approval_mentions_cfg _env_default("DISCORD_APPROVAL_MENTIONS", str(approval_mentions_cfg).lower()) _gate("free_response_channels", "DISCORD_FREE_RESPONSE_CHANNELS", from_platform_extra=False) + _gate("force_thread_channels", "DISCORD_FORCE_THREAD_CHANNELS", from_platform_extra=False) for key, env_key in (("auto_thread", "DISCORD_AUTO_THREAD"), ("reactions", "DISCORD_REACTIONS")): if key in discord_cfg: seeded_extra[key] = discord_cfg[key] @@ -7073,8 +7090,9 @@ def register(ctx) -> None: setup_fn=interactive_setup, # YAML→env bridge: ``discord:`` config keys → ``DISCORD_*`` env vars read via os.getenv(). # YAML→env config bridge — owns the translation of ``config.yaml`` ``discord:`` keys - # (require_mention, free_response_channels, auto_thread, reactions, ignored_channels, - # allowed_channels, no_thread_channels, allow_mentions.*, reply_to_mode, thread_require_mention) + # (require_mention, free_response_channels, force_thread_channels, auto_thread, reactions, + # ignored_channels, allowed_channels, no_thread_channels, allow_mentions.*, + # reply_to_mode, thread_require_mention) # into ``DISCORD_*`` env vars that the adapter reads via ``os.getenv()``. Replaces the hardcoded # block that used to live in ``gateway/config.py``. Hook contract: #24836. apply_yaml_config_fn=_apply_yaml_config, diff --git a/tests/conftest.py b/tests/conftest.py index acaca8d8c5f94..99eb2de7581bd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -445,6 +445,7 @@ def _looks_like_credential(name: str) -> bool: "SLACK_REACTIONS", "DISCORD_REQUIRE_MENTION", "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_FORCE_THREAD_CHANNELS", "TELEGRAM_REQUIRE_MENTION", "WHATSAPP_REQUIRE_MENTION", "DINGTALK_REQUIRE_MENTION", diff --git a/tests/gateway/test_discord_free_response.py b/tests/gateway/test_discord_free_response.py index fc58b982a3783..b133795a779a4 100644 --- a/tests/gateway/test_discord_free_response.py +++ b/tests/gateway/test_discord_free_response.py @@ -1,6 +1,7 @@ """Tests for Discord free-response defaults and mention gating.""" from datetime import datetime, timezone +import os from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import sys @@ -109,6 +110,7 @@ def adapter(monkeypatch): "DISCORD_REQUIRE_MENTION", "DISCORD_THREAD_REQUIRE_MENTION", "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_FORCE_THREAD_CHANNELS", "DISCORD_AUTO_THREAD", "DISCORD_NO_THREAD_CHANNELS", "DISCORD_ALLOWED_CHANNELS", @@ -278,7 +280,7 @@ async def test_discord_voice_linked_channel_skips_mention_requirement_and_auto_t @pytest.mark.asyncio async def test_discord_free_response_channel_skips_auto_thread(adapter, monkeypatch): - """Free-response channels should reply inline, never spawn a new thread. + """Free-response channels should reply inline by default. Without this, every message in a free-response channel would auto-create a fresh thread (since the channel bypasses the @mention gate, every @@ -307,6 +309,69 @@ async def test_discord_free_response_channel_skips_auto_thread(adapter, monkeypa assert event.source.chat_type == "group" +@pytest.mark.asyncio +async def test_discord_force_thread_channel_threads_free_response(adapter, monkeypatch): + """Selected mention-free intake channels can opt back into auto-threading.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789") + monkeypatch.setenv("DISCORD_FORCE_THREAD_CHANNELS", "789") + monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False) + + fake_thread = FakeThread( + channel_id=555, + name="auto-thread", + parent=FakeTextChannel(channel_id=789), + ) + adapter._auto_create_thread = AsyncMock(return_value=fake_thread) + message = make_message( + channel=FakeTextChannel(channel_id=789), + content="threaded intake without mention", + ) + + 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" + assert event.source.chat_id == "555" + assert event.source.thread_id == "555" + assert event.source.parent_chat_id == "789" + + +@pytest.mark.asyncio +async def test_discord_no_thread_overrides_force_thread_channel(adapter, monkeypatch): + """The explicit inline-reply opt-out wins when both lists contain a channel.""" + monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true") + monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "789") + monkeypatch.setenv("DISCORD_FORCE_THREAD_CHANNELS", "789") + monkeypatch.setenv("DISCORD_NO_THREAD_CHANNELS", "789") + + adapter._auto_create_thread = AsyncMock() + message = make_message( + channel=FakeTextChannel(channel_id=789), + content="forced but explicitly inline", + ) + + await adapter._handle_message(message) + + adapter._auto_create_thread.assert_not_awaited() + adapter.handle_message.assert_awaited_once() + assert adapter.handle_message.await_args.args[0].source.chat_type == "group" + + +def test_discord_force_thread_channels_yaml_bridge(monkeypatch): + monkeypatch.delenv("DISCORD_FORCE_THREAD_CHANNELS", raising=False) + + extras = discord_platform._apply_yaml_config( + {"discord": {"force_thread_channels": [1491973769726791812, "#intake"]}}, + {"force_thread_channels": [1491973769726791812, "#intake"]}, + ) + + assert extras["force_thread_channels"] == "1491973769726791812,#intake" + assert os.environ["DISCORD_FORCE_THREAD_CHANNELS"] == "1491973769726791812,#intake" + + @pytest.mark.asyncio async def test_fetch_channel_context_stops_at_self_message_and_reverses_to_chronological_order(adapter, monkeypatch): monkeypatch.setenv("DISCORD_ALLOW_BOTS", "all") diff --git a/tests/plugins/platforms/test_discord_gate_isolation.py b/tests/plugins/platforms/test_discord_gate_isolation.py index 0844c7372a3c8..be3478d6ca9ce 100644 --- a/tests/plugins/platforms/test_discord_gate_isolation.py +++ b/tests/plugins/platforms/test_discord_gate_isolation.py @@ -35,6 +35,7 @@ "GATEWAY_ALLOWED_USERS", "DISCORD_NO_THREAD_CHANNELS", "DISCORD_FREE_RESPONSE_CHANNELS", + "DISCORD_FORCE_THREAD_CHANNELS", "DISCORD_ALLOW_BOTS", ] @@ -114,6 +115,14 @@ def test_ignored_channels_isolated(self): assert a._get_ignored_channels() == {"311"} assert b._get_ignored_channels() == {"322"} + def test_force_thread_channels_isolated(self): + a = _adapter() + b = _adapter() + _snapshot(a, {"DISCORD_FORCE_THREAD_CHANNELS": "411"}) + _snapshot(b, {"DISCORD_FORCE_THREAD_CHANNELS": "422"}) + assert a._discord_force_thread_channels() == {"411"} + assert b._discord_force_thread_channels() == {"422"} + class TestTwoAdapterUserRoleIsolation: def test_allowed_users_isolated(self): diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 1296aa2c03061..01e950661f131 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -345,6 +345,7 @@ These are set automatically by the Docker terminal backend when `proxy.enabled: | `DISCORD_COMMAND_SYNC_POLICY` | Discord slash-command startup sync policy: `safe` (diff and reconcile), `bulk` (legacy `tree.sync()`), or `off` | | `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_FORCE_THREAD_CHANNELS` | Comma-separated free-response channel IDs that should still auto-create threads | | `DISCORD_AUTO_THREAD` | Auto-thread long replies when supported | | `DISCORD_ALLOW_ANY_ATTACHMENT` | When `true`, accept attachments of any file type (not just the built-in PDF/text/zip/office allowlist). Unknown types are cached and surfaced to the agent as a local path so it can inspect them via `terminal` / `read_file` / `ffprobe`. Default `false`. | | `DISCORD_MAX_ATTACHMENT_BYTES` | Maximum bytes per attachment the gateway will cache. Default `33554432` (32 MiB). Set to `0` for no cap (attachments are held in memory while being written). | diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index dfad0cc25b560..28f7005c842d1 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -299,6 +299,7 @@ Discord behavior is controlled through two files: **`~/.hermes/.env`** for crede | `DISCORD_REQUIRE_MENTION` | No | `true` | When `true`, the bot only responds in server channels when `@mentioned`. Set to `false` to respond to all messages in every channel. | | `DISCORD_THREAD_REQUIRE_MENTION` | No | `false` | When `true`, the in-thread mention shortcut is disabled — threads are gated the same as channels, requiring `@mention` even after the bot has already participated. Use this when multiple bots share a thread and you want each to fire only on explicit `@mention`. | | `DISCORD_FREE_RESPONSE_CHANNELS` | No | — | Comma-separated channel IDs where the bot responds without requiring an `@mention`, even when `DISCORD_REQUIRE_MENTION` is `true`. | +| `DISCORD_FORCE_THREAD_CHANNELS` | No | — | Comma-separated free-response channel IDs that should still auto-create a thread for each new top-level message. | | `DISCORD_IGNORE_NO_MENTION` | No | `true` | When `true`, the bot stays silent if a message `@mentions` other users but does **not** mention the bot. Prevents the bot from jumping into conversations directed at other people. Only applies in server channels, not DMs. | | `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. | @@ -335,6 +336,7 @@ discord: require_mention: true # Require @mention in server channels thread_require_mention: false # If true, require @mention in threads too (multi-bot threads) free_response_channels: "" # Comma-separated channel IDs (or YAML list) + force_thread_channels: [] # Free-response channels that should still auto-thread auto_thread: true # Auto-create threads on @mention reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds @@ -400,7 +402,23 @@ discord: If a thread's parent channel is in this list, the thread also becomes mention-free. -Free-response channels also **skip auto-threading** — the bot replies inline rather than spinning off a new thread per message. This keeps the channel usable as a lightweight chat surface. If you want threading behavior, don't list the channel as free-response (use normal `@mention` flow instead). +Free-response channels also **skip auto-threading by default** — the bot replies inline rather than spinning off a new thread per message. This keeps the channel usable as a lightweight chat surface. For a mention-free intake channel that should still create one thread per top-level message, also list it in [`force_thread_channels`](#discordforce_thread_channels). + +#### `discord.force_thread_channels` + +**Type:** string or list — **Default:** `""` + +Free-response channel IDs that should still use auto-threading for top-level messages. This supports bot intake channels where users post without an `@mention`, while each request receives its own thread and session. + +```yaml +discord: + free_response_channels: + - 1234567890 + force_thread_channels: + - 1234567890 # Mention-free, but creates a thread per top-level message +``` + +Channels listed in [`no_thread_channels`](#discordno_thread_channels) still reply inline; that setting is the stronger opt-out. #### `discord.auto_thread` @@ -408,7 +426,7 @@ Free-response channels also **skip auto-threading** — the bot replies inline r When enabled, every `@mention` in a regular text channel automatically creates a new thread for the conversation. This keeps the main channel clean and gives each conversation its own isolated session history. Once a thread is created, subsequent messages in that thread don't require `@mention` — the bot knows it's already participating. Set [`thread_require_mention`](#discordthread_require_mention) to `true` to disable this in-thread shortcut for multi-bot setups. -Messages sent in existing threads or DMs are unaffected by this setting. Channels listed in `discord.free_response_channels` or `discord.no_thread_channels` also bypass auto-threading and get inline replies instead. +Messages sent in existing threads or DMs are unaffected by this setting. Channels listed in `discord.free_response_channels` or `discord.no_thread_channels` also bypass auto-threading and get inline replies instead, unless a free-response channel is explicitly listed in `discord.force_thread_channels`. #### `discord.reactions`