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
28 changes: 26 additions & 2 deletions plugins/platforms/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2620,6 +2620,25 @@ async def _handle_slack_message(self, event: dict) -> None:
return

original_text = event.get("text", "")
raw_original_text = original_text

# Slack app_mention/message events include the literal leading bot
# mention in text (e.g. "<@U_BOT> !status"). Strip that addressing
# prefix before command detection so mentioned commands behave like
# the same command typed directly in an addressed thread/channel.
team_id_for_mention = event.get("team") or event.get("team_id") or ""
bot_uid_for_mention = self._team_bot_user_ids.get(
team_id_for_mention,
self._bot_user_id,
)
if bot_uid_for_mention:
original_text = re.sub(
rf"^\s*<@{re.escape(bot_uid_for_mention)}>\s*",
"",
original_text,
count=1,
)
mention_stripped_original_text = original_text

# Slack blocks native slash commands inside threads ("/queue is not
# supported in threads. Sorry!"). As a workaround, recognise a
Expand Down Expand Up @@ -2659,7 +2678,12 @@ async def _handle_slack_message(self, event: dict) -> None:
# Only append if the blocks contain text not already present
# in the plain text field (avoids duplication).
stripped_blocks = blocks_text.strip()
if stripped_blocks and stripped_blocks not in text.strip():
if (
stripped_blocks
and stripped_blocks not in text.strip()
and stripped_blocks != raw_original_text.strip()
and stripped_blocks != mention_stripped_original_text.strip()
):
logger.debug(
"Slack: extracted additional text from blocks "
"(likely quoted/forwarded content): %s",
Expand Down Expand Up @@ -2824,7 +2848,7 @@ async def _handle_slack_message(self, event: dict) -> None:
# 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 ""
routing_text = raw_original_text or ""
is_mentioned = bool(
(bot_uid and f"<@{bot_uid}>" in routing_text)
or self._slack_message_matches_mention_patterns(routing_text)
Expand Down
49 changes: 48 additions & 1 deletion tests/gateway/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -1129,7 +1129,14 @@ class TestBangPrefixCommands:
command before downstream processing.
"""

def _make_event(self, text, thread_ts=None, channel_type="im", channel="D123"):
def _make_event(
self,
text,
thread_ts=None,
channel_type="im",
channel="D123",
blocks=None,
):
evt = {
"text": text,
"user": "U_USER",
Expand All @@ -1139,6 +1146,8 @@ def _make_event(self, text, thread_ts=None, channel_type="im", channel="D123"):
}
if thread_ts:
evt["thread_ts"] = thread_ts
if blocks is not None:
evt["blocks"] = blocks
return evt

@pytest.mark.asyncio
Expand Down Expand Up @@ -1173,6 +1182,15 @@ async def test_bang_works_inside_thread(self, adapter):
# same thread.
assert msg_event.source.thread_id == "1111111111.000001"

@pytest.mark.asyncio

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses the helper's default DM event, which bypasses channel mention gating. Please make it a channel event (channel_type="channel", channel="C123") so the regression also proves raw_original_text preserves the mention for routing after early stripping.

async def test_leading_bot_mention_before_bang_command_is_stripped(self, adapter):
"""Slack includes the addressed bot mention in app_mention text."""
await adapter._handle_slack_message(self._make_event("<@U_BOT> !status"))

msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/status"
assert msg_event.message_type == MessageType.COMMAND

@pytest.mark.asyncio
async def test_bang_unknown_token_passes_through_unchanged(self, adapter):
"""``!nice work`` is just a casual message — must NOT be rewritten."""
Expand All @@ -1191,6 +1209,35 @@ async def test_bang_with_bot_suffix_resolves(self, adapter):
assert msg_event.text.startswith("/stop@hermes")
assert msg_event.message_type == MessageType.COMMAND

@pytest.mark.asyncio
async def test_bang_command_does_not_reappend_matching_rich_text(self, adapter):
"""Slack's rich_text block mirrors plain text for normal messages.

``!commands`` is rewritten to ``/commands`` before rich block extraction.
The dedupe check must compare against Slack's original plain text too;
otherwise the matching rich_text block is appended as a bogus argument
and downstream handlers see ``/commands\n!commands``.
"""
blocks = [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [{"type": "text", "text": "!commands"}],
}
],
}
]

await adapter._handle_slack_message(
self._make_event("!commands", blocks=blocks)
)

msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/commands"
assert msg_event.message_type == MessageType.COMMAND

@pytest.mark.asyncio
async def test_plain_slash_still_works(self, adapter):
"""Sanity check — ``/queue`` (top-level channel/DM) still dispatches."""
Expand Down