wip wip wip pluginify everything
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
class TestMatrixExecApprovalReactions:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_exec_approval_registers_prompt_and_seeds_reactions(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||
from hermes_agent_matrix import MatrixAdapter
|
||||
|
||||
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||
adapter._client = types.SimpleNamespace()
|
||||
adapter.send = AsyncMock(return_value=types.SimpleNamespace(success=True, message_id="$evt1"))
|
||||
adapter._send_reaction = AsyncMock(return_value="$r")
|
||||
|
||||
result = await adapter.send_exec_approval(
|
||||
chat_id="!room:example.org",
|
||||
command="rm -rf /tmp/test",
|
||||
session_key="sess-1",
|
||||
description="dangerous",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert adapter._approval_prompt_by_session["sess-1"] == "$evt1"
|
||||
assert adapter._approval_prompts_by_event["$evt1"].session_key == "sess-1"
|
||||
assert adapter._send_reaction.await_count == 2
|
||||
emojis = [call.args[2] for call in adapter._send_reaction.await_args_list]
|
||||
assert emojis == ["✅", "❎"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reaction_resolves_pending_approval(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_ALLOWED_USERS", "@liizfq:liizfq.top")
|
||||
from hermes_agent_matrix import MatrixAdapter, _MatrixApprovalPrompt
|
||||
|
||||
adapter = MatrixAdapter(PlatformConfig(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.org"}))
|
||||
# Resolve user_id so _is_self_sender doesn't defensively drop all traffic (#15763).
|
||||
adapter._user_id = "@bot:example.org"
|
||||
adapter._approval_prompts_by_event["$target"] = _MatrixApprovalPrompt(
|
||||
session_key="sess-1", chat_id="!room:example.org", message_id="$target"
|
||||
)
|
||||
adapter._approval_prompt_by_session["sess-1"] = "$target"
|
||||
|
||||
content = {"m.relates_to": {"event_id": "$target", "key": "✅"}}
|
||||
event = types.SimpleNamespace(
|
||||
sender="@liizfq:liizfq.top",
|
||||
event_id="$react1",
|
||||
room_id="!room:example.org",
|
||||
content=content,
|
||||
)
|
||||
|
||||
with patch("tools.approval.resolve_gateway_approval", return_value=1) as mock_resolve:
|
||||
await adapter._on_reaction(event)
|
||||
|
||||
mock_resolve.assert_called_once_with("sess-1", "once")
|
||||
assert "$target" not in adapter._approval_prompts_by_event
|
||||
assert "sess-1" not in adapter._approval_prompt_by_session
|
||||
@@ -0,0 +1,813 @@
|
||||
"""Tests for Matrix require-mention gating and auto-thread features."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
# The matrix adapter module is importable without mautrix installed
|
||||
# (module-level imports use try/except with stubs). No need for
|
||||
# module-level mock installation — tests that call adapter methods
|
||||
# needing real mautrix APIs mock them individually.
|
||||
|
||||
|
||||
def _make_adapter(tmp_path=None):
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from hermes_agent_matrix import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="syt_test_token",
|
||||
extra={
|
||||
"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@hermes:example.org",
|
||||
},
|
||||
)
|
||||
adapter = MatrixAdapter(config)
|
||||
adapter._text_batch_delay_seconds = 0 # disable batching for tests
|
||||
adapter.handle_message = AsyncMock()
|
||||
adapter._startup_ts = time.time() - 10 # avoid startup grace filter
|
||||
return adapter
|
||||
|
||||
|
||||
def _set_dm(adapter, room_id="!room1:example.org", is_dm=True):
|
||||
"""Mark a room as DM (or not) in the adapter's cache."""
|
||||
adapter._dm_rooms[room_id] = is_dm
|
||||
|
||||
|
||||
def _make_event(
|
||||
body,
|
||||
sender="@alice:example.org",
|
||||
event_id="$evt1",
|
||||
room_id="!room1:example.org",
|
||||
formatted_body=None,
|
||||
thread_id=None,
|
||||
mention_user_ids=None,
|
||||
):
|
||||
"""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"}
|
||||
if formatted_body:
|
||||
content["formatted_body"] = formatted_body
|
||||
content["format"] = "org.matrix.custom.html"
|
||||
|
||||
if mention_user_ids is not None:
|
||||
content["m.mentions"] = {"user_ids": mention_user_ids}
|
||||
|
||||
relates_to = {}
|
||||
if thread_id:
|
||||
relates_to["rel_type"] = "m.thread"
|
||||
relates_to["event_id"] = thread_id
|
||||
if relates_to:
|
||||
content["m.relates_to"] = relates_to
|
||||
|
||||
return SimpleNamespace(
|
||||
sender=sender,
|
||||
event_id=event_id,
|
||||
room_id=room_id,
|
||||
timestamp=int(time.time() * 1000),
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mention detection helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsBotMentioned:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
|
||||
def test_full_user_id_in_body(self):
|
||||
assert self.adapter._is_bot_mentioned("hey @hermes:example.org help")
|
||||
|
||||
def test_localpart_in_body(self):
|
||||
assert self.adapter._is_bot_mentioned("hermes can you help?")
|
||||
|
||||
def test_localpart_case_insensitive(self):
|
||||
assert self.adapter._is_bot_mentioned("HERMES can you help?")
|
||||
|
||||
def test_matrix_pill_in_formatted_body(self):
|
||||
html = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
|
||||
assert self.adapter._is_bot_mentioned("Hermes help", html)
|
||||
|
||||
def test_no_mention(self):
|
||||
assert not self.adapter._is_bot_mentioned("hello everyone")
|
||||
|
||||
def test_empty_body(self):
|
||||
assert not self.adapter._is_bot_mentioned("")
|
||||
|
||||
def test_partial_localpart_no_match(self):
|
||||
# "hermesbot" should not match word-boundary check for "hermes"
|
||||
assert not self.adapter._is_bot_mentioned("hermesbot is here")
|
||||
|
||||
# m.mentions.user_ids — MSC3952 / Matrix v1.7 authoritative mentions
|
||||
# Ported from openclaw/openclaw#64796
|
||||
|
||||
def test_m_mentions_user_ids_authoritative(self):
|
||||
"""m.mentions.user_ids alone is sufficient — no body text needed."""
|
||||
assert self.adapter._is_bot_mentioned(
|
||||
"please reply", # no @hermes anywhere in body
|
||||
mention_user_ids=["@hermes:example.org"],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_with_body_mention(self):
|
||||
"""Both m.mentions and body mention — should still be True."""
|
||||
assert self.adapter._is_bot_mentioned(
|
||||
"hey @hermes:example.org help",
|
||||
mention_user_ids=["@hermes:example.org"],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_other_user_only(self):
|
||||
"""m.mentions with a different user — bot is NOT mentioned."""
|
||||
assert not self.adapter._is_bot_mentioned(
|
||||
"hello",
|
||||
mention_user_ids=["@alice:example.org"],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_empty_list(self):
|
||||
"""Empty user_ids list — falls through to text detection."""
|
||||
assert not self.adapter._is_bot_mentioned(
|
||||
"hello everyone",
|
||||
mention_user_ids=[],
|
||||
)
|
||||
|
||||
def test_m_mentions_user_ids_none(self):
|
||||
"""None mention_user_ids — falls through to text detection."""
|
||||
assert not self.adapter._is_bot_mentioned(
|
||||
"hello everyone",
|
||||
mention_user_ids=None,
|
||||
)
|
||||
|
||||
|
||||
class TestStripMention:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
|
||||
def test_strip_full_user_id(self):
|
||||
result = self.adapter._strip_mention("@hermes:example.org help me")
|
||||
assert result == "help me"
|
||||
|
||||
def test_localpart_preserved(self):
|
||||
"""Bare localpart (no @) is preserved — avoids false positives in paths."""
|
||||
result = self.adapter._strip_mention("hermes help me")
|
||||
assert result == "hermes help me"
|
||||
|
||||
def test_localpart_in_path_preserved(self):
|
||||
"""Localpart inside a file path must not be damaged."""
|
||||
result = self.adapter._strip_mention("read /home/hermes/config.yaml")
|
||||
assert result == "read /home/hermes/config.yaml"
|
||||
|
||||
def test_strip_localpart_when_explicit_at_mention(self):
|
||||
result = self.adapter._strip_mention("@hermes help me")
|
||||
assert result == "help me"
|
||||
|
||||
def test_does_not_strip_bare_localpart_word(self):
|
||||
# Regression: plain words like "Hermes Agent" should not be mutated.
|
||||
result = self.adapter._strip_mention("Hermes Agent")
|
||||
assert result == "Hermes Agent"
|
||||
|
||||
def test_strip_returns_empty_for_mention_only(self):
|
||||
result = self.adapter._strip_mention("@hermes:example.org")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound mention payloads
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOutboundMentions:
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.mock_client = MagicMock()
|
||||
self.mock_client.send_message_event = AsyncMock(return_value="$evt1")
|
||||
self.adapter._client = self.mock_client
|
||||
|
||||
@staticmethod
|
||||
def _sent_content(mock_client):
|
||||
call_args = mock_client.send_message_event.call_args
|
||||
return call_args.args[2] if len(call_args.args) > 2 else call_args.kwargs["content"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_adds_matrix_mentions_and_formatted_body(self):
|
||||
result = await self.adapter.send(
|
||||
"!room1:example.org",
|
||||
"Hello @alice:example.org, please check this.",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert content["formatted_body"] == (
|
||||
'Hello <a href="https://matrix.to/#/@alice:example.org">'
|
||||
"@alice:example.org</a>, please check this."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dedupes_mentions_and_ignores_code_spans(self):
|
||||
await self.adapter.send(
|
||||
"!room1:example.org",
|
||||
"Ping @alice:example.org and @alice:example.org, not `@code:example.org`.",
|
||||
)
|
||||
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert "@code:example.org</a>" not in content["formatted_body"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_message_preserves_mentions(self):
|
||||
result = await self.adapter.edit_message(
|
||||
"!room1:example.org",
|
||||
"$original",
|
||||
"Updated for @alice:example.org",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert content["m.new_content"]["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
assert content["m.new_content"]["formatted_body"] == (
|
||||
'Updated for <a href="https://matrix.to/#/@alice:example.org">'
|
||||
"@alice:example.org</a>"
|
||||
)
|
||||
assert content["formatted_body"] == (
|
||||
'* Updated for <a href="https://matrix.to/#/@alice:example.org">'
|
||||
"@alice:example.org</a>"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_simple_notice_adds_mentions(self):
|
||||
result = await self.adapter._send_simple_message(
|
||||
"!room1:example.org",
|
||||
"Heads up @alice:example.org",
|
||||
msgtype="m.notice",
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
content = self._sent_content(self.mock_client)
|
||||
assert content["msgtype"] == "m.notice"
|
||||
assert content["m.mentions"] == {"user_ids": ["@alice:example.org"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Require-mention gating in _on_room_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_default_ignores_unmentioned(monkeypatch):
|
||||
"""Default (require_mention=true): messages without mention are ignored."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello everyone")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_default_processes_mentioned(monkeypatch):
|
||||
"""Default: messages with mention are processed, mention stripped."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("@hermes:example.org help me")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_html_pill(monkeypatch):
|
||||
"""Bot mentioned via HTML pill should be processed."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
formatted = '<a href="https://matrix.to/#/@hermes:example.org">Hermes</a> help'
|
||||
event = _make_event("Hermes help", formatted_body=formatted)
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_m_mentions_user_ids(monkeypatch):
|
||||
"""m.mentions.user_ids is authoritative per MSC3952 — no body mention needed.
|
||||
|
||||
Ported from openclaw/openclaw#64796.
|
||||
"""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
# Body has NO mention, but m.mentions.user_ids includes the bot.
|
||||
event = _make_event(
|
||||
"please reply",
|
||||
mention_user_ids=["@hermes:example.org"],
|
||||
)
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_m_mentions_other_user_ignored(monkeypatch):
|
||||
"""m.mentions.user_ids mentioning another user should NOT activate the bot."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event(
|
||||
"hey alice check this",
|
||||
mention_user_ids=["@alice:example.org"],
|
||||
)
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_dm_always_responds(monkeypatch):
|
||||
"""DMs always respond regardless of mention setting."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
# Mark the room as a DM via the adapter's cache.
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hello without mention")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_strips_full_mxid(monkeypatch):
|
||||
"""DMs strip the full MXID from body when require_mention is on (default)."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_preserves_localpart_in_body(monkeypatch):
|
||||
"""DMs no longer strip bare localpart — only the full MXID is removed."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hermes help me")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "hermes help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_mention_passes_empty_string(monkeypatch):
|
||||
"""A message that is only a mention should pass through as empty, not be dropped."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("@hermes:example.org")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_free_response_room(monkeypatch):
|
||||
"""Free-response rooms bypass mention requirement."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_FREE_RESPONSE_ROOMS", "!room1:example.org,!room2:example.org"
|
||||
)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello without mention", room_id="!room1:example.org")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_bot_participated_thread(monkeypatch):
|
||||
"""Threads with prior bot participation bypass mention requirement."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._threads.mark("$thread1")
|
||||
|
||||
event = _make_event("hello without mention", thread_id="$thread1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_disabled(monkeypatch):
|
||||
"""MATRIX_REQUIRE_MENTION=false: all messages processed."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello without mention")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "hello without mention"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_require_mention_disabled_skips_stripping(monkeypatch):
|
||||
"""MATRIX_REQUIRE_MENTION=false: mention text is NOT stripped from body."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("@hermes:example.org help me")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.text == "@hermes:example.org help me"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auto-thread in _on_room_message
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_default_creates_thread(monkeypatch):
|
||||
"""Default (auto_thread=true): sets thread_id to event.event_id."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$msg1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_preserves_existing_thread(monkeypatch):
|
||||
"""If message is already in a thread, thread_id is not overridden."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
adapter._threads.mark("$thread_root")
|
||||
event = _make_event("reply in thread", thread_id="$thread_root")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$thread_root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_skips_dm(monkeypatch):
|
||||
"""DMs should not get auto-threaded."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hello dm", event_id="$dm1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_disabled(monkeypatch):
|
||||
"""MATRIX_AUTO_THREAD=false: thread_id stays None."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_thread_tracks_participation(monkeypatch):
|
||||
"""Auto-created threads are tracked in _threads."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "false")
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
adapter = _make_adapter()
|
||||
event = _make_event("hello", event_id="$msg1")
|
||||
|
||||
with patch.object(adapter._threads, "_save"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
assert "$msg1" in adapter._threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestThreadPersistence:
|
||||
def test_empty_state_file(self, tmp_path, monkeypatch):
|
||||
"""No state file → empty set."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: tmp_path / "matrix_threads.json",
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
assert "$nonexistent" not in adapter._threads
|
||||
|
||||
def test_track_thread_persists(self, tmp_path, monkeypatch):
|
||||
"""mark() writes to disk."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
adapter._threads.mark("$thread_abc")
|
||||
|
||||
data = json.loads(state_path.read_text())
|
||||
assert "$thread_abc" in data
|
||||
|
||||
def test_threads_survive_reload(self, tmp_path, monkeypatch):
|
||||
"""Persisted threads are loaded by a new adapter instance."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
state_path.write_text(json.dumps(["$t1", "$t2"]))
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
assert "$t1" in adapter._threads
|
||||
assert "$t2" in adapter._threads
|
||||
|
||||
def test_cap_max_tracked_threads(self, tmp_path, monkeypatch):
|
||||
"""Thread set is trimmed to max_tracked."""
|
||||
from gateway.platforms.helpers import ThreadParticipationTracker
|
||||
|
||||
state_path = tmp_path / "matrix_threads.json"
|
||||
monkeypatch.setattr(
|
||||
ThreadParticipationTracker,
|
||||
"_state_path",
|
||||
lambda self: state_path,
|
||||
)
|
||||
adapter = _make_adapter()
|
||||
adapter._threads._max_tracked = 5
|
||||
|
||||
for i in range(10):
|
||||
adapter._threads.mark(f"$t{i}")
|
||||
|
||||
data = json.loads(state_path.read_text())
|
||||
assert len(data) == 5
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DM mention-thread feature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_disabled_by_default(monkeypatch):
|
||||
"""Default (dm_mention_threads=false): DM with mention should NOT create a thread."""
|
||||
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_creates_thread(monkeypatch):
|
||||
"""MATRIX_DM_MENTION_THREADS=true: DM with @mention creates a thread."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help me", event_id="$dm1")
|
||||
|
||||
with patch.object(adapter._threads, "_save"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$dm1"
|
||||
assert msg.text == "help me"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_no_mention_no_thread(monkeypatch):
|
||||
"""MATRIX_DM_MENTION_THREADS=true: DM without mention does NOT create a thread."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("hello without mention", event_id="$dm1")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_preserves_existing_thread(monkeypatch):
|
||||
"""MATRIX_DM_MENTION_THREADS=true: DM already in a thread keeps that thread_id."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
adapter._threads.mark("$existing_thread")
|
||||
event = _make_event("@hermes:example.org help me", thread_id="$existing_thread")
|
||||
|
||||
await adapter._on_room_message(event)
|
||||
adapter.handle_message.assert_awaited_once()
|
||||
msg = adapter.handle_message.await_args.args[0]
|
||||
assert msg.source.thread_id == "$existing_thread"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_mention_thread_tracks_participation(monkeypatch):
|
||||
"""DM mention-thread tracks the thread in _threads."""
|
||||
monkeypatch.setenv("MATRIX_DM_MENTION_THREADS", "true")
|
||||
monkeypatch.setenv("MATRIX_AUTO_THREAD", "false")
|
||||
|
||||
adapter = _make_adapter()
|
||||
_set_dm(adapter)
|
||||
event = _make_event("@hermes:example.org help", event_id="$dm1")
|
||||
|
||||
with patch.object(adapter._threads, "_save"):
|
||||
await adapter._on_room_message(event)
|
||||
|
||||
assert "$dm1" in adapter._threads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML config bridge
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMatrixConfigBridge:
|
||||
def test_yaml_bridge_sets_env_vars(self, monkeypatch, tmp_path):
|
||||
"""Matrix YAML config should bridge to env vars."""
|
||||
monkeypatch.delenv("MATRIX_REQUIRE_MENTION", raising=False)
|
||||
monkeypatch.delenv("MATRIX_FREE_RESPONSE_ROOMS", raising=False)
|
||||
monkeypatch.delenv("MATRIX_AUTO_THREAD", raising=False)
|
||||
|
||||
yaml_content = {
|
||||
"matrix": {
|
||||
"require_mention": False,
|
||||
"free_response_rooms": ["!room1:example.org", "!room2:example.org"],
|
||||
"auto_thread": False,
|
||||
}
|
||||
}
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(yaml_content))
|
||||
|
||||
# Simulate the bridge logic from gateway/config.py
|
||||
yaml_cfg = yaml.safe_load(config_file.read_text())
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "require_mention" in matrix_cfg and not os.getenv(
|
||||
"MATRIX_REQUIRE_MENTION"
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()
|
||||
)
|
||||
frc = matrix_cfg.get("free_response_rooms")
|
||||
if frc is not None and not os.getenv("MATRIX_FREE_RESPONSE_ROOMS"):
|
||||
if isinstance(frc, list):
|
||||
frc = ",".join(str(v) for v in frc)
|
||||
monkeypatch.setenv("MATRIX_FREE_RESPONSE_ROOMS", str(frc))
|
||||
if "auto_thread" in matrix_cfg and not os.getenv("MATRIX_AUTO_THREAD"):
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_AUTO_THREAD", str(matrix_cfg["auto_thread"]).lower()
|
||||
)
|
||||
|
||||
assert os.getenv("MATRIX_REQUIRE_MENTION") == "false"
|
||||
assert (
|
||||
os.getenv("MATRIX_FREE_RESPONSE_ROOMS")
|
||||
== "!room1:example.org,!room2:example.org"
|
||||
)
|
||||
assert os.getenv("MATRIX_AUTO_THREAD") == "false"
|
||||
|
||||
def test_yaml_bridge_sets_dm_mention_threads(self, monkeypatch, tmp_path):
|
||||
"""Matrix YAML dm_mention_threads should bridge to env var."""
|
||||
monkeypatch.delenv("MATRIX_DM_MENTION_THREADS", raising=False)
|
||||
|
||||
import os
|
||||
|
||||
import yaml
|
||||
|
||||
yaml_content = {"matrix": {"dm_mention_threads": True}}
|
||||
config_file = tmp_path / "config.yaml"
|
||||
config_file.write_text(yaml.dump(yaml_content))
|
||||
|
||||
yaml_cfg = yaml.safe_load(config_file.read_text())
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if isinstance(matrix_cfg, dict):
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv(
|
||||
"MATRIX_DM_MENTION_THREADS"
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_DM_MENTION_THREADS",
|
||||
str(matrix_cfg["dm_mention_threads"]).lower(),
|
||||
)
|
||||
|
||||
assert os.getenv("MATRIX_DM_MENTION_THREADS") == "true"
|
||||
|
||||
def test_env_vars_take_precedence_over_yaml(self, monkeypatch):
|
||||
"""Env vars should not be overwritten by YAML values."""
|
||||
monkeypatch.setenv("MATRIX_REQUIRE_MENTION", "true")
|
||||
|
||||
import os
|
||||
|
||||
yaml_cfg = {"matrix": {"require_mention": False}}
|
||||
matrix_cfg = yaml_cfg.get("matrix", {})
|
||||
if "require_mention" in matrix_cfg and not os.getenv("MATRIX_REQUIRE_MENTION"):
|
||||
monkeypatch.setenv(
|
||||
"MATRIX_REQUIRE_MENTION", str(matrix_cfg["require_mention"]).lower()
|
||||
)
|
||||
|
||||
assert os.getenv("MATRIX_REQUIRE_MENTION") == "true"
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Tests for Matrix voice message support (MSC3245).
|
||||
|
||||
Updated for the mautrix-python SDK (no more matrix-nio / nio imports).
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
# Try importing mautrix; skip entire file if not available.
|
||||
try:
|
||||
import mautrix as _mautrix_probe
|
||||
if not isinstance(_mautrix_probe, types.ModuleType) or not hasattr(_mautrix_probe, "__file__"):
|
||||
pytest.skip("mautrix in sys.modules is a mock, not the real package", allow_module_level=True)
|
||||
except ImportError:
|
||||
pytest.skip("mautrix not installed", allow_module_level=True)
|
||||
|
||||
from gateway.platforms.base import MessageType
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_adapter():
|
||||
"""Create a MatrixAdapter with mocked config."""
|
||||
from hermes_agent_matrix import MatrixAdapter
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="***",
|
||||
extra={
|
||||
"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org",
|
||||
},
|
||||
)
|
||||
adapter = MatrixAdapter(config)
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_audio_event(
|
||||
event_id: str = "$audio_event",
|
||||
sender: str = "@alice:example.org",
|
||||
room_id: str = "!test:example.org",
|
||||
body: str = "Voice message",
|
||||
url: str = "mxc://example.org/abc123",
|
||||
is_voice: bool = False,
|
||||
mimetype: str = "audio/ogg",
|
||||
timestamp: int = 9999999999000, # ms
|
||||
):
|
||||
"""
|
||||
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:
|
||||
is_voice: If True, adds org.matrix.msc3245.voice field to content.
|
||||
"""
|
||||
content = {
|
||||
"msgtype": "m.audio",
|
||||
"body": body,
|
||||
"url": url,
|
||||
"info": {
|
||||
"mimetype": mimetype,
|
||||
},
|
||||
}
|
||||
|
||||
if is_voice:
|
||||
content["org.matrix.msc3245.voice"] = {}
|
||||
|
||||
event = SimpleNamespace(
|
||||
event_id=event_id,
|
||||
sender=sender,
|
||||
room_id=room_id,
|
||||
timestamp=timestamp,
|
||||
content=content,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
def _make_state_store(member_count: int = 2):
|
||||
"""Create a mock state store with get_members/get_member support."""
|
||||
store = MagicMock()
|
||||
# get_members returns a list of member user IDs
|
||||
members = [MagicMock() for _ in range(member_count)]
|
||||
store.get_members = AsyncMock(return_value=members)
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixVoiceMessageDetection:
|
||||
"""Test that MSC3245 voice messages are detected and tagged correctly."""
|
||||
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
self.adapter._startup_ts = 0.0
|
||||
self.adapter._dm_rooms = {}
|
||||
self.adapter._message_handler = AsyncMock()
|
||||
# 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:]}"
|
||||
# Mock client for authenticated download — download_media returns bytes directly
|
||||
self.adapter._client = MagicMock()
|
||||
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
|
||||
async def test_voice_message_has_type_voice(self):
|
||||
"""Voice messages (with MSC3245 field) should be MessageType.VOICE."""
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
# Capture the MessageEvent passed to handle_message
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None, "No event was captured"
|
||||
assert captured_event.message_type == MessageType.VOICE, \
|
||||
f"Expected MessageType.VOICE, got {captured_event.message_type}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_message_has_local_path(self):
|
||||
"""Voice messages should have a local cached path in media_urls."""
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
assert len(captured_event.media_urls) > 0
|
||||
# Should be a local path, not an HTTP URL
|
||||
assert not captured_event.media_urls[0].startswith("http"), \
|
||||
f"media_urls should contain local path, got {captured_event.media_urls[0]}"
|
||||
# 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"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_audio_without_msc3245_stays_audio_type(self):
|
||||
"""Regular audio uploads (no MSC3245 field) should remain MessageType.AUDIO."""
|
||||
event = _make_audio_event(is_voice=False) # NOT a voice message
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.message_type == MessageType.AUDIO, \
|
||||
f"Expected MessageType.AUDIO for non-voice, got {captured_event.message_type}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regular_audio_is_cached_locally(self):
|
||||
"""Regular audio uploads are cached locally for downstream tool access.
|
||||
|
||||
Since PR #bec02f37 (encrypted-media caching refactor), all media
|
||||
types — photo, audio, video, document — are cached locally when
|
||||
received so tools can read them as real files. This applies equally
|
||||
to voice messages and regular audio.
|
||||
"""
|
||||
event = _make_audio_event(is_voice=False)
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should be a local path, not an HTTP URL.
|
||||
assert not captured_event.media_urls[0].startswith("http"), \
|
||||
f"Regular audio should be cached locally, got {captured_event.media_urls[0]}"
|
||||
self.adapter._client.download_media.assert_awaited_once()
|
||||
assert captured_event.media_types == ["audio/ogg"]
|
||||
|
||||
|
||||
class TestMatrixVoiceCacheFallback:
|
||||
"""Test graceful fallback when voice caching fails."""
|
||||
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
self.adapter._startup_ts = 0.0
|
||||
self.adapter._dm_rooms = {}
|
||||
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._client = MagicMock()
|
||||
self.adapter._client.state_store = _make_state_store()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_cache_failure_falls_back_to_http_url(self):
|
||||
"""If caching fails (download returns None), voice message should still be delivered with HTTP URL."""
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
# download_media returns None on failure
|
||||
self.adapter._client.download_media = AsyncMock(return_value=None)
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
# Should fall back to HTTP URL
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Should fall back to HTTP URL on cache failure, got {captured_event.media_urls[0]}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_voice_cache_exception_falls_back_to_http_url(self):
|
||||
"""Unexpected download exceptions should also fall back to HTTP URL."""
|
||||
event = _make_audio_event(is_voice=True)
|
||||
|
||||
self.adapter._client.download_media = AsyncMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
captured_event = None
|
||||
|
||||
async def capture(msg_event):
|
||||
nonlocal captured_event
|
||||
captured_event = msg_event
|
||||
|
||||
self.adapter.handle_message = capture
|
||||
|
||||
await self.adapter._on_room_message(event)
|
||||
|
||||
assert captured_event is not None
|
||||
assert captured_event.media_urls is not None
|
||||
assert captured_event.media_urls[0].startswith("http"), \
|
||||
f"Should fall back to HTTP URL on exception, got {captured_event.media_urls[0]}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: send_voice includes MSC3245 field
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMatrixSendVoiceMSC3245:
|
||||
"""Test that send_voice includes MSC3245 field for native voice rendering."""
|
||||
|
||||
def setup_method(self):
|
||||
self.adapter = _make_adapter()
|
||||
self.adapter._user_id = "@bot:example.org"
|
||||
# Mock client — upload_media returns a ContentURI string
|
||||
self.adapter._client = MagicMock()
|
||||
self.upload_call = None
|
||||
|
||||
async def mock_upload_media(data, mime_type=None, filename=None, **kwargs):
|
||||
self.upload_call = {"data": data, "mime_type": mime_type, "filename": filename}
|
||||
return "mxc://example.org/uploaded"
|
||||
|
||||
self.adapter._client.upload_media = mock_upload_media
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@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."""
|
||||
# Create a temp audio file
|
||||
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as f:
|
||||
f.write(b"fake audio data")
|
||||
temp_path = f.name
|
||||
|
||||
try:
|
||||
# Capture the message content sent via send_message_event
|
||||
sent_content = None
|
||||
|
||||
async def mock_send_message_event(room_id, event_type, content):
|
||||
nonlocal sent_content
|
||||
sent_content = content
|
||||
# send_message_event returns an EventID string
|
||||
return "$sent_event"
|
||||
|
||||
self.adapter._client.send_message_event = mock_send_message_event
|
||||
|
||||
await self.adapter.send_voice(
|
||||
chat_id="!room:example.org",
|
||||
audio_path=temp_path,
|
||||
caption="Test voice",
|
||||
)
|
||||
|
||||
assert sent_content is not None, "No message was sent"
|
||||
assert "org.matrix.msc3245.voice" in sent_content, \
|
||||
f"MSC3245 voice field missing from content: {sent_content.keys()}"
|
||||
assert sent_content["msgtype"] == "m.audio"
|
||||
assert sent_content["info"]["mimetype"] == "audio/ogg"
|
||||
assert self.upload_call is not None, "Expected upload_media() to be called"
|
||||
assert isinstance(self.upload_call["data"], bytes)
|
||||
assert self.upload_call["mime_type"] == "audio/ogg"
|
||||
assert self.upload_call["filename"].endswith(".ogg")
|
||||
|
||||
finally:
|
||||
os.unlink(temp_path)
|
||||
Reference in New Issue
Block a user