From 5d747a91c48b19b274e357cd840791ca62a60212 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 22 Jul 2026 04:58:29 -0700 Subject: [PATCH] fix(slack): humanize inbound user mentions + ground bot identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack delivers user mentions as opaque IDs (<@U123>). The agent had no way to tell one participant from another — or from itself — so it could misread a mention of a human as a self-mention and answer messages addressed to that person (the "bot thinks it's @someone-else" bug). Two cooperating fixes: - _humanize_user_mentions rewrites remaining <@UID> tokens (the bot's own mention is stripped earlier) to @DisplayName in the trigger text and reply_to_text — the Slack equivalent of Discord's clean_content. Handles the labelled <@UID|handle> form; unresolvable IDs fall back to the raw ID. - _build_identity_prompt injects an ephemeral per-turn system-prompt line via the channel_prompt seam (applied at API-call time, never persisted — prompt caching preserved) naming the bot's own workspace handle (per-team in multi-workspace installs) so the agent has a positive "that's me" anchor. Salvaged from #55340 by @benbarclay, rebased over the workspace-scoped user-name cache (team_id-aware resolution) on main. --- plugins/platforms/slack/adapter.py | 100 +++++++++++ .../test_slack_mention_humanization.py | 158 ++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tests/gateway/test_slack_mention_humanization.py diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 1d26ffa3e233..bf76bd85021a 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -667,6 +667,10 @@ class SlackAdapter(BasePlatformAdapter): self._app: Optional[Any] = None self._handler: Optional[Any] = None self._bot_user_id: Optional[str] = None + # Bot identity per workspace, used to ground the agent ("you are @X on + # Slack") so it never mistakes a human's mention for a self-mention. + self._bot_display_name: Optional[str] = None # primary workspace bot name + self._team_bot_names: Dict[str, str] = {} # team_id → bot display name # Slack user IDs are workspace-local. Cache names by workspace as well # so a multi-workspace Socket Mode process never reuses another # tenant's display name. @@ -1418,6 +1422,8 @@ class SlackAdapter(BasePlatformAdapter): self._bot_user_id = None self._team_clients = {} self._team_bot_user_ids = {} + self._bot_display_name = None + self._team_bot_names = {} # First token is the primary — used for AsyncApp / Socket Mode primary_token = bot_tokens[0] @@ -1436,12 +1442,15 @@ class SlackAdapter(BasePlatformAdapter): self._team_clients[team_id] = client self._team_bot_user_ids[team_id] = bot_user_id + self._team_bot_names[team_id] = bot_name # First token always wins as the primary bot user id; we # cleared ``_bot_user_id`` above so this picks up the current # token's identity even on reconnect. if self._bot_user_id is None: self._bot_user_id = bot_user_id + if self._bot_display_name is None: + self._bot_display_name = bot_name logger.info( "[Slack] Authenticated as @%s in workspace %s (team: %s)", @@ -2730,6 +2739,71 @@ class SlackAdapter(BasePlatformAdapter): self._user_name_cache[cache_key] = user_id return user_id + async def _humanize_user_mentions( + self, text: str, chat_id: str = "", team_id: str = "" + ) -> str: + """Replace raw ``<@UID>`` user-mention tokens with ``@DisplayName``. + + Slack delivers mentions as opaque IDs (``<@U123>``). Without this, the + agent sees ``<@U123>`` and has no way to tell one participant from + another — or from itself — which makes it misread a mention of a human + as a mention of the bot and reply to messages addressed to that person + (the "bot thinks it's @someone-else" bug). Discord avoids this entirely + by feeding the agent ``message.clean_content`` (IDs already rendered as + names); this is the Slack equivalent. + + The bot's own mention is stripped separately before this runs, so any + tokens left here are other participants. Names are resolved via the + cached :meth:`_resolve_user_name`, so repeated tokens cost one + ``users.info`` lookup per distinct user per process. + """ + if not text or "<@" not in text: + return text + # Capture the bare user ID inside <@...>; Slack IDs are alnum (U…/W…), + # optionally carrying a label like <@U123|alice> — keep only the ID. + ids = set(re.findall(r"<@([A-Z0-9]+)(?:\|[^>]*)?>", text)) + if not ids: + return text + for uid in ids: + name = await self._resolve_user_name( + uid, chat_id=chat_id, team_id=team_id + ) + # Fall back to the raw ID if resolution yields nothing usable + # (keeps the message intact rather than emptying a mention). + display = (name or uid).strip() or uid + # Replace both the bare and labelled forms of this exact ID. + text = re.sub(rf"<@{uid}(?:\|[^>]*)?>", f"@{display}", text) + return text + + def _build_identity_prompt(self, team_id: str = "") -> str: + """Return an ephemeral system-prompt line grounding the bot's identity. + + Injected via the per-turn ``channel_prompt`` seam (applied at API-call + time, never persisted to history — so it does NOT break per-conversation + prompt caching). Tells the agent its own Slack handle so it can + distinguish a mention OF ITSELF from a mention of another participant + whose name happens to resemble its own — the failure in the reported + bug, where the bot saw a human's mention and claimed it was the one + being addressed. Inbound mentions are rendered as ``@DisplayName`` + (see :meth:`_humanize_user_mentions`), so naming the bot's own display + name here gives the agent a positive anchor for "that's me." + """ + name = ( + (team_id and self._team_bot_names.get(team_id)) + or self._bot_display_name + or "" + ).strip() + if not name: + return "" + return ( + f"You are connected to this Slack workspace as the bot " + f'"@{name}". In messages, each line is prefixed with the sender\'s ' + f"name, and mentions are shown as @DisplayName. Only treat a " + f'message as directed at you when it mentions "@{name}" ' + f"specifically; a mention of any other participant is not a " + f"mention of you, even if their name is similar." + ) + async def send_image_file( self, chat_id: str, @@ -4388,6 +4462,17 @@ class SlackAdapter(BasePlatformAdapter): channel_id, None, ) + # Prepend the bot's Slack identity (ephemeral — applied at API-call + # time, never persisted, so prompt caching is preserved) so the agent + # knows its own handle and won't read a human's mention as a self- + # mention. Combine with any per-channel prompt rather than overwriting. + _identity_prompt = self._build_identity_prompt(team_id) + if _identity_prompt: + _channel_prompt = ( + f"{_identity_prompt}\n\n{_channel_prompt}".strip() + if _channel_prompt + else _identity_prompt + ) _auto_skill = resolve_channel_skills( self.config.extra, channel_id, @@ -4410,9 +4495,24 @@ class SlackAdapter(BasePlatformAdapter): ) or None ) + if reply_to_text: + reply_to_text = await self._humanize_user_mentions( + reply_to_text, chat_id=channel_id, team_id=team_id + ) except Exception: # pragma: no cover - defensive reply_to_text = None + # Humanize remaining user mentions: the bot's own mention was already + # stripped above, so any ``<@UID>`` left in the trigger text refers to + # OTHER participants. Render them as ``@DisplayName`` so the agent can + # tell who is being addressed and never mistakes a human's mention for + # a mention of itself (the "bot thinks it's @someone-else" bug). + # Mirrors Discord's clean_content. channel_context (thread backfill) + # already renders senders by display name via _format_thread_context. + text = await self._humanize_user_mentions( + text, chat_id=channel_id, team_id=team_id + ) + msg_event = MessageEvent( text=text, message_type=msg_type, diff --git a/tests/gateway/test_slack_mention_humanization.py b/tests/gateway/test_slack_mention_humanization.py new file mode 100644 index 000000000000..e254a2f4a057 --- /dev/null +++ b/tests/gateway/test_slack_mention_humanization.py @@ -0,0 +1,158 @@ +""" +Tests for Slack inbound mention humanization + bot identity grounding. + +Slack delivers user mentions as opaque IDs (``<@U123>``). Passing those to the +agent raw leaves it unable to tell one participant from another — or from +itself — so it can misread a mention of a human as a self-mention and reply to +messages addressed to that person (the reported "bot thinks it's @someone-else" +bug). Two cooperating fixes: + + * ``_humanize_user_mentions`` rewrites ``<@UID>`` → ``@DisplayName`` (the + Slack equivalent of Discord's ``clean_content``). + * ``_build_identity_prompt`` returns an ephemeral system-prompt line naming + the bot's own Slack handle so the agent has a positive "that's me" anchor. +""" + +import sys +from unittest.mock import MagicMock + +import pytest + + +# --------------------------------------------------------------------------- +# Mock slack-bolt if not installed (same pattern as test_slack_mention.py) +# --------------------------------------------------------------------------- + +def _ensure_slack_mock(): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", + slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + sys.modules.setdefault(name, mod) + sys.modules.setdefault("aiohttp", MagicMock()) + + +_ensure_slack_mock() + +import plugins.platforms.slack.adapter as _slack_mod # noqa: E402 +_slack_mod.SLACK_AVAILABLE = True + +from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402 + + +def _make_adapter(): + # object.__new__ skips __init__ (heavy setup) — established slack-test pattern. + return object.__new__(SlackAdapter) + + +def _adapter_with_names(names): + """Adapter whose _resolve_user_name returns from a fixed UID→name map.""" + adapter = _make_adapter() + + async def _resolve(user_id, chat_id="", team_id=""): + return names.get(user_id, user_id) + + adapter._resolve_user_name = _resolve # type: ignore[assignment] + return adapter + + +# ----- _humanize_user_mentions ------------------------------------------------- + +@pytest.mark.asyncio +async def test_humanizes_single_mention(): + adapter = _adapter_with_names({"U07ALICE": "Alice Example"}) + out = await adapter._humanize_user_mentions( + "<@U07ALICE> I think thread is prob the right default", chat_id="C1" + ) + assert out == "@Alice Example I think thread is prob the right default" + assert "<@" not in out + + +@pytest.mark.asyncio +async def test_humanizes_multiple_distinct_mentions(): + adapter = _adapter_with_names( + {"U07ALICE": "Alice Example", "U07BOB": "Bob Example"} + ) + out = await adapter._humanize_user_mentions( + "hey <@U07ALICE> and <@U07BOB>", chat_id="C1" + ) + assert out == "hey @Alice Example and @Bob Example" + + +@pytest.mark.asyncio +async def test_handles_labelled_mention_form(): + # Slack sometimes sends <@UID|handle>; only the ID drives resolution. + adapter = _adapter_with_names({"U07ALICE": "Alice Example"}) + out = await adapter._humanize_user_mentions("<@U07ALICE|alice> hi", chat_id="C1") + assert out == "@Alice Example hi" + + +@pytest.mark.asyncio +async def test_repeated_mention_all_replaced(): + adapter = _adapter_with_names({"U07ALICE": "Alice Example"}) + out = await adapter._humanize_user_mentions( + "<@U07ALICE> ping <@U07ALICE>", chat_id="C1" + ) + assert out == "@Alice Example ping @Alice Example" + + +@pytest.mark.asyncio +async def test_unresolvable_mention_falls_back_to_id(): + # Resolution returns the bare ID; keep the message intact, don't empty it. + adapter = _adapter_with_names({}) + out = await adapter._humanize_user_mentions("<@U07GHOST> hi", chat_id="C1") + assert out == "@U07GHOST hi" + + +@pytest.mark.asyncio +async def test_no_mentions_returns_unchanged(): + adapter = _adapter_with_names({"U07ALICE": "Alice Example"}) + out = await adapter._humanize_user_mentions("plain text, no pings", chat_id="C1") + assert out == "plain text, no pings" + + +# ----- _build_identity_prompt -------------------------------------------------- + +def test_identity_prompt_names_the_bot(): + adapter = _make_adapter() + adapter._bot_display_name = "TestBot" + adapter._team_bot_names = {} + prompt = adapter._build_identity_prompt(team_id="T1") + assert "@TestBot" in prompt + # Must instruct that another participant's mention is not a self-mention. + assert "not a mention of you" in prompt + + +def test_identity_prompt_prefers_per_team_name(): + adapter = _make_adapter() + adapter._bot_display_name = "PrimaryBot" + adapter._team_bot_names = {"T2": "WorkspaceTwoBot"} + prompt = adapter._build_identity_prompt(team_id="T2") + assert "@WorkspaceTwoBot" in prompt + assert "PrimaryBot" not in prompt + + +def test_identity_prompt_empty_when_name_unknown(): + # Before connect (no name resolved) the prompt must be empty, not a + # half-formed line — callers skip injecting an empty string. + adapter = _make_adapter() + adapter._bot_display_name = None + adapter._team_bot_names = {} + assert adapter._build_identity_prompt(team_id="T1") == ""