Export Feishu workspace artifact helpers

This commit is contained in:
sunke 2026-03-21 00:08:23 +08:00
parent 50ce9ac1c6
commit 5c88d62d75
6 changed files with 219 additions and 3 deletions

View File

@ -189,6 +189,10 @@
"types": "./dist/plugin-sdk/discord-core.d.ts",
"default": "./dist/plugin-sdk/discord-core.js"
},
"./plugin-sdk/feishu": {
"types": "./dist/plugin-sdk/feishu.d.ts",
"default": "./dist/plugin-sdk/feishu.js"
},
"./plugin-sdk/matrix": {
"types": "./dist/plugin-sdk/matrix.d.ts",
"default": "./dist/plugin-sdk/matrix.js"

View File

@ -37,6 +37,7 @@
"telegram-core",
"discord",
"discord-core",
"feishu",
"matrix",
"slack",
"slack-core",

View File

@ -78,7 +78,11 @@ export {
buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState,
} from "./status-helpers.js";
export { withTempDownloadPath } from "./temp-path.js";
export {
buildAgentWorkspaceArtifactPath,
resolveAgentWorkspaceOutputPath,
withTempDownloadPath,
} from "./temp-path.js";
export {
buildFeishuConversationId,
parseFeishuConversationId,

View File

@ -12,6 +12,7 @@ import type {
} from "openclaw/plugin-sdk/core";
import * as directoryRuntimeSdk from "openclaw/plugin-sdk/directory-runtime";
import * as discordSdk from "openclaw/plugin-sdk/discord";
import * as feishuSdk from "openclaw/plugin-sdk/feishu";
import * as imessageSdk from "openclaw/plugin-sdk/imessage";
import * as imessageCoreSdk from "openclaw/plugin-sdk/imessage-core";
import * as lazyRuntimeSdk from "openclaw/plugin-sdk/lazy-runtime";
@ -61,7 +62,6 @@ describe("plugin-sdk subpath exports", () => {
expect(pluginSdkSubpaths).not.toContain("acpx");
expect(pluginSdkSubpaths).not.toContain("compat");
expect(pluginSdkSubpaths).not.toContain("device-pair");
expect(pluginSdkSubpaths).not.toContain("feishu");
expect(pluginSdkSubpaths).not.toContain("google");
expect(pluginSdkSubpaths).not.toContain("googlechat");
expect(pluginSdkSubpaths).not.toContain("irc");
@ -137,6 +137,12 @@ describe("plugin-sdk subpath exports", () => {
expect(typeof directoryRuntimeSdk.listResolvedDirectoryEntriesFromSources).toBe("function");
});
it("exports feishu helpers from the dedicated subpath", () => {
expect(typeof feishuSdk.buildAgentWorkspaceArtifactPath).toBe("function");
expect(typeof feishuSdk.resolveAgentWorkspaceOutputPath).toBe("function");
expect(typeof feishuSdk.withTempDownloadPath).toBe("function");
});
it("exports channel runtime helpers from the dedicated subpath", () => {
expect(typeof channelRuntimeSdk.createChannelDirectoryAdapter).toBe("function");
expect(typeof channelRuntimeSdk.createRuntimeOutboundDelegates).toBe("function");

View File

@ -2,7 +2,26 @@ import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { buildRandomTempFilePath, withTempDownloadPath } from "./temp-path.js";
import {
buildAgentWorkspaceArtifactPath,
buildRandomTempFilePath,
resolveAgentWorkspaceOutputPath,
withTempDownloadPath,
} from "./temp-path.js";
const cfg = {
agents: {
defaults: {
workspace: "/Users/admin/.openclaw/workspace",
},
list: [
{
id: "ops",
workspace: "/Users/admin/.openclaw/workspace-ops",
},
],
},
};
describe("buildRandomTempFilePath", () => {
it("builds deterministic paths when now/uuid are provided", () => {
@ -69,3 +88,65 @@ describe("withTempDownloadPath", () => {
expect(capturedPath).not.toContain("..");
});
});
describe("buildAgentWorkspaceArtifactPath", () => {
it("places artifacts inside the resolved agent workspace", () => {
const output = buildAgentWorkspaceArtifactPath({
cfg,
agentId: "ops",
prefix: "feishu-resource",
preferredFileName: "weekly-report.xlsx",
pathSegments: [".openclaw", "artifacts", "feishu"],
now: 123,
uuid: "abc",
});
expect(output.workspaceDir).toBe("/Users/admin/.openclaw/workspace-ops");
expect(output.absolutePath).toBe(
"/Users/admin/.openclaw/workspace-ops/.openclaw/artifacts/feishu/weekly-report-123-abc.xlsx",
);
expect(output.workspacePath).toBe(".openclaw/artifacts/feishu/weekly-report-123-abc.xlsx");
});
});
describe("resolveAgentWorkspaceOutputPath", () => {
it("auto-generates a workspace-local artifact path when output_path is omitted", () => {
const output = resolveAgentWorkspaceOutputPath({
cfg,
agentId: "ops",
prefix: "drive-file",
preferredFileName: "summary.pdf",
pathSegments: [".openclaw", "artifacts", "feishu"],
now: 456,
uuid: "xyz",
});
expect(output.absolutePath).toBe(
"/Users/admin/.openclaw/workspace-ops/.openclaw/artifacts/feishu/summary-456-xyz.pdf",
);
expect(output.workspacePath).toBe(".openclaw/artifacts/feishu/summary-456-xyz.pdf");
});
it("resolves relative output paths inside the agent workspace", () => {
const output = resolveAgentWorkspaceOutputPath({
cfg,
agentId: "ops",
prefix: "drive-file",
outputPath: "exports/report.xlsx",
});
expect(output.absolutePath).toBe("/Users/admin/.openclaw/workspace-ops/exports/report.xlsx");
expect(output.workspacePath).toBe("exports/report.xlsx");
});
it("rejects relative output paths that escape the agent workspace", () => {
expect(() =>
resolveAgentWorkspaceOutputPath({
cfg,
agentId: "ops",
prefix: "drive-file",
outputPath: "../outside/report.xlsx",
}),
).toThrow("Relative output_path must stay within the current workspace");
});
});

View File

@ -1,6 +1,8 @@
import crypto from "node:crypto";
import { mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
function sanitizePrefix(prefix: string): string {
@ -27,6 +29,15 @@ function sanitizeFileName(fileName: string): string {
return normalized || "download.bin";
}
function sanitizeWorkspacePathSegment(segment: string): string {
const normalized = segment
.normalize("NFKC")
.replace(/[\\/]+/g, "-")
.replace(/\p{Cc}/gu, "")
.trim();
return normalized || "artifact";
}
function resolveTempRoot(tmpDir?: string): string {
return tmpDir ?? resolvePreferredOpenClawTmpDir();
}
@ -59,6 +70,115 @@ export function buildRandomTempFilePath(params: {
return path.join(resolveTempRoot(params.tmpDir), `${prefix}-${now}-${uuid}${extension}`);
}
export type WorkspaceArtifactPath = {
workspaceDir: string;
absolutePath: string;
workspacePath?: string;
};
function toWorkspaceRelativePath(workspaceDir: string, absolutePath: string): string | undefined {
const relativePath = path.relative(workspaceDir, absolutePath);
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
return undefined;
}
return relativePath.split(path.sep).join(path.posix.sep);
}
function normalizeWorkspaceExtension(params: {
extension?: string;
preferredFileName?: string;
}): string {
if (params.extension) {
return sanitizeExtension(params.extension);
}
const preferredExt = params.preferredFileName ? path.extname(params.preferredFileName) : "";
return sanitizeExtension(preferredExt);
}
function buildWorkspaceArtifactFileName(params: {
prefix: string;
extension?: string;
preferredFileName?: string;
now?: number;
uuid?: string;
}): string {
const baseName = params.preferredFileName
? path.basename(params.preferredFileName, path.extname(params.preferredFileName))
: params.prefix;
const normalizedBase = sanitizeWorkspacePathSegment(baseName);
const nowCandidate = params.now;
const now =
typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
? Math.trunc(nowCandidate)
: Date.now();
const uuid = params.uuid?.trim() || crypto.randomUUID();
return `${normalizedBase}-${now}-${uuid}${normalizeWorkspaceExtension(params)}`;
}
function normalizeArtifactSegments(segments?: string[]): string[] {
const normalized = (segments ?? [".openclaw", "artifacts", "downloads"])
.map((segment) => sanitizeWorkspacePathSegment(segment))
.filter(Boolean);
return normalized.length > 0 ? normalized : [".openclaw", "artifacts", "downloads"];
}
export function buildAgentWorkspaceArtifactPath(params: {
cfg: OpenClawConfig;
agentId: string;
prefix: string;
extension?: string;
preferredFileName?: string;
pathSegments?: string[];
now?: number;
uuid?: string;
}): WorkspaceArtifactPath {
const workspaceDir = path.resolve(resolveAgentWorkspaceDir(params.cfg, params.agentId));
const fileName = buildWorkspaceArtifactFileName(params);
const absolutePath = path.join(
workspaceDir,
...normalizeArtifactSegments(params.pathSegments),
fileName,
);
return {
workspaceDir,
absolutePath,
workspacePath: toWorkspaceRelativePath(workspaceDir, absolutePath),
};
}
export function resolveAgentWorkspaceOutputPath(params: {
cfg: OpenClawConfig;
agentId: string;
outputPath?: string;
prefix: string;
extension?: string;
preferredFileName?: string;
pathSegments?: string[];
now?: number;
uuid?: string;
}): WorkspaceArtifactPath {
if (!params.outputPath?.trim()) {
return buildAgentWorkspaceArtifactPath(params);
}
const workspaceDir = path.resolve(resolveAgentWorkspaceDir(params.cfg, params.agentId));
const rawOutputPath = params.outputPath.trim();
const absolutePath = path.isAbsolute(rawOutputPath)
? rawOutputPath
: path.resolve(workspaceDir, rawOutputPath);
const workspacePath = toWorkspaceRelativePath(workspaceDir, absolutePath);
if (!path.isAbsolute(rawOutputPath) && !workspacePath) {
throw new Error("Relative output_path must stay within the current workspace");
}
return {
workspaceDir,
absolutePath,
workspacePath,
};
}
/** Create a temporary download directory, run the callback, then clean it up best-effort. */
export async function withTempDownloadPath<T>(
params: {