fix(slack): humanize inbound user mentions + ground bot identity

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.
This commit is contained in:
Ben 2026-07-22 04:58:29 -07:00 committed by Teknium
parent 503c0c0e51
commit 5d747a91c4
2 changed files with 258 additions and 0 deletions

View file

@ -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,

View file

@ -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") == ""