feat(matrix): support outbound attachments via message tool

- extend message tool with optional media paths for channel delivery

- switch Matrix uploads to stream providers and handle encrypted-room payloads

- add/expand tests for message tool media forwarding and Matrix upload edge cases
This commit is contained in:
Alexander Minges
2026-02-11 11:50:36 +01:00
parent d4d87bb4e5
commit 6a40665753
5 changed files with 195 additions and 52 deletions

View File

@@ -45,6 +45,7 @@ class _FakeAsyncClient:
self.join_calls: list[str] = []
self.callbacks: list[tuple[object, object]] = []
self.response_callbacks: list[tuple[object, object]] = []
self.rooms: dict[str, object] = {}
self.room_send_calls: list[dict[str, object]] = []
self.typing_calls: list[tuple[str, bool, int]] = []
self.download_calls: list[dict[str, object]] = []
@@ -122,6 +123,11 @@ class _FakeAsyncClient:
):
if self.raise_on_upload:
raise RuntimeError("upload failed")
if isinstance(data_provider, (bytes, bytearray)):
raise TypeError(
f"data_provider type {type(data_provider)!r} is not of a usable type "
"(Callable, IOBase)"
)
self.upload_calls.append(
{
"data_provider": data_provider,
@@ -133,6 +139,16 @@ class _FakeAsyncClient:
)
if self.upload_response is not None:
return self.upload_response
if encrypt:
return (
SimpleNamespace(content_uri="mxc://example.org/uploaded"),
{
"v": "v2",
"iv": "iv",
"hashes": {"sha256": "hash"},
"key": {"alg": "A256CTR", "k": "key"},
},
)
return SimpleNamespace(content_uri="mxc://example.org/uploaded"), None
async def content_repository_config(self):
@@ -775,6 +791,8 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None:
)
assert len(client.upload_calls) == 1
assert not isinstance(client.upload_calls[0]["data_provider"], (bytes, bytearray))
assert hasattr(client.upload_calls[0]["data_provider"], "read")
assert client.upload_calls[0]["filename"] == "test.txt"
assert client.upload_calls[0]["filesize"] == 5
assert len(client.room_send_calls) == 2
@@ -783,6 +801,36 @@ async def test_send_uploads_media_and_sends_file_event(tmp_path) -> None:
assert client.room_send_calls[1]["content"]["body"] == "Please review."
@pytest.mark.asyncio
async def test_send_uses_encrypted_media_payload_in_encrypted_room(tmp_path) -> None:
channel = MatrixChannel(_make_config(e2ee_enabled=True), MessageBus())
client = _FakeAsyncClient("", "", "", None)
client.rooms["!encrypted:matrix.org"] = SimpleNamespace(encrypted=True)
channel.client = client
file_path = tmp_path / "secret.txt"
file_path.write_text("topsecret", encoding="utf-8")
await channel.send(
OutboundMessage(
channel="matrix",
chat_id="!encrypted:matrix.org",
content="",
media=[str(file_path)],
)
)
assert len(client.upload_calls) == 1
assert client.upload_calls[0]["encrypt"] is True
assert len(client.room_send_calls) == 1
content = client.room_send_calls[0]["content"]
assert content["msgtype"] == "m.file"
assert "file" in content
assert "url" not in content
assert content["file"]["url"] == "mxc://example.org/uploaded"
assert content["file"]["hashes"]["sha256"] == "hash"
@pytest.mark.asyncio
async def test_send_does_not_parse_attachment_marker_without_media(tmp_path) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
@@ -833,6 +881,33 @@ async def test_send_workspace_restriction_blocks_external_attachment(tmp_path) -
assert client.room_send_calls[0]["content"]["body"] == "[attachment: external.txt - upload failed]"
@pytest.mark.asyncio
async def test_send_handles_upload_exception_and_reports_failure(tmp_path) -> None:
channel = MatrixChannel(_make_config(), MessageBus())
client = _FakeAsyncClient("", "", "", None)
client.raise_on_upload = True
channel.client = client
file_path = tmp_path / "broken.txt"
file_path.write_text("hello", encoding="utf-8")
await channel.send(
OutboundMessage(
channel="matrix",
chat_id="!room:matrix.org",
content="Please review.",
media=[str(file_path)],
)
)
assert len(client.upload_calls) == 0
assert len(client.room_send_calls) == 1
assert (
client.room_send_calls[0]["content"]["body"]
== "Please review.\n[attachment: broken.txt - upload failed]"
)
@pytest.mark.asyncio
async def test_send_uses_server_upload_limit_when_smaller_than_local_limit(tmp_path) -> None:
channel = MatrixChannel(_make_config(max_media_bytes=10), MessageBus())

View File

@@ -0,0 +1,37 @@
import pytest
from nanobot.agent.tools.message import MessageTool
from nanobot.bus.events import OutboundMessage
@pytest.mark.asyncio
async def test_message_tool_sends_media_paths_with_default_context() -> None:
sent: list[OutboundMessage] = []
async def _send(msg: OutboundMessage) -> None:
sent.append(msg)
tool = MessageTool(
send_callback=_send,
default_channel="test-channel",
default_chat_id="!room:example.org",
)
result = await tool.execute(
content="Here is the file.",
media=[" /tmp/test.txt ", "", " ", "/tmp/report.pdf"],
)
assert result == "Message sent to test-channel:!room:example.org"
assert len(sent) == 1
assert sent[0].channel == "test-channel"
assert sent[0].chat_id == "!room:example.org"
assert sent[0].content == "Here is the file."
assert sent[0].media == ["/tmp/test.txt", "/tmp/report.pdf"]
@pytest.mark.asyncio
async def test_message_tool_returns_error_when_no_target_context() -> None:
tool = MessageTool()
result = await tool.execute(content="test")
assert result == "Error: No target channel/chat specified"