mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-12 13:52:15 +00:00
fix(discord): accept raw direct bot mentions and ignore bare mention-only pings
Some legitimate @bot pings were dropped because the mention gates relied on message.mentions alone, which does not always populate raw <@ID> / <@!ID> forms (mobile, edited, relayed messages). A bare @bot with no other text could also spawn a fake empty-text turn. - add _self_is_explicitly_mentioned() / _raw_mentioned_user_ids() helpers that treat the bot as mentioned via resolved mentions OR raw content forms - use them at the allow_bots=mentions gate, multi-agent bot filtering, the mention-strip/mention_prefix step, and the require_mention gate - drop bare mention-only pings (no text, no media, no injection, no backfill context) instead of injecting a placeholder empty turn Co-authored-by: Teknium <teknium1@gmail.com>
This commit is contained in:
parent
97e0bbef53
commit
608e8a6062
3 changed files with 133 additions and 11 deletions
|
|
@ -1064,7 +1064,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if allow_bots == "none":
|
||||
return
|
||||
elif allow_bots == "mentions":
|
||||
if not self._client.user or self._client.user not in message.mentions:
|
||||
if not self._self_is_explicitly_mentioned(message):
|
||||
return
|
||||
# "all" falls through; bot is permitted — skip the
|
||||
# human-user allowlist below (bots aren't in it).
|
||||
|
|
@ -1093,11 +1093,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
# This replaces the older DISCORD_IGNORE_NO_MENTION logic
|
||||
# with bot-aware filtering that works correctly when multiple
|
||||
# agents share a channel.
|
||||
if not isinstance(message.channel, discord.DMChannel) and message.mentions:
|
||||
_self_mentioned = (
|
||||
self._client.user is not None
|
||||
and self._client.user in message.mentions
|
||||
)
|
||||
_raw_self_mention = self._self_is_explicitly_mentioned(message)
|
||||
if not isinstance(message.channel, discord.DMChannel) and (
|
||||
message.mentions or _raw_self_mention
|
||||
):
|
||||
_self_mentioned = _raw_self_mention
|
||||
_other_bots_mentioned = any(
|
||||
m.bot and m != self._client.user
|
||||
for m in message.mentions
|
||||
|
|
@ -4607,6 +4607,30 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
return {part.strip() for part in s.split(",") if part.strip()}
|
||||
return set()
|
||||
|
||||
def _raw_mentioned_user_ids(self, message: Any) -> set:
|
||||
"""Extract Discord user-mention IDs directly from raw message content.
|
||||
|
||||
Covers both raw forms — ``<@ID>`` and the legacy ``<@!ID>`` nickname
|
||||
form — which ``message.mentions`` does not always populate (mobile,
|
||||
edited, or relayed messages can carry the mention in the content while
|
||||
leaving the resolved ``mentions`` list empty).
|
||||
"""
|
||||
content = getattr(message, "content", "") or ""
|
||||
return {match.group(1) for match in re.finditer(r"<@!?(\d+)>", content)}
|
||||
|
||||
def _self_is_explicitly_mentioned(self, message: Any) -> bool:
|
||||
"""Return True when this bot is explicitly @mentioned in the message.
|
||||
|
||||
Treats the bot as mentioned if it is either present in the resolved
|
||||
``message.mentions`` list OR referenced by its raw ``<@ID>`` / ``<@!ID>``
|
||||
form in the message content.
|
||||
"""
|
||||
if not self._client or not self._client.user:
|
||||
return False
|
||||
if self._client.user in getattr(message, "mentions", []):
|
||||
return True
|
||||
return str(self._client.user.id) in self._raw_mentioned_user_ids(message)
|
||||
|
||||
def _discord_channel_keys(self, message: Any, parent_channel_id: Optional[str] = None) -> set[str]:
|
||||
"""Return channel identifiers accepted by Discord channel config gates.
|
||||
|
||||
|
|
@ -5634,10 +5658,11 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
if snapshot_text_parts and not raw_content:
|
||||
raw_content = "\n".join(snapshot_text_parts)
|
||||
normalized_content = raw_content
|
||||
if self._client.user and self._client.user in message.mentions:
|
||||
if self._self_is_explicitly_mentioned(message):
|
||||
mention_prefix = True
|
||||
normalized_content = normalized_content.replace(f"<@{self._client.user.id}>", "").strip()
|
||||
normalized_content = normalized_content.replace(f"<@!{self._client.user.id}>", "").strip()
|
||||
if self._client.user:
|
||||
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
|
||||
if not isinstance(message.channel, discord.DMChannel):
|
||||
channel_ids = {str(message.channel.id)}
|
||||
|
|
@ -5686,7 +5711,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
)
|
||||
|
||||
if require_mention and not is_free_channel and not in_bot_thread:
|
||||
if self._client.user not in message.mentions and not mention_prefix:
|
||||
if not self._self_is_explicitly_mentioned(message) and not mention_prefix:
|
||||
return
|
||||
# Auto-thread: when enabled, automatically create a thread for every
|
||||
# @mention in a text channel so each conversation is isolated (like Slack).
|
||||
|
|
@ -5992,6 +6017,23 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
# When channel_context is present, a bare mention means "catch me up"
|
||||
# — the context IS the message, so skip the placeholder.
|
||||
if (not event_text or not event_text.strip()) and not _channel_context:
|
||||
# Bare mention-only ping (e.g. "@Bot" with nothing else, including
|
||||
# raw <@!ID> forms) with no media, no injected text, and no backfill
|
||||
# context: drop it instead of spawning a fake empty-text turn.
|
||||
# mention_prefix was computed (and message.content stripped) above,
|
||||
# so reuse it rather than re-reading the now-stripped content.
|
||||
if (
|
||||
mention_prefix
|
||||
and not media_urls
|
||||
and not pending_text_injection
|
||||
):
|
||||
logger.info(
|
||||
"[%s] Ignoring mention-only message from %s in %s",
|
||||
self.name,
|
||||
getattr(message.author, "display_name", getattr(message.author, "name", "unknown")),
|
||||
getattr(message.channel, "id", "unknown"),
|
||||
)
|
||||
return
|
||||
event_text = "(The user sent a message with no text content)"
|
||||
|
||||
_chan = message.channel
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Tests for Discord bot message filtering (DISCORD_ALLOW_BOTS)."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
|
@ -40,6 +41,19 @@ def _make_message(*, author=None, content="hello", mentions=None, is_dm=False):
|
|||
class TestDiscordBotFilter(unittest.TestCase):
|
||||
"""Test the DISCORD_ALLOW_BOTS filtering logic."""
|
||||
|
||||
@staticmethod
|
||||
def _self_is_explicitly_mentioned(message, client_user):
|
||||
"""Mirror adapter._self_is_explicitly_mentioned: resolved or raw mention."""
|
||||
if not client_user:
|
||||
return False
|
||||
if client_user in message.mentions:
|
||||
return True
|
||||
raw_ids = {
|
||||
m.group(1)
|
||||
for m in re.finditer(r"<@!?(\d+)>", getattr(message, "content", "") or "")
|
||||
}
|
||||
return str(client_user.id) in raw_ids
|
||||
|
||||
def _run_filter(self, message, allow_bots="none", client_user=None):
|
||||
"""Simulate the on_message filter logic and return whether message was accepted."""
|
||||
# Replicate the exact filter logic from discord.py on_message
|
||||
|
|
@ -51,7 +65,7 @@ class TestDiscordBotFilter(unittest.TestCase):
|
|||
if allow == "none":
|
||||
return False
|
||||
elif allow == "mentions":
|
||||
if not client_user or client_user not in message.mentions:
|
||||
if not self._self_is_explicitly_mentioned(message, client_user):
|
||||
return False
|
||||
# "all" falls through
|
||||
|
||||
|
|
@ -97,6 +111,13 @@ class TestDiscordBotFilter(unittest.TestCase):
|
|||
msg = _make_message(author=bot, mentions=[our_user])
|
||||
self.assertTrue(self._run_filter(msg, "mentions", our_user))
|
||||
|
||||
def test_allow_bots_mentions_accepts_with_raw_content_mention(self):
|
||||
"""Raw <@!ID> mention counts even when message.mentions is empty."""
|
||||
our_user = _make_author(is_self=True)
|
||||
bot = _make_author(bot=True)
|
||||
msg = _make_message(author=bot, content=f"<@!{our_user.id}> relay", mentions=[])
|
||||
self.assertTrue(self._run_filter(msg, "mentions", our_user))
|
||||
|
||||
def test_default_is_none(self):
|
||||
"""Default behavior (no env var) should be 'none'."""
|
||||
default = os.getenv("DISCORD_ALLOW_BOTS", "none")
|
||||
|
|
|
|||
|
|
@ -349,6 +349,65 @@ async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, mo
|
|||
assert event.text == "hello with mention"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_accepts_raw_bot_mentions_when_required(adapter, monkeypatch):
|
||||
"""Raw <@!ID> mention should trigger even when message.mentions is empty."""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||
|
||||
bot_user = adapter._client.user
|
||||
message = make_message(
|
||||
channel=FakeTextChannel(channel_id=322),
|
||||
content=f"<@!{bot_user.id}> hello from raw mention",
|
||||
mentions=[],
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
event = adapter.handle_message.await_args.args[0]
|
||||
assert event.text == "hello from raw mention"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_ignores_bare_bot_mentions_without_text(adapter, monkeypatch):
|
||||
"""A bare raw @bot ping with no other text should be dropped, not a fake turn."""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||
|
||||
bot_user = adapter._client.user
|
||||
message = make_message(
|
||||
channel=FakeTextChannel(channel_id=323),
|
||||
content=f"<@{bot_user.id}>",
|
||||
mentions=[],
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_ignores_bare_bot_mentions_with_populated_mentions(adapter, monkeypatch):
|
||||
"""Bare @bot ping is dropped even when message.mentions resolves the bot."""
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
|
||||
monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
|
||||
|
||||
bot_user = adapter._client.user
|
||||
message = make_message(
|
||||
channel=FakeTextChannel(channel_id=324),
|
||||
content=f"<@{bot_user.id}>",
|
||||
mentions=[bot_user],
|
||||
)
|
||||
|
||||
await adapter._handle_message(message)
|
||||
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discord_dms_ignore_mention_requirement(adapter, monkeypatch):
|
||||
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue