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 12:15:52 +01:00
parent 1dc01235a3
commit 3de30bb15e
5 changed files with 195 additions and 46 deletions
+5 -2
View File
@@ -100,8 +100,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. When using tools, explain what you're doing.
When remembering something, write to {workspace_path}/memory/MEMORY.md"""
+41 -30
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,77 +8,88 @@ 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"},
"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"
}
},
"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,
**kwargs: Any
media: list[str] | None = None,
**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"
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
content=content,
media=media_paths,
)
try:
await self._send_callback(msg)
return f"Message sent to {channel}:{chat_id}"
+37 -14
View File
@@ -385,6 +385,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"
@@ -395,11 +396,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,
@@ -407,6 +407,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:
@@ -520,25 +538,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 {}: {}",
@@ -559,8 +581,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)
+75
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):
@@ -758,6 +774,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
@@ -766,6 +784,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())
@@ -816,6 +864,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())
+37
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"