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`.
|
- `/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.
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user