Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 22 additions & 4 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
67 changes: 66 additions & 1 deletion tests/gateway/test_discord_free_response.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions tests/plugins/platforms/test_discord_gate_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"GATEWAY_ALLOWED_USERS",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_FORCE_THREAD_CHANNELS",
"DISCORD_ALLOW_BOTS",
]

Expand Down Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
22 changes: 20 additions & 2 deletions website/docs/user-guide/messaging/discord.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -400,15 +402,31 @@ 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`

**Type:** boolean — **Default:** `true`

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`

Expand Down