mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 07:51:45 +08:00
Compare commits
9 Commits
fix/plugin
...
feat/mautr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac31b7cc09 | ||
|
|
9f47b1e9dd | ||
|
|
153451ad72 | ||
|
|
ac082e552d | ||
|
|
afd59e52b6 | ||
|
|
7ed4250144 | ||
|
|
7311c64557 | ||
|
|
f80ea6bb30 | ||
|
|
3f255e8f69 |
File diff suppressed because it is too large
Load Diff
@@ -1724,7 +1724,7 @@ class GatewayRunner:
|
|||||||
elif platform == Platform.MATRIX:
|
elif platform == Platform.MATRIX:
|
||||||
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
|
from gateway.platforms.matrix import MatrixAdapter, check_matrix_requirements
|
||||||
if not check_matrix_requirements():
|
if not check_matrix_requirements():
|
||||||
logger.warning("Matrix: matrix-nio not installed or credentials not set. Run: pip install 'matrix-nio[e2e]'")
|
logger.warning("Matrix: mautrix not installed or credentials not set. Run: pip install 'mautrix[encryption]'")
|
||||||
return None
|
return None
|
||||||
return MatrixAdapter(config)
|
return MatrixAdapter(config)
|
||||||
|
|
||||||
|
|||||||
@@ -1442,7 +1442,7 @@ _PLATFORMS = [
|
|||||||
" Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\",
|
" Or via API: curl -X POST https://your-server/_matrix/client/v3/login \\",
|
||||||
" -d '{\"type\":\"m.login.password\",\"user\":\"@bot:server\",\"password\":\"...\"}'",
|
" -d '{\"type\":\"m.login.password\",\"user\":\"@bot:server\",\"password\":\"...\"}'",
|
||||||
"4. Alternatively, provide user ID + password and Hermes will log in directly",
|
"4. Alternatively, provide user ID + password and Hermes will log in directly",
|
||||||
"5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'matrix-nio[e2e]')",
|
"5. For E2EE: set MATRIX_ENCRYPTION=true (requires pip install 'mautrix[encryption]')",
|
||||||
"6. To find your user ID: it's @username:your-server (shown in Element profile)",
|
"6. To find your user ID: it's @username:your-server (shown in Element profile)",
|
||||||
],
|
],
|
||||||
"vars": [
|
"vars": [
|
||||||
|
|||||||
@@ -1925,9 +1925,9 @@ def _setup_matrix():
|
|||||||
save_env_value("MATRIX_ENCRYPTION", "true")
|
save_env_value("MATRIX_ENCRYPTION", "true")
|
||||||
print_success("E2EE enabled")
|
print_success("E2EE enabled")
|
||||||
|
|
||||||
matrix_pkg = "matrix-nio[e2e]" if want_e2ee else "matrix-nio"
|
matrix_pkg = "mautrix[encryption]" if want_e2ee else "mautrix"
|
||||||
try:
|
try:
|
||||||
__import__("nio")
|
__import__("mautrix")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
print_info(f"Installing {matrix_pkg}...")
|
print_info(f"Installing {matrix_pkg}...")
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "py
|
|||||||
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||||
cron = ["croniter>=6.0.0,<7"]
|
cron = ["croniter>=6.0.0,<7"]
|
||||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||||
matrix = ["matrix-nio[e2e]>=0.24.0,<1", "Markdown>=3.6,<4"]
|
matrix = ["mautrix[encryption]>=0.20,<1", "Markdown>=3.6,<4"]
|
||||||
cli = ["simple-term-menu>=1.0,<2"]
|
cli = ["simple-term-menu>=1.0,<2"]
|
||||||
tts-premium = ["elevenlabs>=1.0,<2"]
|
tts-premium = ["elevenlabs>=1.0,<2"]
|
||||||
voice = [
|
voice = [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,24 +11,59 @@ import pytest
|
|||||||
from gateway.config import PlatformConfig
|
from gateway.config import PlatformConfig
|
||||||
|
|
||||||
|
|
||||||
def _ensure_nio_mock():
|
def _ensure_mautrix_mock():
|
||||||
"""Install a mock nio module when matrix-nio isn't available."""
|
"""Install mock mautrix modules when mautrix-python isn't available."""
|
||||||
if "nio" in sys.modules and hasattr(sys.modules["nio"], "__file__"):
|
if "mautrix" in sys.modules and hasattr(sys.modules["mautrix"], "__file__"):
|
||||||
return
|
return
|
||||||
nio_mod = MagicMock()
|
|
||||||
nio_mod.MegolmEvent = type("MegolmEvent", (), {})
|
# Root module
|
||||||
nio_mod.RoomMessageText = type("RoomMessageText", (), {})
|
mautrix_mod = MagicMock()
|
||||||
nio_mod.RoomMessageImage = type("RoomMessageImage", (), {})
|
|
||||||
nio_mod.RoomMessageAudio = type("RoomMessageAudio", (), {})
|
# mautrix.types — commonly imported types
|
||||||
nio_mod.RoomMessageVideo = type("RoomMessageVideo", (), {})
|
types_mod = MagicMock()
|
||||||
nio_mod.RoomMessageFile = type("RoomMessageFile", (), {})
|
types_mod.EventType = MagicMock()
|
||||||
nio_mod.DownloadResponse = type("DownloadResponse", (), {})
|
types_mod.RoomID = str
|
||||||
nio_mod.MemoryDownloadResponse = type("MemoryDownloadResponse", (), {})
|
types_mod.UserID = str
|
||||||
nio_mod.InviteMemberEvent = type("InviteMemberEvent", (), {})
|
types_mod.EventID = str
|
||||||
sys.modules.setdefault("nio", nio_mod)
|
types_mod.ContentURI = str
|
||||||
|
types_mod.RoomCreatePreset = MagicMock()
|
||||||
|
types_mod.PresenceState = MagicMock()
|
||||||
|
types_mod.PaginationDirection = MagicMock()
|
||||||
|
types_mod.SyncToken = str
|
||||||
|
types_mod.TrustState = MagicMock()
|
||||||
|
|
||||||
|
# mautrix.client
|
||||||
|
client_mod = MagicMock()
|
||||||
|
client_mod.Client = MagicMock()
|
||||||
|
client_mod.InternalEventType = MagicMock()
|
||||||
|
|
||||||
|
# mautrix.client.state_store
|
||||||
|
state_store_mod = MagicMock()
|
||||||
|
state_store_mod.MemoryStateStore = MagicMock()
|
||||||
|
state_store_mod.MemorySyncStore = MagicMock()
|
||||||
|
|
||||||
|
# mautrix.api
|
||||||
|
api_mod = MagicMock()
|
||||||
|
api_mod.HTTPAPI = MagicMock()
|
||||||
|
|
||||||
|
# mautrix.crypto
|
||||||
|
crypto_mod = MagicMock()
|
||||||
|
crypto_mod.OlmMachine = MagicMock()
|
||||||
|
crypto_store_mod = MagicMock()
|
||||||
|
crypto_store_mod.MemoryCryptoStore = MagicMock()
|
||||||
|
crypto_attachments_mod = MagicMock()
|
||||||
|
|
||||||
|
sys.modules.setdefault("mautrix", mautrix_mod)
|
||||||
|
sys.modules.setdefault("mautrix.types", types_mod)
|
||||||
|
sys.modules.setdefault("mautrix.client", client_mod)
|
||||||
|
sys.modules.setdefault("mautrix.client.state_store", state_store_mod)
|
||||||
|
sys.modules.setdefault("mautrix.api", api_mod)
|
||||||
|
sys.modules.setdefault("mautrix.crypto", crypto_mod)
|
||||||
|
sys.modules.setdefault("mautrix.crypto.store", crypto_store_mod)
|
||||||
|
sys.modules.setdefault("mautrix.crypto.attachments", crypto_attachments_mod)
|
||||||
|
|
||||||
|
|
||||||
_ensure_nio_mock()
|
_ensure_mautrix_mock()
|
||||||
|
|
||||||
|
|
||||||
def _make_adapter(tmp_path=None):
|
def _make_adapter(tmp_path=None):
|
||||||
@@ -50,24 +85,25 @@ def _make_adapter(tmp_path=None):
|
|||||||
return adapter
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
def _make_room(room_id="!room1:example.org", member_count=5, is_dm=False):
|
def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
|
||||||
"""Create a fake Matrix room."""
|
"""Mark a room as DM (or not) in the adapter's cache."""
|
||||||
room = SimpleNamespace(
|
adapter._dm_rooms[room_id] = is_dm
|
||||||
room_id=room_id,
|
|
||||||
member_count=member_count,
|
|
||||||
users={},
|
|
||||||
)
|
|
||||||
return room
|
|
||||||
|
|
||||||
|
|
||||||
def _make_event(
|
def _make_event(
|
||||||
body,
|
body,
|
||||||
sender="@alice:example.org",
|
sender="@alice:example.org",
|
||||||
event_id="$evt1",
|
event_id="$evt1",
|
||||||
|
room_id="!room1:example.org",
|
||||||
formatted_body=None,
|
formatted_body=None,
|
||||||
thread_id=None,
|
thread_id=None,
|
||||||
):
|
):
|
||||||
"""Create a fake RoomMessageText event."""
|
"""Create a fake room message event.
|
||||||
|
|
||||||
|
The mautrix adapter reads ``event.room_id``, ``event.sender``,
|
||||||
|
``event.event_id``, ``event.timestamp``, and ``event.content``
|
||||||
|
(a dict with ``msgtype``, ``body``, etc.).
|
||||||
|
"""
|
||||||
content = {"body": body, "msgtype": "m.text"}
|
content = {"body": body, "msgtype": "m.text"}
|
||||||
if formatted_body:
|
if formatted_body:
|
||||||
content["formatted_body"] = formatted_body
|
content["formatted_body"] = formatted_body
|
||||||
@@ -83,9 +119,9 @@ def _make_event(
|
|||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
sender=sender,
|
sender=sender,
|
||||||
event_id=event_id,
|
event_id=event_id,
|
||||||
server_timestamp=int(time.time() * 1000),
|
room_id=room_id,
|
||||||
body=body,
|
timestamp=int(time.time() * 1000),
|
||||||
source={"content": content},
|
content=content,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,10 +188,9 @@ async def test_require_mention_default_ignores_unmentioned(monkeypatch):
|
|||||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("hello everyone")
|
event = _make_event("hello everyone")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_not_awaited()
|
adapter.handle_message.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@@ -167,10 +202,9 @@ async def test_require_mention_default_processes_mentioned(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("@hermes:example.org help me")
|
event = _make_event("@hermes:example.org help me")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.text == "help me"
|
assert msg.text == "help me"
|
||||||
@@ -184,11 +218,10 @@ async def test_require_mention_html_pill(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
|
formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
|
||||||
event = _make_event("Hermes help", formatted_body=formatted)
|
event = _make_event("Hermes help", formatted_body=formatted)
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -200,11 +233,11 @@ async def test_require_mention_dm_always_responds(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
# member_count=2 triggers DM detection
|
# Mark the room as a DM via the adapter's cache.
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("hello without mention")
|
event = _make_event("hello without mention")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -216,10 +249,10 @@ async def test_dm_strips_mention(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("@hermes:example.org help me")
|
event = _make_event("@hermes:example.org help me")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.text == "help me"
|
assert msg.text == "help me"
|
||||||
@@ -233,10 +266,9 @@ async def test_bare_mention_passes_empty_string(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("@hermes:example.org")
|
event = _make_event("@hermes:example.org")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.text == ""
|
assert msg.text == ""
|
||||||
@@ -250,10 +282,9 @@ async def test_require_mention_free_response_room(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(room_id="!room1:example.org")
|
event = _make_event("hello without mention", room_id="!room1:example.org")
|
||||||
event = _make_event("hello without mention")
|
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -267,10 +298,9 @@ async def test_require_mention_bot_participated_thread(monkeypatch):
|
|||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
adapter._bot_participated_threads.add("$thread1")
|
adapter._bot_participated_threads.add("$thread1")
|
||||||
|
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("hello without mention", thread_id="$thread1")
|
event = _make_event("hello without mention", thread_id="$thread1")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
@@ -282,10 +312,9 @@ async def test_require_mention_disabled(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("hello without mention")
|
event = _make_event("hello without mention")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.text == "hello without mention"
|
assert msg.text == "hello without mention"
|
||||||
@@ -303,10 +332,9 @@ async def test_auto_thread_default_creates_thread(monkeypatch):
|
|||||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("hello", event_id="$msg1")
|
event = _make_event("hello", event_id="$msg1")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id == "$msg1"
|
assert msg.source.thread_id == "$msg1"
|
||||||
@@ -320,10 +348,9 @@ async def test_auto_thread_preserves_existing_thread(monkeypatch):
|
|||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
adapter._bot_participated_threads.add("$thread_root")
|
adapter._bot_participated_threads.add("$thread_root")
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("reply in thread", thread_id="$thread_root")
|
event = _make_event("reply in thread", thread_id="$thread_root")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id == "$thread_root"
|
assert msg.source.thread_id == "$thread_root"
|
||||||
@@ -336,10 +363,10 @@ async def test_auto_thread_skips_dm(monkeypatch):
|
|||||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("hello dm", event_id="$dm1")
|
event = _make_event("hello dm", event_id="$dm1")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id is None
|
assert msg.source.thread_id is None
|
||||||
@@ -352,10 +379,9 @@ async def test_auto_thread_disabled(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("hello", event_id="$msg1")
|
event = _make_event("hello", event_id="$msg1")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id is None
|
assert msg.source.thread_id is None
|
||||||
@@ -368,11 +394,10 @@ async def test_auto_thread_tracks_participation(monkeypatch):
|
|||||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room()
|
|
||||||
event = _make_event("hello", event_id="$msg1")
|
event = _make_event("hello", event_id="$msg1")
|
||||||
|
|
||||||
with patch.object(adapter, "_save_participated_threads"):
|
with patch.object(adapter, "_save_participated_threads"):
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
|
|
||||||
assert "$msg1" in adapter._bot_participated_threads
|
assert "$msg1" in adapter._bot_participated_threads
|
||||||
|
|
||||||
@@ -448,10 +473,10 @@ async def test_dm_mention_thread_disabled_by_default(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id is None
|
assert msg.source.thread_id is None
|
||||||
@@ -464,11 +489,11 @@ async def test_dm_mention_thread_creates_thread(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||||
|
|
||||||
with patch.object(adapter, "_save_participated_threads"):
|
with patch.object(adapter, "_save_participated_threads"):
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
|
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
@@ -483,10 +508,10 @@ async def test_dm_mention_thread_no_mention_no_thread(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("hello without mention", event_id="$dm1")
|
event = _make_event("hello without mention", event_id="$dm1")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id is None
|
assert msg.source.thread_id is None
|
||||||
@@ -499,11 +524,11 @@ async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
|
_set_dm(adapter)
|
||||||
adapter._bot_participated_threads.add("$existing_thread")
|
adapter._bot_participated_threads.add("$existing_thread")
|
||||||
room = _make_room(member_count=2)
|
|
||||||
event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
|
event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
|
||||||
|
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
adapter.handle_message.assert_awaited_once()
|
adapter.handle_message.assert_awaited_once()
|
||||||
msg = adapter.handle_message.await_args.args[0]
|
msg = adapter.handle_message.await_args.args[0]
|
||||||
assert msg.source.thread_id == "$existing_thread"
|
assert msg.source.thread_id == "$existing_thread"
|
||||||
@@ -516,11 +541,11 @@ async def test_dm_mention_thread_tracks_participation(monkeypatch):
|
|||||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||||
|
|
||||||
adapter = _make_adapter()
|
adapter = _make_adapter()
|
||||||
room = _make_room(member_count=2)
|
_set_dm(adapter)
|
||||||
event = _make_event("@hermes:example.org help", event_id="$dm1")
|
event = _make_event("@hermes:example.org help", event_id="$dm1")
|
||||||
|
|
||||||
with patch.object(adapter, "_save_participated_threads"):
|
with patch.object(adapter, "_save_participated_threads"):
|
||||||
await adapter._on_room_message(room, event)
|
await adapter._on_room_message(event)
|
||||||
|
|
||||||
assert "$dm1" in adapter._bot_participated_threads
|
assert "$dm1" in adapter._bot_participated_threads
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
"""Tests for Matrix voice message support (MSC3245)."""
|
"""Tests for Matrix voice message support (MSC3245).
|
||||||
|
|
||||||
|
Updated for the mautrix-python SDK (no more matrix-nio / nio imports).
|
||||||
|
"""
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
import types
|
import types
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
# Try importing real nio; skip entire file if not available.
|
# Try importing mautrix; skip entire file if not available.
|
||||||
# A MagicMock in sys.modules (from another test) is not the real package.
|
|
||||||
try:
|
try:
|
||||||
import nio as _nio_probe
|
import mautrix as _mautrix_probe
|
||||||
if not isinstance(_nio_probe, types.ModuleType) or not hasattr(_nio_probe, "__file__"):
|
if not isinstance(_mautrix_probe, types.ModuleType) or not hasattr(_mautrix_probe, "__file__"):
|
||||||
pytest.skip("nio in sys.modules is a mock, not the real package", allow_module_level=True)
|
pytest.skip("mautrix in sys.modules is a mock, not the real package", allow_module_level=True)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pytest.skip("matrix-nio not installed", allow_module_level=True)
|
pytest.skip("mautrix not installed", allow_module_level=True)
|
||||||
|
|
||||||
from gateway.platforms.base import MessageType
|
from gateway.platforms.base import MessageType
|
||||||
|
|
||||||
@@ -25,7 +30,7 @@ def _make_adapter():
|
|||||||
"""Create a MatrixAdapter with mocked config."""
|
"""Create a MatrixAdapter with mocked config."""
|
||||||
from gateway.platforms.matrix import MatrixAdapter
|
from gateway.platforms.matrix import MatrixAdapter
|
||||||
from gateway.config import PlatformConfig
|
from gateway.config import PlatformConfig
|
||||||
|
|
||||||
config = PlatformConfig(
|
config = PlatformConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
token="***",
|
token="***",
|
||||||
@@ -38,32 +43,26 @@ def _make_adapter():
|
|||||||
return adapter
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
def _make_room(room_id: str = "!test:example.org", member_count: int = 2):
|
|
||||||
"""Create a mock Matrix room."""
|
|
||||||
room = MagicMock()
|
|
||||||
room.room_id = room_id
|
|
||||||
room.member_count = member_count
|
|
||||||
return room
|
|
||||||
|
|
||||||
|
|
||||||
def _make_audio_event(
|
def _make_audio_event(
|
||||||
event_id: str = "$audio_event",
|
event_id: str = "$audio_event",
|
||||||
sender: str = "@alice:example.org",
|
sender: str = "@alice:example.org",
|
||||||
|
room_id: str = "!test:example.org",
|
||||||
body: str = "Voice message",
|
body: str = "Voice message",
|
||||||
url: str = "mxc://example.org/abc123",
|
url: str = "mxc://example.org/abc123",
|
||||||
is_voice: bool = False,
|
is_voice: bool = False,
|
||||||
mimetype: str = "audio/ogg",
|
mimetype: str = "audio/ogg",
|
||||||
timestamp: float = 9999999999000, # ms
|
timestamp: int = 9999999999000, # ms
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a mock RoomMessageAudio event that passes isinstance checks.
|
Create a mock mautrix room message event.
|
||||||
|
|
||||||
|
In mautrix, the handler receives a single event object with attributes
|
||||||
|
``room_id``, ``sender``, ``event_id``, ``timestamp``, and ``content``
|
||||||
|
(a dict-like or serializable object).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
is_voice: If True, adds org.matrix.msc3245.voice field to content
|
is_voice: If True, adds org.matrix.msc3245.voice field to content.
|
||||||
"""
|
"""
|
||||||
import nio
|
|
||||||
|
|
||||||
# Build the source dict that nio events expose via .source
|
|
||||||
content = {
|
content = {
|
||||||
"msgtype": "m.audio",
|
"msgtype": "m.audio",
|
||||||
"body": body,
|
"body": body,
|
||||||
@@ -72,39 +71,35 @@ def _make_audio_event(
|
|||||||
"mimetype": mimetype,
|
"mimetype": mimetype,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if is_voice:
|
if is_voice:
|
||||||
content["org.matrix.msc3245.voice"] = {}
|
content["org.matrix.msc3245.voice"] = {}
|
||||||
|
|
||||||
# Create a real nio RoomMessageAudio-like object
|
event = SimpleNamespace(
|
||||||
# We use MagicMock but configure __class__ to pass isinstance check
|
event_id=event_id,
|
||||||
event = MagicMock(spec=nio.RoomMessageAudio)
|
sender=sender,
|
||||||
event.event_id = event_id
|
room_id=room_id,
|
||||||
event.sender = sender
|
timestamp=timestamp,
|
||||||
event.body = body
|
content=content,
|
||||||
event.url = url
|
)
|
||||||
event.server_timestamp = timestamp
|
|
||||||
event.source = {
|
|
||||||
"type": "m.room.message",
|
|
||||||
"content": content,
|
|
||||||
}
|
|
||||||
# For MIME type extraction - needs to be a dict
|
|
||||||
event.content = content
|
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
def _make_download_response(body: bytes = b"fake audio data"):
|
def _make_state_store(member_count: int = 2):
|
||||||
"""Create a mock nio.MemoryDownloadResponse."""
|
"""Create a mock state store with get_members/get_member support."""
|
||||||
import nio
|
store = MagicMock()
|
||||||
resp = MagicMock()
|
# get_members returns a list of member user IDs
|
||||||
resp.body = body
|
members = [MagicMock() for _ in range(member_count)]
|
||||||
resp.__class__ = nio.MemoryDownloadResponse
|
store.get_members = AsyncMock(return_value=members)
|
||||||
return resp
|
# get_member returns a single member info object
|
||||||
|
member = MagicMock()
|
||||||
|
member.displayname = "Alice"
|
||||||
|
store.get_member = AsyncMock(return_value=member)
|
||||||
|
return store
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests: MSC3245 Voice Detection (RED -> GREEN)
|
# Tests: MSC3245 Voice Detection
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestMatrixVoiceMessageDetection:
|
class TestMatrixVoiceMessageDetection:
|
||||||
@@ -118,27 +113,28 @@ class TestMatrixVoiceMessageDetection:
|
|||||||
self.adapter._message_handler = AsyncMock()
|
self.adapter._message_handler = AsyncMock()
|
||||||
# Mock _mxc_to_http to return a fake HTTP URL
|
# Mock _mxc_to_http to return a fake HTTP URL
|
||||||
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
||||||
# Mock client for authenticated download
|
# Mock client for authenticated download — download_media returns bytes directly
|
||||||
self.adapter._client = MagicMock()
|
self.adapter._client = MagicMock()
|
||||||
self.adapter._client.download = AsyncMock(return_value=_make_download_response())
|
self.adapter._client.download_media = AsyncMock(return_value=b"fake audio data")
|
||||||
|
# State store for DM detection
|
||||||
|
self.adapter._client.state_store = _make_state_store()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_message_has_type_voice(self):
|
async def test_voice_message_has_type_voice(self):
|
||||||
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
||||||
room = _make_room()
|
|
||||||
event = _make_audio_event(is_voice=True)
|
event = _make_audio_event(is_voice=True)
|
||||||
|
|
||||||
# Capture the MessageEvent passed to handle_message
|
# Capture the MessageEvent passed to handle_message
|
||||||
captured_event = None
|
captured_event = None
|
||||||
|
|
||||||
async def capture(msg_event):
|
async def capture(msg_event):
|
||||||
nonlocal captured_event
|
nonlocal captured_event
|
||||||
captured_event = msg_event
|
captured_event = msg_event
|
||||||
|
|
||||||
self.adapter.handle_message = capture
|
self.adapter.handle_message = capture
|
||||||
|
|
||||||
await self.adapter._on_room_message_media(room, event)
|
await self.adapter._on_room_message(event)
|
||||||
|
|
||||||
assert captured_event is not None, "No event was captured"
|
assert captured_event is not None, "No event was captured"
|
||||||
assert captured_event.message_type == MessageType.VOICE, \
|
assert captured_event.message_type == MessageType.VOICE, \
|
||||||
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
||||||
@@ -146,44 +142,43 @@ class TestMatrixVoiceMessageDetection:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_message_has_local_path(self):
|
async def test_voice_message_has_local_path(self):
|
||||||
"""Voice messages should have a local cached path in media_urls."""
|
"""Voice messages should have a local cached path in media_urls."""
|
||||||
room = _make_room()
|
|
||||||
event = _make_audio_event(is_voice=True)
|
event = _make_audio_event(is_voice=True)
|
||||||
|
|
||||||
captured_event = None
|
captured_event = None
|
||||||
|
|
||||||
async def capture(msg_event):
|
async def capture(msg_event):
|
||||||
nonlocal captured_event
|
nonlocal captured_event
|
||||||
captured_event = msg_event
|
captured_event = msg_event
|
||||||
|
|
||||||
self.adapter.handle_message = capture
|
self.adapter.handle_message = capture
|
||||||
|
|
||||||
await self.adapter._on_room_message_media(room, event)
|
await self.adapter._on_room_message(event)
|
||||||
|
|
||||||
assert captured_event is not None
|
assert captured_event is not None
|
||||||
assert captured_event.media_urls is not None
|
assert captured_event.media_urls is not None
|
||||||
assert len(captured_event.media_urls) > 0
|
assert len(captured_event.media_urls) > 0
|
||||||
# Should be a local path, not an HTTP URL
|
# Should be a local path, not an HTTP URL
|
||||||
assert not captured_event.media_urls[0].startswith("http"), \
|
assert not captured_event.media_urls[0].startswith("http"), \
|
||||||
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
||||||
self.adapter._client.download.assert_awaited_once_with(mxc=event.url)
|
# download_media is called with a ContentURI wrapping the mxc URL
|
||||||
|
self.adapter._client.download_media.assert_awaited_once()
|
||||||
assert captured_event.media_types == ["audio/ogg"]
|
assert captured_event.media_types == ["audio/ogg"]
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_audio_without_msc3245_stays_audio_type(self):
|
async def test_audio_without_msc3245_stays_audio_type(self):
|
||||||
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
||||||
room = _make_room()
|
|
||||||
event = _make_audio_event(is_voice=False) # NOT a voice message
|
event = _make_audio_event(is_voice=False) # NOT a voice message
|
||||||
|
|
||||||
captured_event = None
|
captured_event = None
|
||||||
|
|
||||||
async def capture(msg_event):
|
async def capture(msg_event):
|
||||||
nonlocal captured_event
|
nonlocal captured_event
|
||||||
captured_event = msg_event
|
captured_event = msg_event
|
||||||
|
|
||||||
self.adapter.handle_message = capture
|
self.adapter.handle_message = capture
|
||||||
|
|
||||||
await self.adapter._on_room_message_media(room, event)
|
await self.adapter._on_room_message(event)
|
||||||
|
|
||||||
assert captured_event is not None
|
assert captured_event is not None
|
||||||
assert captured_event.message_type == MessageType.AUDIO, \
|
assert captured_event.message_type == MessageType.AUDIO, \
|
||||||
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
||||||
@@ -191,25 +186,24 @@ class TestMatrixVoiceMessageDetection:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_regular_audio_has_http_url(self):
|
async def test_regular_audio_has_http_url(self):
|
||||||
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
"""Regular audio uploads should keep HTTP URL (not cached locally)."""
|
||||||
room = _make_room()
|
|
||||||
event = _make_audio_event(is_voice=False)
|
event = _make_audio_event(is_voice=False)
|
||||||
|
|
||||||
captured_event = None
|
captured_event = None
|
||||||
|
|
||||||
async def capture(msg_event):
|
async def capture(msg_event):
|
||||||
nonlocal captured_event
|
nonlocal captured_event
|
||||||
captured_event = msg_event
|
captured_event = msg_event
|
||||||
|
|
||||||
self.adapter.handle_message = capture
|
self.adapter.handle_message = capture
|
||||||
|
|
||||||
await self.adapter._on_room_message_media(room, event)
|
await self.adapter._on_room_message(event)
|
||||||
|
|
||||||
assert captured_event is not None
|
assert captured_event is not None
|
||||||
assert captured_event.media_urls is not None
|
assert captured_event.media_urls is not None
|
||||||
# Should be HTTP URL, not local path
|
# Should be HTTP URL, not local path
|
||||||
assert captured_event.media_urls[0].startswith("http"), \
|
assert captured_event.media_urls[0].startswith("http"), \
|
||||||
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
f"Non-voice audio should have HTTP URL, got {captured_event.media_urls[0]}"
|
||||||
self.adapter._client.download.assert_not_awaited()
|
self.adapter._client.download_media.assert_not_awaited()
|
||||||
assert captured_event.media_types == ["audio/ogg"]
|
assert captured_event.media_types == ["audio/ogg"]
|
||||||
|
|
||||||
|
|
||||||
@@ -224,29 +218,26 @@ class TestMatrixVoiceCacheFallback:
|
|||||||
self.adapter._message_handler = AsyncMock()
|
self.adapter._message_handler = AsyncMock()
|
||||||
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
self.adapter._mxc_to_http = lambda url: f"https://matrix.example.org/_matrix/media/v3/download/{url[6:]}"
|
||||||
self.adapter._client = MagicMock()
|
self.adapter._client = MagicMock()
|
||||||
|
self.adapter._client.state_store = _make_state_store()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
||||||
"""If caching fails, voice message should still be delivered with HTTP URL."""
|
"""If caching fails (download returns None), voice message should still be delivered with HTTP URL."""
|
||||||
room = _make_room()
|
|
||||||
event = _make_audio_event(is_voice=True)
|
event = _make_audio_event(is_voice=True)
|
||||||
|
|
||||||
# Make download fail
|
# download_media returns None on failure
|
||||||
import nio
|
self.adapter._client.download_media = AsyncMock(return_value=None)
|
||||||
error_resp = MagicMock()
|
|
||||||
error_resp.__class__ = nio.DownloadError
|
|
||||||
self.adapter._client.download = AsyncMock(return_value=error_resp)
|
|
||||||
|
|
||||||
captured_event = None
|
captured_event = None
|
||||||
|
|
||||||
async def capture(msg_event):
|
async def capture(msg_event):
|
||||||
nonlocal captured_event
|
nonlocal captured_event
|
||||||
captured_event = msg_event
|
captured_event = msg_event
|
||||||
|
|
||||||
self.adapter.handle_message = capture
|
self.adapter.handle_message = capture
|
||||||
|
|
||||||
await self.adapter._on_room_message_media(room, event)
|
await self.adapter._on_room_message(event)
|
||||||
|
|
||||||
assert captured_event is not None
|
assert captured_event is not None
|
||||||
assert captured_event.media_urls is not None
|
assert captured_event.media_urls is not None
|
||||||
# Should fall back to HTTP URL
|
# Should fall back to HTTP URL
|
||||||
@@ -256,10 +247,9 @@ class TestMatrixVoiceCacheFallback:
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
||||||
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
||||||
room = _make_room()
|
|
||||||
event = _make_audio_event(is_voice=True)
|
event = _make_audio_event(is_voice=True)
|
||||||
|
|
||||||
self.adapter._client.download = AsyncMock(side_effect=RuntimeError("boom"))
|
self.adapter._client.download_media = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
|
|
||||||
captured_event = None
|
captured_event = None
|
||||||
|
|
||||||
@@ -269,7 +259,7 @@ class TestMatrixVoiceCacheFallback:
|
|||||||
|
|
||||||
self.adapter.handle_message = capture
|
self.adapter.handle_message = capture
|
||||||
|
|
||||||
await self.adapter._on_room_message_media(room, event)
|
await self.adapter._on_room_message(event)
|
||||||
|
|
||||||
assert captured_event is not None
|
assert captured_event is not None
|
||||||
assert captured_event.media_urls is not None
|
assert captured_event.media_urls is not None
|
||||||
@@ -278,7 +268,7 @@ class TestMatrixVoiceCacheFallback:
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Tests: send_voice includes MSC3245 field (RED -> GREEN)
|
# Tests: send_voice includes MSC3245 field
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestMatrixSendVoiceMSC3245:
|
class TestMatrixSendVoiceMSC3245:
|
||||||
@@ -287,62 +277,52 @@ class TestMatrixSendVoiceMSC3245:
|
|||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
self.adapter = _make_adapter()
|
self.adapter = _make_adapter()
|
||||||
self.adapter._user_id = "@bot:example.org"
|
self.adapter._user_id = "@bot:example.org"
|
||||||
# Mock client with successful upload
|
# Mock client — upload_media returns a ContentURI string
|
||||||
self.adapter._client = MagicMock()
|
self.adapter._client = MagicMock()
|
||||||
self.upload_call = None
|
self.upload_call = None
|
||||||
|
|
||||||
async def mock_upload(*args, **kwargs):
|
async def mock_upload_media(data, mime_type=None, filename=None, **kwargs):
|
||||||
self.upload_call = (args, kwargs)
|
self.upload_call = {"data": data, "mime_type": mime_type, "filename": filename}
|
||||||
import nio
|
return "mxc://example.org/uploaded"
|
||||||
resp = MagicMock()
|
|
||||||
resp.content_uri = "mxc://example.org/uploaded"
|
|
||||||
resp.__class__ = nio.UploadResponse
|
|
||||||
return resp, None
|
|
||||||
|
|
||||||
self.adapter._client.upload = mock_upload
|
self.adapter._client.upload_media = mock_upload_media
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_voice_includes_msc3245_field(self):
|
@patch("mimetypes.guess_type", return_value=("audio/ogg", None))
|
||||||
|
async def test_send_voice_includes_msc3245_field(self, _mock_guess):
|
||||||
"""send_voice should include org.matrix.msc3245.voice in message content."""
|
"""send_voice should include org.matrix.msc3245.voice in message content."""
|
||||||
import tempfile
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Create a temp audio file
|
# Create a temp audio file
|
||||||
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
||||||
f.write(b"fake audio data")
|
f.write(b"fake audio data")
|
||||||
temp_path = f.name
|
temp_path = f.name
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Capture the message content sent to room_send
|
# Capture the message content sent via send_message_event
|
||||||
sent_content = None
|
sent_content = None
|
||||||
|
|
||||||
async def mock_room_send(room_id, event_type, content):
|
async def mock_send_message_event(room_id, event_type, content):
|
||||||
nonlocal sent_content
|
nonlocal sent_content
|
||||||
sent_content = content
|
sent_content = content
|
||||||
resp = MagicMock()
|
# send_message_event returns an EventID string
|
||||||
resp.event_id = "$sent_event"
|
return "$sent_event"
|
||||||
import nio
|
|
||||||
resp.__class__ = nio.RoomSendResponse
|
self.adapter._client.send_message_event = mock_send_message_event
|
||||||
return resp
|
|
||||||
|
|
||||||
self.adapter._client.room_send = mock_room_send
|
|
||||||
|
|
||||||
await self.adapter.send_voice(
|
await self.adapter.send_voice(
|
||||||
chat_id="!room:example.org",
|
chat_id="!room:example.org",
|
||||||
audio_path=temp_path,
|
audio_path=temp_path,
|
||||||
caption="Test voice",
|
caption="Test voice",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert sent_content is not None, "No message was sent"
|
assert sent_content is not None, "No message was sent"
|
||||||
assert "org.matrix.msc3245.voice" in sent_content, \
|
assert "org.matrix.msc3245.voice" in sent_content, \
|
||||||
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
||||||
assert sent_content["msgtype"] == "m.audio"
|
assert sent_content["msgtype"] == "m.audio"
|
||||||
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
||||||
assert self.upload_call is not None, "Expected upload() to be called"
|
assert self.upload_call is not None, "Expected upload_media() to be called"
|
||||||
args, kwargs = self.upload_call
|
assert isinstance(self.upload_call["data"], bytes)
|
||||||
assert isinstance(args[0], io.BytesIO)
|
assert self.upload_call["mime_type"] == "audio/ogg"
|
||||||
assert kwargs["content_type"] == "audio/ogg"
|
assert self.upload_call["filename"].endswith(".ogg")
|
||||||
assert kwargs["filename"].endswith(".ogg")
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
os.unlink(temp_path)
|
os.unlink(temp_path)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _parse_setup_imports():
|
|||||||
class TestSetupShutilImport:
|
class TestSetupShutilImport:
|
||||||
def test_shutil_imported_at_module_level(self):
|
def test_shutil_imported_at_module_level(self):
|
||||||
"""shutil must be imported at module level so setup_gateway can use it
|
"""shutil must be imported at module level so setup_gateway can use it
|
||||||
for the matrix-nio auto-install path (line ~2126)."""
|
for the mautrix auto-install path."""
|
||||||
names = _parse_setup_imports()
|
names = _parse_setup_imports()
|
||||||
assert "shutil" in names, (
|
assert "shutil" in names, (
|
||||||
"shutil is not imported at the top of hermes_cli/setup.py. "
|
"shutil is not imported at the top of hermes_cli/setup.py. "
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ def _load_optional_dependencies():
|
|||||||
|
|
||||||
|
|
||||||
def test_matrix_extra_linux_only_in_all():
|
def test_matrix_extra_linux_only_in_all():
|
||||||
"""matrix-nio[e2e] depends on python-olm which is upstream-broken on modern
|
"""mautrix[encryption] depends on python-olm which is upstream-broken on
|
||||||
macOS (archived libolm, C++ errors with Clang 21+). The [matrix] extra is
|
modern macOS (archived libolm, C++ errors with Clang 21+). The [matrix]
|
||||||
included in [all] but gated to Linux via a platform marker so that
|
extra is included in [all] but gated to Linux via a platform marker so
|
||||||
``hermes update`` doesn't fail on macOS."""
|
that ``hermes update`` doesn't fail on macOS."""
|
||||||
optional_dependencies = _load_optional_dependencies()
|
optional_dependencies = _load_optional_dependencies()
|
||||||
|
|
||||||
assert "matrix" in optional_dependencies
|
assert "matrix" in optional_dependencies
|
||||||
|
|||||||
77
uv.lock
generated
77
uv.lock
generated
@@ -152,19 +152,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/99/84ba7273339d0f3dfa57901b846489d2e5c2cd731470167757f1935fffbd/aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54", size = 9981, upload-time = "2024-11-06T10:44:52.917Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aiohttp-socks"
|
|
||||||
version = "0.11.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "aiohttp" },
|
|
||||||
{ name = "python-socks" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiosignal"
|
name = "aiosignal"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -253,12 +240,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomicwrites"
|
|
||||||
version = "1.4.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/87/c6/53da25344e3e3a9c01095a89f16dbcda021c609ddb42dd6d7c0528236fb2/atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11", size = 14227, upload-time = "2022-07-08T18:31:40.459Z" }
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atroposlib"
|
name = "atroposlib"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@@ -376,6 +357,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base58"
|
||||||
|
version = "2.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "blinker"
|
name = "blinker"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -1692,7 +1682,7 @@ all = [
|
|||||||
{ name = "honcho-ai" },
|
{ name = "honcho-ai" },
|
||||||
{ name = "lark-oapi" },
|
{ name = "lark-oapi" },
|
||||||
{ name = "markdown", marker = "sys_platform == 'linux'" },
|
{ name = "markdown", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "matrix-nio", extra = ["e2e"], marker = "sys_platform == 'linux'" },
|
{ name = "mautrix", extra = ["encryption"], marker = "sys_platform == 'linux'" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "mistralai" },
|
{ name = "mistralai" },
|
||||||
{ name = "modal" },
|
{ name = "modal" },
|
||||||
@@ -1738,7 +1728,7 @@ honcho = [
|
|||||||
]
|
]
|
||||||
matrix = [
|
matrix = [
|
||||||
{ name = "markdown" },
|
{ name = "markdown" },
|
||||||
{ name = "matrix-nio", extra = ["e2e"] },
|
{ name = "mautrix", extra = ["encryption"] },
|
||||||
]
|
]
|
||||||
mcp = [
|
mcp = [
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
@@ -1846,7 +1836,7 @@ requires-dist = [
|
|||||||
{ name = "jinja2", specifier = ">=3.1.5,<4" },
|
{ name = "jinja2", specifier = ">=3.1.5,<4" },
|
||||||
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" },
|
{ name = "lark-oapi", marker = "extra == 'feishu'", specifier = ">=1.5.3,<2" },
|
||||||
{ name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" },
|
{ name = "markdown", marker = "extra == 'matrix'", specifier = ">=3.6,<4" },
|
||||||
{ name = "matrix-nio", extras = ["e2e"], marker = "extra == 'matrix'", specifier = ">=0.24.0,<1" },
|
{ name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = ">=0.20,<1" },
|
||||||
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
|
{ name = "mcp", marker = "extra == 'dev'", specifier = ">=1.2.0,<2" },
|
||||||
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
|
{ name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.2.0,<2" },
|
||||||
{ name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" },
|
{ name = "mistralai", marker = "extra == 'mistral'", specifier = ">=2.3.0,<3" },
|
||||||
@@ -2601,30 +2591,25 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matrix-nio"
|
name = "mautrix"
|
||||||
version = "0.25.2"
|
version = "0.21.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aiohttp-socks" },
|
{ name = "attrs" },
|
||||||
{ name = "h11" },
|
{ name = "yarl" },
|
||||||
{ name = "h2" },
|
|
||||||
{ name = "jsonschema" },
|
|
||||||
{ name = "pycryptodome" },
|
|
||||||
{ name = "unpaddedbase64" },
|
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480, upload-time = "2024-10-04T07:51:41.62Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/74/a7/8d6d0589e211ecf3a72ce4b28cc32c857c4043d1a6963d63ac9f726af653/mautrix-0.21.0.tar.gz", hash = "sha256:a14e0582e114cb241f282f9e717014608f36c03f1dc59afcd71b4e81780ffe2e", size = 254726, upload-time = "2025-11-17T13:53:09.996Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982, upload-time = "2024-10-04T07:51:39.451Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/d6/d4b3ae380dacdc9fb07bc3eb7dd17f43b8a7ce391465a184d1094acb66c1/mautrix-0.21.0-py3-none-any.whl", hash = "sha256:1cba30d69f46351918a3b8bc4e5657465cac8470d42ddd2287a742653cab7194", size = 334131, upload-time = "2025-11-17T13:53:08.117Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
e2e = [
|
encryption = [
|
||||||
{ name = "atomicwrites" },
|
{ name = "base58" },
|
||||||
{ name = "cachetools" },
|
{ name = "pycryptodome" },
|
||||||
{ name = "peewee" },
|
|
||||||
{ name = "python-olm" },
|
{ name = "python-olm" },
|
||||||
|
{ name = "unpaddedbase64" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3337,15 +3322,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/3e/2218fa29637781b8e7ac35a928108ff2614ddd40879389d3af2caa725af5/parallel_web-0.4.2-py3-none-any.whl", hash = "sha256:aa3a4a9aecc08972c5ce9303271d4917903373dff4dd277d9a3e30f9cff53346", size = 144012, upload-time = "2026-03-09T22:24:33.979Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "peewee"
|
|
||||||
version = "3.19.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/88/b0/79462b42e89764998756e0557f2b58a15610a5b4512fbbcccae58fba7237/peewee-3.19.0.tar.gz", hash = "sha256:f88292a6f0d7b906cb26bca9c8599b8f4d8920ebd36124400d0cbaaaf915511f", size = 974035, upload-time = "2026-01-07T17:24:59.597Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/41/19c65578ef9a54b3083253c68a607f099642747168fe00f3a2bceb7c3a34/peewee-3.19.0-py3-none-any.whl", hash = "sha256:de220b94766e6008c466e00ce4ba5299b9a832117d9eb36d45d0062f3cfd7417", size = 411885, upload-time = "2026-01-07T17:24:58.33Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.1.1"
|
version = "12.1.1"
|
||||||
@@ -4008,15 +3984,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/79/93/f6729f10149305262194774d6c8b438c0b084740cf239f48ab97b4df02fa/python_olm-3.2.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a5e68a2f4b5a2bfa5fdb5dbfa22396a551730df6c4a572235acaa96e997d3f", size = 297000, upload-time = "2023-11-28T19:25:31.045Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/93/f6729f10149305262194774d6c8b438c0b084740cf239f48ab97b4df02fa/python_olm-3.2.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a5e68a2f4b5a2bfa5fdb5dbfa22396a551730df6c4a572235acaa96e997d3f", size = 297000, upload-time = "2023-11-28T19:25:31.045Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-socks"
|
|
||||||
version = "2.8.1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/36/0b/cd77011c1bc01b76404f7aba07fca18aca02a19c7626e329b40201217624/python_socks-2.8.1.tar.gz", hash = "sha256:698daa9616d46dddaffe65b87db222f2902177a2d2b2c0b9a9361df607ab3687", size = 38909, upload-time = "2026-02-16T05:24:00.745Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/15/fe/9a58cb6eec633ff6afae150ca53c16f8cc8b65862ccb3d088051efdfceb7/python_socks-2.8.1-py3-none-any.whl", hash = "sha256:28232739c4988064e725cdbcd15be194743dd23f1c910f784163365b9d7be035", size = 55087, upload-time = "2026-02-16T05:23:59.147Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-telegram-bot"
|
name = "python-telegram-bot"
|
||||||
version = "22.6"
|
version = "22.6"
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ gateway/platforms/
|
|||||||
├── slack.py # Slack Socket Mode
|
├── slack.py # Slack Socket Mode
|
||||||
├── whatsapp.py # WhatsApp Business Cloud API
|
├── whatsapp.py # WhatsApp Business Cloud API
|
||||||
├── signal.py # Signal via signal-cli REST API
|
├── signal.py # Signal via signal-cli REST API
|
||||||
├── matrix.py # Matrix via matrix-nio (optional E2EE)
|
├── matrix.py # Matrix via mautrix (optional E2EE)
|
||||||
├── mattermost.py # Mattermost WebSocket API
|
├── mattermost.py # Mattermost WebSocket API
|
||||||
├── email.py # Email via IMAP/SMTP
|
├── email.py # Email via IMAP/SMTP
|
||||||
├── sms.py # SMS via Twilio
|
├── sms.py # SMS via Twilio
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ description: "Set up Hermes Agent as a Matrix bot"
|
|||||||
|
|
||||||
# Matrix Setup
|
# Matrix Setup
|
||||||
|
|
||||||
Hermes Agent integrates with Matrix, the open, federated messaging protocol. Matrix lets you run your own homeserver or use a public one like matrix.org — either way, you keep control of your communications. The bot connects via the `matrix-nio` Python SDK, processes messages through the Hermes Agent pipeline (including tool use, memory, and reasoning), and responds in real time. It supports text, file attachments, images, audio, video, and optional end-to-end encryption (E2EE).
|
Hermes Agent integrates with Matrix, the open, federated messaging protocol. Matrix lets you run your own homeserver or use a public one like matrix.org — either way, you keep control of your communications. The bot connects via the `mautrix` Python SDK, processes messages through the Hermes Agent pipeline (including tool use, memory, and reasoning), and responds in real time. It supports text, file attachments, images, audio, video, and optional end-to-end encryption (E2EE).
|
||||||
|
|
||||||
Hermes works with any Matrix homeserver — Synapse, Conduit, Dendrite, or matrix.org.
|
Hermes works with any Matrix homeserver — Synapse, Conduit, Dendrite, or matrix.org.
|
||||||
|
|
||||||
@@ -234,11 +234,11 @@ Hermes supports Matrix end-to-end encryption, so you can chat with your bot in e
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
E2EE requires the `matrix-nio` library with encryption extras and the `libolm` C library:
|
E2EE requires the `mautrix` library with encryption extras and the `libolm` C library:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install matrix-nio with E2EE support
|
# Install mautrix with E2EE support
|
||||||
pip install 'matrix-nio[e2e]'
|
pip install 'mautrix[encryption]'
|
||||||
|
|
||||||
# Or install with hermes extras
|
# Or install with hermes extras
|
||||||
pip install 'hermes-agent[matrix]'
|
pip install 'hermes-agent[matrix]'
|
||||||
@@ -277,7 +277,7 @@ If you delete the `~/.hermes/platforms/matrix/store/` directory, the bot loses i
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
:::info
|
:::info
|
||||||
If `matrix-nio[e2e]` is not installed or `libolm` is missing, the bot falls back to a plain (unencrypted) client automatically. You'll see a warning in the logs.
|
If `mautrix[encryption]` is not installed or `libolm` is missing, the bot falls back to a plain (unencrypted) client automatically. You'll see a warning in the logs.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## Home Room
|
## Home Room
|
||||||
@@ -321,14 +321,14 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
|||||||
|
|
||||||
If this returns your user info, the token is valid. If it returns an error, generate a new token.
|
If this returns your user info, the token is valid. If it returns an error, generate a new token.
|
||||||
|
|
||||||
### "matrix-nio not installed" error
|
### "mautrix not installed" error
|
||||||
|
|
||||||
**Cause**: The `matrix-nio` Python package is not installed.
|
**Cause**: The `mautrix` Python package is not installed.
|
||||||
|
|
||||||
**Fix**: Install it:
|
**Fix**: Install it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install 'matrix-nio[e2e]'
|
pip install 'mautrix[encryption]'
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with Hermes extras:
|
Or with Hermes extras:
|
||||||
|
|||||||
Reference in New Issue
Block a user