Skip to content
Closed
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
44 changes: 44 additions & 0 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2866,6 +2866,10 @@ async def send(
)
return result

# Resolve readable @Name references into real <@id> mentions (opt-in
# via DISCORD_RESOLVE_MENTIONS) so the model can actually ping a user or
# another bot by name; a bare "@Name" from an LLM is otherwise inert text.
content = await self._resolve_outbound_mentions(content, channel)
# Format and split message if needed
formatted = self.format_message(content)
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
Expand Down Expand Up @@ -4784,6 +4788,46 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
logger.error("[%s] Failed to get chat info for %s: %s", self.name, chat_id, e, exc_info=True)
return {"name": str(chat_id), "type": "dm", "error": str(e)}

async def _resolve_outbound_mentions(self, content: str, channel: Any) -> str:
"""Rewrite readable ``@Name`` references in an OUTGOING message into real
Discord mentions (``<@id>``) so the bot can actually ping a user or another
bot by name. Opt-in via ``DISCORD_RESOLVE_MENTIONS`` (unset/false = no change).

LLMs reliably emit a friendly ``@Display Name`` instead of the raw ``<@id>``
Discord requires, so without this a bot's attempt to tag someone is inert
plain text. Matching is against the guild's own members (name / display_name /
global_name, case-insensitive, longest name first so ``@neko bot`` wins over a
member named ``neko``); ``@everyone``/roles stay governed by ``allowed_mentions``.
"""
if os.getenv("DISCORD_RESOLVE_MENTIONS", "false").strip().lower() not in ("1", "true", "yes"):
return content
if not content or "@" not in content:
return content
guild = getattr(channel, "guild", None)
if guild is None:
return content
pairs = []
seen = set()
for member in (getattr(guild, "members", None) or []):
uid = str(member.id)
for nm in (getattr(member, "display_name", None),
getattr(member, "global_name", None),
getattr(member, "name", None)):
key = (nm.lower(), uid) if nm else None
if nm and key not in seen:
seen.add(key)
pairs.append((nm, uid))
# Longest names first so "@neko bot" resolves before a member named "neko".
pairs.sort(key=lambda p: len(p[0]), reverse=True)
for nm, uid in pairs:
token = f"<@{uid}>"
if token in content:
continue # already a real mention
pat = re.compile(r"(?<![\w<@])@" + re.escape(nm) + r"(?![\w])", re.IGNORECASE)
if pat.search(content):
content = pat.sub(token, content)
return content

async def _resolve_allowed_usernames(self) -> None:
"""
Resolve non-numeric entries in DISCORD_ALLOWED_USERS to Discord user IDs.
Expand Down
71 changes: 71 additions & 0 deletions tests/gateway/test_discord_outbound_mentions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Unit tests for the Discord adapter's opt-in outbound mention resolution.

Exercises the real method on an init-bypassed adapter instance so the test
rides the module's real import path (it would fail if adapter.py stopped
importing re/os), without reading or exec'ing the adapter source.
"""
import asyncio
import os

import pytest

from plugins.platforms.discord.adapter import DiscordAdapter


class _Member:
def __init__(self, id, display_name=None, name=None, global_name=None):
self.id = id
self.display_name = display_name
self.name = name
self.global_name = global_name


class _Guild:
def __init__(self, members):
self.members = members


class _Channel:
def __init__(self, guild):
self.guild = guild


def _run(content, flag="true"):
# Bypass the heavy __init__ (needs a live client); the method under test
# reads no instance state, only the env flag and channel.guild.members.
adapter = object.__new__(DiscordAdapter)
guild = _Guild([
_Member(200, display_name="Support Bot", name="supportbot"),
_Member(300, display_name="Alice", name="alice"),
_Member(400, display_name="Al", name="al"),
])
ch = _Channel(guild)
if flag is None:
os.environ.pop("DISCORD_RESOLVE_MENTIONS", None)
else:
os.environ["DISCORD_RESOLVE_MENTIONS"] = flag
return asyncio.run(adapter._resolve_outbound_mentions(content, ch))


@pytest.mark.parametrize("content,expected", [
("@Support Bot can you take this?", "<@200> can you take this?"), # multi-word name
("@support bot pls", "<@200> pls"), # case-insensitive
("hey @Alice and @Support Bot", "hey <@300> and <@200>"), # multiple, longest-first
("<@200> already tagged", "<@200> already tagged"), # already a real mention
("mail me@example.com", "mail me@example.com"), # not a mention (word-char before @)
("@Nobody here", "@Nobody here"), # unknown name untouched
])
def test_resolves_when_enabled(content, expected):
assert _run(content, "true") == expected


def test_noop_when_disabled():
assert _run("@Support Bot hi", "false") == "@Support Bot hi"


def test_noop_when_unset():
assert _run("@Support Bot hi", None) == "@Support Bot hi"


def test_shorter_name_still_resolves_alone():
assert _run("ping @Al now", "true") == "ping <@400> now"