Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
fd0664b
fix(qqbot): allow scoped sandbox media sends
zhangguiping-xydt Jun 14, 2026
10305c7
fix(qqbot): thread scoped media access through all outbound paths
zhangguiping-xydt Jun 14, 2026
453394b
fix(qqbot): thread workspaceDir into gateway reply media context
zhangguiping-xydt Jun 14, 2026
d520b26
fix(qqbot): wrap host-read media sends with structured error handling
zhangguiping-xydt Jun 15, 2026
679d4fb
fix(qqbot): preserve host-read media quota errors
zhangguiping-xydt Jun 15, 2026
6d52e74
fix(qqbot): preserve host-read quota fallback path
zhangguiping-xydt Jun 15, 2026
5901a38
fix(qqbot): resolve host-read quota fallback path
zhangguiping-xydt Jun 15, 2026
07be9b4
test(qqbot): satisfy host-read media lint
zhangguiping-xydt Jun 15, 2026
8347f2c
fix(qqbot): cover host-read voice media
zhangguiping-xydt Jun 15, 2026
bc18a30
fix(qqbot): satisfy host-read voice lint
zhangguiping-xydt Jun 15, 2026
a789eb1
Drop incidental formatting from PR merge
zhangguiping-xydt Jun 20, 2026
e2a962b
fix(qqbot): map sandbox workspace media paths
zhangguiping-xydt Jun 29, 2026
2164c66
fix(qqbot): preserve host-read media roots
sliverp Jun 30, 2026
1808999
fix(qqbot): scope sandbox media delivery roots
zhangguiping-xydt Jul 2, 2026
e5f914a
fix(qqbot): map virtual media roots before host read
zhangguiping-xydt Jul 2, 2026
2d24f6f
fix(qqbot): scope structured payload media roots
sliverp Jul 3, 2026
e5c6dbb
fix(qqbot): keep auto media helper internal
sliverp Jul 3, 2026
aa89f73
docs(changelog): note QQBot scoped sandbox media fix (#92872) (thanks…
sliverp Jul 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,15 @@ Docs: https://docs.openclaw.ai
- **Mobile and UI stability:** preserve iOS chat line breaks and final replies, improve Android pairing and TLS recovery, hide expired pairing cards, and keep workspace file rails scrollable. (#98304, #98117, #98366, #98439, #98483, #98049, #98646, #98611) Thanks @joshavant, @Jabato01, @ooiuuii, @wuqxuan, @645648406-max, and @zw-xysk.
- **Codex and approval flows:** report ChatGPT authentication correctly, rename destructive approval mode to `ask`, classify dynamic goal and session tool results accurately, and derive terminal-idle timeouts from the explicit run deadline. (#91240, #98501, #98659, #96856, #85296) Thanks @849261680, @ukstem, @kevinslin, @yetval, @nxmxbbd, @alkor2000, and @vincentkoc.
- **Configuration and plugin health:** surface unloadable channel plugins, preserve defaulted provider base URLs during patches, validate bundled plugin updates by manifest contract, and retain legacy ClawHub families where required. (#96397, #98396, #98010, #98249) Thanks @849261680, @momothemage, @weltmaister, @LiLan0125, @herove, and @Patrick-Erichsen.
- **QQBot media delivery:** scope sandbox-generated media sends to the active session's workspace so `/workspace/...` and relative generated-file paths resolve safely across QQBot media tags, structured payloads, and streaming delivery. (#92872) Thanks @zhangguiping-xydt.

### Complete contribution record

This audited record covers the complete 66e676d29b92d040716376a75aca32bad655cfac..3e50f41dd6ea3446b5c98a2f19ec70982ac908e6 history: 211 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
This audited record covers the complete 66e676d29b92d040716376a75aca32bad655cfac..3e50f41dd6ea3446b5c98a2f19ec70982ac908e6 history: 212 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.

#### Pull requests

- **PR #92872** fix(qqbot): allow scoped sandbox media sends. Thanks @zhangguiping-xydt.
- **PR #96502** fix(moonshot): bound video description JSON response reads. Thanks @hugenshen and @cursoragent.
- **PR #98249** Preserve legacy ClawHub family for selected plugins. Thanks @Patrick-Erichsen.
- **PR #93767** fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags. Thanks @DrHack1.
Expand Down
25 changes: 14 additions & 11 deletions extensions/qqbot/skills/qqbot-media/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
## 用法

```
<qqmedia>路径或URL</qqmedia>
<qqmedia>{实际路径或URL}</qqmedia>
```

系统根据文件扩展名自动识别类型并路由:
Expand All @@ -18,7 +18,8 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
- `.silk/.wav/.mp3/.ogg/.aac/.flac` 等 → 语音
- `.mp4/.mov/.avi/.mkv/.webm` 等 → 视频
- 其他扩展名 → 文件
- 无扩展名的 URL → 默认按图片处理
- 无扩展名的当前会话本地/host-read 媒体 → 按加载出的实际媒体类型路由
- 无扩展名的远程 URL → 可能按文件发送;如需图片/语音/视频,请提供能识别类型的 URL/路径或使用明确媒体标签

## 接收媒体

Expand All @@ -29,12 +30,14 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq

## 规则

1. **路径必须是绝对路径**(以 `/` 或 `http` 开头)
2. **标签必须用开闭标签包裹路径**:`<qqmedia>路径</qqmedia>`
3. **待发送的本地文件须落在 OpenClaw 媒体目录下**:生成、下载或复制出的文件应写入 **`~/.openclaw/media/qqbot/`**(或其子目录),再写进 `<qqmedia>`。不要只放在 `~/.openclaw/workspace/` 等工作区根目录——平台安全策略只允许从 `~/.openclaw/media/`(含 `media/qqbot`)等受信根路径上传,否则会拦截、发不出去。
4. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB
5. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
6. 发送语音时不要重复语音中已朗读的文字
7. 多个媒体用多个标签
8. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
9. 不要扫描或发送上下文之外的本地文件;只使用用户提供、工具生成,或明确位于受信 media 目录中的路径
1. **标签必须用开闭标签包裹实际路径或 URL**:`<qqmedia>{实际路径或URL}</qqmedia>`
2. **使用你实际看到的文件路径**:刚创建文件时,用创建结果显示的路径;只有当沙箱 workspace-write 创建结果实际显示 `/workspace/...` 时,才按原样使用该路径,例如 `<qqmedia>/workspace/report.pdf</qqmedia>`。
3. **附件路径直接使用上下文给出的路径**:如果路径来自会话【附件】上下文,不要改写成 `/workspace/...`。
4. **URL 可以直接发送**:例如 `<qqmedia>https://example.com/image.png</qqmedia>`。
5. **本地路径仍受安全根限制**:只能发送当前会话授权的 agent workspace、scoped media roots、OpenClaw 媒体目录或 QQBot 媒体目录内的文件;不要使用 `..` 逃出工作区。
6. **不要扫描或主动发送上下文之外的本地文件**:只使用用户提供、工具刚生成,或当前会话上下文明确给出的路径。
7. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB
8. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
9. 发送语音时不要重复语音中已朗读的文字
10. 多个媒体用多个标签
11. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
62 changes: 62 additions & 0 deletions extensions/qqbot/src/channel.message-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,26 @@ type SentTextParams = {
to?: string;
text?: string;
replyToId?: string | null;
mediaAccess?: {
localRoots?: readonly string[];
workspaceDir?: string;
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};

type SentMediaParams = {
to?: string;
text?: string;
mediaUrl?: string;
mediaAccess?: {
localRoots?: readonly string[];
workspaceDir?: string;
readFile?: (filePath: string) => Promise<Buffer>;
};
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};

function latestMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
Expand Down Expand Up @@ -82,16 +96,24 @@ describe("qqbot message adapter", () => {
expect(result?.receipt.platformMessageIds).toEqual(["qq-text-1"]);
},
media: async () => {
const mediaAccess = {
localRoots: ["/tmp/openclaw-sandbox"],
workspaceDir: "/tmp/workspace",
};
const result = await qqbotPlugin.message?.send?.media?.({
cfg,
to: "qqbot:c2c:user-1",
text: "image",
mediaUrl: "https://example.com/image.png",
mediaAccess,
mediaLocalRoots: ["/tmp/openclaw-sandbox"],
});
const sent = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams;
expect(sent.to).toBe("qqbot:c2c:user-1");
expect(sent.text).toBe("image");
expect(sent.mediaUrl).toBe("https://example.com/image.png");
expect(sent.mediaAccess).toBe(mediaAccess);
expect(sent.mediaLocalRoots).toEqual(["/tmp/openclaw-sandbox"]);
expect(result?.receipt.platformMessageIds).toEqual(["qq-media-1"]);
},
replyTo: async () => {
Expand Down Expand Up @@ -164,4 +186,44 @@ describe("qqbot message adapter", () => {
}),
).rejects.toThrow("QQBot message adapter send did not return a platform message id");
});

it("forwards scoped media access through outbound text and media sends", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("report"));
const mediaAccess = {
localRoots: ["/tmp/openclaw-sandbox"],
workspaceDir: "/tmp/workspace",
readFile: mediaReadFile,
};
const mediaLocalRoots = ["/tmp/openclaw-sandbox"];

sendTextMock.mockResolvedValueOnce({ messageId: "qq-text-media-1" });
await qqbotPlugin.outbound?.sendText?.({
cfg,
to: "qqbot:c2c:user-1",
text: "<qqmedia>/tmp/openclaw-sandbox/report.docx</qqmedia>",
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
const sentText = latestMockArg(sendTextMock, "sendText") as SentTextParams;
expect(sentText.mediaAccess).toBe(mediaAccess);
expect(sentText.mediaLocalRoots).toBe(mediaLocalRoots);
expect(sentText.mediaReadFile).toBe(mediaReadFile);

sendMediaMock.mockResolvedValueOnce({ messageId: "qq-media-local-1" });
await qqbotPlugin.outbound?.sendMedia?.({
cfg,
to: "qqbot:c2c:user-1",
text: "report",
mediaUrl: "/tmp/openclaw-sandbox/report.docx",
mediaAccess,
mediaLocalRoots,
mediaReadFile,
});
const sentMedia = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams;
expect(sentMedia.mediaUrl).toBe("/tmp/openclaw-sandbox/report.docx");
expect(sentMedia.mediaAccess).toBe(mediaAccess);
expect(sentMedia.mediaLocalRoots).toBe(mediaLocalRoots);
expect(sentMedia.mediaReadFile).toBe(mediaReadFile);
});
});
80 changes: 52 additions & 28 deletions extensions/qqbot/src/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { qqbotDoctor } from "./doctor.js";
import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
import { clearAccountCredentials } from "./engine/config/credentials.js";
import { chunkQQBotMarkdownText } from "./engine/messaging/markdown-table-chunking.js";
import type { OutboundMediaAccessContext } from "./engine/messaging/outbound-types.js";
import {
normalizeTarget as coreNormalizeTarget,
looksLikeQQBotTarget,
Expand Down Expand Up @@ -61,13 +62,15 @@ function createQQBotSendReceipt(params: {
});
}

async function sendQQBotText(params: {
cfg: OpenClawConfig;
to: string;
text: string;
accountId?: string | null;
replyToId?: string | null;
}) {
async function sendQQBotText(
params: {
cfg: OpenClawConfig;
to: string;
text: string;
accountId?: string | null;
replyToId?: string | null;
} & OutboundMediaAccessContext,
) {
// Ensure bridge/gateway.ts module-level registrations (audio adapter factory,
// platform adapter, etc.) have executed before engine code runs.
await loadGatewayModule();
Expand All @@ -79,6 +82,9 @@ async function sendQQBotText(params: {
accountId: params.accountId,
replyToId: params.replyToId,
account: toGatewayAccount(account),
...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}),
...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
});
return {
channel: "qqbot" as const,
Expand All @@ -92,14 +98,16 @@ async function sendQQBotText(params: {
};
}

async function sendQQBotMedia(params: {
cfg: OpenClawConfig;
to: string;
text?: string | null;
mediaUrl?: string | null;
accountId?: string | null;
replyToId?: string | null;
}) {
async function sendQQBotMedia(
params: {
cfg: OpenClawConfig;
to: string;
text?: string | null;
mediaUrl?: string | null;
accountId?: string | null;
replyToId?: string | null;
} & OutboundMediaAccessContext,
) {
// Same guard as sendText — ensure adapters are registered.
await loadGatewayModule();
const account = resolveQQBotAccount(params.cfg, params.accountId);
Expand All @@ -111,6 +119,9 @@ async function sendQQBotMedia(params: {
accountId: params.accountId,
replyToId: params.replyToId,
account: toGatewayAccount(account),
...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}),
...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
});
return {
channel: "qqbot" as const,
Expand All @@ -124,6 +135,15 @@ async function sendQQBotMedia(params: {
};
}

function resolveQQBotOutboundMediaAccessContext(ctx: unknown): OutboundMediaAccessContext {
const record = ctx && typeof ctx === "object" ? (ctx as OutboundMediaAccessContext) : undefined;
return {
...(record?.mediaAccess ? { mediaAccess: record.mediaAccess } : {}),
...(record?.mediaLocalRoots ? { mediaLocalRoots: record.mediaLocalRoots } : {}),
...(record?.mediaReadFile ? { mediaReadFile: record.mediaReadFile } : {}),
};
}

function toQQBotMessageSendResult(result: Awaited<ReturnType<typeof sendQQBotText>>) {
if (result.meta?.error) {
throw new Error(result.meta.error);
Expand Down Expand Up @@ -155,6 +175,7 @@ const qqbotMessageAdapter = defineChannelMessageAdapter({
text: ctx.text,
accountId: ctx.accountId,
replyToId: ctx.replyToId,
...resolveQQBotOutboundMediaAccessContext(ctx),
}),
),
media: async (ctx) =>
Expand All @@ -166,6 +187,7 @@ const qqbotMessageAdapter = defineChannelMessageAdapter({
mediaUrl: ctx.mediaUrl,
accountId: ctx.accountId,
replyToId: ctx.replyToId,
...resolveQQBotOutboundMediaAccessContext(ctx),
}),
),
},
Expand Down Expand Up @@ -267,22 +289,24 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
payload,
hint,
}),
sendText: async ({ to, text, accountId, replyToId, cfg }) =>
sendText: async (ctx) =>
await sendQQBotText({
cfg,
to,
text,
accountId,
replyToId,
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
accountId: ctx.accountId,
replyToId: ctx.replyToId,
...resolveQQBotOutboundMediaAccessContext(ctx),
}),
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) =>
sendMedia: async (ctx) =>
await sendQQBotMedia({
cfg,
to,
text,
mediaUrl,
accountId,
replyToId,
cfg: ctx.cfg,
to: ctx.to,
text: ctx.text,
mediaUrl: ctx.mediaUrl,
accountId: ctx.accountId,
replyToId: ctx.replyToId,
...resolveQQBotOutboundMediaAccessContext(ctx),
}),
},
gateway: {
Expand Down
Loading
Loading