feat(qq): prefer file_data for local uploads
Some checks failed
Test Suite / test (3.12) (push) Has been cancelled
Test Suite / test (3.13) (push) Has been cancelled
Test Suite / test (3.11) (push) Has been cancelled

This commit is contained in:
Hua
2026-03-20 08:39:14 +08:00
parent e910769a9e
commit 73af8c574e
4 changed files with 194 additions and 27 deletions

View File

@@ -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`. - `/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. - `/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. - 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. - 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` 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. - `/skill uninstall` runs in a non-interactive context, so keep passing `--yes` when shelling out to ClawHub.

View File

@@ -749,15 +749,16 @@ nanobot gateway
Now send a message to the bot from QQ — it should respond! 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 Outbound QQ media sends remote `http(s)` images through the QQ rich-media `url` flow directly.
sent directly. Local image files can also be sent when `mediaBaseUrl` points to a public URL and For local image files, nanobot first publishes or maps the file to a public URL, then tries the
`mediaPublicDir` matches a directory under `workspace/public`; nanobot maps that local public path documented `file_data` upload path together with that URL; if the installed QQ SDK/runtime path
to a URL and then sends that URL through QQ. The built-in gateway route exposes does not accept that upload, nanobot falls back to the existing URL-only rich-media flow.
`workspace/public` as `/public/`, so a common setup is `mediaBaseUrl = https://your-host/public/qq/`. The built-in gateway route exposes `workspace/public` as `/public/`, so a common setup is
If you generate screenshots under `workspace/out`, nanobot will automatically create a hard link in `mediaBaseUrl = https://your-host/public/qq/`. Local QQ files are accepted from two controlled
`workspace/public/qq` first, then send that public URL. Files outside `mediaPublicDir` and locations only: files already under `mediaPublicDir`, and generated image files under
`workspace/out` are rejected. Without that publishing config, local files still fall back to a text `workspace/out`, which nanobot will automatically hard-link into `workspace/public/qq` before
notice. 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, 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 it should write them under `workspace/out` instead of the workspace root so channel publishing rules

View File

@@ -1,6 +1,7 @@
"""QQ channel implementation using botpy SDK.""" """QQ channel implementation using botpy SDK."""
import asyncio import asyncio
import base64
import os import os
import secrets import secrets
from collections import deque from collections import deque
@@ -19,16 +20,19 @@ from nanobot.utils.helpers import detect_image_mime, ensure_dir
try: try:
import botpy import botpy
from botpy.http import Route
from botpy.message import C2CMessage, GroupMessage from botpy.message import C2CMessage, GroupMessage
QQ_AVAILABLE = True QQ_AVAILABLE = True
except ImportError: except ImportError:
QQ_AVAILABLE = False QQ_AVAILABLE = False
botpy = None botpy = None
Route = None
C2CMessage = None C2CMessage = None
GroupMessage = None GroupMessage = None
if TYPE_CHECKING: if TYPE_CHECKING:
from botpy.http import Route
from botpy.message import C2CMessage, GroupMessage from botpy.message import C2CMessage, GroupMessage
@@ -233,6 +237,11 @@ class QQChannel(BaseChannel):
self._msg_seq += 1 self._msg_seq += 1
return self._msg_seq 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: 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.""" """Send a plain-text QQ message."""
payload = { payload = {
@@ -286,6 +295,48 @@ class QQChannel(BaseChannel):
msg_seq=self._next_msg_seq(), 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: async def start(self) -> None:
"""Start the QQ bot.""" """Start the QQ bot."""
if not QQ_AVAILABLE: if not QQ_AVAILABLE:
@@ -340,7 +391,9 @@ class QQChannel(BaseChannel):
for media_path in msg.media: for media_path in msg.media:
resolved_media = media_path resolved_media = media_path
local_media_path: Path | None = None
if not self._is_remote_media(media_path): 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) resolved_media, publish_error = await self._publish_local_media(media_path)
if not resolved_media: if not resolved_media:
logger.warning( logger.warning(
@@ -360,13 +413,37 @@ class QQChannel(BaseChannel):
continue continue
try: try:
await self._post_remote_media_message( if local_media_path is not None:
msg.chat_id, try:
msg_type, await self._post_local_media_message(
resolved_media, msg.chat_id,
msg.content if msg.content and not content_sent else None, msg_type,
msg_id, 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: if msg.content and not content_sent:
content_sent = True content_sent = True
except Exception as media_error: except Exception as media_error:

View File

@@ -1,4 +1,5 @@
import os import os
from base64 import b64encode
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -15,6 +16,24 @@ class _FakeApi:
self.group_calls: list[dict] = [] self.group_calls: list[dict] = []
self.c2c_file_calls: list[dict] = [] self.c2c_file_calls: list[dict] = []
self.group_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: async def post_c2c_message(self, **kwargs) -> None:
self.c2c_calls.append(kwargs) 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", "method": "POST",
"file_type": 1, "path": "/v2/users/{openid}/files",
"url": "https://files.example.com/public/qq/demo.png", "params": {"openid": "user123"},
"srv_send_msg": False, "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 == [ assert channel._client.api.c2c_calls == [
{ {
"openid": "user123", "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].name.startswith("outside-")
assert published[0].suffix == ".png" assert published[0].suffix == ".png"
assert os.stat(source).st_ino == os.stat(published[0]).st_ino 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", "method": "POST",
"file_type": 1, "path": "/v2/users/{openid}/files",
"url": f"https://files.example.com/public/qq/{published[0].name}", "params": {"openid": "user123"},
"srv_send_msg": False, "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 == [ assert channel._client.api.c2c_calls == [
{ {
"openid": "user123", "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 @pytest.mark.asyncio
async def test_send_local_media_symlink_to_outside_public_dir_is_rejected( async def test_send_local_media_symlink_to_outside_public_dir_is_rejected(
monkeypatch, monkeypatch,