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
2 changes: 2 additions & 0 deletions contributors/emails/nntruonghan@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
tieubao
# PR #69206 salvage (discord: outbound @Name -> <@id> mention resolution)
11 changes: 11 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1961,6 +1961,17 @@
# override: DISCORD_APPROVAL_MENTIONS. Default false avoids surprise
# pings.
"approval_mentions": False,
# When True, a readable "@Display Name" the agent writes in an OUTGOING
# message is rewritten into a real Discord mention (<@id>) by matching
# the guild's own members, so the person or bot is actually pinged. A
# model reliably writes "@Name" rather than the raw <@id> Discord needs,
# which otherwise renders as inert plain text. @everyone and roles stay
# governed by the existing allowed_mentions safe defaults.
# Requires the privileged Server Members Intent, which the adapter
# requests automatically when this is on — it must also be enabled in
# the Discord Developer Portal (Bot -> Privileged Gateway Intents) or
# the bot will not come online. Default false avoids surprise pings.
"resolve_outbound_mentions": False,
# Discord voice-channel inactivity timeout, in seconds. Set to 0 to
# keep the bot in VC until an explicit `/voice leave` / disconnect.
"voice_channel_inactivity_timeout_seconds": 300,
Expand Down
109 changes: 101 additions & 8 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,33 @@ def check_discord_requirements() -> bool:
return True


def _needs_members_intent(allowed_user_ids, allowed_role_ids) -> bool:
"""Whether the privileged Server Members intent must be requested.

Requesting a privileged intent that is not enabled in the Discord Developer
Portal stops the bot coming online at all, so it is only asked for when
something actually reads ``guild.members``:

* a non-numeric allowlist entry, which has to be resolved by username;
* any role allowlist, since role checks walk the member list;
* outbound ``@Name`` resolution (``discord.resolve_outbound_mentions``),
which matches against ``guild.members`` and would silently resolve
nothing without it.

``"*"`` is the open-mode wildcard honored in ``_is_allowed_user``, not a
username to resolve, so it must NOT pull the intent in — that is the
migrate-from-OpenClaw path, which would otherwise fail to come online.

Extracted as a module-level helper so the condition is testable without a
live client.
"""
return (
any(entry != "*" and not entry.isdigit() for entry in (allowed_user_ids or ()))
or bool(allowed_role_ids)
or _env_bool("DISCORD_RESOLVE_MENTIONS", False)
)


def _build_allowed_mentions():
"""Build Discord ``AllowedMentions`` with safe defaults, overridable via env.

Expand Down Expand Up @@ -1278,14 +1305,8 @@ async def connect(self, *, is_reconnect: bool = False) -> bool:
intents.message_content = True
intents.dm_messages = True
intents.guild_messages = True
intents.members = (
# ``"*"`` is the open-mode wildcard (honored in _is_allowed_user),
# not a username to resolve, so it must not pull in the privileged
# Server Members intent — exactly the migrate-from-OpenClaw path
# the wildcard fix targets would otherwise silently fail to come
# online when Members Intent isn't enabled in the Developer Portal.
any(entry != "*" and not entry.isdigit() for entry in self._allowed_user_ids)
or bool(self._allowed_role_ids) # Need members intent for role lookup
intents.members = _needs_members_intent(
self._allowed_user_ids, self._allowed_role_ids
)
intents.voice_states = True

Expand Down Expand Up @@ -3064,6 +3085,13 @@ async def send(
if not channel:
return SendResult(success=False, error=f"Channel {chat_id} not found")

# Resolve readable @Name references into real <@id> mentions (opt-in
# via discord.resolve_outbound_mentions) so the model can actually ping
# a user or another bot by name; a bare "@Name" from an LLM is otherwise
# inert text. Done before the forum branch so a forum thread's starter
# post gets the same treatment as an ordinary channel message.
content = await self._resolve_outbound_mentions(content, channel)

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.

Please cover the corresponding outbound edit path as well: edit_message() formats and calls msg.edit() without resolving names (adapter.py:3207-3241), and its overflow helper sends continuation chunks directly. Streaming responses can otherwise still deliver inert @Name text.


# Forum channels reject channel.send() — create a thread post instead.
if self._is_forum_parent(channel):
result = await self._send_to_forum(channel, content)
Expand Down Expand Up @@ -3323,7 +3351,21 @@ async def edit_message(
channel = self._client.get_channel(int(chat_id))
if not channel:
channel = await self._client.fetch_channel(int(chat_id))
# Keep upstream's partial message -- it avoids an API fetch. The
# resolution below reads ``channel``, not ``msg``, so it is
# unaffected by which form this is.
msg = channel.get_partial_message(int(message_id))

# Resolve @Name -> <@id> on the FINAL edit only, so a streamed
# response ends up with real mentions like a plain send() does.
# Deliberately skipped mid-stream: the text is still partial there,
# so a member named "Al" would match while "@Alice" is only
# half-written, and an edit can deliver that ping. Resolving once at
# finalize means the message the user keeps is the correct one.
# Doing it here also covers _edit_overflow_split below, which
# re-formats this same ``content``.
if finalize:
content = await self._resolve_outbound_mentions(content, channel)
formatted = self.format_message(content)

_preview_key = (str(chat_id), str(message_id))
Expand Down Expand Up @@ -5247,6 +5289,51 @@ 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.

Gated on ``discord.resolve_outbound_mentions`` in config.yaml (bridged to
the ``DISCORD_RESOLVE_MENTIONS`` env var by ``_apply_yaml_config``, the
same way ``discord.approval_mentions`` is). Default off, so an existing
deployment sees no change until it opts in.

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 not _env_bool("DISCORD_RESOLVE_MENTIONS", False):
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 []):

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.

guild.members is not reliably populated unless the privileged members intent is requested. The startup condition currently enables that intent only for named allowlist entries or role authorization (adapter.py:1185-1193); include this opt-in in that condition and document/test the Discord Portal requirement.

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 Expand Up @@ -9951,6 +10038,12 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
)
if approval_mentions_cfg is not None and not os.getenv("DISCORD_APPROVAL_MENTIONS"):
os.environ["DISCORD_APPROVAL_MENTIONS"] = str(approval_mentions_cfg).lower()
resolve_mentions_cfg = (
discord_cfg["resolve_outbound_mentions"] if "resolve_outbound_mentions" in discord_cfg
else platform_extra_cfg.get("resolve_outbound_mentions")
)
if resolve_mentions_cfg is not None and not os.getenv("DISCORD_RESOLVE_MENTIONS"):
os.environ["DISCORD_RESOLVE_MENTIONS"] = str(resolve_mentions_cfg).lower()
frc = discord_cfg.get("free_response_channels")
if frc is not None:
if isinstance(frc, list):
Expand Down
Loading
Loading