@dai_ming/plugin-deliverables 1.0.18 → 1.0.20
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/README.md +4 -3
- package/agents-rules/deliverables.md +16 -12
- package/index.js +127 -7
- package/mcp-servers/deliverables.js +195 -34
- package/openclaw-plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +6 -2
- package/skills/deliverables/SKILL.md +8 -1
- package/test/index.test.js +73 -0
- package/test/mcp-server.test.js +44 -0
package/README.md
CHANGED
|
@@ -182,8 +182,9 @@ fi
|
|
|
182
182
|
| `group_id` | string | — | 群聊 ID |
|
|
183
183
|
| `user_id` | string | — | 用户 ID |
|
|
184
184
|
| `content_text` | string | — | 文本内容(单文件场景) |
|
|
185
|
-
| `content_base64` | string | — | Base64
|
|
186
|
-
| `
|
|
185
|
+
| `content_base64` | string | — | Base64 编码的二进制内容(单文件场景);工具会清理空白并校验格式 |
|
|
186
|
+
| `file_path` | string | — | 本地文件路径,推荐用于 PDF/PPT/图片/zip 等二进制文件,工具会读取并自动编码 |
|
|
187
|
+
| `files` | array | — | 多文件列表(游戏/站点场景,优先使用),每项可传 `content_text`、`content_base64` 或 `file_path` |
|
|
187
188
|
|
|
188
189
|
返回值包含 `preview_url`、`download_url`、`reply_markdown`(可直接附在回复消息中)。
|
|
189
190
|
|
|
@@ -201,7 +202,7 @@ fi
|
|
|
201
202
|
|
|
202
203
|
这三层一起工作的目标是:即使 prompt 漂移,也尽量把“message + file/media”这条旁路堵住,统一收敛到 `deliverables__upload_deliverable`。
|
|
203
204
|
|
|
204
|
-
另外,Palz
|
|
205
|
+
另外,Palz 渠道下如果交付物回复包含可信 gateway 交付物链接,会被拆成“内容简介 + 最终链接”两条消息;任意外部 URL 或非 gateway OSS URL 不会被包装成 `file_url` 文件消息。runtime plugin 还会分别打印这两次真实 HTTP 发送的 request/response 日志,便于直接从 pod stdout 排查。
|
|
205
206
|
|
|
206
207
|
---
|
|
207
208
|
|
|
@@ -54,22 +54,26 @@ Tool name note:
|
|
|
54
54
|
1. Create content under current agent workspace `output/` directory first (for example `output/report.md`), then call the deliverables upload tool exposed in this session.
|
|
55
55
|
2. If you need to use the `write` tool, the target path MUST be inside `output/`. Never write deliverable files to the workspace root.
|
|
56
56
|
3. If you already wrote the file to the wrong place, move/copy it into `output/` before finalizing the task and before describing the saved path to the user.
|
|
57
|
-
4.
|
|
58
|
-
5. If
|
|
59
|
-
6.
|
|
60
|
-
7.
|
|
61
|
-
8. If
|
|
62
|
-
9.
|
|
63
|
-
10.
|
|
64
|
-
11.
|
|
65
|
-
12.
|
|
57
|
+
4. For binary deliverables such as PDF, PPT, images, video, or zip files, pass `file_path` pointing at the file under `output/`; do not paste shell output or partial base64 into `content_base64`.
|
|
58
|
+
5. If the upload tool returns an error, retry with a valid `file_path` or report the upload failure. Never invent OSS, preview, or download URLs.
|
|
59
|
+
6. If format is HTML, prefer `type=article` and file name ends with `.html`.
|
|
60
|
+
7. Every single-file deliverable MUST use a file name with an explicit extension.
|
|
61
|
+
8. If format is markdown/text, use `type=article` and `.md`/`.txt`.
|
|
62
|
+
9. If the user did not specify a document/text format, default to Markdown and use a `.md` file name.
|
|
63
|
+
10. For multi-file deliverables (game/site), you MUST prefer `type=game` with `files[]`, keep files as a folder structure, and do NOT zip before upload unless the user explicitly asks for a zip package.
|
|
64
|
+
11. Static multi-file game/site deliverables SHOULD include a root `index.html` so the preview link can open the homepage directly.
|
|
65
|
+
12. If a generated project requires starting a separate backend service, custom port, database, or long-running process to work, do NOT pretend the deliverables preview can run it. Tell the user that deliverables preview only supports static output, and that runtime projects need deployment/ingress instead.
|
|
66
|
+
13. After successful upload, the final assistant message MUST start with 1-2 short sentences in your own words, briefly describing what you generated for the user.
|
|
67
|
+
14. The intro must be based on the actual deliverable content/request, not a fixed sentence like `交付物已上传成功,可直接在线预览或下载。`
|
|
68
|
+
15. If tool result contains `reply_markdown`, append that field verbatim after your intro on the following lines.
|
|
69
|
+
16. Otherwise final reply MUST include Markdown links (short label + full URL target):
|
|
66
70
|
`预览链接:[点击预览](<full_url>)`
|
|
67
71
|
`下载链接:[点击下载](<full_url>)`
|
|
68
|
-
|
|
72
|
+
17. For multi-file/game deliverables, if the tool gives directory-style output, the second line may be:
|
|
69
73
|
`文件列表:[查看目录](<full_url>)`
|
|
70
74
|
Keep that format instead of forcing a zip link.
|
|
71
|
-
|
|
72
|
-
|
|
75
|
+
18. Do NOT only say "已保存到工作空间".
|
|
76
|
+
19. Do NOT append workspace path or a raw URL block after the Markdown links.
|
|
73
77
|
|
|
74
78
|
### Exception
|
|
75
79
|
|
package/index.js
CHANGED
|
@@ -4,14 +4,24 @@ const FETCH_PATCH_KEY = "__plugin_deliverables_palz_fetch_patch__";
|
|
|
4
4
|
const SUMMARY_CACHE_KEY = "__plugin_deliverables_summary_cache__";
|
|
5
5
|
const SUMMARY_CACHE_LIMIT = 200;
|
|
6
6
|
const SHORT_SUMMARY_THRESHOLD = 120;
|
|
7
|
+
const DEFAULT_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
|
|
8
|
+
const DEFAULT_GATEWAY_INTERNAL_URL = "http://claw-gateway:8080";
|
|
9
|
+
const DELIVERABLE_GATEWAY_PATH_RE = /^\/openclaw-gateway\/(?:output|preview)(?:\/|$)/u;
|
|
10
|
+
const DELIVERABLE_LINK_PREFIX_RE =
|
|
11
|
+
/^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]/u;
|
|
12
|
+
const DELIVERABLE_LINK_LABEL_RE =
|
|
13
|
+
/^\s*(?:[-*+]\s+)?(?:预览链接|下载链接|文件列表|项目入口|在线预览|在线体验|目录链接)\s*[::]\s*\[[^\]]+\]\([^)]+\)\s*$/u;
|
|
7
14
|
|
|
8
15
|
const RUNTIME_DELIVERABLES_GUIDANCE = [
|
|
9
16
|
"## Deliverables Runtime Guard (HARD CONSTRAINT)",
|
|
10
17
|
"",
|
|
11
18
|
"- These rules apply to the main agent and all subagents.",
|
|
12
19
|
"- When the user asks for a document, article, report, HTML page, Markdown file, PPT, image, archive, game, or any other file deliverable, you MUST create it under the current workspace `output/` directory and upload it with `deliverables__upload_deliverable`.",
|
|
20
|
+
"- For binary deliverables such as PDF, PPT, images, video, or zip files, pass `file_path` to `deliverables__upload_deliverable` after writing the file under `output/`; do not paste command output or partial base64 into `content_base64`.",
|
|
21
|
+
"- Every single-file deliverable must use a file name with an explicit extension. If the user did not specify a document/text format, default to Markdown and use a `.md` file name.",
|
|
13
22
|
"- Do not use direct message attachments to deliver generated files. This includes the message tool attachment fields such as `media`, `path`, `filePath`, `buffer`, `attachment`, or similar file-carrying fields.",
|
|
14
23
|
"- Do not emit `MEDIA:` file references for user-facing deliverables. Deliverable links must come from the deliverables upload tool instead.",
|
|
24
|
+
"- If the deliverables upload tool returns an error, retry with a valid `file_path` or explain the upload failure; never invent or rewrite OSS/download URLs.",
|
|
15
25
|
"- After a successful upload, reply with a substantive content summary before the links. For documents/articles/reports, prefer 1 short intro plus 3-6 concise bullets covering key sections, highlights, or findings, then preserve the Markdown links returned by the deliverables tool.",
|
|
16
26
|
].join("\n");
|
|
17
27
|
|
|
@@ -391,16 +401,97 @@ function extractDeliverableURL(line) {
|
|
|
391
401
|
return "";
|
|
392
402
|
}
|
|
393
403
|
|
|
404
|
+
function configuredGatewayHosts() {
|
|
405
|
+
const values = [
|
|
406
|
+
process.env.CLAW_GATEWAY_PUBLIC_URL,
|
|
407
|
+
process.env.CLAW_GATEWAY_URL,
|
|
408
|
+
DEFAULT_GATEWAY_PUBLIC_URL,
|
|
409
|
+
DEFAULT_GATEWAY_INTERNAL_URL,
|
|
410
|
+
];
|
|
411
|
+
const hosts = new Set();
|
|
412
|
+
for (const value of values) {
|
|
413
|
+
if (!isString(value) || !value.trim()) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const parsed = new URL(value.trim());
|
|
418
|
+
hosts.add(parsed.host.toLowerCase());
|
|
419
|
+
hosts.add(parsed.hostname.toLowerCase());
|
|
420
|
+
} catch (_err) {
|
|
421
|
+
// Ignore malformed configuration placeholders.
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return hosts;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function isTrustedDeliverableURL(rawURL) {
|
|
428
|
+
if (!isString(rawURL) || !rawURL.trim()) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
let parsed;
|
|
432
|
+
try {
|
|
433
|
+
parsed = new URL(rawURL.trim());
|
|
434
|
+
} catch (_err) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
if (!DELIVERABLE_GATEWAY_PATH_RE.test(parsed.pathname)) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
const hosts = configuredGatewayHosts();
|
|
441
|
+
const host = parsed.host.toLowerCase();
|
|
442
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
443
|
+
return hosts.has(host) || hosts.has(hostname);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function isOSSLikeURL(rawURL) {
|
|
447
|
+
if (!isString(rawURL) || !rawURL.trim()) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
let parsed;
|
|
451
|
+
try {
|
|
452
|
+
parsed = new URL(rawURL.trim());
|
|
453
|
+
} catch (_err) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
const host = parsed.hostname.toLowerCase();
|
|
457
|
+
return (
|
|
458
|
+
host.includes("aliyuncs.com") ||
|
|
459
|
+
host.includes("oss-cn-") ||
|
|
460
|
+
parsed.searchParams.has("OSSAccessKeyId")
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function findUntrustedOSSDeliverableURL(text) {
|
|
465
|
+
const normalized = String(text || "").replace(/\r\n/g, "\n");
|
|
466
|
+
if (!normalized.trim()) {
|
|
467
|
+
return "";
|
|
468
|
+
}
|
|
469
|
+
const lines = normalized.split("\n");
|
|
470
|
+
let hasTrustedDeliverableURL = false;
|
|
471
|
+
let firstSuspiciousURL = "";
|
|
472
|
+
for (const line of lines) {
|
|
473
|
+
const url = extractDeliverableURL(line);
|
|
474
|
+
if (!url) {
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
if (isTrustedDeliverableURL(url)) {
|
|
478
|
+
hasTrustedDeliverableURL = true;
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (!firstSuspiciousURL && DELIVERABLE_LINK_PREFIX_RE.test(line) && isOSSLikeURL(url)) {
|
|
482
|
+
firstSuspiciousURL = url;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return !hasTrustedDeliverableURL ? firstSuspiciousURL : "";
|
|
486
|
+
}
|
|
487
|
+
|
|
394
488
|
function isDeliverableLinkLine(line) {
|
|
395
489
|
const text = String(line || "");
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
)
|
|
400
|
-
) {
|
|
401
|
-
return true;
|
|
490
|
+
const url = extractDeliverableURL(text);
|
|
491
|
+
if (!url || !isTrustedDeliverableURL(url)) {
|
|
492
|
+
return false;
|
|
402
493
|
}
|
|
403
|
-
return
|
|
494
|
+
return DELIVERABLE_LINK_LABEL_RE.test(text) || !!url;
|
|
404
495
|
}
|
|
405
496
|
|
|
406
497
|
function splitDeliverableMessage(text) {
|
|
@@ -477,6 +568,14 @@ function cloneBodyAsFileLink(body, fileUrl, suffix) {
|
|
|
477
568
|
return next;
|
|
478
569
|
}
|
|
479
570
|
|
|
571
|
+
function cloneBodyAsBlockedDeliverableLink(body) {
|
|
572
|
+
return cloneBody(
|
|
573
|
+
body,
|
|
574
|
+
"交付物上传未成功,已阻止发送未通过 gateway 交付物系统生成的 OSS 文件链接。请重新通过交付物上传工具上传后再发送。",
|
|
575
|
+
"",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
480
579
|
function shouldSplitPalzPayload(body) {
|
|
481
580
|
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
482
581
|
return null;
|
|
@@ -505,6 +604,7 @@ function shouldSplitPalzPayload(body) {
|
|
|
505
604
|
return {
|
|
506
605
|
summary: cachedSummary,
|
|
507
606
|
links: split.links,
|
|
607
|
+
primaryLinkUrl: split.primaryLinkUrl,
|
|
508
608
|
};
|
|
509
609
|
}
|
|
510
610
|
return split;
|
|
@@ -580,6 +680,17 @@ function installPalzFetchPatch(api) {
|
|
|
580
680
|
|
|
581
681
|
const split = shouldSplitPalzPayload(parsed);
|
|
582
682
|
if (!split) {
|
|
683
|
+
const suspiciousURL = isString(parsed.content)
|
|
684
|
+
? findUntrustedOSSDeliverableURL(parsed.content)
|
|
685
|
+
: "";
|
|
686
|
+
if (suspiciousURL) {
|
|
687
|
+
const blockedBody = cloneBodyAsBlockedDeliverableLink(parsed);
|
|
688
|
+
const blockedBodyStr = JSON.stringify(blockedBody);
|
|
689
|
+
api.logger.warn?.(
|
|
690
|
+
`[plugin-deliverables] blocked untrusted OSS deliverable link target=${String(parsed.conversation_id || "")} url=${suspiciousURL}`,
|
|
691
|
+
);
|
|
692
|
+
return originalFetch(input, Object.assign({}, init, { body: blockedBodyStr }));
|
|
693
|
+
}
|
|
583
694
|
return originalFetch(input, init);
|
|
584
695
|
}
|
|
585
696
|
|
|
@@ -677,5 +788,14 @@ const plugin = {
|
|
|
677
788
|
},
|
|
678
789
|
};
|
|
679
790
|
|
|
791
|
+
plugin.__test = {
|
|
792
|
+
extractDeliverableURL,
|
|
793
|
+
findUntrustedOSSDeliverableURL,
|
|
794
|
+
isDeliverableLinkLine,
|
|
795
|
+
isOSSLikeURL,
|
|
796
|
+
isTrustedDeliverableURL,
|
|
797
|
+
splitDeliverableMessage,
|
|
798
|
+
};
|
|
799
|
+
|
|
680
800
|
module.exports = plugin;
|
|
681
801
|
module.exports.default = plugin;
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* CLAW_GATEWAY_API_KEY / OPENCLAW_GATEWAY_API_KEY — API key with access to /openclaw-gateway/be/*
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
var fs = require("fs");
|
|
15
16
|
var http = require("http");
|
|
16
17
|
var https = require("https");
|
|
18
|
+
var path = require("path");
|
|
17
19
|
|
|
18
20
|
var GATEWAY_URL = (process.env.CLAW_GATEWAY_URL || "http://claw-gateway:8080").replace(/\/$/, "");
|
|
19
21
|
var GATEWAY_PUBLIC = (process.env.CLAW_GATEWAY_PUBLIC_URL || GATEWAY_URL).replace(/\/$/, "");
|
|
@@ -22,13 +24,78 @@ var GATEWAY_PUBLIC = (process.env.CLAW_GATEWAY_PUBLIC_URL || GATEWAY_URL).replac
|
|
|
22
24
|
// breaking deliverable uploads during rolling migration.
|
|
23
25
|
var API_KEY = process.env.CLAW_GATEWAY_API_KEY || process.env.OPENCLAW_GATEWAY_API_KEY || "api-key-1";
|
|
24
26
|
|
|
27
|
+
function trimString(value) {
|
|
28
|
+
return typeof value === "string" ? value.trim() : "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasExtension(fileName) {
|
|
32
|
+
return /\.[A-Za-z0-9]+$/.test(trimString(fileName));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function looksLikeHTMLContent(contentText) {
|
|
36
|
+
var text = trimString(contentText).toLowerCase();
|
|
37
|
+
if (!text) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
if (text.indexOf("<!doctype html") === 0 || text.indexOf("<html") === 0) {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
return text.indexOf("<head") >= 0 ||
|
|
44
|
+
text.indexOf("<body") >= 0 ||
|
|
45
|
+
text.indexOf("<main") >= 0 ||
|
|
46
|
+
text.indexOf("<section") >= 0 ||
|
|
47
|
+
text.indexOf("<article") >= 0 ||
|
|
48
|
+
text.indexOf("<style") >= 0 ||
|
|
49
|
+
text.indexOf("<script") >= 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultExtensionForDeliverable(args) {
|
|
53
|
+
var deliverableType = trimString(args && args.type);
|
|
54
|
+
switch (deliverableType) {
|
|
55
|
+
case "article":
|
|
56
|
+
case "game":
|
|
57
|
+
return looksLikeHTMLContent(args && args.content_text) ? ".html" : ".md";
|
|
58
|
+
case "zip":
|
|
59
|
+
return ".zip";
|
|
60
|
+
case "ppt":
|
|
61
|
+
return ".pptx";
|
|
62
|
+
case "image":
|
|
63
|
+
return ".png";
|
|
64
|
+
case "video":
|
|
65
|
+
return ".mp4";
|
|
66
|
+
default:
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeDeliverableFileName(args) {
|
|
72
|
+
var fileName = trimString(args && args.file_name);
|
|
73
|
+
if (!fileName && trimString(args && args.file_path)) {
|
|
74
|
+
fileName = path.basename(trimString(args && args.file_path));
|
|
75
|
+
}
|
|
76
|
+
if (!fileName) {
|
|
77
|
+
fileName = "file";
|
|
78
|
+
}
|
|
79
|
+
if (trimString(args && args.type) === "link") {
|
|
80
|
+
return fileName;
|
|
81
|
+
}
|
|
82
|
+
if (Array.isArray(args && args.files) && args.files.length > 0) {
|
|
83
|
+
return fileName;
|
|
84
|
+
}
|
|
85
|
+
if (hasExtension(fileName)) {
|
|
86
|
+
return fileName;
|
|
87
|
+
}
|
|
88
|
+
var ext = defaultExtensionForDeliverable(args);
|
|
89
|
+
return ext ? fileName + ext : fileName;
|
|
90
|
+
}
|
|
91
|
+
|
|
25
92
|
var TOOL_DEFS = [
|
|
26
93
|
{
|
|
27
94
|
name: "upload_deliverable",
|
|
28
95
|
description: [
|
|
29
96
|
"将 AI 生成的内容(文章、游戏、图片等)上传为交付物,返回可分享的下载/预览链接。",
|
|
30
|
-
"
|
|
31
|
-
"多文件交付物(网页游戏/静态站点等):必须优先提供 files 列表,每项包含 name(相对路径)和 content_text 或
|
|
97
|
+
"单文件交付物:文本内容提供 content_text;PDF/PPT/图片/zip 等二进制文件优先提供 file_path,由工具读取文件并自动编码,不要手工复制 base64。",
|
|
98
|
+
"多文件交付物(网页游戏/静态站点等):必须优先提供 files 列表,每项包含 name(相对路径)和 content_text、content_base64 或 file_path,不要先打 zip。",
|
|
32
99
|
"静态多文件预览建议在根目录提供 index.html;需要单独启动端口/后端服务的项目不属于交付物预览范围,应走部署流程。",
|
|
33
100
|
"返回的 download_url / preview_url 为长期可用链接。"
|
|
34
101
|
].join(" "),
|
|
@@ -54,7 +121,7 @@ var TOOL_DEFS = [
|
|
|
54
121
|
},
|
|
55
122
|
file_name: {
|
|
56
123
|
type: "string",
|
|
57
|
-
description: "
|
|
124
|
+
description: "用户可见的文件名,单文件必须带扩展名,例如 report.md 或 report.html。若用户未指定文档格式,默认使用 .md。多文件交付时这里应是目录名/项目名,不要写成 .zip,除非用户明确要求压缩包。"
|
|
58
125
|
},
|
|
59
126
|
content_text: {
|
|
60
127
|
type: "string",
|
|
@@ -62,7 +129,11 @@ var TOOL_DEFS = [
|
|
|
62
129
|
},
|
|
63
130
|
content_base64: {
|
|
64
131
|
type: "string",
|
|
65
|
-
description: "Base64
|
|
132
|
+
description: "Base64 编码的二进制内容,单文件时使用。二进制文件更推荐 file_path,避免复制/截断导致上传失败。"
|
|
133
|
+
},
|
|
134
|
+
file_path: {
|
|
135
|
+
type: "string",
|
|
136
|
+
description: "本地文件路径,推荐用于 PDF/PPT/图片/zip 等二进制交付物。工具会读取文件并自动生成 contentBase64。"
|
|
66
137
|
},
|
|
67
138
|
files: {
|
|
68
139
|
type: "array",
|
|
@@ -72,7 +143,8 @@ var TOOL_DEFS = [
|
|
|
72
143
|
properties: {
|
|
73
144
|
name: { type: "string", description: "文件在交付物目录内的相对路径,例如 index.html 或 assets/main.js" },
|
|
74
145
|
content_text: { type: "string", description: "文本内容" },
|
|
75
|
-
content_base64: { type: "string", description: "Base64 二进制内容" }
|
|
146
|
+
content_base64: { type: "string", description: "Base64 二进制内容" },
|
|
147
|
+
file_path: { type: "string", description: "本地文件路径,工具会读取并自动编码" }
|
|
76
148
|
},
|
|
77
149
|
required: ["name"]
|
|
78
150
|
}
|
|
@@ -167,43 +239,120 @@ function buildReplyMarkdown(opts) {
|
|
|
167
239
|
return lines.join("\n");
|
|
168
240
|
}
|
|
169
241
|
|
|
170
|
-
|
|
242
|
+
function normalizeBase64Content(value, label) {
|
|
243
|
+
var text = trimString(value);
|
|
244
|
+
if (!text) {
|
|
245
|
+
return "";
|
|
246
|
+
}
|
|
247
|
+
if (/^data:/i.test(text)) {
|
|
248
|
+
var commaIndex = text.indexOf(",");
|
|
249
|
+
if (commaIndex >= 0) {
|
|
250
|
+
text = text.slice(commaIndex + 1);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
text = text.replace(/\s+/g, "");
|
|
254
|
+
if (!text) {
|
|
255
|
+
return "";
|
|
256
|
+
}
|
|
257
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
|
|
258
|
+
throw new Error("invalid base64 for " + label + ": contains non-base64 characters; use file_path for binary files");
|
|
259
|
+
}
|
|
260
|
+
var unpadded = text.replace(/=+$/, "");
|
|
261
|
+
if (unpadded.indexOf("=") >= 0) {
|
|
262
|
+
throw new Error("invalid base64 for " + label + ": padding must be at the end; use file_path for binary files");
|
|
263
|
+
}
|
|
264
|
+
var remainder = unpadded.length % 4;
|
|
265
|
+
if (remainder === 1) {
|
|
266
|
+
throw new Error("invalid base64 for " + label + ": truncated input; use file_path for binary files");
|
|
267
|
+
}
|
|
268
|
+
var padded = unpadded;
|
|
269
|
+
if (remainder === 2) {
|
|
270
|
+
padded += "==";
|
|
271
|
+
} else if (remainder === 3) {
|
|
272
|
+
padded += "=";
|
|
273
|
+
}
|
|
274
|
+
return Buffer.from(padded, "base64").toString("base64");
|
|
275
|
+
}
|
|
171
276
|
|
|
172
|
-
function
|
|
277
|
+
function readFileAsBase64(filePath, label) {
|
|
278
|
+
var rawPath = trimString(filePath);
|
|
279
|
+
if (!rawPath) {
|
|
280
|
+
return "";
|
|
281
|
+
}
|
|
282
|
+
var resolvedPath = path.resolve(process.cwd(), rawPath);
|
|
283
|
+
var stat;
|
|
284
|
+
try {
|
|
285
|
+
stat = fs.statSync(resolvedPath);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
throw new Error("cannot read " + label + " file_path " + rawPath + ": " + err.message);
|
|
288
|
+
}
|
|
289
|
+
if (!stat.isFile()) {
|
|
290
|
+
throw new Error("cannot read " + label + " file_path " + rawPath + ": not a file");
|
|
291
|
+
}
|
|
292
|
+
return fs.readFileSync(resolvedPath).toString("base64");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function buildContentPayload(entry, label) {
|
|
296
|
+
var filePath = trimString(entry && entry.file_path);
|
|
297
|
+
if (filePath) {
|
|
298
|
+
return {
|
|
299
|
+
contentText: "",
|
|
300
|
+
contentBase64: readFileAsBase64(filePath, label)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
contentText: (entry && entry.content_text) || "",
|
|
305
|
+
contentBase64: normalizeBase64Content(entry && entry.content_base64, label)
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function deriveReleaseName() {
|
|
310
|
+
var release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
|
|
311
|
+
if (!release) {
|
|
312
|
+
var tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
|
|
313
|
+
if (tok.indexOf("oc-") === 0) release = tok.slice(3);
|
|
314
|
+
}
|
|
315
|
+
return release;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function buildUploadRequestBody(args) {
|
|
319
|
+
args = args || {};
|
|
320
|
+
var normalizedFileName = normalizeDeliverableFileName(args);
|
|
173
321
|
var body = {
|
|
174
322
|
resourceId: args.resource_id,
|
|
175
323
|
groupId: args.group_id,
|
|
176
324
|
userId: args.user_id,
|
|
177
325
|
type: args.type,
|
|
178
|
-
fileName:
|
|
179
|
-
release:
|
|
326
|
+
fileName: normalizedFileName,
|
|
327
|
+
release: deriveReleaseName()
|
|
180
328
|
};
|
|
181
329
|
|
|
182
330
|
if (args.files && args.files.length > 0) {
|
|
183
|
-
body.files = args.files.map(function(f) {
|
|
331
|
+
body.files = args.files.map(function(f, index) {
|
|
332
|
+
var payload = buildContentPayload(f, "files[" + index + "] " + f.name);
|
|
184
333
|
return {
|
|
185
334
|
name: f.name,
|
|
186
|
-
contentText:
|
|
187
|
-
contentBase64:
|
|
335
|
+
contentText: payload.contentText,
|
|
336
|
+
contentBase64: payload.contentBase64
|
|
188
337
|
};
|
|
189
338
|
});
|
|
190
339
|
} else {
|
|
191
|
-
|
|
192
|
-
body.
|
|
340
|
+
var singlePayload = buildContentPayload(args, "content_base64");
|
|
341
|
+
body.contentText = singlePayload.contentText;
|
|
342
|
+
body.contentBase64 = singlePayload.contentBase64;
|
|
193
343
|
}
|
|
194
344
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// 2) OPENCLAW_RELEASE / BOT_ID (compat)
|
|
198
|
-
// 3) fallback to stripping "oc-" prefix from OPENCLAW_GATEWAY_TOKEN
|
|
199
|
-
var release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
|
|
200
|
-
if (!release) {
|
|
201
|
-
var tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
|
|
202
|
-
if (tok.indexOf("oc-") === 0) release = tok.slice(3);
|
|
203
|
-
}
|
|
204
|
-
body.release = release;
|
|
345
|
+
return body;
|
|
346
|
+
}
|
|
205
347
|
|
|
206
|
-
|
|
348
|
+
// ─── Tool implementations ─────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
function uploadDeliverable(args) {
|
|
351
|
+
return Promise.resolve().then(function() {
|
|
352
|
+
return buildUploadRequestBody(args);
|
|
353
|
+
}).then(function(body) {
|
|
354
|
+
return httpRequest("POST", "/openclaw-gateway/be/deliverables", body);
|
|
355
|
+
}).then(function(resp) {
|
|
207
356
|
var d = resp.body.data || resp.body;
|
|
208
357
|
// Prefer backend-aware previewUrl returned by gateway (OSS/output/link).
|
|
209
358
|
// Fallback to legacy gateway preview endpoint for compatibility.
|
|
@@ -252,7 +401,7 @@ function handleMessage(msg) {
|
|
|
252
401
|
send({ jsonrpc: "2.0", id: id, result: {
|
|
253
402
|
protocolVersion: "2024-11-05",
|
|
254
403
|
capabilities: { tools: {} },
|
|
255
|
-
serverInfo: { name: "deliverables", version: "1.0.
|
|
404
|
+
serverInfo: { name: "deliverables", version: "1.0.3" }
|
|
256
405
|
}});
|
|
257
406
|
return Promise.resolve();
|
|
258
407
|
|
|
@@ -284,12 +433,24 @@ function handleMessage(msg) {
|
|
|
284
433
|
}
|
|
285
434
|
}
|
|
286
435
|
|
|
287
|
-
|
|
288
|
-
rl
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
436
|
+
function startMCPServer() {
|
|
437
|
+
var rl = require("readline").createInterface({ input: process.stdin, terminal: false });
|
|
438
|
+
rl.on("line", function(line) {
|
|
439
|
+
if (!line.trim()) return;
|
|
440
|
+
var msg;
|
|
441
|
+
try { msg = JSON.parse(line); } catch(e) { return; }
|
|
442
|
+
handleMessage(msg).catch(function(err) {
|
|
443
|
+
process.stderr.write("[deliverables] unhandled error: " + err.message + "\n");
|
|
444
|
+
});
|
|
294
445
|
});
|
|
295
|
-
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
module.exports.__test = {
|
|
449
|
+
buildContentPayload: buildContentPayload,
|
|
450
|
+
buildUploadRequestBody: buildUploadRequestBody,
|
|
451
|
+
normalizeBase64Content: normalizeBase64Content
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
if (require.main === module) {
|
|
455
|
+
startMCPServer();
|
|
456
|
+
}
|
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.20",
|
|
6
6
|
"skills": ["./skills"],
|
|
7
7
|
"configSchema": {
|
|
8
8
|
"type": "object",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dai_ming/plugin-deliverables",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.20",
|
|
4
4
|
"description": "OpenClaw deliverables plugin — upload AI-generated files to OSS and return shareable preview/download links",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openclaw",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
],
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"main": "index.js",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"test": "node test/index.test.js && node test/mcp-server.test.js"
|
|
15
|
+
},
|
|
13
16
|
"files": [
|
|
14
17
|
"INSTALL.md",
|
|
15
18
|
"index.js",
|
|
@@ -17,7 +20,8 @@
|
|
|
17
20
|
"openclaw-plugin.json",
|
|
18
21
|
"mcp-servers/",
|
|
19
22
|
"skills/",
|
|
20
|
-
"agents-rules/"
|
|
23
|
+
"agents-rules/",
|
|
24
|
+
"test/"
|
|
21
25
|
],
|
|
22
26
|
"openclaw": {
|
|
23
27
|
"extensions": [
|
|
@@ -32,11 +32,18 @@ description: 上传AI生成的文件到交付物系统,返回可分享的预
|
|
|
32
32
|
| `group_id` | 消息元数据中的 `group_id` 或 `conversation_id` | `group_abc123` |
|
|
33
33
|
| `user_id` | 消息元数据中的 `sender_id` 或 `owner_id` | `cbb0fab9...` |
|
|
34
34
|
| `type` | 根据内容选择:`article`/`game`/`image`/`video`/`ppt`/`zip`/`link` | `article` |
|
|
35
|
-
| `file_name` |
|
|
35
|
+
| `file_name` | 有意义的文件名,单文件必须含扩展名;若用户未指定文档格式,默认用 `.md` | `report-2026.html` |
|
|
36
36
|
| `content_text` | 文件的完整文本内容(HTML/Markdown等) | `<html>...</html>` |
|
|
37
|
+
| `file_path` | PDF/PPT/图片/zip 等二进制文件的本地路径,推荐使用,避免手工复制 base64 | `output/sample.pdf` |
|
|
37
38
|
|
|
38
39
|
> **直接对话(direct chat)时**:`group_id` 填 `conversation_id`,`user_id` 填 `owner_id`。
|
|
39
40
|
|
|
41
|
+
## 二进制文件上传(强制)
|
|
42
|
+
|
|
43
|
+
- PDF、PPT、图片、视频、zip 等二进制文件必须优先传 `file_path`,不要把 `base64` 命令输出复制到 `content_base64`。
|
|
44
|
+
- `file_path` 指向你已经写入 `output/` 的文件,例如 `output/sample.pdf`;上传工具会读取文件并自动编码。
|
|
45
|
+
- 如果上传工具返回错误,必须修正参数后重试,或明确告诉用户上传失败;禁止编造 OSS、下载或预览链接。
|
|
46
|
+
|
|
40
47
|
## 多文件(游戏)
|
|
41
48
|
|
|
42
49
|
type 为 `game` 时,使用 `files` 数组替代 `content_text`:
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
|
|
5
|
+
process.env.CLAW_GATEWAY_PUBLIC_URL = "https://claw-gateway.csagentai.com";
|
|
6
|
+
|
|
7
|
+
const plugin = require("../index.js");
|
|
8
|
+
const {
|
|
9
|
+
findUntrustedOSSDeliverableURL,
|
|
10
|
+
isDeliverableLinkLine,
|
|
11
|
+
isTrustedDeliverableURL,
|
|
12
|
+
splitDeliverableMessage,
|
|
13
|
+
} = plugin.__test;
|
|
14
|
+
|
|
15
|
+
const gatewayURL =
|
|
16
|
+
"https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/sample.pdf";
|
|
17
|
+
const bogusOSSURL =
|
|
18
|
+
"https://palz-deliverable.oss-cn-shanghai.aliyuncs.com/user/res/sample.pdf?OSSAccessKeyId=fake";
|
|
19
|
+
|
|
20
|
+
assert.strictEqual(isTrustedDeliverableURL(gatewayURL), true);
|
|
21
|
+
assert.strictEqual(isTrustedDeliverableURL(bogusOSSURL), false);
|
|
22
|
+
assert.strictEqual(
|
|
23
|
+
isDeliverableLinkLine(`预览链接:[点击预览](${gatewayURL})`),
|
|
24
|
+
true,
|
|
25
|
+
);
|
|
26
|
+
assert.strictEqual(
|
|
27
|
+
isDeliverableLinkLine(`预览链接:[点击预览](${bogusOSSURL})`),
|
|
28
|
+
false,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const split = splitDeliverableMessage(
|
|
32
|
+
[
|
|
33
|
+
"我生成了一个 1 页 PDF。",
|
|
34
|
+
"",
|
|
35
|
+
`预览链接:[点击预览](${gatewayURL})`,
|
|
36
|
+
`下载链接:[点击下载](${gatewayURL})`,
|
|
37
|
+
].join("\n"),
|
|
38
|
+
);
|
|
39
|
+
assert.ok(split);
|
|
40
|
+
assert.strictEqual(split.primaryLinkUrl, gatewayURL);
|
|
41
|
+
|
|
42
|
+
assert.strictEqual(
|
|
43
|
+
splitDeliverableMessage(
|
|
44
|
+
[
|
|
45
|
+
"我生成了一个 1 页 PDF。",
|
|
46
|
+
"",
|
|
47
|
+
`预览链接:[点击预览](${bogusOSSURL})`,
|
|
48
|
+
`下载链接:[点击下载](${bogusOSSURL})`,
|
|
49
|
+
].join("\n"),
|
|
50
|
+
),
|
|
51
|
+
null,
|
|
52
|
+
);
|
|
53
|
+
assert.strictEqual(
|
|
54
|
+
findUntrustedOSSDeliverableURL(
|
|
55
|
+
[
|
|
56
|
+
"我生成了一个 1 页 PDF。",
|
|
57
|
+
"",
|
|
58
|
+
`预览链接:[点击预览](${bogusOSSURL})`,
|
|
59
|
+
].join("\n"),
|
|
60
|
+
),
|
|
61
|
+
bogusOSSURL,
|
|
62
|
+
);
|
|
63
|
+
assert.strictEqual(
|
|
64
|
+
findUntrustedOSSDeliverableURL(
|
|
65
|
+
[
|
|
66
|
+
"我生成了一个大文件。",
|
|
67
|
+
"",
|
|
68
|
+
`预览链接:[点击预览](${gatewayURL})`,
|
|
69
|
+
`下载链接:[点击下载](${bogusOSSURL})`,
|
|
70
|
+
].join("\n"),
|
|
71
|
+
),
|
|
72
|
+
"",
|
|
73
|
+
);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const assert = require("assert");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
|
|
8
|
+
const {
|
|
9
|
+
buildUploadRequestBody,
|
|
10
|
+
normalizeBase64Content,
|
|
11
|
+
} = require("../mcp-servers/deliverables.js").__test;
|
|
12
|
+
|
|
13
|
+
assert.strictEqual(
|
|
14
|
+
normalizeBase64Content("SGVs\nbG8", "content_base64"),
|
|
15
|
+
Buffer.from("Hello").toString("base64"),
|
|
16
|
+
);
|
|
17
|
+
assert.strictEqual(
|
|
18
|
+
normalizeBase64Content("data:application/pdf;base64,JVBERi0xLjM", "content_base64"),
|
|
19
|
+
Buffer.from("%PDF-1.3").toString("base64"),
|
|
20
|
+
);
|
|
21
|
+
assert.throws(
|
|
22
|
+
() => normalizeBase64Content("SGVsb", "content_base64"),
|
|
23
|
+
/truncated input/,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "deliverables-mcp-"));
|
|
27
|
+
const pdfPath = path.join(tmpDir, "sample.pdf");
|
|
28
|
+
const pdfBytes = Buffer.from("%PDF-1.3\nsample\n", "utf8");
|
|
29
|
+
fs.writeFileSync(pdfPath, pdfBytes);
|
|
30
|
+
|
|
31
|
+
const body = buildUploadRequestBody({
|
|
32
|
+
resource_id: "res-1",
|
|
33
|
+
group_id: "group-1",
|
|
34
|
+
user_id: "user-1",
|
|
35
|
+
type: "article",
|
|
36
|
+
file_name: "sample.pdf",
|
|
37
|
+
file_path: pdfPath,
|
|
38
|
+
content_base64: "not-used",
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(body.resourceId, "res-1");
|
|
42
|
+
assert.strictEqual(body.fileName, "sample.pdf");
|
|
43
|
+
assert.strictEqual(body.contentText, "");
|
|
44
|
+
assert.strictEqual(body.contentBase64, pdfBytes.toString("base64"));
|