@dai_ming/plugin-deliverables 1.0.20 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +6 -6
- package/README.md +11 -9
- package/index.js +765 -3
- package/mcp-servers/deliverables.js +61 -2
- package/openclaw-plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/test/index.test.js +41 -0
- package/test/mcp-server.test.js +16 -0
package/INSTALL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# plugin-deliverables 安装文档
|
|
2
2
|
|
|
3
|
-
本文档描述 `@dai_ming/plugin-deliverables@1.0.
|
|
3
|
+
本文档描述 `@dai_ming/plugin-deliverables@1.0.22` 的安装、升级与验证方式。
|
|
4
4
|
|
|
5
5
|
## 1. 目标
|
|
6
6
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
适用于已经支持 `openclaw plugins install` 的运行环境。
|
|
23
23
|
|
|
24
24
|
```bash
|
|
25
|
-
openclaw plugins install @dai_ming/plugin-deliverables@1.0.
|
|
25
|
+
openclaw plugins install @dai_ming/plugin-deliverables@1.0.22 --pin
|
|
26
26
|
openclaw plugins enable plugin-deliverables
|
|
27
27
|
```
|
|
28
28
|
|
|
@@ -47,7 +47,7 @@ openclaw plugins list
|
|
|
47
47
|
|
|
48
48
|
```yaml
|
|
49
49
|
installPlugins:
|
|
50
|
-
- "@dai_ming/plugin-deliverables@1.0.
|
|
50
|
+
- "@dai_ming/plugin-deliverables@1.0.22"
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Helm 初始化时会自动完成:
|
|
@@ -66,8 +66,8 @@ Helm 初始化时会自动完成:
|
|
|
66
66
|
```bash
|
|
67
67
|
mkdir -p /home/node/.openclaw/extensions-extra/plugin-deliverables
|
|
68
68
|
cd /tmp
|
|
69
|
-
npm pack @dai_ming/plugin-deliverables@1.0.
|
|
70
|
-
tar xzf dai_ming-plugin-deliverables-1.0.
|
|
69
|
+
npm pack @dai_ming/plugin-deliverables@1.0.22 --registry https://registry.npmjs.org
|
|
70
|
+
tar xzf dai_ming-plugin-deliverables-1.0.22.tgz \
|
|
71
71
|
-C /home/node/.openclaw/extensions-extra/plugin-deliverables \
|
|
72
72
|
--strip-components=1
|
|
73
73
|
```
|
|
@@ -212,5 +212,5 @@ cat /home/node/.openclaw/extensions-extra/plugin-deliverables/package.json
|
|
|
212
212
|
必须是:
|
|
213
213
|
|
|
214
214
|
```json
|
|
215
|
-
{ "version": "1.0.
|
|
215
|
+
{ "version": "1.0.22" }
|
|
216
216
|
```
|
package/README.md
CHANGED
|
@@ -24,17 +24,18 @@ OpenClaw 交付物插件 — 让 AI Agent 将生成的文件(文章、HTML 页
|
|
|
24
24
|
现在这个包同时支持 OpenClaw 原生插件加载。也就是说,除了网关侧用 `openclaw-plugin.json` 注入 MCP/skill/AGENTS 规则外,OpenClaw 还会读取 `openclaw.plugin.json` + `index.js`,把运行时 hook 也一并启用。
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
openclaw plugins install @dai_ming/plugin-deliverables@1.0.
|
|
27
|
+
openclaw plugins install @dai_ming/plugin-deliverables@1.0.22 --pin
|
|
28
28
|
openclaw plugins enable plugin-deliverables
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
这一步启用后,插件会额外提供三层兜底:
|
|
32
32
|
|
|
33
33
|
1. `before_prompt_build`:把“交付物必须 upload-first”的硬规则注入到主 agent 和子 agent 的系统上下文
|
|
34
|
-
2. `before_tool_call
|
|
35
|
-
3.
|
|
34
|
+
2. `before_tool_call`:记录 `write` 生成的工作区文件,并阻止通过 `message` 附件字段直接发文件
|
|
35
|
+
3. Palz 出站补丁:最终消息发送前扫描内部工作区路径/近期写入文件,自动上传为交付物并改写成 gateway 链接
|
|
36
|
+
4. `message_sending`:如果仍然有媒体/文件旁路发送,最终发送前直接取消
|
|
36
37
|
|
|
37
|
-
>
|
|
38
|
+
> 注意:非 Palz 渠道仍以 `before_prompt_build` + `before_tool_call` + `message_sending` 兜底为主;Palz 渠道具备自动上传和文本改写能力。
|
|
38
39
|
|
|
39
40
|
### 方式一:通过 claw-gateway Helm 部署(推荐)
|
|
40
41
|
|
|
@@ -43,11 +44,11 @@ openclaw plugins enable plugin-deliverables
|
|
|
43
44
|
```yaml
|
|
44
45
|
# values.yaml(或 claw-gateway 管理界面的 Helm 参数)
|
|
45
46
|
installPlugins:
|
|
46
|
-
- "@dai_ming/plugin-deliverables@1.0.
|
|
47
|
+
- "@dai_ming/plugin-deliverables@1.0.22"
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
initContainer 执行顺序:
|
|
50
|
-
1. **Phase 3**:`npm pack @dai_ming/plugin-deliverables@1.0.
|
|
51
|
+
1. **Phase 3**:`npm pack @dai_ming/plugin-deliverables@1.0.22` 下载 tarball → 解压到 `/data/extensions-extra/plugin-deliverables/`
|
|
51
52
|
2. **Phase 3b**:在插件目录执行 `npm install --omit=dev`(本插件无运行时依赖,此步骤跳过)
|
|
52
53
|
3. **Phase 3e**:读取 `openclaw-plugin.json` 清单,自动:
|
|
53
54
|
- 复制 `mcp-servers/deliverables.js` → `/data/mcp-servers/deliverables.js`
|
|
@@ -197,10 +198,11 @@ fi
|
|
|
197
198
|
| Hook | 作用 |
|
|
198
199
|
|------|------|
|
|
199
200
|
| `before_prompt_build` | 把“生成文件必须走交付物上传”的硬规则注入系统上下文,覆盖主 agent 和子 agent |
|
|
200
|
-
| `before_tool_call` |
|
|
201
|
+
| `before_tool_call` | 记录 `write` 生成的工作区文件;阻止通过 `message` 工具的 `media/path/filePath/buffer` 等字段直接发送文件 |
|
|
202
|
+
| Palz 出站补丁 | 扫描最终消息中的 `/data/workspace-*`、`output/*.ext`、反引号文件名等工作区文件引用,自动上传后替换为交付物链接 |
|
|
201
203
|
| `message_sending` | 如果上游仍然产生了媒体/文件旁路发送,在最终出站前直接取消 |
|
|
202
204
|
|
|
203
|
-
|
|
205
|
+
这些兜底一起工作的目标是:即使 prompt 漂移,也尽量把“工作区路径 / message + file/media”这类旁路堵住,统一收敛到 gateway 交付物链接。
|
|
204
206
|
|
|
205
207
|
另外,Palz 渠道下如果交付物回复包含可信 gateway 交付物链接,会被拆成“内容简介 + 最终链接”两条消息;任意外部 URL 或非 gateway OSS URL 不会被包装成 `file_url` 文件消息。runtime plugin 还会分别打印这两次真实 HTTP 发送的 request/response 日志,便于直接从 pod stdout 排查。
|
|
206
208
|
|
|
@@ -224,7 +226,7 @@ npm publish --registry https://registry.npmjs.org --access public
|
|
|
224
226
|
|
|
225
227
|
| claw-gateway 版本 | plugin 版本 |
|
|
226
228
|
|-------------------|-------------|
|
|
227
|
-
| 当前 | 1.0.
|
|
229
|
+
| 当前 | 1.0.22 |
|
|
228
230
|
|
|
229
231
|
---
|
|
230
232
|
|
package/index.js
CHANGED
|
@@ -1,16 +1,76 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const http = require("http");
|
|
5
|
+
const https = require("https");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
3
8
|
const FETCH_PATCH_KEY = "__plugin_deliverables_palz_fetch_patch__";
|
|
4
9
|
const SUMMARY_CACHE_KEY = "__plugin_deliverables_summary_cache__";
|
|
10
|
+
const PENDING_FILES_KEY = "__plugin_deliverables_pending_files__";
|
|
11
|
+
const UPLOAD_CACHE_KEY = "__plugin_deliverables_upload_cache__";
|
|
5
12
|
const SUMMARY_CACHE_LIMIT = 200;
|
|
6
13
|
const SHORT_SUMMARY_THRESHOLD = 120;
|
|
7
14
|
const DEFAULT_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
|
|
8
15
|
const DEFAULT_GATEWAY_INTERNAL_URL = "http://claw-gateway:8080";
|
|
16
|
+
const AUTO_UPLOAD_TTL_MS = 15 * 60 * 1000;
|
|
17
|
+
const AUTO_UPLOAD_MAX_FILES = 8;
|
|
18
|
+
const AUTO_UPLOAD_MAX_BYTES = 200 * 1024 * 1024;
|
|
19
|
+
const AUTO_UPLOAD_SCAN_MAX_ENTRIES = 4000;
|
|
20
|
+
const AUTO_UPLOAD_SCAN_DEPTH = 5;
|
|
9
21
|
const DELIVERABLE_GATEWAY_PATH_RE = /^\/openclaw-gateway\/(?:output|preview)(?:\/|$)/u;
|
|
10
22
|
const DELIVERABLE_LINK_PREFIX_RE =
|
|
11
23
|
/^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]/u;
|
|
12
24
|
const DELIVERABLE_LINK_LABEL_RE =
|
|
13
25
|
/^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]\s*\[[^\]]+\]\([^)]+\)\s*$/u;
|
|
26
|
+
const INTERNAL_ABSOLUTE_PATH_RE =
|
|
27
|
+
/(?:\/data\/workspace-[^\s`"'<>,。;:、))\]}]+|\/home\/node\/\.openclaw\/workspace(?:-[A-Za-z0-9_.-]+)?\/[^\s`"'<>,。;:、))\]}]+)/gu;
|
|
28
|
+
const INLINE_FILE_REF_RE = /`([^`\n]{1,512}\.[A-Za-z0-9]{1,16})`/gu;
|
|
29
|
+
const OUTPUT_RELATIVE_REF_RE = /(?:^|[\s(::])((?:\.\/)?output\/[^\s`"'<>,。;:、))\]}]+\.[A-Za-z0-9]{1,16})/gu;
|
|
30
|
+
const FILE_EXTENSION_RE = /\.[A-Za-z0-9]{1,16}$/u;
|
|
31
|
+
const TEXT_EXTENSIONS = new Set([
|
|
32
|
+
".css",
|
|
33
|
+
".csv",
|
|
34
|
+
".html",
|
|
35
|
+
".htm",
|
|
36
|
+
".js",
|
|
37
|
+
".json",
|
|
38
|
+
".jsx",
|
|
39
|
+
".log",
|
|
40
|
+
".md",
|
|
41
|
+
".markdown",
|
|
42
|
+
".mjs",
|
|
43
|
+
".py",
|
|
44
|
+
".sql",
|
|
45
|
+
".svg",
|
|
46
|
+
".ts",
|
|
47
|
+
".tsx",
|
|
48
|
+
".txt",
|
|
49
|
+
".xml",
|
|
50
|
+
".yaml",
|
|
51
|
+
".yml",
|
|
52
|
+
]);
|
|
53
|
+
const IMAGE_EXTENSIONS = new Set([".apng", ".avif", ".bmp", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp"]);
|
|
54
|
+
const VIDEO_EXTENSIONS = new Set([".avi", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".webm"]);
|
|
55
|
+
const PPT_EXTENSIONS = new Set([".ppt", ".pptx"]);
|
|
56
|
+
const ARCHIVE_EXTENSIONS = new Set([".7z", ".gz", ".rar", ".tar", ".tgz", ".zip"]);
|
|
57
|
+
const IGNORED_PATH_PARTS = new Set([
|
|
58
|
+
".git",
|
|
59
|
+
".hg",
|
|
60
|
+
".svn",
|
|
61
|
+
"node_modules",
|
|
62
|
+
"sessions",
|
|
63
|
+
"skills",
|
|
64
|
+
".cache",
|
|
65
|
+
"__pycache__",
|
|
66
|
+
]);
|
|
67
|
+
const IGNORED_FILE_NAMES = new Set([
|
|
68
|
+
"AGENTS.md",
|
|
69
|
+
"SOUL.md",
|
|
70
|
+
"openclaw.json",
|
|
71
|
+
"openclaw-runtime.json",
|
|
72
|
+
"package-lock.json",
|
|
73
|
+
]);
|
|
14
74
|
|
|
15
75
|
const RUNTIME_DELIVERABLES_GUIDANCE = [
|
|
16
76
|
"## Deliverables Runtime Guard (HARD CONSTRAINT)",
|
|
@@ -29,6 +89,648 @@ function isString(value) {
|
|
|
29
89
|
return typeof value === "string";
|
|
30
90
|
}
|
|
31
91
|
|
|
92
|
+
function trimString(value) {
|
|
93
|
+
return isString(value) ? value.trim() : "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getPendingFileStore() {
|
|
97
|
+
if (!globalThis[PENDING_FILES_KEY] || !(globalThis[PENDING_FILES_KEY] instanceof Map)) {
|
|
98
|
+
globalThis[PENDING_FILES_KEY] = new Map();
|
|
99
|
+
}
|
|
100
|
+
return globalThis[PENDING_FILES_KEY];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getUploadCache() {
|
|
104
|
+
if (!globalThis[UPLOAD_CACHE_KEY] || !(globalThis[UPLOAD_CACHE_KEY] instanceof Map)) {
|
|
105
|
+
globalThis[UPLOAD_CACHE_KEY] = new Map();
|
|
106
|
+
}
|
|
107
|
+
return globalThis[UPLOAD_CACHE_KEY];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function pruneTimedMap(map, ttlMs) {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
for (const [key, value] of map.entries()) {
|
|
113
|
+
if (!value || !value.createdAt || now - Number(value.createdAt) > ttlMs) {
|
|
114
|
+
map.delete(key);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeSlash(value) {
|
|
120
|
+
return String(value || "").replace(/\\/g, "/");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function stripTrailingPathPunctuation(value) {
|
|
124
|
+
return String(value || "").replace(/[.,;:!?,。;:!?、))\]}》】]+$/u, "");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function hasFileExtension(value) {
|
|
128
|
+
return FILE_EXTENSION_RE.test(path.basename(stripTrailingPathPunctuation(value)));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isURLLike(value) {
|
|
132
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(String(value || ""));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function fileExtension(filePath) {
|
|
136
|
+
return path.extname(stripTrailingPathPunctuation(filePath)).toLowerCase();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isTextLikeFile(filePath) {
|
|
140
|
+
return TEXT_EXTENSIONS.has(fileExtension(filePath));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function deliverableTypeForPath(filePath, isDirectory) {
|
|
144
|
+
if (isDirectory) {
|
|
145
|
+
return "game";
|
|
146
|
+
}
|
|
147
|
+
const ext = fileExtension(filePath);
|
|
148
|
+
if (IMAGE_EXTENSIONS.has(ext) && ext !== ".svg") {
|
|
149
|
+
return "image";
|
|
150
|
+
}
|
|
151
|
+
if (VIDEO_EXTENSIONS.has(ext)) {
|
|
152
|
+
return "video";
|
|
153
|
+
}
|
|
154
|
+
if (PPT_EXTENSIONS.has(ext)) {
|
|
155
|
+
return "ppt";
|
|
156
|
+
}
|
|
157
|
+
if (ARCHIVE_EXTENSIONS.has(ext)) {
|
|
158
|
+
return "zip";
|
|
159
|
+
}
|
|
160
|
+
return "article";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isAllowedWorkspacePath(candidatePath) {
|
|
164
|
+
const normalized = normalizeSlash(path.resolve(candidatePath));
|
|
165
|
+
return (
|
|
166
|
+
normalized.indexOf("/data/workspace-") === 0 ||
|
|
167
|
+
normalized.indexOf("/home/node/.openclaw/workspace") === 0
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function shouldIgnorePath(candidatePath) {
|
|
172
|
+
const normalized = normalizeSlash(candidatePath);
|
|
173
|
+
const parts = normalized.split("/").filter(Boolean);
|
|
174
|
+
for (const part of parts) {
|
|
175
|
+
if (IGNORED_PATH_PARTS.has(part)) {
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return IGNORED_FILE_NAMES.has(path.basename(normalized));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function safeStat(candidatePath) {
|
|
183
|
+
try {
|
|
184
|
+
return fs.statSync(candidatePath);
|
|
185
|
+
} catch (_err) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function workspaceRoots() {
|
|
191
|
+
const roots = [];
|
|
192
|
+
["/home/node/.openclaw/workspace-main", "/home/node/.openclaw/workspace"].forEach((root) => {
|
|
193
|
+
if (safeStat(root)) {
|
|
194
|
+
roots.push(root);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
try {
|
|
198
|
+
for (const entry of fs.readdirSync("/data")) {
|
|
199
|
+
if (!entry || entry.indexOf("workspace-") !== 0) {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const root = path.join("/data", entry);
|
|
203
|
+
if (safeStat(root)) {
|
|
204
|
+
roots.push(root);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
} catch (_err) {
|
|
208
|
+
// /data is not guaranteed in non-pod test environments.
|
|
209
|
+
}
|
|
210
|
+
return roots;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function resolveCandidatePath(rawPath) {
|
|
214
|
+
const cleaned = stripTrailingPathPunctuation(trimString(rawPath));
|
|
215
|
+
if (!cleaned || isURLLike(cleaned)) {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
if (path.isAbsolute(cleaned)) {
|
|
219
|
+
return path.resolve(cleaned);
|
|
220
|
+
}
|
|
221
|
+
return cleaned;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findFileByRelativeOrBaseName(rawRef) {
|
|
225
|
+
const cleaned = stripTrailingPathPunctuation(trimString(rawRef)).replace(/^\.\//, "");
|
|
226
|
+
if (!cleaned || isURLLike(cleaned) || !hasFileExtension(cleaned)) {
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
const roots = workspaceRoots();
|
|
230
|
+
const directCandidates = [];
|
|
231
|
+
for (const root of roots) {
|
|
232
|
+
directCandidates.push(path.join(root, cleaned));
|
|
233
|
+
directCandidates.push(path.join(root, "output", cleaned));
|
|
234
|
+
directCandidates.push(path.join(root, path.basename(cleaned)));
|
|
235
|
+
directCandidates.push(path.join(root, "output", path.basename(cleaned)));
|
|
236
|
+
}
|
|
237
|
+
for (const candidate of directCandidates) {
|
|
238
|
+
const stat = safeStat(candidate);
|
|
239
|
+
if (stat && (stat.isFile() || stat.isDirectory()) && isAllowedWorkspacePath(candidate) && !shouldIgnorePath(candidate)) {
|
|
240
|
+
return path.resolve(candidate);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const basename = path.basename(cleaned);
|
|
245
|
+
const found = [];
|
|
246
|
+
let visited = 0;
|
|
247
|
+
function scan(dir, depth) {
|
|
248
|
+
if (depth < 0 || visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || shouldIgnorePath(dir)) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
let entries;
|
|
252
|
+
try {
|
|
253
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
254
|
+
} catch (_err) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
visited += 1;
|
|
262
|
+
const full = path.join(dir, entry.name);
|
|
263
|
+
if (shouldIgnorePath(full)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (entry.isDirectory()) {
|
|
267
|
+
scan(full, depth - 1);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (entry.isFile() && entry.name === basename) {
|
|
271
|
+
const stat = safeStat(full);
|
|
272
|
+
if (stat) {
|
|
273
|
+
found.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const root of roots) {
|
|
279
|
+
scan(root, AUTO_UPLOAD_SCAN_DEPTH);
|
|
280
|
+
}
|
|
281
|
+
found.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
282
|
+
return found.length > 0 ? path.resolve(found[0].path) : "";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function extractFileReferencesFromText(text) {
|
|
286
|
+
const normalized = String(text || "");
|
|
287
|
+
const refs = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
function add(raw, value, kind) {
|
|
290
|
+
const cleaned = stripTrailingPathPunctuation(value);
|
|
291
|
+
if (!cleaned || isURLLike(cleaned)) {
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
const key = `${kind}:${cleaned}`;
|
|
295
|
+
if (seen.has(key)) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
seen.add(key);
|
|
299
|
+
refs.push({ raw, value: cleaned, kind });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
for (const match of normalized.matchAll(INTERNAL_ABSOLUTE_PATH_RE)) {
|
|
303
|
+
add(match[0], match[0], "absolute");
|
|
304
|
+
}
|
|
305
|
+
for (const match of normalized.matchAll(INLINE_FILE_REF_RE)) {
|
|
306
|
+
add(match[0], match[1], "inline");
|
|
307
|
+
}
|
|
308
|
+
for (const match of normalized.matchAll(OUTPUT_RELATIVE_REF_RE)) {
|
|
309
|
+
add(match[1], match[1], "relative");
|
|
310
|
+
}
|
|
311
|
+
return refs;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function resolveFileReference(ref) {
|
|
315
|
+
if (!ref || !ref.value) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
const candidate = resolveCandidatePath(ref.value);
|
|
319
|
+
const resolved = path.isAbsolute(candidate) ? candidate : findFileByRelativeOrBaseName(candidate);
|
|
320
|
+
if (!resolved) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
const stat = safeStat(resolved);
|
|
324
|
+
if (!stat || (!stat.isFile() && !stat.isDirectory())) {
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
if (!isAllowedWorkspacePath(resolved) || shouldIgnorePath(resolved)) {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
raw: ref.raw,
|
|
332
|
+
path: resolved,
|
|
333
|
+
fileName: path.basename(resolved),
|
|
334
|
+
isDirectory: stat.isDirectory(),
|
|
335
|
+
size: stat.isDirectory() ? 0 : stat.size,
|
|
336
|
+
mtimeMs: stat.mtimeMs,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function collectDirectoryFiles(dirPath) {
|
|
341
|
+
const files = [];
|
|
342
|
+
let total = 0;
|
|
343
|
+
let visited = 0;
|
|
344
|
+
function scan(current, depth) {
|
|
345
|
+
if (depth < 0 || visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || shouldIgnorePath(current)) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
let entries;
|
|
349
|
+
try {
|
|
350
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
351
|
+
} catch (_err) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
if (visited > AUTO_UPLOAD_SCAN_MAX_ENTRIES || files.length >= AUTO_UPLOAD_MAX_FILES * 50) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
visited += 1;
|
|
359
|
+
const full = path.join(current, entry.name);
|
|
360
|
+
if (shouldIgnorePath(full)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (entry.isDirectory()) {
|
|
364
|
+
scan(full, depth - 1);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (!entry.isFile()) {
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const stat = safeStat(full);
|
|
371
|
+
if (!stat || total + stat.size > AUTO_UPLOAD_MAX_BYTES) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
total += stat.size;
|
|
375
|
+
const rel = normalizeSlash(path.relative(dirPath, full));
|
|
376
|
+
if (isTextLikeFile(full)) {
|
|
377
|
+
files.push({ name: rel, contentText: fs.readFileSync(full, "utf8") });
|
|
378
|
+
} else {
|
|
379
|
+
files.push({ name: rel, contentBase64: fs.readFileSync(full).toString("base64") });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
scan(dirPath, AUTO_UPLOAD_SCAN_DEPTH);
|
|
384
|
+
return files;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function extractStringField(obj, keys) {
|
|
388
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
389
|
+
return "";
|
|
390
|
+
}
|
|
391
|
+
for (const key of keys) {
|
|
392
|
+
const value = obj[key];
|
|
393
|
+
if (isString(value) && value.trim()) {
|
|
394
|
+
return value.trim();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function extractContextString(event, keys) {
|
|
401
|
+
const direct = extractStringField(event, keys);
|
|
402
|
+
if (direct) {
|
|
403
|
+
return direct;
|
|
404
|
+
}
|
|
405
|
+
const containers = [
|
|
406
|
+
event && event.params,
|
|
407
|
+
event && event.metadata,
|
|
408
|
+
event && event.context,
|
|
409
|
+
event && event.ctx,
|
|
410
|
+
event && event.message,
|
|
411
|
+
];
|
|
412
|
+
for (const container of containers) {
|
|
413
|
+
const value = extractStringField(container, keys);
|
|
414
|
+
if (value) {
|
|
415
|
+
return value;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return "";
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function payloadResourceId(body) {
|
|
422
|
+
return extractStringField(body, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function payloadGroupId(body) {
|
|
426
|
+
return extractStringField(body, ["group_id", "groupId", "conversation_id", "conversationId"]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function payloadUserId(body) {
|
|
430
|
+
return extractStringField(body, ["user_id", "userId", "sender_id", "senderId", "owner_id", "ownerId"]);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function isWriteTool(toolName) {
|
|
434
|
+
const normalized = normalizeToolName(toolName);
|
|
435
|
+
return normalized === "write" || normalized.endsWith("__write") || normalized === "write_file" || normalized.endsWith("__write_file");
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function extractWritePath(params) {
|
|
439
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
440
|
+
return "";
|
|
441
|
+
}
|
|
442
|
+
return extractStringField(params, [
|
|
443
|
+
"path",
|
|
444
|
+
"file_path",
|
|
445
|
+
"filePath",
|
|
446
|
+
"filename",
|
|
447
|
+
"fileName",
|
|
448
|
+
"target",
|
|
449
|
+
"target_path",
|
|
450
|
+
"targetPath",
|
|
451
|
+
"destination",
|
|
452
|
+
]);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function rememberPendingFile(event) {
|
|
456
|
+
const params = event && event.params;
|
|
457
|
+
const writePath = extractWritePath(params);
|
|
458
|
+
if (!writePath || isURLLike(writePath)) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
const store = getPendingFileStore();
|
|
462
|
+
pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
|
|
463
|
+
const resourceId = extractContextString(event, ["resource_id", "resourceId", "conversation_id", "conversationId"]);
|
|
464
|
+
const key = `${resourceId || "unknown"}:${writePath}`;
|
|
465
|
+
store.set(key, {
|
|
466
|
+
path: writePath,
|
|
467
|
+
resourceId,
|
|
468
|
+
createdAt: Date.now(),
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function pendingCandidatesForPayload(body) {
|
|
473
|
+
const resourceId = payloadResourceId(body);
|
|
474
|
+
if (!resourceId) {
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
const store = getPendingFileStore();
|
|
478
|
+
pruneTimedMap(store, AUTO_UPLOAD_TTL_MS);
|
|
479
|
+
const out = [];
|
|
480
|
+
for (const entry of store.values()) {
|
|
481
|
+
if (!entry || entry.resourceId !== resourceId) {
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const ref = { raw: entry.path, value: entry.path, kind: "pending" };
|
|
485
|
+
const resolved = resolveFileReference(ref);
|
|
486
|
+
if (resolved) {
|
|
487
|
+
out.push(resolved);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return out;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function gatewayURL() {
|
|
494
|
+
return (process.env.CLAW_GATEWAY_URL || DEFAULT_GATEWAY_INTERNAL_URL).replace(/\/$/, "");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function gatewayPublicURL() {
|
|
498
|
+
return (process.env.CLAW_GATEWAY_PUBLIC_URL || process.env.CLAW_GATEWAY_URL || DEFAULT_GATEWAY_PUBLIC_URL).replace(/\/$/, "");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function apiKey() {
|
|
502
|
+
return process.env.CLAW_GATEWAY_API_KEY || process.env.OPENCLAW_GATEWAY_API_KEY || "api-key-1";
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function deriveReleaseName() {
|
|
506
|
+
let release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
|
|
507
|
+
if (!release) {
|
|
508
|
+
const tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
|
|
509
|
+
if (tok.indexOf("oc-") === 0) {
|
|
510
|
+
release = tok.slice(3);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return release;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function httpJSONRequest(method, requestPath, body) {
|
|
517
|
+
return new Promise((resolve, reject) => {
|
|
518
|
+
let parsed;
|
|
519
|
+
try {
|
|
520
|
+
parsed = new URL(gatewayURL() + requestPath);
|
|
521
|
+
} catch (err) {
|
|
522
|
+
reject(err);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const isHTTPS = parsed.protocol === "https:";
|
|
526
|
+
const transport = isHTTPS ? https : http;
|
|
527
|
+
const bodyStr = body ? JSON.stringify(body) : "";
|
|
528
|
+
const req = transport.request(
|
|
529
|
+
{
|
|
530
|
+
hostname: parsed.hostname,
|
|
531
|
+
port: parsed.port || (isHTTPS ? 443 : 80),
|
|
532
|
+
path: parsed.pathname + (parsed.search || ""),
|
|
533
|
+
method,
|
|
534
|
+
headers: {
|
|
535
|
+
"Content-Type": "application/json",
|
|
536
|
+
"X-API-Key": apiKey(),
|
|
537
|
+
"Content-Length": Buffer.byteLength(bodyStr),
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
(res) => {
|
|
541
|
+
const chunks = [];
|
|
542
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
543
|
+
res.on("end", () => {
|
|
544
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
545
|
+
let obj;
|
|
546
|
+
try {
|
|
547
|
+
obj = JSON.parse(text);
|
|
548
|
+
} catch (_err) {
|
|
549
|
+
reject(new Error(`gateway ${res.statusCode}: non-JSON response: ${text.slice(0, 200)}`));
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (res.statusCode >= 400) {
|
|
553
|
+
reject(new Error(`gateway ${res.statusCode}: ${obj.message || text.slice(0, 200)}`));
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
resolve(obj);
|
|
557
|
+
});
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
req.on("error", reject);
|
|
561
|
+
if (bodyStr) {
|
|
562
|
+
req.write(bodyStr);
|
|
563
|
+
}
|
|
564
|
+
req.end();
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function uploadCacheKey(candidate, resourceId) {
|
|
569
|
+
return `${resourceId || ""}:${candidate.path}:${candidate.mtimeMs || 0}:${candidate.size || 0}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async function uploadCandidate(candidate, body) {
|
|
573
|
+
const resourceId = payloadResourceId(body);
|
|
574
|
+
const cache = getUploadCache();
|
|
575
|
+
pruneTimedMap(cache, AUTO_UPLOAD_TTL_MS);
|
|
576
|
+
const cacheKey = uploadCacheKey(candidate, resourceId);
|
|
577
|
+
const cached = cache.get(cacheKey);
|
|
578
|
+
if (cached && cached.result) {
|
|
579
|
+
return cached.result;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const stat = safeStat(candidate.path);
|
|
583
|
+
if (!stat) {
|
|
584
|
+
throw new Error(`file no longer exists: ${candidate.fileName}`);
|
|
585
|
+
}
|
|
586
|
+
const requestBody = {
|
|
587
|
+
resourceId,
|
|
588
|
+
groupId: payloadGroupId(body),
|
|
589
|
+
userId: payloadUserId(body),
|
|
590
|
+
release: deriveReleaseName(),
|
|
591
|
+
type: deliverableTypeForPath(candidate.path, stat.isDirectory()),
|
|
592
|
+
fileName: candidate.fileName || path.basename(candidate.path),
|
|
593
|
+
};
|
|
594
|
+
if (stat.isDirectory()) {
|
|
595
|
+
const files = collectDirectoryFiles(candidate.path);
|
|
596
|
+
if (files.length === 0) {
|
|
597
|
+
throw new Error(`directory has no uploadable files: ${candidate.fileName}`);
|
|
598
|
+
}
|
|
599
|
+
requestBody.files = files;
|
|
600
|
+
} else {
|
|
601
|
+
if (stat.size > AUTO_UPLOAD_MAX_BYTES) {
|
|
602
|
+
throw new Error(`file exceeds auto-upload limit: ${candidate.fileName}`);
|
|
603
|
+
}
|
|
604
|
+
requestBody.contentBase64 = fs.readFileSync(candidate.path).toString("base64");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const response = await httpJSONRequest("POST", "/openclaw-gateway/be/deliverables", requestBody);
|
|
608
|
+
const data = response.data || response;
|
|
609
|
+
const previewURL = data.previewUrl || `${gatewayPublicURL()}/openclaw-gateway/preview/${data.uuid}`;
|
|
610
|
+
const result = {
|
|
611
|
+
fileName: requestBody.fileName,
|
|
612
|
+
uuid: data.uuid,
|
|
613
|
+
previewURL,
|
|
614
|
+
downloadURL: data.downloadUrl || previewURL,
|
|
615
|
+
};
|
|
616
|
+
cache.set(cacheKey, { result, createdAt: Date.now() });
|
|
617
|
+
return result;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function dedupeCandidates(candidates) {
|
|
621
|
+
const out = [];
|
|
622
|
+
const seen = new Set();
|
|
623
|
+
for (const candidate of candidates) {
|
|
624
|
+
if (!candidate || !candidate.path) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const key = path.resolve(candidate.path);
|
|
628
|
+
if (seen.has(key)) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
seen.add(key);
|
|
632
|
+
out.push(candidate);
|
|
633
|
+
if (out.length >= AUTO_UPLOAD_MAX_FILES) {
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return out;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function escapeMarkdownLabel(label) {
|
|
641
|
+
return String(label || "文件").replace(/[[\]()`]/g, "");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function removeInternalPathLeaks(text, candidates) {
|
|
645
|
+
let out = String(text || "");
|
|
646
|
+
for (const candidate of candidates) {
|
|
647
|
+
if (!candidate || !candidate.raw) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
out = out.split(candidate.raw).join(candidate.fileName || path.basename(candidate.path));
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function appendDeliverableLinks(text, uploads) {
|
|
656
|
+
const lines = [];
|
|
657
|
+
uploads.forEach((upload) => {
|
|
658
|
+
const label = escapeMarkdownLabel(upload.fileName);
|
|
659
|
+
if (upload.previewURL) {
|
|
660
|
+
lines.push(`预览链接:[${label}](${upload.previewURL})`);
|
|
661
|
+
}
|
|
662
|
+
if (upload.downloadURL && upload.downloadURL !== upload.previewURL) {
|
|
663
|
+
lines.push(`下载链接:[${label}](${upload.downloadURL})`);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
if (lines.length === 0) {
|
|
667
|
+
return text;
|
|
668
|
+
}
|
|
669
|
+
return `${String(text || "").trim()}\n\n交付物链接:\n${lines.join("\n")}`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function hasTrustedDeliverableLink(text) {
|
|
673
|
+
return splitDeliverableMessage(text) !== null;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function shouldSkipAutoUploadPayload(body) {
|
|
677
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
if (!isString(body.content) || !body.content.trim()) {
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
if (body.stream_id || body.seq !== undefined || body.delta !== undefined || body.is_final !== undefined) {
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
if (body.palz_msg_type || body.tool_content) {
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function autoUploadAndRewritePayload(body, api) {
|
|
693
|
+
if (shouldSkipAutoUploadPayload(body) || hasTrustedDeliverableLink(body.content)) {
|
|
694
|
+
return body;
|
|
695
|
+
}
|
|
696
|
+
const refs = extractFileReferencesFromText(body.content);
|
|
697
|
+
const explicitCandidates = refs.map(resolveFileReference).filter(Boolean);
|
|
698
|
+
const candidates = dedupeCandidates(explicitCandidates.concat(pendingCandidatesForPayload(body)));
|
|
699
|
+
if (candidates.length === 0) {
|
|
700
|
+
return body;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const uploads = [];
|
|
704
|
+
const failures = [];
|
|
705
|
+
for (const candidate of candidates) {
|
|
706
|
+
try {
|
|
707
|
+
uploads.push(await uploadCandidate(candidate, body));
|
|
708
|
+
} catch (err) {
|
|
709
|
+
failures.push({ candidate, error: err });
|
|
710
|
+
api.logger.warn?.(
|
|
711
|
+
`[plugin-deliverables] auto-upload failed file=${candidate.fileName || candidate.path} error=${err.message}`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
if (uploads.length === 0) {
|
|
716
|
+
if (explicitCandidates.length === 0) {
|
|
717
|
+
return body;
|
|
718
|
+
}
|
|
719
|
+
const next = Object.assign({}, body);
|
|
720
|
+
next.content = "交付物上传失败,已阻止发送工作区内部文件路径。请稍后重试或联系管理员排查上传链路。";
|
|
721
|
+
return next;
|
|
722
|
+
}
|
|
723
|
+
const next = Object.assign({}, body);
|
|
724
|
+
next.content = appendDeliverableLinks(removeInternalPathLeaks(body.content, candidates), uploads);
|
|
725
|
+
if (failures.length > 0) {
|
|
726
|
+
next.content += `\n\n另有 ${failures.length} 个文件上传失败,已避免发送内部路径。`;
|
|
727
|
+
}
|
|
728
|
+
api.logger.info?.(
|
|
729
|
+
`[plugin-deliverables] auto-uploaded ${uploads.length} workspace file(s) for target=${String(body.conversation_id || "")}`,
|
|
730
|
+
);
|
|
731
|
+
return next;
|
|
732
|
+
}
|
|
733
|
+
|
|
32
734
|
function getSummaryCache() {
|
|
33
735
|
if (!globalThis[SUMMARY_CACHE_KEY] || !(globalThis[SUMMARY_CACHE_KEY] instanceof Map)) {
|
|
34
736
|
globalThis[SUMMARY_CACHE_KEY] = new Map();
|
|
@@ -520,6 +1222,7 @@ function splitDeliverableMessage(text) {
|
|
|
520
1222
|
|
|
521
1223
|
const linkLines = [];
|
|
522
1224
|
const linkUrls = [];
|
|
1225
|
+
const linkItems = [];
|
|
523
1226
|
for (let i = firstLinkIndex; i < lines.length; i += 1) {
|
|
524
1227
|
const line = lines[i];
|
|
525
1228
|
if (isLinksHeading(line)) {
|
|
@@ -532,6 +1235,7 @@ function splitDeliverableMessage(text) {
|
|
|
532
1235
|
const url = extractDeliverableURL(normalizedLine);
|
|
533
1236
|
if (url) {
|
|
534
1237
|
linkUrls.push(url);
|
|
1238
|
+
linkItems.push({ line: normalizedLine, url });
|
|
535
1239
|
}
|
|
536
1240
|
}
|
|
537
1241
|
}
|
|
@@ -546,10 +1250,54 @@ function splitDeliverableMessage(text) {
|
|
|
546
1250
|
return {
|
|
547
1251
|
summary,
|
|
548
1252
|
links,
|
|
1253
|
+
linkUrls,
|
|
1254
|
+
linkItems,
|
|
549
1255
|
primaryLinkUrl: linkUrls.length > 0 ? linkUrls[0] : "",
|
|
1256
|
+
fileLinkUrl: selectPalzFileURL(linkItems),
|
|
550
1257
|
};
|
|
551
1258
|
}
|
|
552
1259
|
|
|
1260
|
+
function extractDeliverableIdentity(rawURL) {
|
|
1261
|
+
if (!isString(rawURL) || !rawURL.trim()) {
|
|
1262
|
+
return "";
|
|
1263
|
+
}
|
|
1264
|
+
let parsed;
|
|
1265
|
+
try {
|
|
1266
|
+
parsed = new URL(rawURL.trim());
|
|
1267
|
+
} catch (_err) {
|
|
1268
|
+
return "";
|
|
1269
|
+
}
|
|
1270
|
+
if (!DELIVERABLE_GATEWAY_PATH_RE.test(parsed.pathname)) {
|
|
1271
|
+
return "";
|
|
1272
|
+
}
|
|
1273
|
+
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
1274
|
+
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
1275
|
+
for (const part of parts) {
|
|
1276
|
+
if (uuidRe.test(part)) {
|
|
1277
|
+
return part.toLowerCase();
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
return parsed.href;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function selectPalzFileURL(linkItems) {
|
|
1284
|
+
if (!Array.isArray(linkItems) || linkItems.length === 0) {
|
|
1285
|
+
return "";
|
|
1286
|
+
}
|
|
1287
|
+
const identities = new Set();
|
|
1288
|
+
for (const item of linkItems) {
|
|
1289
|
+
const identity = extractDeliverableIdentity(item && item.url);
|
|
1290
|
+
if (identity) {
|
|
1291
|
+
identities.add(identity);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (identities.size !== 1) {
|
|
1295
|
+
return "";
|
|
1296
|
+
}
|
|
1297
|
+
const download = linkItems.find((item) => /下载链接/u.test(String((item && item.line) || "")));
|
|
1298
|
+
return (download && download.url) || linkItems[0].url || "";
|
|
1299
|
+
}
|
|
1300
|
+
|
|
553
1301
|
function cloneBody(body, content, suffix) {
|
|
554
1302
|
const next = {};
|
|
555
1303
|
Object.keys(body).forEach((key) => {
|
|
@@ -605,6 +1353,7 @@ function shouldSplitPalzPayload(body) {
|
|
|
605
1353
|
summary: cachedSummary,
|
|
606
1354
|
links: split.links,
|
|
607
1355
|
primaryLinkUrl: split.primaryLinkUrl,
|
|
1356
|
+
fileLinkUrl: split.fileLinkUrl,
|
|
608
1357
|
};
|
|
609
1358
|
}
|
|
610
1359
|
return split;
|
|
@@ -678,6 +1427,11 @@ function installPalzFetchPatch(api) {
|
|
|
678
1427
|
return originalFetch(input, init);
|
|
679
1428
|
}
|
|
680
1429
|
|
|
1430
|
+
const rewritten = await autoUploadAndRewritePayload(parsed, api);
|
|
1431
|
+
if (rewritten !== parsed) {
|
|
1432
|
+
parsed = rewritten;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
681
1435
|
const split = shouldSplitPalzPayload(parsed);
|
|
682
1436
|
if (!split) {
|
|
683
1437
|
const suspiciousURL = isString(parsed.content)
|
|
@@ -695,14 +1449,15 @@ function installPalzFetchPatch(api) {
|
|
|
695
1449
|
}
|
|
696
1450
|
|
|
697
1451
|
const summaryBody = cloneBody(parsed, split.summary, "__summary");
|
|
698
|
-
const
|
|
699
|
-
|
|
1452
|
+
const fileLinkUrl = split.fileLinkUrl || "";
|
|
1453
|
+
const linksBody = fileLinkUrl
|
|
1454
|
+
? cloneBodyAsFileLink(parsed, fileLinkUrl, "__links")
|
|
700
1455
|
: cloneBody(parsed, split.links, "__links");
|
|
701
1456
|
const summaryBodyStr = JSON.stringify(summaryBody);
|
|
702
1457
|
const linksBodyStr = JSON.stringify(linksBody);
|
|
703
1458
|
|
|
704
1459
|
api.logger.info?.(
|
|
705
|
-
`[plugin-deliverables] split deliverable reply injected by plugin-deliverables target=${String(parsed.conversation_id || "")} summaryMsgId=${String(summaryBody.msg_id || "")} linksMsgId=${String(linksBody.msg_id || "")} linksMode=${
|
|
1460
|
+
`[plugin-deliverables] split deliverable reply injected by plugin-deliverables target=${String(parsed.conversation_id || "")} summaryMsgId=${String(summaryBody.msg_id || "")} linksMsgId=${String(linksBody.msg_id || "")} linksMode=${fileLinkUrl ? "file_url" : "text"}`,
|
|
706
1461
|
);
|
|
707
1462
|
api.logger.info?.(
|
|
708
1463
|
`[plugin-deliverables] palz summary request body_length=${summaryBodyStr.length}\n request_body=${summaryBodyStr}`,
|
|
@@ -759,6 +1514,10 @@ const plugin = {
|
|
|
759
1514
|
cacheUploadSummary(event.params);
|
|
760
1515
|
return;
|
|
761
1516
|
}
|
|
1517
|
+
if (isWriteTool(event.toolName)) {
|
|
1518
|
+
rememberPendingFile(event);
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
762
1521
|
if (!isOutboundMessageTool(event.toolName)) {
|
|
763
1522
|
return;
|
|
764
1523
|
}
|
|
@@ -789,11 +1548,14 @@ const plugin = {
|
|
|
789
1548
|
};
|
|
790
1549
|
|
|
791
1550
|
plugin.__test = {
|
|
1551
|
+
deliverableTypeForPath,
|
|
1552
|
+
extractFileReferencesFromText,
|
|
792
1553
|
extractDeliverableURL,
|
|
793
1554
|
findUntrustedOSSDeliverableURL,
|
|
794
1555
|
isDeliverableLinkLine,
|
|
795
1556
|
isOSSLikeURL,
|
|
796
1557
|
isTrustedDeliverableURL,
|
|
1558
|
+
selectPalzFileURL,
|
|
797
1559
|
splitDeliverableMessage,
|
|
798
1560
|
};
|
|
799
1561
|
|
|
@@ -274,12 +274,70 @@ function normalizeBase64Content(value, label) {
|
|
|
274
274
|
return Buffer.from(padded, "base64").toString("base64");
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
function candidateWorkspaceRoots() {
|
|
278
|
+
var roots = [];
|
|
279
|
+
[
|
|
280
|
+
process.env.OPENCLAW_WORKSPACE_PATH,
|
|
281
|
+
process.env.WORKSPACE_PATH,
|
|
282
|
+
"/home/node/.openclaw/workspace-main",
|
|
283
|
+
"/home/node/.openclaw/workspace",
|
|
284
|
+
"/data/workspace",
|
|
285
|
+
"/data/workspace-main"
|
|
286
|
+
].forEach(function(root) {
|
|
287
|
+
root = trimString(root);
|
|
288
|
+
if (root && roots.indexOf(root) < 0) {
|
|
289
|
+
roots.push(root);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
fs.readdirSync("/data").forEach(function(entry) {
|
|
294
|
+
if (entry && entry.indexOf("workspace-") === 0) {
|
|
295
|
+
var root = path.join("/data", entry);
|
|
296
|
+
if (roots.indexOf(root) < 0) {
|
|
297
|
+
roots.push(root);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
} catch (_err) {}
|
|
302
|
+
return roots;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function resolveReadableFilePath(filePath) {
|
|
306
|
+
var rawPath = trimString(filePath);
|
|
307
|
+
if (!rawPath) {
|
|
308
|
+
return "";
|
|
309
|
+
}
|
|
310
|
+
var candidates = [];
|
|
311
|
+
function add(candidate) {
|
|
312
|
+
if (candidate && candidates.indexOf(candidate) < 0) {
|
|
313
|
+
candidates.push(candidate);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (path.isAbsolute(rawPath)) {
|
|
317
|
+
add(rawPath);
|
|
318
|
+
} else {
|
|
319
|
+
add(path.resolve(process.cwd(), rawPath));
|
|
320
|
+
candidateWorkspaceRoots().forEach(function(root) {
|
|
321
|
+
add(path.join(root, rawPath));
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
325
|
+
try {
|
|
326
|
+
var stat = fs.statSync(candidates[i]);
|
|
327
|
+
if (stat.isFile()) {
|
|
328
|
+
return candidates[i];
|
|
329
|
+
}
|
|
330
|
+
} catch (_err) {}
|
|
331
|
+
}
|
|
332
|
+
return candidates[0] || rawPath;
|
|
333
|
+
}
|
|
334
|
+
|
|
277
335
|
function readFileAsBase64(filePath, label) {
|
|
278
336
|
var rawPath = trimString(filePath);
|
|
279
337
|
if (!rawPath) {
|
|
280
338
|
return "";
|
|
281
339
|
}
|
|
282
|
-
var resolvedPath =
|
|
340
|
+
var resolvedPath = resolveReadableFilePath(rawPath);
|
|
283
341
|
var stat;
|
|
284
342
|
try {
|
|
285
343
|
stat = fs.statSync(resolvedPath);
|
|
@@ -448,7 +506,8 @@ function startMCPServer() {
|
|
|
448
506
|
module.exports.__test = {
|
|
449
507
|
buildContentPayload: buildContentPayload,
|
|
450
508
|
buildUploadRequestBody: buildUploadRequestBody,
|
|
451
|
-
normalizeBase64Content: normalizeBase64Content
|
|
509
|
+
normalizeBase64Content: normalizeBase64Content,
|
|
510
|
+
resolveReadableFilePath: resolveReadableFilePath
|
|
452
511
|
};
|
|
453
512
|
|
|
454
513
|
if (require.main === module) {
|
package/openclaw-plugin.json
CHANGED
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "plugin-deliverables",
|
|
3
3
|
"name": "Deliverables",
|
|
4
4
|
"description": "Deliverables runtime guard for upload-first file delivery with Palz split-send diagnostics.",
|
|
5
|
-
"version": "1.0.
|
|
5
|
+
"version": "1.0.22",
|
|
6
6
|
"skills": ["./skills"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/package.json
CHANGED
package/test/index.test.js
CHANGED
|
@@ -6,9 +6,12 @@ process.env.CLAW_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
|
|
|
6
6
|
|
|
7
7
|
const plugin = require("../index.js");
|
|
8
8
|
const {
|
|
9
|
+
deliverableTypeForPath,
|
|
10
|
+
extractFileReferencesFromText,
|
|
9
11
|
findUntrustedOSSDeliverableURL,
|
|
10
12
|
isDeliverableLinkLine,
|
|
11
13
|
isTrustedDeliverableURL,
|
|
14
|
+
selectPalzFileURL,
|
|
12
15
|
splitDeliverableMessage,
|
|
13
16
|
} = plugin.__test;
|
|
14
17
|
|
|
@@ -38,6 +41,19 @@ const split = splitDeliverableMessage(
|
|
|
38
41
|
);
|
|
39
42
|
assert.ok(split);
|
|
40
43
|
assert.strictEqual(split.primaryLinkUrl, gatewayURL);
|
|
44
|
+
assert.strictEqual(split.fileLinkUrl, gatewayURL);
|
|
45
|
+
|
|
46
|
+
const outputURL =
|
|
47
|
+
"https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/7a9e27b7-08ca-4453-81e8-fe6a630d4566/report.md";
|
|
48
|
+
const previewURL =
|
|
49
|
+
"https://claw-gateway.csagentai.com/openclaw-gateway/preview/7a9e27b7-08ca-4453-81e8-fe6a630d4566";
|
|
50
|
+
assert.strictEqual(
|
|
51
|
+
selectPalzFileURL([
|
|
52
|
+
{ line: `预览链接:[点击预览](${previewURL})`, url: previewURL },
|
|
53
|
+
{ line: `下载链接:[点击下载](${outputURL})`, url: outputURL },
|
|
54
|
+
]),
|
|
55
|
+
outputURL,
|
|
56
|
+
);
|
|
41
57
|
|
|
42
58
|
assert.strictEqual(
|
|
43
59
|
splitDeliverableMessage(
|
|
@@ -71,3 +87,28 @@ assert.strictEqual(
|
|
|
71
87
|
),
|
|
72
88
|
"",
|
|
73
89
|
);
|
|
90
|
+
|
|
91
|
+
const multiSplit = splitDeliverableMessage(
|
|
92
|
+
[
|
|
93
|
+
"已生成多个文件。",
|
|
94
|
+
"",
|
|
95
|
+
`预览链接:[设计文档](${gatewayURL})`,
|
|
96
|
+
`下载链接:[测试报告](https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/report.md)`,
|
|
97
|
+
].join("\n"),
|
|
98
|
+
);
|
|
99
|
+
assert.ok(multiSplit);
|
|
100
|
+
assert.strictEqual(multiSplit.linkUrls.length, 2);
|
|
101
|
+
|
|
102
|
+
const refs = extractFileReferencesFromText(
|
|
103
|
+
"设计文档:`game-brief.md`\n开发路径:/data/workspace-lobster_abc/output/app/index.html\n相对路径 output/report.pdf",
|
|
104
|
+
);
|
|
105
|
+
assert.deepStrictEqual(
|
|
106
|
+
refs.map((ref) => ref.value),
|
|
107
|
+
["/data/workspace-lobster_abc/output/app/index.html", "game-brief.md", "output/report.pdf"],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
assert.strictEqual(deliverableTypeForPath("intro.pdf", false), "article");
|
|
111
|
+
assert.strictEqual(deliverableTypeForPath("cover.png", false), "image");
|
|
112
|
+
assert.strictEqual(deliverableTypeForPath("slides.pptx", false), "ppt");
|
|
113
|
+
assert.strictEqual(deliverableTypeForPath("dist.zip", false), "zip");
|
|
114
|
+
assert.strictEqual(deliverableTypeForPath("/data/workspace-lobster_abc/output/site", true), "game");
|
package/test/mcp-server.test.js
CHANGED
|
@@ -8,6 +8,7 @@ const path = require("path");
|
|
|
8
8
|
const {
|
|
9
9
|
buildUploadRequestBody,
|
|
10
10
|
normalizeBase64Content,
|
|
11
|
+
resolveReadableFilePath,
|
|
11
12
|
} = require("../mcp-servers/deliverables.js").__test;
|
|
12
13
|
|
|
13
14
|
assert.strictEqual(
|
|
@@ -24,6 +25,7 @@ assert.throws(
|
|
|
24
25
|
);
|
|
25
26
|
|
|
26
27
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deliverables-mcp-"));
|
|
28
|
+
process.env.OPENCLAW_WORKSPACE_PATH = tmpDir;
|
|
27
29
|
const pdfPath = path.join(tmpDir, "sample.pdf");
|
|
28
30
|
const pdfBytes = Buffer.from("%PDF-1.3\nsample\n", "utf8");
|
|
29
31
|
fs.writeFileSync(pdfPath, pdfBytes);
|
|
@@ -42,3 +44,17 @@ assert.strictEqual(body.resourceId, "res-1");
|
|
|
42
44
|
assert.strictEqual(body.fileName, "sample.pdf");
|
|
43
45
|
assert.strictEqual(body.contentText, "");
|
|
44
46
|
assert.strictEqual(body.contentBase64, pdfBytes.toString("base64"));
|
|
47
|
+
|
|
48
|
+
const outputDir = path.join(tmpDir, "output");
|
|
49
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
50
|
+
const relativeDoc = path.join(outputDir, "relative.md");
|
|
51
|
+
fs.writeFileSync(relativeDoc, "# Relative\n", "utf8");
|
|
52
|
+
assert.strictEqual(resolveReadableFilePath("output/relative.md"), relativeDoc);
|
|
53
|
+
|
|
54
|
+
const relativeBody = buildUploadRequestBody({
|
|
55
|
+
resource_id: "res-1",
|
|
56
|
+
type: "article",
|
|
57
|
+
file_name: "relative.md",
|
|
58
|
+
file_path: "output/relative.md",
|
|
59
|
+
});
|
|
60
|
+
assert.strictEqual(relativeBody.contentBase64, Buffer.from("# Relative\n").toString("base64"));
|