fix(slack): exclude reserved Slack commands from native slash manifest

Slack has built-in slash commands (e.g. /status, /me, /join) that apps
cannot register. When running `hermes slack manifest --write`, the
generated manifest included /status, causing Slack to reject the entire
manifest with a reserved-command error.

Add _SLACK_RESERVED_COMMANDS frozenset of all known Slack built-ins and
skip them in slack_native_slashes(). Affected commands remain reachable
via /hermes <command>.

Tests updated:
- New test_excludes_slack_reserved_commands validates no leaks
- test_includes_canonical_commands no longer asserts /status
- test_telegram_parity accounts for expected Slack-only exclusions
This commit is contained in:
Prive FE Coder 2026-05-01 09:49:14 -06:00 committed by kshitij
parent 8fcc160f6b
commit a717199bbf
2 changed files with 30 additions and 2 deletions

View file

@ -838,6 +838,13 @@ def discord_skill_commands_by_category(
_SLACK_MAX_SLASH_COMMANDS = 50 _SLACK_MAX_SLASH_COMMANDS = 50
_SLACK_NAME_LIMIT = 32 _SLACK_NAME_LIMIT = 32
_SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]") _SLACK_INVALID_CHARS = re.compile(r"[^a-z0-9_\-]")
_SLACK_RESERVED_COMMANDS = frozenset({
# Built-in Slack slash commands that cannot be registered by apps.
# https://slack.com/help/articles/201259356-Use-built-in-slash-commands
"me", "status", "away", "dnd", "shrug", "remind", "msg", "feed",
"who", "collapse", "expand", "leave", "join", "open", "search",
"topic", "mute", "pro", "shortcuts",
})
def _sanitize_slack_name(raw: str) -> str: def _sanitize_slack_name(raw: str) -> str:
@ -864,6 +871,10 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work). documented form (e.g. ``/background``, ``/bg``, and ``/btw`` all work).
Plugin-registered slash commands are included too. Plugin-registered slash commands are included too.
Commands whose sanitized name collides with a Slack built-in
(e.g. ``/status``, ``/me``, ``/join``) are silently skipped. Users
can still reach them via ``/hermes <command>``.
Results are clamped to Slack's 50-command limit with duplicate-name Results are clamped to Slack's 50-command limit with duplicate-name
avoidance. ``/hermes`` is always reserved as the first entry so the avoidance. ``/hermes`` is always reserved as the first entry so the
legacy ``/hermes <subcommand>`` form keeps working for anything that legacy ``/hermes <subcommand>`` form keeps working for anything that
@ -881,6 +892,8 @@ def slack_native_slashes() -> list[tuple[str, str, str]]:
slack_name = _sanitize_slack_name(name) slack_name = _sanitize_slack_name(name)
if not slack_name or slack_name in seen: if not slack_name or slack_name in seen:
return return
if slack_name in _SLACK_RESERVED_COMMANDS:
return
if len(entries) >= _SLACK_MAX_SLASH_COMMANDS: if len(entries) >= _SLACK_MAX_SLASH_COMMANDS:
return return
# Slack description cap is 2000 chars; keep it short. # Slack description cap is 2000 chars; keep it short.

View file

@ -13,6 +13,7 @@ from hermes_cli.commands import (
SlashCommandAutoSuggest, SlashCommandAutoSuggest,
SlashCommandCompleter, SlashCommandCompleter,
_CMD_NAME_LIMIT, _CMD_NAME_LIMIT,
_SLACK_RESERVED_COMMANDS,
_TG_NAME_LIMIT, _TG_NAME_LIMIT,
_clamp_command_names, _clamp_command_names,
_clamp_telegram_names, _clamp_telegram_names,
@ -299,9 +300,19 @@ class TestSlackNativeSlashes:
def test_includes_canonical_commands(self): def test_includes_canonical_commands(self):
names = {n for n, _d, _h in slack_native_slashes()} names = {n for n, _d, _h in slack_native_slashes()}
# Sample of gateway-available canonical commands # Sample of gateway-available canonical commands
for expected in ("new", "stop", "background", "model", "help", "status"): for expected in ("new", "stop", "background", "model", "help"):
assert expected in names, f"missing canonical /{expected}" assert expected in names, f"missing canonical /{expected}"
def test_excludes_slack_reserved_commands(self):
"""Slack built-in commands (e.g. /status, /me, /join) cannot be
registered by apps and must be excluded from the manifest.
Users can still reach them via /hermes <command>."""
names = {n for n, _d, _h in slack_native_slashes()}
for reserved in _SLACK_RESERVED_COMMANDS:
assert reserved not in names, (
f"/{reserved} is a Slack built-in and must not appear in the manifest"
)
def test_includes_aliases_as_first_class_slashes(self): def test_includes_aliases_as_first_class_slashes(self):
"""Aliases (/btw, /bg, /reset, /q) must be registered as standalone """Aliases (/btw, /bg, /reset, /q) must be registered as standalone
slashes this is the whole point of native-slashes parity.""" slashes this is the whole point of native-slashes parity."""
@ -319,6 +330,9 @@ class TestSlackNativeSlashes:
Telegram but not Slack (because of Slack's 50-slash cap), this Telegram but not Slack (because of Slack's 50-slash cap), this
test fails loudly so we can curate the list rather than silently test fails loudly so we can curate the list rather than silently
dropping parity. dropping parity.
Slack-reserved built-in commands (e.g. /status) are excluded
from parity checks since they cannot be registered on Slack.
""" """
slack_names = {n for n, _d, _h in slack_native_slashes()} slack_names = {n for n, _d, _h in slack_native_slashes()}
tg_names = {n for n, _d in telegram_bot_commands()} tg_names = {n for n, _d in telegram_bot_commands()}
@ -329,7 +343,8 @@ class TestSlackNativeSlashes:
slack_norm = {_norm(n) for n in slack_names} slack_norm = {_norm(n) for n in slack_names}
tg_norm = {_norm(n) for n in tg_names} tg_norm = {_norm(n) for n in tg_names}
missing = tg_norm - slack_norm reserved_norm = {_norm(n) for n in _SLACK_RESERVED_COMMANDS}
missing = (tg_norm - slack_norm) - reserved_norm
assert not missing, ( assert not missing, (
f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}" f"commands on Telegram but missing from Slack native slashes: {sorted(missing)}"
) )