Skip to content

Commit 8d68065

Browse files
Jasmine ZhangJasmine Zhang
authored andcommitted
fix(qqbot): honor durable media roots for cron tts
1 parent 340c245 commit 8d68065

6 files changed

Lines changed: 116 additions & 9 deletions

File tree

extensions/qqbot/src/channel.message-adapter.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type SentMediaParams = {
3636
to?: string;
3737
text?: string;
3838
mediaUrl?: string;
39+
extraLocalRoots?: readonly string[];
3940
};
4041

4142
function latestMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
@@ -114,4 +115,23 @@ describe("qqbot message adapter", () => {
114115
expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified");
115116
expect(proofResults.find((result) => result.capability === "replyTo")?.status).toBe("verified");
116117
});
118+
119+
it("forwards mediaLocalRoots and surfaces underlying media send errors", async () => {
120+
sendMediaMock.mockResolvedValueOnce({
121+
error: "Voice path must be inside QQ Bot media storage",
122+
});
123+
124+
await expect(
125+
qqbotPlugin.message?.send?.media?.({
126+
cfg,
127+
to: "qqbot:c2c:user-1",
128+
text: "voice",
129+
mediaUrl: "/tmp/openclaw-tts/voice.wav",
130+
mediaLocalRoots: ["/tmp/openclaw-tts"],
131+
}),
132+
).rejects.toThrow("Voice path must be inside QQ Bot media storage");
133+
134+
const sent = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams;
135+
expect(sent.extraLocalRoots).toEqual(["/tmp/openclaw-tts"]);
136+
});
117137
});

extensions/qqbot/src/channel.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ async function sendQQBotMedia(params: {
108108
mediaUrl?: string | null;
109109
accountId?: string | null;
110110
replyToId?: string | null;
111+
extraLocalRoots?: readonly string[];
111112
}) {
112113
// Same guard as sendText — ensure adapters are registered.
113114
await loadGatewayModule();
@@ -120,6 +121,7 @@ async function sendQQBotMedia(params: {
120121
accountId: params.accountId,
121122
replyToId: params.replyToId,
122123
account: toGatewayAccount(account),
124+
extraLocalRoots: params.extraLocalRoots,
123125
});
124126
return {
125127
channel: "qqbot" as const,
@@ -134,6 +136,9 @@ async function sendQQBotMedia(params: {
134136
}
135137

136138
function toQQBotMessageSendResult(result: Awaited<ReturnType<typeof sendQQBotText>>) {
139+
if (result.meta?.error) {
140+
throw new Error(result.meta.error);
141+
}
137142
return {
138143
messageId: result.messageId,
139144
receipt: result.receipt,
@@ -169,6 +174,7 @@ const qqbotMessageAdapter = defineChannelMessageAdapter({
169174
mediaUrl: ctx.mediaUrl,
170175
accountId: ctx.accountId,
171176
replyToId: ctx.replyToId,
177+
extraLocalRoots: ctx.mediaLocalRoots,
172178
}),
173179
),
174180
},
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
6+
const senderSendMediaMock = vi.hoisted(() => vi.fn());
7+
const accountToCredsMock = vi.hoisted(() =>
8+
vi.fn(() => ({ appId: "app", clientSecret: "secret" })),
9+
);
10+
const waitForFileMock = vi.hoisted(() => vi.fn(async () => 16));
11+
const audioFileToSilkBase64Mock = vi.hoisted(() => vi.fn(async () => "silk-base64"));
12+
const shouldTranscodeVoiceMock = vi.hoisted(() => vi.fn(() => false));
13+
14+
vi.mock("./sender.js", () => ({
15+
senderSendMedia: senderSendMediaMock,
16+
sendMedia: senderSendMediaMock,
17+
accountToCreds: accountToCredsMock,
18+
UploadDailyLimitExceededError: class UploadDailyLimitExceededError extends Error {},
19+
}));
20+
21+
vi.mock("./outbound-audio-port.js", () => ({
22+
waitForFile: waitForFileMock,
23+
audioFileToSilkBase64: audioFileToSilkBase64Mock,
24+
shouldTranscodeVoice: shouldTranscodeVoiceMock,
25+
}));
26+
27+
import { sendVoice } from "./outbound-media-send.js";
28+
29+
describe("qqbot sendVoice extraLocalRoots", () => {
30+
const tempPaths: string[] = [];
31+
32+
afterEach(() => {
33+
vi.clearAllMocks();
34+
for (const target of tempPaths.splice(0)) {
35+
fs.rmSync(target, { recursive: true, force: true });
36+
}
37+
});
38+
39+
it("accepts trusted local voice files from mediaLocalRoots for durable sends", async () => {
40+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-voice-root-"));
41+
tempPaths.push(root);
42+
const voicePath = path.join(root, "voice.wav");
43+
fs.writeFileSync(voicePath, "voice");
44+
45+
senderSendMediaMock.mockResolvedValueOnce({ id: "msg-1", timestamp: 123 });
46+
47+
const result = await sendVoice(
48+
{
49+
targetType: "c2c",
50+
targetId: "OPENID",
51+
account: { appId: "app", clientSecret: "secret", accountId: "default" },
52+
extraLocalRoots: [root],
53+
},
54+
voicePath,
55+
undefined,
56+
true,
57+
);
58+
59+
expect(result).toMatchObject({ channel: "qqbot", messageId: "msg-1" });
60+
expect(senderSendMediaMock).toHaveBeenCalledTimes(1);
61+
expect(senderSendMediaMock.mock.calls[0]?.[0]).toMatchObject({
62+
kind: "voice",
63+
source: { base64: "silk-base64" },
64+
localPathForMeta: fs.realpathSync(voicePath),
65+
});
66+
});
67+
});

extensions/qqbot/src/engine/messaging/outbound-media-send.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,15 @@ export function buildMediaTarget(ctx: {
5959
to: string;
6060
account: GatewayAccount;
6161
replyToId?: string | null;
62+
extraLocalRoots?: readonly string[];
6263
}): MediaTargetContext {
6364
const target = parseTarget(ctx.to);
6465
return {
6566
targetType: target.type,
6667
targetId: target.id,
6768
account: ctx.account,
6869
replyToId: ctx.replyToId ?? undefined,
70+
extraLocalRoots: ctx.extraLocalRoots,
6971
};
7072
}
7173

@@ -307,6 +309,7 @@ export async function sendVoice(
307309
): Promise<OutboundResult> {
308310
const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", {
309311
allowMissingLocalPath: true,
312+
extraLocalRoots: ctx.extraLocalRoots ? [...ctx.extraLocalRoots] : undefined,
310313
});
311314
if (!resolvedMediaPath.ok) {
312315
return { channel: "qqbot", error: resolvedMediaPath.error };
@@ -368,16 +371,19 @@ async function sendVoiceFromLocal(
368371
}
369372

370373
// Re-check containment after the file appears to prevent symlink-race escapes.
371-
const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
372-
if (!safeMediaPath) {
374+
const safeMediaPath = resolveOutboundMediaPath(mediaPath, "voice", {
375+
extraLocalRoots: ctx.extraLocalRoots ? [...ctx.extraLocalRoots] : undefined,
376+
});
377+
if (!safeMediaPath.ok) {
373378
debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
374-
return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };
379+
return { channel: "qqbot", error: safeMediaPath.error };
375380
}
381+
const safeLocalPath = safeMediaPath.mediaPath;
376382

377-
const needsTranscode = shouldTranscodeVoice(safeMediaPath);
383+
const needsTranscode = shouldTranscodeVoice(safeLocalPath);
378384

379385
if (needsTranscode && !transcodeEnabled) {
380-
const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath));
386+
const ext = normalizeLowercaseStringOrEmpty(path.extname(safeLocalPath));
381387
debugLog(
382388
`sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`,
383389
);
@@ -388,11 +394,11 @@ async function sendVoiceFromLocal(
388394
}
389395

390396
try {
391-
const silkBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats);
397+
const silkBase64 = await audioFileToSilkBase64(safeLocalPath, directUploadFormats);
392398
let uploadBase64 = silkBase64;
393399

394400
if (!uploadBase64) {
395-
const buf = await readFileAsync(safeMediaPath);
401+
const buf = await readFileAsync(safeLocalPath);
396402
uploadBase64 = buf.toString("base64");
397403
debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
398404
} else {
@@ -409,7 +415,7 @@ async function sendVoiceFromLocal(
409415
kind: "voice",
410416
source: { base64: uploadBase64 },
411417
msgId: ctx.replyToId,
412-
localPathForMeta: safeMediaPath,
418+
localPathForMeta: safeLocalPath,
413419
});
414420
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
415421
}

extensions/qqbot/src/engine/messaging/outbound-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface OutboundContext {
1313
export interface MediaOutboundContext extends OutboundContext {
1414
mediaUrl: string;
1515
mimeType?: string;
16+
extraLocalRoots?: readonly string[];
1617
}
1718

1819
/**
@@ -45,4 +46,5 @@ export interface MediaTargetContext {
4546
account: GatewayAccount;
4647
replyToId?: string;
4748
logPrefix?: string;
49+
extraLocalRoots?: readonly string[];
4850
}

extensions/qqbot/src/engine/messaging/outbound.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,19 @@ export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResu
319319

320320
const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "media", {
321321
allowMissingLocalPath: true,
322+
extraLocalRoots: ctx.extraLocalRoots ? [...ctx.extraLocalRoots] : undefined,
322323
});
323324
if (!resolvedMediaPath.ok) {
324325
return { channel: "qqbot", error: resolvedMediaPath.error };
325326
}
326327
const mediaUrl = resolvedMediaPath.mediaPath;
327328

328-
const target = buildMediaTarget({ to, account, replyToId });
329+
const target = buildMediaTarget({
330+
to,
331+
account,
332+
replyToId,
333+
extraLocalRoots: ctx.extraLocalRoots,
334+
});
329335

330336
if (isAudioFile(mediaUrl, mimeType)) {
331337
const formats =

0 commit comments

Comments
 (0)