From 73af8c574ef28b758260c7b6e9cbdc5dffdfb678 Mon Sep 17 00:00:00 2001 From: Hua Date: Fri, 20 Mar 2026 08:39:14 +0800 Subject: [PATCH] feat(qq): prefer file_data for local uploads --- AGENTS.md | 2 +- README.md | 19 +++---- nanobot/channels/qq.py | 91 +++++++++++++++++++++++++++++--- tests/test_qq_channel.py | 109 +++++++++++++++++++++++++++++++++++---- 4 files changed, 194 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 271e0db..31a7176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ Do not commit real API keys, tokens, chat logs, or workspace data. Keep local se - `/skill` currently supports `search`, `install`, `uninstall`, `list`, and `update`. Keep subcommand dispatch in `nanobot/agent/loop.py`. - `/mcp` supports the default `list` behavior (and explicit `/mcp list`) to show configured MCP servers and registered MCP tools. - Agent runtime config should be hot-reloaded from the active `config.json` for safe in-process fields such as `tools.mcpServers`, `tools.web.*`, `tools.exec.*`, `tools.restrictToWorkspace`, `agents.defaults.model`, `agents.defaults.maxToolIterations`, `agents.defaults.contextWindowTokens`, `agents.defaults.maxTokens`, `agents.defaults.temperature`, `agents.defaults.reasoningEffort`, `channels.sendProgress`, and `channels.sendToolHints`. Channel connection settings and provider credentials still require a restart. -- QQ outbound media uses QQ's URL-based rich-media API. Remote `http(s)` image URLs can be sent directly. Local files are allowed from two controlled locations only: the configured `mediaPublicDir` inside `workspace/public`, and generated image files under `workspace/out`, which the QQ channel may hard-link into `public/` automatically before sending. Do not auto-publish from any other directory. +- QQ outbound media sends remote `http(s)` image URLs directly. For local QQ images, prefer the documented rich-media `file_data` upload path together with the public `url`, and keep the URL-only flow as a fallback for SDK/runtime compatibility. Local files are allowed from two controlled locations only: the configured `mediaPublicDir` inside `workspace/public`, and generated image files under `workspace/out`, which the QQ channel may hard-link into `public/` automatically before sending. Do not auto-publish from any other directory. - Generated screenshots, downloads, and other temporary user-delivery artifacts should be written under `workspace/out`, not the workspace root. Channel publishing rules assume that location. - `/skill` shells out to `npx clawhub@latest`; it requires Node.js/`npx` at runtime. - `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub. diff --git a/README.md b/README.md index 22c1a50..4109114 100644 --- a/README.md +++ b/README.md @@ -749,15 +749,16 @@ nanobot gateway Now send a message to the bot from QQ — it should respond! -Outbound QQ media always uses the QQ `url`-based rich-media API. Remote `http(s)` image URLs can be -sent directly. Local image files can also be sent when `mediaBaseUrl` points to a public URL and -`mediaPublicDir` matches a directory under `workspace/public`; nanobot maps that local public path -to a URL and then sends that URL through QQ. The built-in gateway route exposes -`workspace/public` as `/public/`, so a common setup is `mediaBaseUrl = https://your-host/public/qq/`. -If you generate screenshots under `workspace/out`, nanobot will automatically create a hard link in -`workspace/public/qq` first, then send that public URL. Files outside `mediaPublicDir` and -`workspace/out` are rejected. Without that publishing config, local files still fall back to a text -notice. +Outbound QQ media sends remote `http(s)` images through the QQ rich-media `url` flow directly. +For local image files, nanobot first publishes or maps the file to a public URL, then tries the +documented `file_data` upload path together with that URL; if the installed QQ SDK/runtime path +does not accept that upload, nanobot falls back to the existing URL-only rich-media flow. +The built-in gateway route exposes `workspace/public` as `/public/`, so a common setup is +`mediaBaseUrl = https://your-host/public/qq/`. Local QQ files are accepted from two controlled +locations only: files already under `mediaPublicDir`, and generated image files under +`workspace/out`, which nanobot will automatically hard-link into `workspace/public/qq` before +sending. Files outside `mediaPublicDir` and `workspace/out` are rejected. Without that publishing +config, local files still fall back to a text notice. When an agent uses shell/browser tools to create screenshots or other temporary files for delivery, it should write them under `workspace/out` instead of the workspace root so channel publishing rules diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 8f44dc8..2d6f98e 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -1,6 +1,7 @@ """QQ channel implementation using botpy SDK.""" import asyncio +import base64 import os import secrets from collections import deque @@ -19,16 +20,19 @@ from nanobot.utils.helpers import detect_image_mime, ensure_dir try: import botpy + from botpy.http import Route from botpy.message import C2CMessage, GroupMessage QQ_AVAILABLE = True except ImportError: QQ_AVAILABLE = False botpy = None + Route = None C2CMessage = None GroupMessage = None if TYPE_CHECKING: + from botpy.http import Route from botpy.message import C2CMessage, GroupMessage @@ -233,6 +237,11 @@ class QQChannel(BaseChannel): self._msg_seq += 1 return self._msg_seq + @staticmethod + def _encode_file_data(path: Path) -> str: + """Encode a local media file as base64 for QQ rich-media upload.""" + return base64.b64encode(path.read_bytes()).decode("ascii") + async def _post_text_message(self, chat_id: str, msg_type: str, content: str, msg_id: str | None) -> None: """Send a plain-text QQ message.""" payload = { @@ -286,6 +295,48 @@ class QQChannel(BaseChannel): msg_seq=self._next_msg_seq(), ) + async def _post_local_media_message( + self, + chat_id: str, + msg_type: str, + media_url: str, + local_path: Path, + content: str | None, + msg_id: str | None, + ) -> None: + """Upload a local QQ image using the documented file_data field, then send it.""" + if not self._client or Route is None: + raise RuntimeError("QQ client not initialized") + + payload = { + "file_type": 1, + "url": media_url, + "file_data": self._encode_file_data(local_path), + "srv_send_msg": False, + } + if msg_type == "group": + route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=chat_id) + media = await self._client.api._http.request(route, json=payload) + await self._client.api.post_group_message( + group_openid=chat_id, + msg_type=7, + content=content, + media=media, + msg_id=msg_id, + msg_seq=self._next_msg_seq(), + ) + else: + route = Route("POST", "/v2/users/{openid}/files", openid=chat_id) + media = await self._client.api._http.request(route, json=payload) + await self._client.api.post_c2c_message( + openid=chat_id, + msg_type=7, + content=content, + media=media, + msg_id=msg_id, + msg_seq=self._next_msg_seq(), + ) + async def start(self) -> None: """Start the QQ bot.""" if not QQ_AVAILABLE: @@ -340,7 +391,9 @@ class QQChannel(BaseChannel): for media_path in msg.media: resolved_media = media_path + local_media_path: Path | None = None if not self._is_remote_media(media_path): + local_media_path = Path(media_path).expanduser() resolved_media, publish_error = await self._publish_local_media(media_path) if not resolved_media: logger.warning( @@ -360,13 +413,37 @@ class QQChannel(BaseChannel): continue try: - await self._post_remote_media_message( - msg.chat_id, - msg_type, - resolved_media, - msg.content if msg.content and not content_sent else None, - msg_id, - ) + if local_media_path is not None: + try: + await self._post_local_media_message( + msg.chat_id, + msg_type, + resolved_media, + local_media_path.resolve(strict=True), + msg.content if msg.content and not content_sent else None, + msg_id, + ) + except Exception as local_upload_error: + logger.warning( + "QQ local file_data upload failed for {}: {}, falling back to URL-only upload", + local_media_path, + local_upload_error, + ) + await self._post_remote_media_message( + msg.chat_id, + msg_type, + resolved_media, + msg.content if msg.content and not content_sent else None, + msg_id, + ) + else: + await self._post_remote_media_message( + msg.chat_id, + msg_type, + resolved_media, + msg.content if msg.content and not content_sent else None, + msg_id, + ) if msg.content and not content_sent: content_sent = True except Exception as media_error: diff --git a/tests/test_qq_channel.py b/tests/test_qq_channel.py index acf635c..519d34d 100644 --- a/tests/test_qq_channel.py +++ b/tests/test_qq_channel.py @@ -1,4 +1,5 @@ import os +from base64 import b64encode from types import SimpleNamespace import pytest @@ -15,6 +16,24 @@ class _FakeApi: self.group_calls: list[dict] = [] self.c2c_file_calls: list[dict] = [] self.group_file_calls: list[dict] = [] + self.raw_file_upload_calls: list[dict] = [] + self.raise_on_raw_file_upload = False + self._http = SimpleNamespace(request=self._request) + + async def _request(self, route, json=None, **kwargs) -> dict: + if self.raise_on_raw_file_upload: + raise RuntimeError("raw upload failed") + self.raw_file_upload_calls.append( + { + "method": route.method, + "path": route.path, + "params": route.parameters, + "json": json, + } + ) + if "/groups/" in route.path: + return {"file_info": "group-file-info", "file_uuid": "group-file", "ttl": 60} + return {"file_info": "c2c-file-info", "file_uuid": "c2c-file", "ttl": 60} async def post_c2c_message(self, **kwargs) -> None: self.c2c_calls.append(kwargs) @@ -210,14 +229,20 @@ async def test_send_local_media_under_public_dir_uses_c2c_file_api( ) ) - assert channel._client.api.c2c_file_calls == [ + assert channel._client.api.raw_file_upload_calls == [ { - "openid": "user123", - "file_type": 1, - "url": "https://files.example.com/public/qq/demo.png", - "srv_send_msg": False, + "method": "POST", + "path": "/v2/users/{openid}/files", + "params": {"openid": "user123"}, + "json": { + "file_type": 1, + "url": "https://files.example.com/public/qq/demo.png", + "file_data": b64encode(b"fake-png").decode("ascii"), + "srv_send_msg": False, + }, } ] + assert channel._client.api.c2c_file_calls == [] assert channel._client.api.c2c_calls == [ { "openid": "user123", @@ -272,14 +297,20 @@ async def test_send_local_media_from_out_auto_links_into_public_then_uses_c2c_fi assert published[0].name.startswith("outside-") assert published[0].suffix == ".png" assert os.stat(source).st_ino == os.stat(published[0]).st_ino - assert channel._client.api.c2c_file_calls == [ + assert channel._client.api.raw_file_upload_calls == [ { - "openid": "user123", - "file_type": 1, - "url": f"https://files.example.com/public/qq/{published[0].name}", - "srv_send_msg": False, + "method": "POST", + "path": "/v2/users/{openid}/files", + "params": {"openid": "user123"}, + "json": { + "file_type": 1, + "url": f"https://files.example.com/public/qq/{published[0].name}", + "file_data": b64encode(b"\x89PNG\r\n\x1a\nfake-png").decode("ascii"), + "srv_send_msg": False, + }, } ] + assert channel._client.api.c2c_file_calls == [] assert channel._client.api.c2c_calls == [ { "openid": "user123", @@ -342,6 +373,64 @@ async def test_send_local_media_outside_public_and_out_falls_back_to_text_notice ] +@pytest.mark.asyncio +async def test_send_local_media_falls_back_to_url_only_upload_when_file_data_upload_fails( + monkeypatch, + tmp_path, +) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + public_dir = workspace / "public" / "qq" + public_dir.mkdir(parents=True) + source = public_dir / "demo.png" + source.write_bytes(b"fake-png") + + channel = QQChannel( + QQConfig( + app_id="app", + secret="secret", + allow_from=["*"], + media_base_url="https://files.example.com/public/qq", + media_public_dir="public/qq", + media_ttl_seconds=0, + ), + MessageBus(), + workspace=workspace, + ) + channel._client = _FakeClient() + channel._client.api.raise_on_raw_file_upload = True + monkeypatch.setattr("nanobot.channels.qq.validate_url_target", lambda url: (True, "")) + + await channel.send( + OutboundMessage( + channel="qq", + chat_id="user123", + content="hello", + media=[str(source)], + metadata={"message_id": "msg1"}, + ) + ) + + assert channel._client.api.c2c_file_calls == [ + { + "openid": "user123", + "file_type": 1, + "url": "https://files.example.com/public/qq/demo.png", + "srv_send_msg": False, + } + ] + assert channel._client.api.c2c_calls == [ + { + "openid": "user123", + "msg_type": 7, + "content": "hello", + "media": {"file_info": "c2c-file-info", "file_uuid": "c2c-file", "ttl": 60}, + "msg_id": "msg1", + "msg_seq": 2, + } + ] + + @pytest.mark.asyncio async def test_send_local_media_symlink_to_outside_public_dir_is_rejected( monkeypatch,