fix(slack): dispatch mentioned bang commands in threads

This commit is contained in:
cypres0099 2026-07-13 12:18:46 -05:00 committed by Teknium
parent ef8936d599
commit 5f3f1948b7
2 changed files with 133 additions and 18 deletions

View file

@ -181,6 +181,23 @@ def _slack_mention_detection_text(event: dict) -> str:
return (flat.strip() + "\n" + " ".join(extra)).strip()
def _rewrite_known_bang_command(text: str) -> str:
"""Rewrite a known leading ``!cmd`` to the gateway ``/cmd`` form."""
if not text.startswith("!"):
return text
try:
from hermes_cli.commands import is_gateway_known_command
first_token = text[1:].split(maxsplit=1)[0]
cmd_name = first_token.split("@", 1)[0].lower()
if cmd_name and "/" not in cmd_name and is_gateway_known_command(cmd_name):
return "/" + text[1:]
except Exception: # pragma: no cover - defensive
pass
return text
def _extract_text_from_slack_blocks(blocks: list) -> str:
"""Extract readable text from Slack Block Kit blocks, including quoted/forwarded content.
@ -3788,24 +3805,9 @@ class SlackAdapter(BasePlatformAdapter):
# gateway dispatcher) handles it like a normal slash command. Only
# rewrite when the first token resolves to a known gateway command
# so casual messages like "!nice work" pass through unchanged.
command_probe_text = original_text.lstrip()
if command_probe_text.startswith("!"):
try:
from hermes_cli.commands import is_gateway_known_command
first_token = command_probe_text[1:].split(maxsplit=1)[0]
# Strip "@suffix" the same way get_command() does, so
# forms like ``!stop@hermes`` still resolve.
cmd_name = first_token.split("@", 1)[0].lower()
if (
cmd_name
and "/" not in cmd_name
and is_gateway_known_command(cmd_name)
):
original_text = "/" + command_probe_text[1:]
command_probe_text = original_text
except Exception: # pragma: no cover - defensive
pass
command_probe_text = _rewrite_known_bang_command(original_text.lstrip())
if command_probe_text != original_text.lstrip():
original_text = command_probe_text
is_command_text = command_probe_text.startswith("/")
text = original_text
@ -4026,6 +4028,18 @@ class SlackAdapter(BasePlatformAdapter):
if is_mentioned:
# Strip the bot mention from the text
text = text.replace(f"<@{bot_uid}>", "").strip()
# Re-run command normalization against the canonical Slack text,
# not the block-augmented agent text. Otherwise quoted/forwarded
# rich-text payload can become accidental command arguments.
mention_stripped = original_text.replace(f"<@{bot_uid}>", "").strip()
command_text = _rewrite_known_bang_command(mention_stripped)
if command_text != mention_stripped:
original_text = command_text
text = command_text
# Refresh command classification: the bang was hidden behind
# the leading mention when the first probe ran.
command_probe_text = command_text
is_command_text = True
# Register this thread so all future messages auto-trigger the bot.
# Skipped in strict mode: strict_mention=true bots must be
# re-mentioned every turn, so remembering the thread would

View file

@ -1559,6 +1559,107 @@ class TestBangPrefixCommands:
assert msg_event.text.startswith("/queue")
assert msg_event.message_type == MessageType.COMMAND
@pytest.mark.asyncio
async def test_mention_prefixed_bang_is_rewritten(self, adapter):
evt = self._make_event(
"<@U_BOT> !new",
thread_ts="1111111111.000001",
channel_type="channel",
channel="C123",
)
await adapter._handle_slack_message(evt)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/new"
assert msg_event.message_type == MessageType.COMMAND
@pytest.mark.asyncio
async def test_mention_prefixed_bang_no_space(self, adapter):
evt = self._make_event(
"<@U_BOT>!new",
thread_ts="1111111111.000001",
channel_type="channel",
channel="C123",
)
await adapter._handle_slack_message(evt)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/new"
assert msg_event.message_type == MessageType.COMMAND
@pytest.mark.asyncio
async def test_mention_prefixed_unknown_bang_passes_through(self, adapter):
evt = self._make_event(
"<@U_BOT> !nice work",
channel_type="channel",
channel="C123",
)
await adapter._handle_slack_message(evt)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "!nice work"
assert msg_event.message_type != MessageType.COMMAND
@pytest.mark.asyncio
async def test_thread_command_skips_context_prefix(self, adapter):
adapter._has_active_session_for_thread = MagicMock(return_value=False)
adapter._fetch_thread_context = AsyncMock(
side_effect=AssertionError(
"_fetch_thread_context must not be called for slash commands"
)
)
evt = self._make_event(
"<@U_BOT> !new",
thread_ts="1111111111.000001",
channel_type="channel",
channel="C123",
)
await adapter._handle_slack_message(evt)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/new"
assert msg_event.message_type == MessageType.COMMAND
@pytest.mark.asyncio
async def test_mention_command_drops_rich_text_command_arguments(self, adapter):
evt = self._make_event(
"<@U_BOT> !model",
thread_ts="1111111111.000001",
channel_type="channel",
channel="C123",
)
evt["blocks"] = [
{
"type": "rich_text",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "user", "user_id": "U_BOT"},
{"type": "text", "text": " !model"},
],
},
{
"type": "rich_text_quote",
"elements": [
{
"type": "rich_text_section",
"elements": [
{"type": "text", "text": "quoted context"}
],
}
],
},
],
}
]
await adapter._handle_slack_message(evt)
msg_event = adapter.handle_message.call_args[0][0]
assert msg_event.text == "/model"
assert "quoted context" not in msg_event.text
assert msg_event.message_type == MessageType.COMMAND
# ---------------------------------------------------------------------------
# TestIncomingDocumentHandling