fix(qqbot): enforce media storage boundary for all outbound local file paths [AI] (#63271)

* fix: address issue

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-04-09 17:56:37 +05:30
committed by GitHub
parent 414b7b5ac4
commit 604777e441
4 changed files with 612 additions and 16 deletions

View File

@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- fix(qqbot): enforce media storage boundary for all outbound local file paths [AI]. (#63271) Thanks @pgondhi987.
- iMessage/self-chat: distinguish normal DM outbound rows from true self-chat using `destination_caller_id` plus chat participants, while preserving multi-handle self-chat aliases so outbound DM replies stop looping back as inbound messages. (#61619) Thanks @neeravmakwana.
- fix(browser): auto-generate browser control auth token for none/trusted-proxy modes [AI]. (#63280) Thanks @pgondhi987.
- fix(exec): replace TOCTOU check-then-read with atomic pinned-fd open in script preflight [AI]. (#62333) Thanks @pgondhi987.

View File

@@ -17,6 +17,9 @@ type MediaTargetContext = {
account: QQBotAccount;
logPrefix: string;
};
type SendDocumentOptions = {
allowQQBotDataDownloads?: boolean;
};
type QQBotFrameworkCommandResult =
| string
@@ -44,14 +47,22 @@ function resolveQQBotAccount(config: unknown, accountId?: string): QQBotAccount
return resolve(config, accountId);
}
function sendDocument(context: MediaTargetContext, filePath: string) {
function sendDocument(
context: MediaTargetContext,
filePath: string,
options?: SendDocumentOptions,
) {
const send = loadBundledEntryExportSync<
(context: MediaTargetContext, filePath: string) => Promise<unknown>
(
context: MediaTargetContext,
filePath: string,
options?: SendDocumentOptions,
) => Promise<unknown>
>(import.meta.url, {
specifier: "./api.js",
exportName: "sendDocument",
});
return send(context, filePath);
return send(context, filePath, options);
}
function getFrameworkCommands(): QQBotFrameworkCommand[] {
@@ -173,7 +184,9 @@ export default defineBundledChannelEntry({
account,
logPrefix: `[qqbot:${account.accountId}]`,
};
await sendDocument(mediaCtx, String(result.filePath));
await sendDocument(mediaCtx, String(result.filePath), {
allowQQBotDataDownloads: true,
});
} catch {
// File send failed; the text summary is still returned below.
}

View File

@@ -0,0 +1,401 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedQQBotAccount } from "./types.js";
import { getQQBotDataDir, getQQBotMediaDir } from "./utils/platform.js";
const apiMocks = vi.hoisted(() => ({
getAccessToken: vi.fn(async () => "token"),
sendC2CFileMessage: vi.fn(async () => ({ id: "msg-c2c-file", timestamp: "ts" })),
sendC2CImageMessage: vi.fn(async () => ({ id: "msg-c2c-image", timestamp: "ts" })),
sendC2CMessage: vi.fn(async () => ({ id: "msg-c2c-text", timestamp: "ts" })),
sendC2CVideoMessage: vi.fn(async () => ({ id: "msg-c2c-video", timestamp: "ts" })),
sendC2CVoiceMessage: vi.fn(async () => ({ id: "msg-c2c-voice", timestamp: "ts" })),
sendChannelMessage: vi.fn(async () => ({ id: "msg-channel", timestamp: "ts" })),
sendDmMessage: vi.fn(async () => ({ id: "msg-dm", timestamp: "ts" })),
sendGroupFileMessage: vi.fn(async () => ({ id: "msg-group-file", timestamp: "ts" })),
sendGroupImageMessage: vi.fn(async () => ({ id: "msg-group-image", timestamp: "ts" })),
sendGroupMessage: vi.fn(async () => ({ id: "msg-group-text", timestamp: "ts" })),
sendGroupVideoMessage: vi.fn(async () => ({ id: "msg-group-video", timestamp: "ts" })),
sendGroupVoiceMessage: vi.fn(async () => ({ id: "msg-group-voice", timestamp: "ts" })),
sendProactiveC2CMessage: vi.fn(async () => ({ id: "msg-proactive-c2c", timestamp: "ts" })),
sendProactiveGroupMessage: vi.fn(async () => ({ id: "msg-proactive-group", timestamp: "ts" })),
}));
const audioConvertMocks = vi.hoisted(() => ({
audioFileToSilkBase64: vi.fn(async () => "c2lsaw=="),
isAudioFile: vi.fn((filePath: string, mimeType?: string) => {
if (mimeType === "voice" || mimeType?.startsWith("audio/")) {
return true;
}
return (
filePath.endsWith(".mp3") ||
filePath.endsWith(".wav") ||
filePath.endsWith(".amr") ||
filePath.endsWith(".ogg")
);
}),
shouldTranscodeVoice: vi.fn(() => false),
waitForFile: vi.fn(async (_filePath: string) => 1024),
}));
const fileUtilsMocks = vi.hoisted(() => ({
checkFileSize: vi.fn(() => ({ ok: true })),
downloadFile: vi.fn(),
fileExistsAsync: vi.fn(async () => true),
formatFileSize: vi.fn((size: number) => `${size}`),
readFileAsync: vi.fn(async () => Buffer.from("file-data")),
}));
vi.mock("./api.js", () => apiMocks);
vi.mock("./utils/audio-convert.js", () => ({
audioFileToSilkBase64: audioConvertMocks.audioFileToSilkBase64,
isAudioFile: audioConvertMocks.isAudioFile,
shouldTranscodeVoice: audioConvertMocks.shouldTranscodeVoice,
waitForFile: audioConvertMocks.waitForFile,
}));
vi.mock("./utils/file-utils.js", () => ({
checkFileSize: fileUtilsMocks.checkFileSize,
downloadFile: fileUtilsMocks.downloadFile,
fileExistsAsync: fileUtilsMocks.fileExistsAsync,
formatFileSize: fileUtilsMocks.formatFileSize,
readFileAsync: fileUtilsMocks.readFileAsync,
}));
vi.mock("./utils/debug-log.js", () => ({
debugError: vi.fn(),
debugLog: vi.fn(),
debugWarn: vi.fn(),
}));
import {
sendDocument,
sendMedia,
sendPhoto,
sendVideoMsg,
sendVoice,
type MediaOutboundContext,
type MediaTargetContext,
type OutboundResult,
} from "./outbound.js";
const createdRoots: string[] = [];
const account: ResolvedQQBotAccount = {
accountId: "default",
enabled: true,
appId: "app-id",
clientSecret: "secret",
secretSource: "config",
markdownSupport: true,
config: {},
};
function buildTarget(): MediaTargetContext {
return {
targetType: "c2c",
targetId: "user-1",
account,
replyToId: "msg-1",
logPrefix: "[qqbot:test]",
};
}
function buildMediaContext(mediaUrl: string): MediaOutboundContext {
return {
to: "qqbot:c2c:user-1",
text: "",
account,
mediaUrl,
replyToId: "msg-1",
};
}
function createOutsideFile(ext: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-security-"));
createdRoots.push(root);
const filePath = path.join(root, `payload${ext}`);
fs.writeFileSync(filePath, "payload", "utf8");
return filePath;
}
function createAllowedCommandDownloadPath(ext: string): string {
const root = fs.mkdtempSync(path.join(getQQBotDataDir("downloads"), "command-download-"));
createdRoots.push(root);
const filePath = path.join(root, `download${ext}`);
fs.writeFileSync(filePath, "payload", "utf8");
return filePath;
}
function createAllowedMediaPath(
ext: string,
options: { createFile?: boolean; content?: string } = {},
): string {
const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-security-"));
createdRoots.push(root);
const filePath = path.join(root, `allowed${ext}`);
if (options.createFile !== false) {
fs.writeFileSync(filePath, options.content ?? "payload", "utf8");
}
return filePath;
}
function createDelayedMissingMediaPath(ext: string): string {
const root = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-delayed-security-"));
createdRoots.push(root);
return path.join(root, "pending", `delayed${ext}`);
}
function createMissingSymlinkEscapePath(ext: string): string | null {
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-symlink-outside-"));
createdRoots.push(outsideRoot);
const inMediaRoot = fs.mkdtempSync(path.join(getQQBotMediaDir(), "outbound-symlink-"));
createdRoots.push(inMediaRoot);
const linkPath = path.join(inMediaRoot, "link");
try {
fs.symlinkSync(outsideRoot, linkPath, "dir");
} catch {
return null;
}
return path.join(linkPath, `delayed${ext}`);
}
function writeFileWithParents(filePath: string, content: string = "payload"): number {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, "utf8");
return fs.statSync(filePath).size;
}
function expectBlocked(result: OutboundResult, expectedError: string): void {
expect(result.channel).toBe("qqbot");
expect(result.error).toBe(expectedError);
expect(apiMocks.getAccessToken).not.toHaveBeenCalled();
}
const nonDotRelativeTraversalPath = "src/../../../../etc/passwd";
afterEach(() => {
vi.clearAllMocks();
for (const root of createdRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
describe("qqbot outbound local media path security", () => {
it("allows local image paths inside QQ Bot media storage", async () => {
const allowedPath = createAllowedMediaPath(".png");
const result = await sendPhoto(buildTarget(), allowedPath);
expect(result.error).toBeUndefined();
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1);
expect(apiMocks.sendC2CImageMessage).toHaveBeenCalledTimes(1);
});
it("blocks local image paths outside QQ Bot media storage", async () => {
const outsidePath = createOutsideFile(".png");
const result = await sendPhoto(buildTarget(), outsidePath);
expectBlocked(result, "Image path must be inside QQ Bot media storage");
});
it("blocks local voice paths outside QQ Bot media storage", async () => {
const outsidePath = createOutsideFile(".mp3");
const result = await sendVoice(buildTarget(), outsidePath, undefined, false);
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
});
it("allows delayed local voice paths inside QQ Bot media storage", async () => {
const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false });
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) =>
writeFileWithParents(candidatePath),
);
const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true);
expect(result.error).toBeUndefined();
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1);
expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1);
});
it("blocks delayed voice paths when a missing segment is replaced by a symlink after precheck", async () => {
const delayedVoicePath = createDelayedMissingMediaPath(".mp3");
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-race-outside-"));
createdRoots.push(outsideRoot);
const symlinkProbe = path.join(path.dirname(path.dirname(delayedVoicePath)), "probe-link");
try {
fs.symlinkSync(outsideRoot, symlinkProbe, "dir");
fs.unlinkSync(symlinkProbe);
} catch {
return;
}
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => {
const symlinkParent = path.dirname(candidatePath);
fs.symlinkSync(outsideRoot, symlinkParent, "dir");
const outsideFile = path.join(outsideRoot, path.basename(candidatePath));
return writeFileWithParents(outsideFile);
});
const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true);
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
});
it("returns a blocked result when missing-path canonicalization cannot resolve root", async () => {
const originalExistsSync = fs.existsSync.bind(fs);
const originalRealpathSync = fs.realpathSync.bind(fs);
const existsSpy = vi.spyOn(fs, "existsSync");
existsSpy.mockImplementation((candidate: fs.PathLike) => {
const candidateText = typeof candidate === "string" ? candidate : candidate.toString();
const root = path.parse(candidateText).root;
if (candidateText === root) {
return false;
}
return originalExistsSync(candidate);
});
const realpathSpy = vi.spyOn(fs, "realpathSync");
realpathSpy.mockImplementation(((candidate: fs.PathLike) => {
const candidateText = typeof candidate === "string" ? candidate : candidate.toString();
const root = path.parse(candidateText).root;
if (candidateText === root) {
throw new Error("missing-root");
}
return originalRealpathSync(candidate);
}) as typeof fs.realpathSync);
try {
const result = await sendVoice(
buildTarget(),
"/qqbot-missing-root/sub/path.mp3",
undefined,
true,
);
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
} finally {
existsSpy.mockRestore();
realpathSpy.mockRestore();
}
});
it("blocks delayed voice paths that escape via symlinked parent directories", async () => {
const delayedVoicePath = createMissingSymlinkEscapePath(".mp3");
if (!delayedVoicePath) {
return;
}
const result = await sendVoice(buildTarget(), delayedVoicePath, undefined, true);
expectBlocked(result, "Voice path must be inside QQ Bot media storage");
});
it("blocks local video paths outside QQ Bot media storage", async () => {
const outsidePath = createOutsideFile(".mp4");
const result = await sendVideoMsg(buildTarget(), outsidePath);
expectBlocked(result, "Video path must be inside QQ Bot media storage");
});
it("blocks local document paths outside QQ Bot media storage", async () => {
const outsidePath = createOutsideFile(".txt");
const result = await sendDocument(buildTarget(), outsidePath);
expectBlocked(result, "File path must be inside QQ Bot media storage");
});
it("blocks QQ Bot command-download paths for sendDocument by default", async () => {
const commandDownloadPath = createAllowedCommandDownloadPath(".txt");
const result = await sendDocument(buildTarget(), commandDownloadPath);
expectBlocked(result, "File path must be inside QQ Bot media storage");
});
it("allows QQ Bot command-download paths for sendDocument when explicitly enabled", async () => {
const commandDownloadPath = createAllowedCommandDownloadPath(".txt");
const result = await sendDocument(buildTarget(), commandDownloadPath, {
allowQQBotDataDownloads: true,
});
expect(result.error).toBeUndefined();
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(2);
expect(apiMocks.sendC2CFileMessage).toHaveBeenCalledTimes(1);
});
it("blocks non-dot relative traversal paths for document sends", async () => {
const result = await sendDocument(buildTarget(), nonDotRelativeTraversalPath);
expectBlocked(result, "File path must be inside QQ Bot media storage");
});
it("blocks sendMedia local paths outside QQ Bot media storage", async () => {
const outsidePath = createOutsideFile(".txt");
const result = await sendMedia(buildMediaContext(outsidePath));
expectBlocked(result, "Media path must be inside QQ Bot media storage");
});
it("allows delayed local audio paths in sendMedia inside QQ Bot media storage", async () => {
const delayedVoicePath = createAllowedMediaPath(".mp3", { createFile: false });
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) =>
writeFileWithParents(candidatePath),
);
const result = await sendMedia(buildMediaContext(delayedVoicePath));
expect(result.error).toBeUndefined();
expect(apiMocks.getAccessToken).toHaveBeenCalledTimes(1);
expect(apiMocks.sendC2CVoiceMessage).toHaveBeenCalledTimes(1);
});
it("blocks sendMedia delayed audio paths when a missing segment is replaced by a symlink", async () => {
const delayedVoicePath = createDelayedMissingMediaPath(".mp3");
const outsideRoot = fs.mkdtempSync(path.join(os.tmpdir(), "qqbot-outbound-race-sendmedia-"));
createdRoots.push(outsideRoot);
const symlinkProbe = path.join(path.dirname(path.dirname(delayedVoicePath)), "probe-link");
try {
fs.symlinkSync(outsideRoot, symlinkProbe, "dir");
fs.unlinkSync(symlinkProbe);
} catch {
return;
}
audioConvertMocks.waitForFile.mockImplementationOnce(async (candidatePath: string) => {
const symlinkParent = path.dirname(candidatePath);
fs.symlinkSync(outsideRoot, symlinkParent, "dir");
const outsideFile = path.join(outsideRoot, path.basename(candidatePath));
return writeFileWithParents(outsideFile);
});
const result = await sendMedia(buildMediaContext(delayedVoicePath));
expectBlocked(
result,
"voice: Voice path must be inside QQ Bot media storage | fallback file: File path must be inside QQ Bot media storage",
);
});
it("blocks sendMedia delayed audio paths that escape via symlinked parents", async () => {
const delayedVoicePath = createMissingSymlinkEscapePath(".mp3");
if (!delayedVoicePath) {
return;
}
const result = await sendMedia(buildMediaContext(delayedVoicePath));
expectBlocked(result, "Media path must be inside QQ Bot media storage");
});
it("blocks non-dot relative traversal paths in sendMedia", async () => {
const result = await sendMedia(buildMediaContext(nonDotRelativeTraversalPath));
expectBlocked(result, "Media path must be inside QQ Bot media storage");
});
});

View File

@@ -1,3 +1,4 @@
import * as fs from "node:fs";
import * as path from "path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
@@ -39,10 +40,11 @@ import {
import { normalizeMediaTags } from "./utils/media-tags.js";
import { decodeCronPayload } from "./utils/payload.js";
import {
getQQBotDataDir,
getQQBotMediaDir,
isLocalPath as isLocalFilePath,
normalizePath,
resolveQQBotLocalMediaPath,
resolveQQBotPayloadLocalFilePath,
sanitizeFileName,
} from "./utils/platform.js";
@@ -273,6 +275,148 @@ function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean {
return account.config?.urlDirectUpload !== false;
}
type QQBotMediaKind = "image" | "voice" | "video" | "file" | "media";
const qqBotMediaKindLabel: Record<QQBotMediaKind, string> = {
image: "Image",
voice: "Voice",
video: "Video",
file: "File",
media: "Media",
};
type ResolvedOutboundMediaPath = { ok: true; mediaPath: string } | { ok: false; error: string };
type ResolveOutboundMediaPathOptions = {
allowMissingLocalPath?: boolean;
extraLocalRoots?: string[];
};
type SendDocumentOptions = {
allowQQBotDataDownloads?: boolean;
};
function isHttpOrDataSource(pathValue: string): boolean {
return (
pathValue.startsWith("http://") ||
pathValue.startsWith("https://") ||
pathValue.startsWith("data:")
);
}
function isPathWithinRoot(candidate: string, root: string): boolean {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function resolveMissingPathWithinMediaRoot(normalizedPath: string): string | null {
const resolvedCandidate = path.resolve(normalizedPath);
if (fs.existsSync(resolvedCandidate)) {
return null;
}
const allowedRoot = path.resolve(getQQBotMediaDir());
let canonicalAllowedRoot: string;
try {
canonicalAllowedRoot = fs.realpathSync(allowedRoot);
} catch {
return null;
}
const missingSegments: string[] = [];
let cursor = resolvedCandidate;
while (!fs.existsSync(cursor)) {
const parent = path.dirname(cursor);
if (parent === cursor) {
break;
}
missingSegments.unshift(path.basename(cursor));
cursor = parent;
}
if (!fs.existsSync(cursor)) {
return null;
}
let canonicalCursor: string;
try {
canonicalCursor = fs.realpathSync(cursor);
} catch {
return null;
}
const canonicalCandidate =
missingSegments.length > 0 ? path.join(canonicalCursor, ...missingSegments) : canonicalCursor;
return isPathWithinRoot(canonicalCandidate, canonicalAllowedRoot) ? canonicalCandidate : null;
}
function resolveExistingPathWithinRoots(
normalizedPath: string,
allowedRoots: readonly string[],
): string | null {
const resolvedCandidate = path.resolve(normalizedPath);
if (!fs.existsSync(resolvedCandidate)) {
return null;
}
let canonicalCandidate: string;
try {
canonicalCandidate = fs.realpathSync(resolvedCandidate);
} catch {
return null;
}
for (const root of allowedRoots) {
const resolvedRoot = path.resolve(root);
const canonicalRoot = fs.existsSync(resolvedRoot)
? fs.realpathSync(resolvedRoot)
: resolvedRoot;
if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
return canonicalCandidate;
}
}
return null;
}
function resolveOutboundMediaPath(
rawPath: string,
prefix: string,
mediaKind: QQBotMediaKind,
options: ResolveOutboundMediaPathOptions = {},
): ResolvedOutboundMediaPath {
const normalizedPath = normalizePath(rawPath);
if (isHttpOrDataSource(normalizedPath)) {
return { ok: true, mediaPath: normalizedPath };
}
const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath);
if (allowedPath) {
return { ok: true, mediaPath: allowedPath };
}
if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
const extraAllowedPath = resolveExistingPathWithinRoots(
normalizedPath,
options.extraLocalRoots,
);
if (extraAllowedPath) {
return { ok: true, mediaPath: extraAllowedPath };
}
}
if (options.allowMissingLocalPath) {
const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
if (allowedMissingPath) {
return { ok: true, mediaPath: allowedMissingPath };
}
}
debugWarn(`${prefix} blocked local ${mediaKind} path outside QQ Bot media storage`);
return {
ok: false,
error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`,
};
}
/**
* Send a photo from a local file, public URL, or Base64 data URL.
*/
@@ -281,7 +425,11 @@ export async function sendPhoto(
imagePath: string,
): Promise<OutboundResult> {
const prefix = ctx.logPrefix ?? "[qqbot]";
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(imagePath));
const resolvedMediaPath = resolveOutboundMediaPath(imagePath, prefix, "image");
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isLocal = isLocalFilePath(mediaPath);
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
const isData = mediaPath.startsWith("data:");
@@ -412,7 +560,13 @@ export async function sendVoice(
transcodeEnabled: boolean = true,
): Promise<OutboundResult> {
const prefix = ctx.logPrefix ?? "[qqbot]";
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(voicePath));
const resolvedMediaPath = resolveOutboundMediaPath(voicePath, prefix, "voice", {
allowMissingLocalPath: true,
});
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
if (isHttp) {
@@ -483,10 +637,17 @@ async function sendVoiceFromLocal(
return { channel: "qqbot", error: "Voice generate failed" };
}
const needsTranscode = shouldTranscodeVoice(mediaPath);
// Re-check containment after the file appears to prevent symlink-race escapes.
const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
if (!safeMediaPath) {
debugWarn(`${prefix} sendVoice: blocked local voice path outside QQ Bot media storage`);
return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };
}
const needsTranscode = shouldTranscodeVoice(safeMediaPath);
if (needsTranscode && !transcodeEnabled) {
const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath));
const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath));
debugLog(
`${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`,
);
@@ -497,11 +658,11 @@ async function sendVoiceFromLocal(
}
try {
const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats);
const silkBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats);
let uploadBase64 = silkBase64;
if (!uploadBase64) {
const buf = await readFileAsync(mediaPath);
const buf = await readFileAsync(safeMediaPath);
uploadBase64 = buf.toString("base64");
debugLog(
`${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`,
@@ -521,7 +682,7 @@ async function sendVoiceFromLocal(
undefined,
ctx.replyToId,
undefined,
mediaPath,
safeMediaPath,
);
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
} else if (ctx.targetType === "group") {
@@ -551,7 +712,11 @@ export async function sendVideoMsg(
videoPath: string,
): Promise<OutboundResult> {
const prefix = ctx.logPrefix ?? "[qqbot]";
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(videoPath));
const resolvedMediaPath = resolveOutboundMediaPath(videoPath, prefix, "video");
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
@@ -670,9 +835,19 @@ async function sendVideoFromLocal(
export async function sendDocument(
ctx: MediaTargetContext,
filePath: string,
options: SendDocumentOptions = {},
): Promise<OutboundResult> {
const prefix = ctx.logPrefix ?? "[qqbot]";
const mediaPath = resolveQQBotLocalMediaPath(normalizePath(filePath));
const extraLocalRoots = options.allowQQBotDataDownloads
? [getQQBotDataDir("downloads")]
: undefined;
const resolvedMediaPath = resolveOutboundMediaPath(filePath, prefix, "file", {
extraLocalRoots,
});
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaPath = resolvedMediaPath.mediaPath;
const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
const fileName = sanitizeFileName(path.basename(mediaPath));
@@ -1282,14 +1457,20 @@ export async function sendProactiveMessage(
/** Send rich media, auto-routing by media type and source. */
export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
const { to, text, replyToId, account, mimeType } = ctx;
const mediaUrl = resolveQQBotLocalMediaPath(normalizePath(ctx.mediaUrl));
if (!account.appId || !account.clientSecret) {
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
}
if (!mediaUrl) {
if (!ctx.mediaUrl) {
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
}
const resolvedMediaPath = resolveOutboundMediaPath(ctx.mediaUrl, "[qqbot:sendMedia]", "media", {
allowMissingLocalPath: true,
});
if (!resolvedMediaPath.ok) {
return { channel: "qqbot", error: resolvedMediaPath.error };
}
const mediaUrl = resolvedMediaPath.mediaPath;
const target = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendMedia]");