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
22 changes: 21 additions & 1 deletion plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3754,6 +3754,10 @@ async def _fetch_channel_context(
except (ValueError, TypeError):
pass # Malformed cache entry — fall back to cold-start scan

history = getattr(channel, "history", None)
if history is None:
return ""

try:
collected = []
# IMPORTANT: pass oldest_first=False explicitly. discord.py 2.x
Expand Down Expand Up @@ -4494,6 +4498,17 @@ async def _handle_message(self, message: DiscordMessage) -> None:
normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip()
normalized_content = normalized_content.replace(f"<@!{self._client.user.id}>", "").strip()
message.content = normalized_content
else:
# Discord role mentions are not included in ``message.mentions``.
# Some deployments expose the agent via an @role (e.g. @Hermes-Agent)
# rather than mentioning the bot user directly. Treat a leading
# role mention as an explicit bot invocation for auto-threading, and
# strip it from the user-visible prompt just like a bot mention.
stripped_role_content = re.sub(r"^(?:<@&\d+>\s*)+", "", normalized_content).strip()
if stripped_role_content != normalized_content:
mention_prefix = True
normalized_content = stripped_role_content

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches every leading role mention, not a role associated with this bot. Please derive the bot member's role IDs in the message guild and only treat an intersection with the leading mentioned role IDs as an explicit Hermes invocation; otherwise an unrelated @role is stripped and forces a thread.

message.content = normalized_content
if not isinstance(message.channel, discord.DMChannel):
channel_ids = {str(message.channel.id)}
if parent_channel_id:
Expand Down Expand Up @@ -4552,7 +4567,12 @@ async def _handle_message(self, message: DiscordMessage) -> 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) or is_free_channel
# Free-response channels normally avoid auto-threading so casual
# unmentioned chat does not spawn a new thread for every message.
# If the user explicitly @mentions the bot, however, treat it as a
# deliberate bot conversation and allow auto-threading unless the
# channel is explicitly listed in no_thread_channels.
skip_thread = bool(channel_ids & no_thread_channels) or (is_free_channel and not mention_prefix)
auto_thread = os.getenv("DISCORD_AUTO_THREAD", "true").lower() in {"true", "1", "yes"}
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
14 changes: 14 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,20 @@ def _make_discord_adapter_wired(runner=None):
return adapter, runner


@pytest.fixture(autouse=True)
def clean_discord_channel_env(monkeypatch):
"""Keep host Discord channel allowlists from leaking into e2e adapter tests."""
for name in (
"DISCORD_ALLOWED_CHANNELS",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_IGNORED_CHANNELS",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_HISTORY_BACKFILL",
"DISCORD_HISTORY_BACKFILL_LIMIT",
):
monkeypatch.delenv(name, raising=False)


@pytest.fixture()
def discord_setup():
return _make_discord_adapter_wired()
Expand Down
72 changes: 72 additions & 0 deletions tests/gateway/test_discord_free_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,78 @@ async def test_discord_auto_thread_enabled_by_default(adapter, monkeypatch):
assert event.source.thread_id == "999"


@pytest.mark.asyncio
async def test_discord_free_response_channel_mention_still_auto_threads(adapter, monkeypatch):
"""Free-response channels should only skip auto-threading for unmentioned casual chat."""
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")

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=123),
content="<@999> please help",
mentions=[adapter._client.user],
)

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.text == "please help"
assert event.source.chat_type == "thread"
assert event.source.thread_id == "999"


@pytest.mark.asyncio
async def test_discord_free_response_channel_role_mention_still_auto_threads(adapter, monkeypatch):
"""Leading role mentions should count as explicit bot conversations for auto-threading."""
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")

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=123),
content="<@&1501526595901460483> 開一個新討論串",
mentions=[],
)

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.text == "開一個新討論串"
assert event.source.chat_type == "thread"
assert event.source.thread_id == "999"


@pytest.mark.asyncio
async def test_discord_free_response_channel_unmentioned_skips_auto_thread(adapter, monkeypatch):
"""Unmentioned messages in free-response channels stay in the parent channel."""
monkeypatch.delenv("DISCORD_AUTO_THREAD", raising=False)
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
monkeypatch.setenv("DISCORD_FREE_RESPONSE_CHANNELS", "123")

adapter._auto_create_thread = AsyncMock()

message = make_message(channel=FakeTextChannel(channel_id=123), content="casual chat")

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.text == "casual chat"
assert event.source.chat_type == "group"


@pytest.mark.asyncio
async def test_discord_reply_message_skips_auto_thread(adapter, monkeypatch):
"""Quote-replies should stay in-channel instead of trying to create a thread."""
Expand Down
14 changes: 14 additions & 0 deletions tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ def get_commands(self):
return [SimpleNamespace(name=n) for n in self.commands]


@pytest.fixture(autouse=True)
def clean_discord_channel_env(monkeypatch):
"""Keep host Discord channel allowlists from leaking into adapter unit tests."""
for name in (
"DISCORD_ALLOWED_CHANNELS",
"DISCORD_FREE_RESPONSE_CHANNELS",
"DISCORD_IGNORED_CHANNELS",
"DISCORD_NO_THREAD_CHANNELS",
"DISCORD_HISTORY_BACKFILL",
"DISCORD_HISTORY_BACKFILL_LIMIT",
):
monkeypatch.delenv(name, raising=False)


@pytest.fixture
def adapter():
config = PlatformConfig(enabled=True, token="***")
Expand Down