diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index 5c1cb9a182e..7b7df2d584f 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -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( diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index c0294b41ec9..4f43ace70ed 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -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 # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index d2539366518..ea85d5f45f5 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -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