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
53 changes: 50 additions & 3 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -2142,6 +2142,14 @@ async def _handle_slack_message(self, event: dict) -> None:
return

original_text = event.get("text", "")
# Keep the unmodified Slack text for later de-duplication against
# Block Kit rich_text extraction. A bang-command rewrite below turns
# ``!model ...`` into ``/model ...``; without this original copy the
# blocks extractor can append the same visible message again because
# ``!model ...`` is no longer a substring of the rewritten text. That
# duplicate line then becomes part of the command args and makes
# ``/model qwen --provider ...`` look like a model name with spaces.
original_slack_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 @@ -2175,13 +2183,35 @@ async def _handle_slack_message(self, event: dict) -> None:
# the plain ``text`` field. Merge block text so the agent sees the
# full message content.
blocks = event.get("blocks")
if blocks:
# Skip blocks extraction for command messages (slash/bang commands).
# Blocks payload contains formatted text with spaces that pollutes
# command args parsing (e.g. "/model qwen --provider X" gets blocks
# appended, making the model name appear to contain spaces).
is_command = (original_text or "").startswith("/")
if not is_command and original_text.startswith("!") and len(original_text) > 1:
try:
from hermes_cli.commands import is_gateway_known_command
_first = original_text[1:].split(maxsplit=1)[0].split("@", 1)[0].lower()
is_command = _first and "/" not in _first and is_gateway_known_command(_first)
except Exception:
pass
if blocks and not is_command:
blocks_text = _extract_text_from_slack_blocks(blocks)
if blocks_text:
# Only append if the blocks contain text not already present
# in the plain text field (avoids duplication).
# in either the current text or the original Slack text. The
# original check matters for bang commands rewritten from
# ``!cmd`` to ``/cmd`` above.
stripped_blocks = blocks_text.strip()
if stripped_blocks and stripped_blocks not in text.strip():
current_text_stripped = text.strip()
original_text_stripped = original_slack_text.strip()
if (
stripped_blocks
and stripped_blocks not in current_text_stripped
and stripped_blocks not in original_text_stripped
and (not current_text_stripped or current_text_stripped not in stripped_blocks)
and (not original_text_stripped or original_text_stripped not in stripped_blocks)
):
logger.debug(
"Slack: extracted additional text from blocks "
"(likely quoted/forwarded content): %s",
Expand Down Expand Up @@ -3366,11 +3396,28 @@ async def _handle_slash_command(self, command: dict) -> None:
# Preserve DM semantics only for DM channel IDs; shared channels must
# keep group semantics so different users do not collide into one
# session key.
#
# If Slack includes thread context in the slash payload, preserve it so
# session-scoped commands like `/model <name>` affect exactly the same
# Slack thread/session that normal messages in that thread use. Without
# this, `/model` from a thread is keyed only by channel+user, so the next
# threaded message misses the override and appears to require --global.
# Slack's native slash-command payloads vary by surface, so accept a few
# known shapes and otherwise leave thread_id unset; users can always use
# the message-based `!model ...` thread command path, which carries
# event.thread_ts.
thread_id = (
command.get("thread_ts")
or command.get("message_ts")
or (command.get("message") or {}).get("thread_ts")
or (command.get("container") or {}).get("thread_ts")
)
is_dm = str(channel_id).startswith("D")
source = self.build_source(
chat_id=channel_id,
chat_type="dm" if is_dm else "group",
user_id=user_id,
thread_id=thread_id or None,
)

event = MessageEvent(
Expand Down
51 changes: 51 additions & 0 deletions tests/gateway/test_slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,34 @@ async def test_dm_slash_command_keeps_dm_session_semantics(self, adapter):
assert event.source.chat_id == "D123"
assert event.source.user_id == "U123"

@pytest.mark.asyncio
async def test_slash_command_preserves_thread_id_when_payload_includes_it(self, adapter):
"""Thread-scoped commands such as /model must key to the Slack thread.

If the slash payload carries thread_ts but the adapter drops it, a
session-only /model switch is stored under the channel/user key while
the next normal threaded message is stored under channel/thread_ts, so
the override is missed and users are forced to use --global.
"""
command = {
"command": "/model",
"text": "qwen --provider openrouter",
"user_id": "U123",
"channel_id": "C123",
"team_id": "T123",
"thread_ts": "1700000000.123456",
}

await adapter._handle_slash_command(command)

adapter.handle_message.assert_awaited_once()
event = adapter.handle_message.await_args.args[0]
assert event.text == "/model qwen --provider openrouter"
assert event.source.chat_type == "group"
assert event.source.chat_id == "C123"
assert event.source.user_id == "U123"
assert event.source.thread_id == "1700000000.123456"


# ---------------------------------------------------------------------------
# TestAppMentionHandler
Expand Down Expand Up @@ -1154,6 +1182,29 @@ async def test_bang_command_with_args_preserved(self, adapter):
assert msg_event.text.startswith("/model gpt-5.4")
assert msg_event.message_type == MessageType.COMMAND

@pytest.mark.asyncio
async def test_bang_command_with_rich_text_block_is_not_duplicated(self, adapter):
"""Slack rich_text blocks mirror message text; bang rewrite must not duplicate args."""
text = "!model qwen3.7-plus --provider opencode-go"
evt = self._make_event(text)
evt["blocks"] = [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [{"type": "text", "text": text}],
}
],
}
]

await adapter._handle_slack_message(evt)

msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/model qwen3.7-plus --provider opencode-go"
assert msg_event.message_type == MessageType.COMMAND

@pytest.mark.asyncio
async def test_bang_works_inside_thread(self, adapter):
"""The whole point: ``!stop`` inside a thread reply dispatches."""
Expand Down