fix(slack): detect Block-Kit-only @mentions in bot filter and router

Closes #52387

Slack messages can carry the bot @mention only inside Block Kit `blocks`
(a `rich_text` section with a `user` element), with the flat top-level
`text` containing just a fallback string. Both mention gates read only the
flat `text`:

- the `allow_bots: mentions` bot-message filter, and
- the `is_mentioned` channel router (`routing_text = original_text`)

so such messages were silently dropped — `allow_bots: mentions` was
effectively non-functional for Block-Kit senders, and the same blind spot
hit `require_mention` / `strict_mention`.

Add `_collect_slack_block_mentions()` (walks blocks, recovers `<@UID>`
tokens from non-quoted rich-text `user` elements) and
`_slack_mention_detection_text()` (flat text + recovered mentions). Both
gates now use the merged detection text. Mentions nested in
`rich_text_quote` are deliberately ignored, preserving the existing
contract that quoted/forwarded content can't trick the bot. Also emit a
debug line when a bot message is dropped so silent drops are diagnosable.

Tests: 4 new cases in tests/gateway/test_slack_mention.py (Block-Kit
mention recovered, flat-text passthrough, no-mention, quoted-mention
ignored). 275 slack tests pass.
This commit is contained in:
Bartok9
2026-06-25 03:27:34 -04:00
committed by Teknium
parent 8fceaac143
commit 1e0f5a6f07
2 changed files with 155 additions and 2 deletions
+66 -2
View File
@@ -113,6 +113,63 @@ def check_slack_requirements() -> bool:
return ensure_and_bind("platform.slack", _import, globals(), prompt=False)
def _collect_slack_block_mentions(blocks: list) -> list:
"""Return ``<@UID>`` mention tokens authored in non-quoted Block Kit text.
Slack's flat top-level ``text`` field does NOT contain mentions that were
authored only inside Block Kit ``blocks`` (e.g. a ``rich_text_section`` with
a ``user`` element). This walker recovers those mentions so the gates can
see Block-Kit-only mentions instead of silently dropping them (#52387).
Mentions nested inside ``rich_text_quote`` (quoted/forwarded content) are
deliberately ignored, so quoted text cannot trick the bot into responding
(matches the existing channel-routing contract).
"""
mentions: list = []
def _walk(node, in_quote: bool) -> None:
if isinstance(node, list):
for item in node:
_walk(item, in_quote)
return
if not isinstance(node, dict):
return
node_type = node.get("type")
quoted = in_quote or node_type == "rich_text_quote"
if node_type == "user" and not quoted:
uid = node.get("user_id", "")
if uid:
mentions.append(f"<@{uid}>")
for key in ("elements", "element"):
child = node.get(key)
if child is not None:
_walk(child, quoted)
try:
_walk(blocks, False)
except Exception: # pragma: no cover - defensive, never break gating
return []
return mentions
def _slack_mention_detection_text(event: dict) -> str:
"""Return the text used for @mention detection on a Slack message event.
Combines the flat top-level ``text`` with any ``<@UID>`` mentions recovered
from non-quoted Block Kit blocks (#52387), so a genuine Block-Kit-only
mention reaches the gates while quoted/forwarded mentions stay ignored.
"""
flat = event.get("text", "") or ""
blocks = event.get("blocks")
if not blocks:
return flat
mentions = _collect_slack_block_mentions(blocks)
extra = [m for m in mentions if m not in flat]
if not extra:
return flat
return (flat.strip() + "\n" + " ".join(extra)).strip()
def _extract_text_from_slack_blocks(blocks: list) -> str:
"""Extract readable text from Slack Block Kit blocks, including quoted/forwarded content.
@@ -3127,8 +3184,14 @@ class SlackAdapter(BasePlatformAdapter):
if allow_bots == "none":
return
elif allow_bots == "mentions":
text_check = event.get("text", "")
# Include Block-Kit-only mentions, not just the flat text (#52387)
text_check = _slack_mention_detection_text(event)
if self._bot_user_id and f"<@{self._bot_user_id}>" not in text_check:
logger.debug(
"[Slack] Dropping bot message under allow_bots=mentions: "
"no <@%s> mention in flat text or blocks",
self._bot_user_id,
)
return
# "all" falls through to process the message
# Always ignore our own messages to prevent echo loops
@@ -3348,7 +3411,8 @@ class SlackAdapter(BasePlatformAdapter):
# 3. The message is in a thread where the bot was previously @mentioned, OR
# 4. There's an existing session for this thread (survives restarts)
bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id)
routing_text = original_text or ""
# Detect mentions authored only inside Block Kit blocks too (#52387)
routing_text = _slack_mention_detection_text(event) or original_text or ""
is_mentioned = bool(
(bot_uid and f"<@{bot_uid}>" in routing_text)
or self._slack_message_matches_mention_patterns(routing_text)
+89
View File
@@ -903,3 +903,92 @@ def test_mention_patterns_trigger_in_channel_without_literal_mention():
assert _would_process(adapter, text="hey hermes what's the status") is True
# Unrelated channel chatter is still ignored.
assert _would_process(adapter, text="lunch anyone?") is False
# ---------------------------------------------------------------------------
# Tests: Block-Kit-only mention detection (#52387)
# ---------------------------------------------------------------------------
from plugins.platforms.slack.adapter import _slack_mention_detection_text # noqa: E402
def _blockkit_mention_event(bot_user_id=BOT_USER_ID, flat_text="Release notification"):
"""A Slack event whose @mention lives ONLY inside Block Kit blocks."""
return {
"text": flat_text,
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "text", "text": "Hey "},
{"type": "user", "user_id": bot_user_id},
{"type": "text", "text": "! I will do a release"},
],
}
],
}
],
}
def test_mention_detection_text_recovers_blockkit_mention():
event = _blockkit_mention_event()
merged = _slack_mention_detection_text(event)
# The flat text alone never contains the mention...
assert f"<@{BOT_USER_ID}>" not in event.get("text", "")
# ...but the merged detection text does.
assert f"<@{BOT_USER_ID}>" in merged
def test_mention_detection_text_no_blocks_returns_flat_text():
event = {"text": f"<@{BOT_USER_ID}> hello"}
assert _slack_mention_detection_text(event) == f"<@{BOT_USER_ID}> hello"
def test_mention_detection_text_no_mention_anywhere():
event = {
"text": "lunch anyone?",
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [{"type": "text", "text": "lunch anyone?"}],
}
],
}
],
}
assert f"<@{BOT_USER_ID}>" not in _slack_mention_detection_text(event)
def test_mention_detection_text_ignores_quoted_blockkit_mention():
"""A mention inside rich_text_quote (forwarded content) must NOT count."""
event = {
"text": "please review",
"blocks": [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_quote",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "text", "text": "Contains "},
{"type": "user", "user_id": BOT_USER_ID},
{"type": "text", "text": " in quoted text"},
],
}
],
}
],
}
],
}
assert f"<@{BOT_USER_ID}>" not in _slack_mention_detection_text(event)