feat(matrix): support bang command aliases

This commit is contained in:
Chris 2026-05-18 10:01:45 -04:00 committed by Siddharth Balyan
parent 6038bfb66e
commit 0022e94d74
3 changed files with 186 additions and 2 deletions

View file

@ -107,6 +107,55 @@ from gateway.platforms.helpers import ThreadParticipationTracker
logger = logging.getLogger(__name__)
_MATRIX_BANG_COMMAND_RE = re.compile(
r"^!([A-Za-z][A-Za-z0-9_-]*)(?=$|\s)(.*)$",
re.DOTALL,
)
def _is_known_matrix_bang_command(name: str) -> bool:
"""Return True when *name* is a Hermes command worth normalizing.
Matrix clients often reserve leading ``/`` for local client commands.
Hermes accepts ``!command`` as a Matrix-friendly alias, but only for
commands that the gateway can actually dispatch so ordinary exclamations
remain normal chat text.
"""
if not name:
return False
candidates = {name.lower(), name.lower().replace("_", "-")}
try:
from hermes_cli.commands import is_gateway_known_command
if any(is_gateway_known_command(candidate) for candidate in candidates):
return True
except Exception:
pass
try:
from agent.skill_commands import get_skill_commands
skill_commands = get_skill_commands() or {}
if any(candidate in skill_commands for candidate in candidates):
return True
except Exception:
pass
return False
def _normalize_matrix_bang_command(text: str) -> str:
"""Convert Matrix ``!command`` aliases to normal Hermes ``/command`` text."""
if not text or not text.startswith("!"):
return text
match = _MATRIX_BANG_COMMAND_RE.match(text)
if not match:
return text
command = match.group(1).lower()
if not _is_known_matrix_bang_command(command):
return text
return f"/{command}{match.group(2) or ''}"
@dataclass
class _MatrixApprovalPrompt:
@ -1747,8 +1796,9 @@ class MatrixAdapter(BasePlatformAdapter):
is_free_room = room_id in self._free_rooms
in_bot_thread = bool(thread_id and thread_id in self._threads)
is_command = body.startswith("/")
if self._require_mention and not is_free_room and not in_bot_thread:
if not is_mentioned:
if not is_mentioned and not is_command:
logger.debug(
"Matrix: ignoring message %s in %s — no @mention "
"(set MATRIX_REQUIRE_MENTION=false to disable)",
@ -1815,6 +1865,7 @@ class MatrixAdapter(BasePlatformAdapter):
body = source_content.get("body", "") or ""
if not body:
return
body = _normalize_matrix_bang_command(body)
ctx = await self._resolve_message_context(
room_id,
@ -1851,7 +1902,7 @@ class MatrixAdapter(BasePlatformAdapter):
body = "\n".join(stripped) if stripped else body
msg_type = MessageType.TEXT
if body.startswith(("!", "/")):
if body.startswith("/"):
msg_type = MessageType.COMMAND
msg_event = MessageEvent(

View file

@ -532,6 +532,114 @@ class TestMatrixReplyFallbackStripping:
assert result == "Line 1\nLine 2\nLine 3"
# ---------------------------------------------------------------------------
# Matrix-friendly command aliases
# ---------------------------------------------------------------------------
class TestMatrixBangCommandAlias:
"""Matrix clients may reserve /commands, so Hermes supports !commands."""
def setup_method(self):
self.adapter = _make_adapter()
self.adapter._is_dm_room = AsyncMock(return_value=True)
self.adapter._get_display_name = AsyncMock(return_value="Alice")
self.adapter._background_read_receipt = MagicMock()
self.adapter._text_batch_delay_seconds = 0
async def _dispatch_text(self, body: str, *, is_dm: bool = True):
captured_event = None
self.adapter._is_dm_room = AsyncMock(return_value=is_dm)
self.adapter._require_mention = True
self.adapter._free_rooms = set()
async def capture(msg_event):
nonlocal captured_event
captured_event = msg_event
self.adapter.handle_message = capture
await self.adapter._handle_text_message(
room_id="!room:example.org",
sender="@alice:example.org",
event_id="$matrix-command-test",
event_ts=0.0,
source_content={"msgtype": "m.text", "body": body},
relates_to={},
)
return captured_event
def test_known_bang_command_normalizes_to_slash_command(self):
from gateway.platforms.matrix import _normalize_matrix_bang_command
assert _normalize_matrix_bang_command("!model") == "/model"
assert (
_normalize_matrix_bang_command("!queue continue the plan")
== "/queue continue the plan"
)
assert (
_normalize_matrix_bang_command("!btw research this")
== "/btw research this"
)
assert _normalize_matrix_bang_command("!tasks") == "/tasks"
def test_unknown_bang_text_is_not_treated_as_command(self):
from gateway.platforms.matrix import _normalize_matrix_bang_command
assert _normalize_matrix_bang_command("!important note") == "!important note"
assert _normalize_matrix_bang_command("! wow") == "! wow"
assert _normalize_matrix_bang_command("plain text") == "plain text"
assert _normalize_matrix_bang_command("/model") == "/model"
@pytest.mark.asyncio
async def test_bang_model_reaches_gateway_as_slash_command(self):
captured_event = await self._dispatch_text("!model")
assert captured_event is not None
assert captured_event.text == "/model"
assert captured_event.message_type == MessageType.COMMAND
assert captured_event.get_command() == "model"
@pytest.mark.asyncio
async def test_bang_queue_preserves_arguments(self):
captured_event = await self._dispatch_text("!queue keep going")
assert captured_event is not None
assert captured_event.text == "/queue keep going"
assert captured_event.message_type == MessageType.COMMAND
assert captured_event.get_command() == "queue"
assert captured_event.get_command_args() == "keep going"
@pytest.mark.asyncio
async def test_unknown_bang_text_stays_normal_text(self):
captured_event = await self._dispatch_text("!important note")
assert captured_event is not None
assert captured_event.text == "!important note"
assert captured_event.message_type == MessageType.TEXT
assert captured_event.get_command() is None
@pytest.mark.asyncio
async def test_bang_command_bypasses_room_mention_requirement(self):
captured_event = await self._dispatch_text("!commands", is_dm=False)
assert captured_event is not None
assert captured_event.text == "/commands"
assert captured_event.message_type == MessageType.COMMAND
@pytest.mark.asyncio
async def test_slash_command_bypasses_room_mention_requirement(self):
captured_event = await self._dispatch_text("/sethome", is_dm=False)
assert captured_event is not None
assert captured_event.text == "/sethome"
assert captured_event.message_type == MessageType.COMMAND
@pytest.mark.asyncio
async def test_unknown_bang_text_does_not_bypass_room_mention_requirement(self):
captured_event = await self._dispatch_text("!important note", is_dm=False)
assert captured_event is None
# ---------------------------------------------------------------------------
# Thread detection
# ---------------------------------------------------------------------------

View file

@ -20,6 +20,7 @@ Before setup, here's the part most people want to know: how Hermes behaves once
| **Rooms** | By default, Hermes requires an `@mention` to respond. Set `MATRIX_REQUIRE_MENTION=false` or add room IDs to `MATRIX_FREE_RESPONSE_ROOMS` for free-response rooms. Room invites are auto-accepted. |
| **Threads** | Hermes supports Matrix threads (MSC3440). If you reply in a thread, Hermes keeps the thread context isolated from the main room timeline. Threads where the bot has already participated do not require a mention. |
| **Auto-threading** | By default, Hermes auto-creates a thread for each message it responds to in a room. This keeps conversations isolated. Set `MATRIX_AUTO_THREAD=false` to disable. |
| **Commands** | Hermes accepts normal `/commands` when your Matrix client sends them. If your client reserves `/` for local commands, use `!commands` instead; Hermes normalizes known `!command` aliases to `/command`. |
| **Shared rooms with multiple users** | By default, Hermes isolates session history per user inside the room. Two people talking in the same room do not share one transcript unless you explicitly disable that. |
:::tip
@ -336,6 +337,7 @@ You can designate a "home room" where the bot sends proactive messages (such as
### Using the Slash Command
Type `/sethome` in any Matrix room where the bot is present. That room becomes the home room.
If your Matrix client intercepts slash commands, type `!sethome` instead.
### Manual Configuration
@ -377,6 +379,29 @@ See also: [admin/user slash command split](../../reference/slash-commands.md#per
To find a Room ID: in Element, go to the room → **Settings****Advanced** → the **Internal room ID** is shown there (starts with `!`).
:::
## Commands in Matrix
Hermes supports the same gateway commands in Matrix that it supports on other
messaging platforms, including `/commands`, `/model`, `/stop`, `/queue`,
`/steer`, `/goal`, `/subgoal`, `/background`, `/bg`, `/btw`, `/tasks`, and
`/yolo`.
Some Matrix clients reserve leading `/` for local client commands and may not
send unknown slash commands to the room. In that case, use `!` as a Matrix-safe
alias:
```text
!commands
!model
!model gpt-5.5 --provider openrouter
!queue continue with the next task
!stop
```
Hermes only normalizes `!command` when the command is known to the gateway, a
registered plugin command, or an installed skill command. Ordinary exclamations
such as `!important` remain normal chat messages.
## Troubleshooting
### Bot is not responding to messages