fix(qq): allow file_data uploads without media url
This commit is contained in:
@@ -35,7 +35,7 @@ Do not commit real API keys, tokens, chat logs, or workspace data. Keep local se
|
|||||||
- 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.
|
||||||
- nanobot does not expose local files over HTTP. If a feature needs a public URL for local files, provide your own static file server and point config such as `mediaBaseUrl` at it.
|
- nanobot does not expose local files over HTTP. If a feature needs a public URL for local files, provide your own static file server and point config such as `mediaBaseUrl` at it.
|
||||||
- Generated screenshots, downloads, and other temporary user-delivery artifacts should be written under `workspace/out`, not the workspace root. Treat that as the generic delivery-artifact root for tools, MCP servers, and skills.
|
- Generated screenshots, downloads, and other temporary user-delivery artifacts should be written under `workspace/out`, not the workspace root. Treat that as the generic delivery-artifact root for tools, MCP servers, and skills.
|
||||||
- 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. QQ consumes delivery artifacts produced elsewhere; `mediaBaseUrl` must expose those generated files through your own static file server.
|
- QQ outbound media sends remote `http(s)` image URLs directly. For local QQ images, try `file_data` upload first. If `mediaBaseUrl` is configured, keep the URL-based path available as a fallback for SDK/runtime compatibility; without it, there is no URL fallback.
|
||||||
- `/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.
|
||||||
- Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user.
|
- Treat empty `/skill search` output as a user-visible "no results" case rather than a silent success. Surface npm/registry failures directly to the user.
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -706,11 +706,10 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`mediaBaseUrl` is optional, but it is required if you want nanobot to send local screenshots or
|
`mediaBaseUrl` is optional. For local QQ images, nanobot will first try direct `file_data` upload
|
||||||
other local image files through QQ. nanobot does not serve local files over HTTP, so
|
from generated delivery artifacts under `workspace/out`. Configuring `mediaBaseUrl` is still
|
||||||
`mediaBaseUrl` must point to your own static file server. Generated delivery artifacts should be
|
recommended, because nanobot can then map those files onto your own static file server and fall
|
||||||
written under `workspace/out`, and `mediaBaseUrl` should expose that directory with matching
|
back to the URL-based rich-media flow when needed.
|
||||||
relative paths.
|
|
||||||
|
|
||||||
Multi-bot example:
|
Multi-bot example:
|
||||||
|
|
||||||
@@ -747,14 +746,11 @@ 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 sends remote `http(s)` images through the QQ rich-media `url` flow directly.
|
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
|
For local image files, nanobot always tries `file_data` upload first. When `mediaBaseUrl` is
|
||||||
documented `file_data` upload path together with that URL; if the installed QQ SDK/runtime path
|
configured, nanobot also maps the same local file onto that public URL and can fall back to the
|
||||||
does not accept that upload, nanobot falls back to the existing URL-only rich-media flow.
|
existing URL-only rich-media flow if direct upload fails. Without `mediaBaseUrl`, nanobot still
|
||||||
nanobot does not serve local files itself, so `mediaBaseUrl` must point to your own HTTP server
|
attempts direct upload, but there is no URL fallback path. Tools and skills should write
|
||||||
that exposes generated delivery artifacts. Tools and skills should write deliverable files under
|
deliverable files under `workspace/out`; QQ accepts only local image files from that directory.
|
||||||
`workspace/out`; QQ maps local image paths from that directory onto `mediaBaseUrl` using the same
|
|
||||||
relative path. Files outside `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
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ if TYPE_CHECKING:
|
|||||||
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
|
||||||
"""Create a botpy Client subclass bound to the given channel."""
|
"""Create a botpy Client subclass bound to the given channel."""
|
||||||
intents = botpy.Intents(public_messages=True, direct_message=True)
|
intents = botpy.Intents(public_messages=True, direct_message=True)
|
||||||
|
http_timeout_seconds = 20
|
||||||
|
|
||||||
class _Bot(botpy.Client):
|
class _Bot(botpy.Client):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
# Disable botpy's file log — nanobot uses loguru; default "botpy.log" fails on read-only fs
|
||||||
super().__init__(intents=intents, ext_handlers=False)
|
super().__init__(intents=intents, timeout=http_timeout_seconds, ext_handlers=False)
|
||||||
|
|
||||||
async def on_ready(self):
|
async def on_ready(self):
|
||||||
logger.info("QQ bot ready: {}", self.robot.name)
|
logger.info("QQ bot ready: {}", self.robot.name)
|
||||||
@@ -96,16 +97,17 @@ class QQChannel(BaseChannel):
|
|||||||
"""Return the active workspace root used by QQ publishing."""
|
"""Return the active workspace root used by QQ publishing."""
|
||||||
return (self._workspace or Path.cwd()).resolve(strict=False)
|
return (self._workspace or Path.cwd()).resolve(strict=False)
|
||||||
|
|
||||||
async def _publish_local_media(self, media_path: str) -> tuple[str | None, str | None]:
|
async def _publish_local_media(
|
||||||
"""Map a local delivery artifact to its served URL."""
|
self,
|
||||||
_, media_url, error = resolve_delivery_media(
|
media_path: str,
|
||||||
|
) -> tuple[Path | None, str | None, str | None]:
|
||||||
|
"""Resolve a local delivery artifact and optionally map it to its served URL."""
|
||||||
|
local_path, media_url, error = resolve_delivery_media(
|
||||||
media_path,
|
media_path,
|
||||||
self._workspace_root(),
|
self._workspace_root(),
|
||||||
self.config.media_base_url,
|
self.config.media_base_url,
|
||||||
)
|
)
|
||||||
if error:
|
return local_path, media_url, error
|
||||||
return None, error
|
|
||||||
return media_url, None
|
|
||||||
|
|
||||||
def _next_msg_seq(self) -> int:
|
def _next_msg_seq(self) -> int:
|
||||||
"""Return the next QQ message sequence number."""
|
"""Return the next QQ message sequence number."""
|
||||||
@@ -174,21 +176,22 @@ class QQChannel(BaseChannel):
|
|||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
msg_type: str,
|
msg_type: str,
|
||||||
media_url: str,
|
media_url: str | None,
|
||||||
local_path: Path,
|
local_path: Path,
|
||||||
content: str | None,
|
content: str | None,
|
||||||
msg_id: str | None,
|
msg_id: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Upload a local QQ image using the documented file_data field, then send it."""
|
"""Upload a local QQ image using file_data and, when available, a public URL."""
|
||||||
if not self._client or Route is None:
|
if not self._client or Route is None:
|
||||||
raise RuntimeError("QQ client not initialized")
|
raise RuntimeError("QQ client not initialized")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"file_type": 1,
|
"file_type": 1,
|
||||||
"url": media_url,
|
|
||||||
"file_data": self._encode_file_data(local_path),
|
"file_data": self._encode_file_data(local_path),
|
||||||
"srv_send_msg": False,
|
"srv_send_msg": False,
|
||||||
}
|
}
|
||||||
|
if media_url:
|
||||||
|
payload["url"] = media_url
|
||||||
if msg_type == "group":
|
if msg_type == "group":
|
||||||
route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=chat_id)
|
route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=chat_id)
|
||||||
media = await self._client.api._http.request(route, json=payload)
|
media = await self._client.api._http.request(route, json=payload)
|
||||||
@@ -265,9 +268,10 @@ class QQChannel(BaseChannel):
|
|||||||
resolved_media = media_path
|
resolved_media = media_path
|
||||||
local_media_path: Path | None = None
|
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()
|
local_media_path, resolved_media, publish_error = await self._publish_local_media(
|
||||||
resolved_media, publish_error = await self._publish_local_media(media_path)
|
media_path
|
||||||
if not resolved_media:
|
)
|
||||||
|
if local_media_path is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"QQ outbound local media could not be published: {} ({})",
|
"QQ outbound local media could not be published: {} ({})",
|
||||||
media_path,
|
media_path,
|
||||||
@@ -278,11 +282,12 @@ class QQChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
ok, error = validate_url_target(resolved_media)
|
if resolved_media:
|
||||||
if not ok:
|
ok, error = validate_url_target(resolved_media)
|
||||||
logger.warning("QQ outbound media blocked by URL validation: {}", error)
|
if not ok:
|
||||||
fallback_lines.append(self._failed_media_notice(media_path, error))
|
logger.warning("QQ outbound media blocked by URL validation: {}", error)
|
||||||
continue
|
fallback_lines.append(self._failed_media_notice(media_path, error))
|
||||||
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if local_media_path is not None:
|
if local_media_path is not None:
|
||||||
@@ -296,18 +301,32 @@ class QQChannel(BaseChannel):
|
|||||||
msg_id,
|
msg_id,
|
||||||
)
|
)
|
||||||
except Exception as local_upload_error:
|
except Exception as local_upload_error:
|
||||||
logger.warning(
|
if resolved_media:
|
||||||
"QQ local file_data upload failed for {}: {}, falling back to URL-only upload",
|
logger.warning(
|
||||||
local_media_path,
|
"QQ local file_data upload failed for {}: {}, falling back to URL-only upload",
|
||||||
local_upload_error,
|
local_media_path,
|
||||||
)
|
local_upload_error,
|
||||||
await self._post_remote_media_message(
|
)
|
||||||
msg.chat_id,
|
await self._post_remote_media_message(
|
||||||
msg_type,
|
msg.chat_id,
|
||||||
resolved_media,
|
msg_type,
|
||||||
msg.content if msg.content and not content_sent else None,
|
resolved_media,
|
||||||
msg_id,
|
msg.content if msg.content and not content_sent else None,
|
||||||
)
|
msg_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"QQ local file_data upload failed for {} without mediaBaseUrl fallback: {}",
|
||||||
|
local_media_path,
|
||||||
|
local_upload_error,
|
||||||
|
)
|
||||||
|
fallback_lines.append(
|
||||||
|
self._failed_media_notice(
|
||||||
|
media_path,
|
||||||
|
"QQ local file_data upload failed",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
await self._post_remote_media_message(
|
await self._post_remote_media_message(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
|
|||||||
@@ -28,11 +28,9 @@ def is_image_file(path: Path) -> bool:
|
|||||||
def resolve_delivery_media(
|
def resolve_delivery_media(
|
||||||
media_path: str | Path,
|
media_path: str | Path,
|
||||||
workspace: Path,
|
workspace: Path,
|
||||||
media_base_url: str,
|
media_base_url: str = "",
|
||||||
) -> tuple[Path | None, str | None, str | None]:
|
) -> tuple[Path | None, str | None, str | None]:
|
||||||
"""Resolve a local delivery artifact to a public URL under media_base_url."""
|
"""Resolve a local delivery artifact and optionally map it to a public URL."""
|
||||||
if not media_base_url:
|
|
||||||
return None, None, "local media publishing is not configured"
|
|
||||||
|
|
||||||
source = Path(media_path).expanduser()
|
source = Path(media_path).expanduser()
|
||||||
try:
|
try:
|
||||||
@@ -55,6 +53,9 @@ def resolve_delivery_media(
|
|||||||
if not is_image_file(resolved):
|
if not is_image_file(resolved):
|
||||||
return None, None, "local delivery media must be an image"
|
return None, None, "local delivery media must be an image"
|
||||||
|
|
||||||
|
if not media_base_url:
|
||||||
|
return resolved, None, None
|
||||||
|
|
||||||
media_url = urljoin(
|
media_url = urljoin(
|
||||||
f"{media_base_url.rstrip('/')}/",
|
f"{media_base_url.rstrip('/')}/",
|
||||||
quote(relative_path.as_posix(), safe="/"),
|
quote(relative_path.as_posix(), safe="/"),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.qq import QQChannel
|
from nanobot.channels.qq import QQChannel, _make_bot_class
|
||||||
from nanobot.config.schema import QQConfig
|
from nanobot.config.schema import QQConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +54,23 @@ class _FakeClient:
|
|||||||
self.api = _FakeApi()
|
self.api = _FakeApi()
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_bot_class_uses_longer_http_timeout(monkeypatch) -> None:
|
||||||
|
if not hasattr(__import__("nanobot.channels.qq", fromlist=["botpy"]).botpy, "Client"):
|
||||||
|
pytest.skip("botpy not installed")
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_init(self, *args, **kwargs) -> None: # noqa: ARG001
|
||||||
|
captured["kwargs"] = kwargs
|
||||||
|
|
||||||
|
monkeypatch.setattr("nanobot.channels.qq.botpy.Client.__init__", fake_init)
|
||||||
|
bot_cls = _make_bot_class(SimpleNamespace(_on_message=None))
|
||||||
|
bot_cls()
|
||||||
|
|
||||||
|
assert captured["kwargs"]["timeout"] == 20
|
||||||
|
assert captured["kwargs"]["ext_handlers"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_group_message_routes_to_group_chat_id() -> None:
|
async def test_on_group_message_routes_to_group_chat_id() -> None:
|
||||||
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["user1"]), MessageBus())
|
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["user1"]), MessageBus())
|
||||||
@@ -164,8 +181,21 @@ async def test_send_group_remote_media_url_uses_file_api_then_media_message(monk
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_local_media_falls_back_to_text_notice_when_publishing_not_configured() -> None:
|
async def test_send_local_media_without_media_base_url_uses_file_data_only(
|
||||||
channel = QQChannel(QQConfig(app_id="app", secret="secret", allow_from=["*"]), MessageBus())
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
out_dir = workspace / "out"
|
||||||
|
out_dir.mkdir()
|
||||||
|
source = out_dir / "demo.png"
|
||||||
|
source.write_bytes(b"\x89PNG\r\n\x1a\nfake-png")
|
||||||
|
|
||||||
|
channel = QQChannel(
|
||||||
|
QQConfig(app_id="app", secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
channel._client = _FakeClient()
|
channel._client = _FakeClient()
|
||||||
|
|
||||||
await channel.send(
|
await channel.send(
|
||||||
@@ -173,18 +203,31 @@ async def test_send_local_media_falls_back_to_text_notice_when_publishing_not_co
|
|||||||
channel="qq",
|
channel="qq",
|
||||||
chat_id="user123",
|
chat_id="user123",
|
||||||
content="hello",
|
content="hello",
|
||||||
media=["/tmp/demo.png"],
|
media=[str(source)],
|
||||||
metadata={"message_id": "msg1"},
|
metadata={"message_id": "msg1"},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
assert channel._client.api.c2c_file_calls == []
|
assert channel._client.api.c2c_file_calls == []
|
||||||
assert channel._client.api.group_file_calls == []
|
assert channel._client.api.group_file_calls == []
|
||||||
|
assert channel._client.api.raw_file_upload_calls == [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v2/users/{openid}/files",
|
||||||
|
"params": {"openid": "user123"},
|
||||||
|
"json": {
|
||||||
|
"file_type": 1,
|
||||||
|
"file_data": b64encode(b"\x89PNG\r\n\x1a\nfake-png").decode("ascii"),
|
||||||
|
"srv_send_msg": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
assert channel._client.api.c2c_calls == [
|
assert channel._client.api.c2c_calls == [
|
||||||
{
|
{
|
||||||
"openid": "user123",
|
"openid": "user123",
|
||||||
"msg_type": 0,
|
"msg_type": 7,
|
||||||
"content": "hello\n[Failed to send: demo.png - local media publishing is not configured]",
|
"content": "hello",
|
||||||
|
"media": {"file_info": "c2c-file-info", "file_uuid": "c2c-file", "ttl": 60},
|
||||||
"msg_id": "msg1",
|
"msg_id": "msg1",
|
||||||
"msg_seq": 2,
|
"msg_seq": 2,
|
||||||
}
|
}
|
||||||
@@ -420,6 +463,47 @@ async def test_send_local_media_falls_back_to_url_only_upload_when_file_data_upl
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_local_media_without_media_base_url_falls_back_to_text_notice_when_file_data_upload_fails(
|
||||||
|
tmp_path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
out_dir = workspace / "out"
|
||||||
|
out_dir.mkdir()
|
||||||
|
source = out_dir / "demo.png"
|
||||||
|
source.write_bytes(b"\x89PNG\r\n\x1a\nfake-png")
|
||||||
|
|
||||||
|
channel = QQChannel(
|
||||||
|
QQConfig(app_id="app", secret="secret", allow_from=["*"]),
|
||||||
|
MessageBus(),
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
channel._client = _FakeClient()
|
||||||
|
channel._client.api.raise_on_raw_file_upload = 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 == []
|
||||||
|
assert channel._client.api.c2c_calls == [
|
||||||
|
{
|
||||||
|
"openid": "user123",
|
||||||
|
"msg_type": 0,
|
||||||
|
"content": "hello\n[Failed to send: demo.png - QQ local file_data upload failed]",
|
||||||
|
"msg_id": "msg1",
|
||||||
|
"msg_seq": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_local_media_symlink_to_outside_out_dir_is_rejected(
|
async def test_send_local_media_symlink_to_outside_out_dir_is_rejected(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user