feat(qq): prefer file_data for local uploads
This commit is contained in:
@@ -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.
|
||||
|
||||
19
README.md
19
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
|
||||
|
||||
@@ -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,6 +413,30 @@ class QQChannel(BaseChannel):
|
||||
continue
|
||||
|
||||
try:
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
"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",
|
||||
"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,
|
||||
|
||||
Reference in New Issue
Block a user