From 5c88d62d75e6f42930bf0eb8713db51ca4a41d74 Mon Sep 17 00:00:00 2001 From: sunke Date: Sat, 21 Mar 2026 00:08:23 +0800 Subject: [PATCH] Export Feishu workspace artifact helpers --- package.json | 4 + scripts/lib/plugin-sdk-entrypoints.json | 1 + src/plugin-sdk/feishu.ts | 6 +- src/plugin-sdk/subpaths.test.ts | 8 +- src/plugin-sdk/temp-path.test.ts | 83 +++++++++++++++- src/plugin-sdk/temp-path.ts | 120 ++++++++++++++++++++++++ 6 files changed, 219 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b8fe827b3e7..d6f5da1554d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e1991f4ab76..2ac6229519c 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -37,6 +37,7 @@ "telegram-core", "discord", "discord-core", + "feishu", "matrix", "slack", "slack-core", diff --git a/src/plugin-sdk/feishu.ts b/src/plugin-sdk/feishu.ts index b616d16fdd0..13d76b6de67 100644 --- a/src/plugin-sdk/feishu.ts +++ b/src/plugin-sdk/feishu.ts @@ -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, diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index ab8c16d71f7..4ca0ff0a26a 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -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"); diff --git a/src/plugin-sdk/temp-path.test.ts b/src/plugin-sdk/temp-path.test.ts index 166a2373b15..026a49c580e 100644 --- a/src/plugin-sdk/temp-path.test.ts +++ b/src/plugin-sdk/temp-path.test.ts @@ -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"); + }); +}); diff --git a/src/plugin-sdk/temp-path.ts b/src/plugin-sdk/temp-path.ts index 436377fe5e1..82675503274 100644 --- a/src/plugin-sdk/temp-path.ts +++ b/src/plugin-sdk/temp-path.ts @@ -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( params: {