@dai_ming/plugin-deliverables 1.0.19 → 1.0.21

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.
@@ -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(/\/$/, "");
@@ -68,6 +70,9 @@ function defaultExtensionForDeliverable(args) {
68
70
 
69
71
  function normalizeDeliverableFileName(args) {
70
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
+ }
71
76
  if (!fileName) {
72
77
  fileName = "file";
73
78
  }
@@ -89,8 +94,8 @@ var TOOL_DEFS = [
89
94
  name: "upload_deliverable",
90
95
  description: [
91
96
  "将 AI 生成的内容(文章、游戏、图片等)上传为交付物,返回可分享的下载/预览链接。",
92
- "单文件交付物:提供 content_text content_base64。",
93
- "多文件交付物(网页游戏/静态站点等):必须优先提供 files 列表,每项包含 name(相对路径)和 content_text 或 content_base64,不要先打 zip。",
97
+ "单文件交付物:文本内容提供 content_text;PDF/PPT/图片/zip 等二进制文件优先提供 file_path,由工具读取文件并自动编码,不要手工复制 base64。",
98
+ "多文件交付物(网页游戏/静态站点等):必须优先提供 files 列表,每项包含 name(相对路径)和 content_text、content_base64file_path,不要先打 zip。",
94
99
  "静态多文件预览建议在根目录提供 index.html;需要单独启动端口/后端服务的项目不属于交付物预览范围,应走部署流程。",
95
100
  "返回的 download_url / preview_url 为长期可用链接。"
96
101
  ].join(" "),
@@ -124,7 +129,11 @@ var TOOL_DEFS = [
124
129
  },
125
130
  content_base64: {
126
131
  type: "string",
127
- description: "Base64 编码的二进制内容(图片、zip 等),单文件时使用"
132
+ description: "Base64 编码的二进制内容,单文件时使用。二进制文件更推荐 file_path,避免复制/截断导致上传失败。"
133
+ },
134
+ file_path: {
135
+ type: "string",
136
+ description: "本地文件路径,推荐用于 PDF/PPT/图片/zip 等二进制交付物。工具会读取文件并自动生成 contentBase64。"
128
137
  },
129
138
  files: {
130
139
  type: "array",
@@ -134,7 +143,8 @@ var TOOL_DEFS = [
134
143
  properties: {
135
144
  name: { type: "string", description: "文件在交付物目录内的相对路径,例如 index.html 或 assets/main.js" },
136
145
  content_text: { type: "string", description: "文本内容" },
137
- content_base64: { type: "string", description: "Base64 二进制内容" }
146
+ content_base64: { type: "string", description: "Base64 二进制内容" },
147
+ file_path: { type: "string", description: "本地文件路径,工具会读取并自动编码" }
138
148
  },
139
149
  required: ["name"]
140
150
  }
@@ -229,9 +239,84 @@ function buildReplyMarkdown(opts) {
229
239
  return lines.join("\n");
230
240
  }
231
241
 
232
- // ─── Tool implementations ─────────────────────────────────────────────────────
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
+ }
233
276
 
234
- function uploadDeliverable(args) {
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 || {};
235
320
  var normalizedFileName = normalizeDeliverableFileName(args);
236
321
  var body = {
237
322
  resourceId: args.resource_id,
@@ -239,34 +324,35 @@ function uploadDeliverable(args) {
239
324
  userId: args.user_id,
240
325
  type: args.type,
241
326
  fileName: normalizedFileName,
242
- release: "" // overwritten below after release name derivation
327
+ release: deriveReleaseName()
243
328
  };
244
329
 
245
330
  if (args.files && args.files.length > 0) {
246
- 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);
247
333
  return {
248
334
  name: f.name,
249
- contentText: f.content_text || "",
250
- contentBase64: f.content_base64 || ""
335
+ contentText: payload.contentText,
336
+ contentBase64: payload.contentBase64
251
337
  };
252
338
  });
253
339
  } else {
254
- body.contentText = args.content_text || "";
255
- body.contentBase64 = args.content_base64 || "";
340
+ var singlePayload = buildContentPayload(args, "content_base64");
341
+ body.contentText = singlePayload.contentText;
342
+ body.contentBase64 = singlePayload.contentBase64;
256
343
  }
257
344
 
258
- // Derive release name:
259
- // 1) botID (runtime env, e.g. "user-xxx") is preferred
260
- // 2) OPENCLAW_RELEASE / BOT_ID (compat)
261
- // 3) fallback to stripping "oc-" prefix from OPENCLAW_GATEWAY_TOKEN
262
- var release = process.env.botID || process.env.OPENCLAW_RELEASE || process.env.BOT_ID || "";
263
- if (!release) {
264
- var tok = process.env.OPENCLAW_GATEWAY_TOKEN || "";
265
- if (tok.indexOf("oc-") === 0) release = tok.slice(3);
266
- }
267
- body.release = release;
345
+ return body;
346
+ }
347
+
348
+ // ─── Tool implementations ─────────────────────────────────────────────────────
268
349
 
269
- return httpRequest("POST", "/openclaw-gateway/be/deliverables", body).then(function(resp) {
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) {
270
356
  var d = resp.body.data || resp.body;
271
357
  // Prefer backend-aware previewUrl returned by gateway (OSS/output/link).
272
358
  // Fallback to legacy gateway preview endpoint for compatibility.
@@ -315,7 +401,7 @@ function handleMessage(msg) {
315
401
  send({ jsonrpc: "2.0", id: id, result: {
316
402
  protocolVersion: "2024-11-05",
317
403
  capabilities: { tools: {} },
318
- serverInfo: { name: "deliverables", version: "1.0.2" }
404
+ serverInfo: { name: "deliverables", version: "1.0.3" }
319
405
  }});
320
406
  return Promise.resolve();
321
407
 
@@ -347,12 +433,24 @@ function handleMessage(msg) {
347
433
  }
348
434
  }
349
435
 
350
- var rl = require("readline").createInterface({ input: process.stdin, terminal: false });
351
- rl.on("line", function(line) {
352
- if (!line.trim()) return;
353
- var msg;
354
- try { msg = JSON.parse(line); } catch(e) { return; }
355
- handleMessage(msg).catch(function(err) {
356
- process.stderr.write("[deliverables] unhandled error: " + err.message + "\n");
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
+ });
357
445
  });
358
- });
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
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plugin-deliverables",
3
- "version": "1.0.19",
3
+ "version": "1.0.21",
4
4
  "npm_package": "@dai_ming/plugin-deliverables",
5
5
  "description": "Deliverables plugin: MCP server + skill + AGENTS rules for AI-generated file uploads",
6
6
  "mcp_servers": {
@@ -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.19",
5
+ "version": "1.0.21",
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.19",
3
+ "version": "1.0.21",
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": [
@@ -34,9 +34,16 @@ description: 上传AI生成的文件到交付物系统,返回可分享的预
34
34
  | `type` | 根据内容选择:`article`/`game`/`image`/`video`/`ppt`/`zip`/`link` | `article` |
35
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,100 @@
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
+ deliverableTypeForPath,
10
+ extractFileReferencesFromText,
11
+ findUntrustedOSSDeliverableURL,
12
+ isDeliverableLinkLine,
13
+ isTrustedDeliverableURL,
14
+ splitDeliverableMessage,
15
+ } = plugin.__test;
16
+
17
+ const gatewayURL =
18
+ "https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/sample.pdf";
19
+ const bogusOSSURL =
20
+ "https://palz-deliverable.oss-cn-shanghai.aliyuncs.com/user/res/sample.pdf?OSSAccessKeyId=fake";
21
+
22
+ assert.strictEqual(isTrustedDeliverableURL(gatewayURL), true);
23
+ assert.strictEqual(isTrustedDeliverableURL(bogusOSSURL), false);
24
+ assert.strictEqual(
25
+ isDeliverableLinkLine(`预览链接:[点击预览](${gatewayURL})`),
26
+ true,
27
+ );
28
+ assert.strictEqual(
29
+ isDeliverableLinkLine(`预览链接:[点击预览](${bogusOSSURL})`),
30
+ false,
31
+ );
32
+
33
+ const split = splitDeliverableMessage(
34
+ [
35
+ "我生成了一个 1 页 PDF。",
36
+ "",
37
+ `预览链接:[点击预览](${gatewayURL})`,
38
+ `下载链接:[点击下载](${gatewayURL})`,
39
+ ].join("\n"),
40
+ );
41
+ assert.ok(split);
42
+ assert.strictEqual(split.primaryLinkUrl, gatewayURL);
43
+
44
+ assert.strictEqual(
45
+ splitDeliverableMessage(
46
+ [
47
+ "我生成了一个 1 页 PDF。",
48
+ "",
49
+ `预览链接:[点击预览](${bogusOSSURL})`,
50
+ `下载链接:[点击下载](${bogusOSSURL})`,
51
+ ].join("\n"),
52
+ ),
53
+ null,
54
+ );
55
+ assert.strictEqual(
56
+ findUntrustedOSSDeliverableURL(
57
+ [
58
+ "我生成了一个 1 页 PDF。",
59
+ "",
60
+ `预览链接:[点击预览](${bogusOSSURL})`,
61
+ ].join("\n"),
62
+ ),
63
+ bogusOSSURL,
64
+ );
65
+ assert.strictEqual(
66
+ findUntrustedOSSDeliverableURL(
67
+ [
68
+ "我生成了一个大文件。",
69
+ "",
70
+ `预览链接:[点击预览](${gatewayURL})`,
71
+ `下载链接:[点击下载](${bogusOSSURL})`,
72
+ ].join("\n"),
73
+ ),
74
+ "",
75
+ );
76
+
77
+ const multiSplit = splitDeliverableMessage(
78
+ [
79
+ "已生成多个文件。",
80
+ "",
81
+ `预览链接:[设计文档](${gatewayURL})`,
82
+ `下载链接:[测试报告](https://claw-gateway.csagentai.com/openclaw-gateway/output/user/res/uuid/report.md)`,
83
+ ].join("\n"),
84
+ );
85
+ assert.ok(multiSplit);
86
+ assert.strictEqual(multiSplit.linkUrls.length, 2);
87
+
88
+ const refs = extractFileReferencesFromText(
89
+ "设计文档:`game-brief.md`\n开发路径:/data/workspace-lobster_abc/output/app/index.html\n相对路径 output/report.pdf",
90
+ );
91
+ assert.deepStrictEqual(
92
+ refs.map((ref) => ref.value),
93
+ ["/data/workspace-lobster_abc/output/app/index.html", "game-brief.md", "output/report.pdf"],
94
+ );
95
+
96
+ assert.strictEqual(deliverableTypeForPath("intro.pdf", false), "article");
97
+ assert.strictEqual(deliverableTypeForPath("cover.png", false), "image");
98
+ assert.strictEqual(deliverableTypeForPath("slides.pptx", false), "ppt");
99
+ assert.strictEqual(deliverableTypeForPath("dist.zip", false), "zip");
100
+ assert.strictEqual(deliverableTypeForPath("/data/workspace-lobster_abc/output/site", true), "game");
@@ -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"));