-
Notifications
You must be signed in to change notification settings - Fork 52.4k
feat(discord): outbound @Name → <@id> via config.yaml (re-scope of #69206) + forum path #75633
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d3ef277
617e0ed
91e9bd4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| tieubao | ||
| # PR #69206 salvage (discord: outbound @Name -> <@id> mention resolution) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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) | ||
|
|
||
| # Forum channels reject channel.send() — create a thread post instead. | ||
| if self._is_forum_parent(channel): | ||
| result = await self._send_to_forum(channel, content) | ||
|
|
@@ -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)) | ||
|
|
@@ -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 []): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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. | ||
|
|
@@ -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): | ||
|
|
||
There was a problem hiding this comment.
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 callsmsg.edit()without resolving names (adapter.py:3207-3241), and its overflow helper sends continuation chunks directly. Streaming responses can otherwise still deliver inert@Nametext.