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:
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
37
tests/test_message_tool.py
Normal file
37
tests/test_message_tool.py
Normal 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"
|
||||
Reference in New Issue
Block a user