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

@@ -102,8 +102,11 @@ Your workspace is at: {workspace_path}
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
Use the 'message' tool only when you need explicit channel delivery behavior:
- Send to a different channel/chat than the current session
- Send one or more file attachments via `media` (local file paths)
For normal conversation text, respond directly without calling the message tool.
Do not claim that attachments are impossible if a channel supports file send and you can provide local paths.
Always be helpful, accurate, and concise. Before calling tools, briefly tell the user what you're about to do (one short sentence in the user's language).
When remembering something important, write to {workspace_path}/memory/MEMORY.md

View File

@@ -1,6 +1,6 @@
"""Message tool for sending messages to users."""
from typing import Any, Callable, Awaitable
from typing import Any, Awaitable, Callable
from nanobot.agent.tools.base import Tool
from nanobot.bus.events import OutboundMessage
@@ -8,84 +8,89 @@ from nanobot.bus.events import OutboundMessage
class MessageTool(Tool):
"""Tool to send messages to users on chat channels."""
def __init__(
self,
self,
send_callback: Callable[[OutboundMessage], Awaitable[None]] | None = None,
default_channel: str = "",
default_chat_id: str = ""
default_chat_id: str = "",
):
self._send_callback = send_callback
self._default_channel = default_channel
self._default_chat_id = default_chat_id
def set_context(self, channel: str, chat_id: str) -> None:
"""Set the current message context."""
self._default_channel = channel
self._default_chat_id = chat_id
def set_send_callback(self, callback: Callable[[OutboundMessage], Awaitable[None]]) -> None:
"""Set the callback for sending messages."""
self._send_callback = callback
@property
def name(self) -> str:
return "message"
@property
def description(self) -> str:
return "Send a message to the user. Use this when you want to communicate something."
return (
"Send a message to the user. Supports optional media/attachment "
"paths for channels that can send files."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"content": {
"type": "string",
"description": "The message content to send"
},
"content": {"type": "string", "description": "The message content to send"},
"channel": {
"type": "string",
"description": "Optional: target channel (telegram, discord, etc.)"
"description": "Optional: target channel (telegram, discord, etc.)",
},
"chat_id": {
"type": "string",
"description": "Optional: target chat/user ID"
"chat_id": {"type": "string", "description": "Optional: target chat/user ID"},
"media": {
"type": "array",
"description": "Optional: local file paths to send as attachments",
"items": {"type": "string"},
},
"chat_id": {"type": "string", "description": "Optional: target chat/user ID"},
"media": {
"type": "array",
"items": {"type": "string"},
"description": "Optional: list of file paths to attach (images, audio, documents)"
}
"description": "Optional: list of file paths to attach (images, audio, documents)",
},
},
"required": ["content"]
"required": ["content"],
}
async def execute(
self,
content: str,
channel: str | None = None,
self,
content: str,
channel: str | None = None,
chat_id: str | None = None,
media: list[str] | None = None,
**kwargs: Any
**kwargs: Any,
) -> str:
channel = channel or self._default_channel
chat_id = chat_id or self._default_chat_id
if not channel or not chat_id:
return "Error: No target channel/chat specified"
if not self._send_callback:
return "Error: Message sending not configured"
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
media=media or []
)
media_paths: list[str] = []
for item in media or []:
if isinstance(item, str):
candidate = item.strip()
if candidate:
media_paths.append(candidate)
msg = OutboundMessage(channel=channel, chat_id=chat_id, content=content, media=media or [])
try:
await self._send_callback(msg)
media_info = f" with {len(media)} attachments" if media else ""

View File

@@ -374,6 +374,7 @@ class MatrixChannel(BaseChannel):
mime: str,
size_bytes: int,
mxc_url: str,
encryption_info: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Build Matrix content payload for an uploaded file/image/audio/video."""
msgtype = "m.file"
@@ -384,11 +385,10 @@ class MatrixChannel(BaseChannel):
elif mime.startswith("video/"):
msgtype = "m.video"
return {
content: dict[str, Any] = {
"msgtype": msgtype,
"body": filename,
"filename": filename,
"url": mxc_url,
"info": {
"mimetype": mime,
"size": size_bytes,
@@ -396,6 +396,24 @@ class MatrixChannel(BaseChannel):
"m.mentions": {},
}
if encryption_info:
# Encrypted media events use `file` metadata (with url/hash/key/iv),
# while unencrypted media events use top-level `url`.
file_info = dict(encryption_info)
file_info["url"] = mxc_url
content["file"] = file_info
else:
content["url"] = mxc_url
return content
def _is_encrypted_room(self, room_id: str) -> bool:
"""Return True if the Matrix room is known as encrypted."""
if not self.client:
return False
room = getattr(self.client, "rooms", {}).get(room_id)
return bool(getattr(room, "encrypted", False))
async def _send_room_content(self, room_id: str, content: dict[str, Any]) -> None:
"""Send Matrix m.room.message content with configured E2EE send options."""
if not self.client:
@@ -513,25 +531,29 @@ class MatrixChannel(BaseChannel):
)
return MATRIX_ATTACHMENT_TOO_LARGE_TEMPLATE.format(filename)
mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream"
encrypt_upload = self.config.e2ee_enabled and self._is_encrypted_room(room_id)
try:
data = resolved.read_bytes()
except OSError as e:
with resolved.open("rb") as data_provider:
upload_result = await self.client.upload(
data_provider,
content_type=mime,
filename=filename,
encrypt=encrypt_upload,
filesize=size_bytes,
)
except Exception as e:
logger.warning(
"Matrix outbound attachment read failed for {} ({}): {}",
"Matrix outbound attachment upload failed for {} ({}): {}",
resolved,
type(e).__name__,
str(e),
)
return MATRIX_ATTACHMENT_UPLOAD_FAILED_TEMPLATE.format(filename)
mime = mimetypes.guess_type(filename, strict=False)[0] or "application/octet-stream"
upload_result = await self.client.upload(
data,
content_type=mime,
filename=filename,
filesize=len(data),
)
upload_response = upload_result[0] if isinstance(upload_result, tuple) else upload_result
encryption_info: dict[str, Any] | None = None
if isinstance(upload_result, tuple) and isinstance(upload_result[1], dict):
encryption_info = upload_result[1]
if isinstance(upload_response, UploadError):
logger.warning(
"Matrix outbound attachment upload failed for {}: {}",
@@ -552,8 +574,9 @@ class MatrixChannel(BaseChannel):
content = self._build_outbound_attachment_content(
filename=filename,
mime=mime,
size_bytes=len(data),
size_bytes=size_bytes,
mxc_url=mxc_url,
encryption_info=encryption_info,
)
try:
await self._send_room_content(room_id, content)

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"