mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-17 09:41:58 +00:00
feat(matrix): support bang command aliases
This commit is contained in:
parent
6038bfb66e
commit
0022e94d74
3 changed files with 186 additions and 2 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue