rol-websocket-channel 1.1.1 → 1.1.2

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.
@@ -1655,4 +1655,80 @@ openclaw admin-bridge pair <key> --endpoint https://api.deotaland.ai
1655
1655
 
1656
1656
  ---
1657
1657
 
1658
- ```
1658
+ ```
1659
+ ```
1660
+
1661
+ ## Artifacts 文件下载(按需上传对象存储)
1662
+
1663
+ 如果文件之前已经上传过,则不会重复上传,而是直接复用已有 `fileUrl`。
1664
+
1665
+
1666
+
1667
+
1668
+ ### 下载前先获取列表
1669
+
1670
+ 建议前端不要直接拿路径去调下载,而是先调用一次 `artifactsList`,确认当前可下载文件的 `artifactId`、`relativePath`、`storageStatus`。
1671
+
1672
+ MQTT 请求示例:
1673
+
1674
+ ```json
1675
+ {
1676
+ "type": "artifactsList",
1677
+ "trace_id": "artifact-list-before-download-001",
1678
+ "data": {}
1679
+ }
1680
+ ```
1681
+
1682
+ 典型顺序:
1683
+
1684
+ 1. `artifactsList`
1685
+ 2. 用户选择某个文件
1686
+ 3. `artifactsEnsureUploaded`
1687
+ 4. 使用返回的 `downloadUrl`
1688
+
1689
+
1690
+
1691
+ ### MQTT 请求示例
1692
+
1693
+ ```json
1694
+ {
1695
+ "type": "artifactsEnsureUploaded",
1696
+ "trace_id": "artifact-download-006",
1697
+ "data": {
1698
+ "baseUrl": "http://api.deotaland.local",
1699
+ "authToken": "123",
1700
+ "relativePath": "me.pdf",
1701
+ "presignedPostBody": {
1702
+ "dir": "artifacts/"
1703
+ }
1704
+ }
1705
+ }
1706
+ ```
1707
+
1708
+ ### 成功返回示例
1709
+
1710
+ ```json
1711
+ {
1712
+ "type": "receiver",
1713
+ "trace_id": "artifact-download-006",
1714
+ "source": "system",
1715
+ "timestamp": 1777364603366,
1716
+ "success": true,
1717
+ "data": {
1718
+ "ok": true,
1719
+ "scope": "workspace",
1720
+ "uploaded": true,
1721
+ "artifactId": "art_fe4672f1badd",
1722
+ "objectKey": "uploads/666ff786-9d01-4159-b4ae-338f385d6ae2.pdf",
1723
+ "downloadUrl": "https://draft-user.s3.us-east-2.amazonaws.com/uploads/666ff786-9d01-4159-b4ae-338f385d6ae2.pdf"
1724
+ }
1725
+ }
1726
+ ```
1727
+
1728
+ ### 前端使用方式
1729
+
1730
+ 1. 调 `artifactsList` 获取文件列表
1731
+ 2. 用户点击下载时,调 `artifactsEnsureUploaded`
1732
+ 3. 直接使用返回的 `data.downloadUrl`
1733
+ 4. 如果返回 `uploaded: false`,表示这次没有重新上传,而是复用了历史上传结果
1734
+
@@ -7,6 +7,15 @@ import { getContext } from './src/shared/context.js';
7
7
  import { wrapAdminCall } from './src/shared/wrapper.js';
8
8
  import { getAgents, getConfig } from './src/admin/methods/admin.js';
9
9
  import { createAgent, deleteAgent, listAgents, updateAgent } from './src/admin/methods/agents-extended.js';
10
+ import {
11
+ createArtifactRecord,
12
+ ensureArtifactUploaded,
13
+ getArtifactContent,
14
+ getArtifactPresignedPost,
15
+ listArtifacts,
16
+ markArtifactUploaded,
17
+ refreshArtifacts
18
+ } from './src/admin/methods/artifacts.js';
10
19
  import {
11
20
  addCron,
12
21
  disableCron,
@@ -463,6 +472,55 @@ export class MessageHandler {
463
472
  });
464
473
  }
465
474
 
475
+ async artifactsList(data: any): Promise<any> {
476
+ return wrapAdminCall(async () => {
477
+ const context = getContext();
478
+ return await listArtifacts(data, context);
479
+ });
480
+ }
481
+
482
+ async artifactsRefresh(data: any): Promise<any> {
483
+ return wrapAdminCall(async () => {
484
+ const context = getContext();
485
+ return await refreshArtifacts(data, context);
486
+ });
487
+ }
488
+
489
+ async artifactsGetContent(data: any): Promise<any> {
490
+ return wrapAdminCall(async () => {
491
+ const context = getContext();
492
+ return await getArtifactContent(data, context);
493
+ });
494
+ }
495
+
496
+ async artifactsEnsureUploaded(data: any): Promise<any> {
497
+ return wrapAdminCall(async () => {
498
+ const context = getContext();
499
+ return await ensureArtifactUploaded(data, context);
500
+ });
501
+ }
502
+
503
+ async artifactsGetPresignedPost(data: any): Promise<any> {
504
+ return wrapAdminCall(async () => {
505
+ const context = getContext();
506
+ return await getArtifactPresignedPost(data, context);
507
+ });
508
+ }
509
+
510
+ async artifactsCreateRecord(data: any): Promise<any> {
511
+ return wrapAdminCall(async () => {
512
+ const context = getContext();
513
+ return await createArtifactRecord(data, context);
514
+ });
515
+ }
516
+
517
+ async artifactsMarkUploaded(data: any): Promise<any> {
518
+ return wrapAdminCall(async () => {
519
+ const context = getContext();
520
+ return await markArtifactUploaded(data, context);
521
+ });
522
+ }
523
+
466
524
  async mem9Install(_data: any): Promise<any> {
467
525
  return wrapAdminCall(async () => {
468
526
  const context = getContext();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rol-websocket-channel",
3
- "version": "1.1.1",
3
+ "version": "1.1.2",
4
4
  "description": "Unified OpenClaw plugin: MQTT Channel + Admin Bridge for remote management",
5
5
  "license": "MIT",
6
6
  "author": "nixgnehc",
@@ -0,0 +1,320 @@
1
+ import { afterEach, describe, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ import {
8
+ ensureArtifactUploaded,
9
+ listArtifacts,
10
+ markArtifactUploaded,
11
+ refreshArtifacts
12
+ } from './artifacts.js';
13
+
14
+ const tempDirs: string[] = [];
15
+ const originalFetch = globalThis.fetch;
16
+
17
+ afterEach(async () => {
18
+ globalThis.fetch = originalFetch;
19
+ while (tempDirs.length > 0) {
20
+ const dir = tempDirs.pop();
21
+ if (dir) {
22
+ await fs.rm(dir, { recursive: true, force: true });
23
+ }
24
+ }
25
+ });
26
+
27
+ describe('artifacts workspace scope', () => {
28
+ test('only indexes allowed artifact types inside workspace', async () => {
29
+ const context = await createMethodContext();
30
+ const workspaceRoot = path.join(context.openclawRoot, 'workspace');
31
+
32
+ await fs.writeFile(path.join(workspaceRoot, 'SOUL.md'), '# root noise\n', 'utf8');
33
+ await fs.mkdir(path.join(workspaceRoot, 'exports'), { recursive: true });
34
+ await fs.mkdir(path.join(workspaceRoot, 'logs'), { recursive: true });
35
+ await fs.mkdir(path.join(workspaceRoot, 'nested', 'media'), { recursive: true });
36
+
37
+ await fs.writeFile(
38
+ path.join(workspaceRoot, 'exports', 'preview.png'),
39
+ 'png-data',
40
+ 'utf8'
41
+ );
42
+ await fs.writeFile(
43
+ path.join(workspaceRoot, 'exports', 'report.pdf'),
44
+ 'pdf-data',
45
+ 'utf8'
46
+ );
47
+ await fs.writeFile(
48
+ path.join(workspaceRoot, 'nested', 'media', 'archive.zip'),
49
+ 'zip-data',
50
+ 'utf8'
51
+ );
52
+ await fs.writeFile(
53
+ path.join(workspaceRoot, 'nested', 'media', 'video.mp4'),
54
+ 'mp4-data',
55
+ 'utf8'
56
+ );
57
+ await fs.writeFile(
58
+ path.join(workspaceRoot, 'exports', 'spec.docx'),
59
+ 'docx-data',
60
+ 'utf8'
61
+ );
62
+ await fs.writeFile(
63
+ path.join(workspaceRoot, 'exports', 'ignored.md'),
64
+ '# markdown noise\n',
65
+ 'utf8'
66
+ );
67
+ await fs.writeFile(
68
+ path.join(workspaceRoot, 'exports', 'ignored.json'),
69
+ '{"noise":true}\n',
70
+ 'utf8'
71
+ );
72
+ await fs.writeFile(
73
+ path.join(workspaceRoot, 'logs', 'ignored.pdf'),
74
+ 'log pdf noise',
75
+ 'utf8'
76
+ );
77
+ await fs.writeFile(
78
+ path.join(workspaceRoot, 'artifacts.json'),
79
+ '[]',
80
+ 'utf8'
81
+ );
82
+
83
+ const result = await refreshArtifacts({}, context) as {
84
+ scope: string;
85
+ count: number;
86
+ items: Array<{ relativePath: string; fileName: string }>;
87
+ manifestPath: string;
88
+ workspaceRoot: string;
89
+ };
90
+
91
+ assert.equal(result.scope, 'workspace');
92
+ assert.equal(result.count, 5);
93
+ assert.deepEqual(
94
+ result.items.map((item) => item.relativePath),
95
+ [
96
+ 'nested/media/archive.zip',
97
+ 'exports/preview.png',
98
+ 'exports/report.pdf',
99
+ 'exports/spec.docx',
100
+ 'nested/media/video.mp4'
101
+ ]
102
+ );
103
+ assert.equal(result.workspaceRoot, workspaceRoot);
104
+ assert.equal(
105
+ result.manifestPath,
106
+ path.join(workspaceRoot, 'artifacts.json')
107
+ );
108
+ });
109
+
110
+ test('list reads workspace manifest without taskId', async () => {
111
+ const context = await createMethodContext();
112
+ const workspaceRoot = path.join(context.openclawRoot, 'workspace');
113
+
114
+ await fs.mkdir(path.join(workspaceRoot, 'exports'), { recursive: true });
115
+ await fs.writeFile(path.join(workspaceRoot, 'exports', 'me.pdf'), 'pdf-data', 'utf8');
116
+
117
+ const refreshed = await refreshArtifacts({}, context) as {
118
+ count: number;
119
+ items: Array<{ relativePath: string }>;
120
+ };
121
+ assert.equal(refreshed.count, 1);
122
+
123
+ const listed = await listArtifacts({ refresh: false }, context) as {
124
+ scope: string;
125
+ count: number;
126
+ items: Array<{ relativePath: string }>;
127
+ manifestPath: string;
128
+ workspaceRoot: string;
129
+ };
130
+
131
+ assert.equal(listed.scope, 'workspace');
132
+ assert.equal(listed.count, 1);
133
+ assert.deepEqual(listed.items.map((item) => item.relativePath), ['exports/me.pdf']);
134
+ assert.equal(listed.manifestPath, path.join(workspaceRoot, 'artifacts.json'));
135
+ assert.equal(listed.workspaceRoot, workspaceRoot);
136
+ });
137
+
138
+ test('ensureUploaded uploads local artifact and updates manifest', async () => {
139
+ const context = await createMethodContext();
140
+ const workspaceRoot = path.join(context.openclawRoot, 'workspace');
141
+ const artifactPath = path.join(workspaceRoot, 'exports', 'me.pdf');
142
+
143
+ await writeApiCoreBotConfig(context.openclawRoot, 'https://api.example.com');
144
+ await fs.mkdir(path.dirname(artifactPath), { recursive: true });
145
+ await fs.writeFile(artifactPath, 'pdf-data', 'utf8');
146
+
147
+ const refreshed = await refreshArtifacts({}, context) as {
148
+ items: Array<{ id: string; relativePath: string }>;
149
+ };
150
+ const artifactId = refreshed.items[0]?.id;
151
+ assert.ok(artifactId);
152
+
153
+ const calls: Array<{ url: string; method: string }> = [];
154
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
155
+ const url = typeof input === 'string'
156
+ ? input
157
+ : input instanceof URL
158
+ ? input.toString()
159
+ : input.url;
160
+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
161
+ calls.push({ url, method });
162
+
163
+ if (url === 'https://api.example.com/api-core-bot/front/s3/get-presigned-post') {
164
+ assert.equal(init?.body !== undefined, true);
165
+ const parsedBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
166
+ assert.equal(parsedBody.dir, 'artifacts/');
167
+ assert.equal(parsedBody.filename, 'me.pdf');
168
+ assert.equal(parsedBody.file_name, undefined);
169
+ return new Response(JSON.stringify({
170
+ code: 0,
171
+ message: '',
172
+ success: true,
173
+ data: {
174
+ url: 'https://upload.example.com',
175
+ fields: {
176
+ key: 'artifacts/me.pdf',
177
+ policy: 'policy-token'
178
+ },
179
+ file_key: 'artifacts/me.pdf',
180
+ file_url: 'https://cdn.example.com/artifacts/me.pdf'
181
+ }
182
+ }), {
183
+ status: 200,
184
+ headers: { 'Content-Type': 'application/json' }
185
+ });
186
+ }
187
+
188
+ if (url === 'https://upload.example.com') {
189
+ assert.ok(init?.body instanceof FormData);
190
+ return new Response(null, { status: 204 });
191
+ }
192
+
193
+ throw new Error(`Unexpected fetch call: ${url}`);
194
+ }) as typeof globalThis.fetch;
195
+
196
+ const ensured = await ensureArtifactUploaded({
197
+ artifactId,
198
+ presignedPostBody: {
199
+ dir: 'artifacts/'
200
+ }
201
+ }, context) as {
202
+ ok: boolean;
203
+ uploaded: boolean;
204
+ downloadUrl: string;
205
+ objectKey: string;
206
+ item: {
207
+ storageStatus: string;
208
+ fileUrl: string | null;
209
+ objectKey: string | null;
210
+ };
211
+ };
212
+
213
+ assert.equal(ensured.ok, true);
214
+ assert.equal(ensured.uploaded, true);
215
+ assert.equal(ensured.downloadUrl, 'https://cdn.example.com/artifacts/me.pdf');
216
+ assert.equal(ensured.objectKey, 'artifacts/me.pdf');
217
+ assert.equal(ensured.item.storageStatus, 'uploaded');
218
+ assert.equal(ensured.item.fileUrl, 'https://cdn.example.com/artifacts/me.pdf');
219
+ assert.equal(ensured.item.objectKey, 'artifacts/me.pdf');
220
+ assert.deepEqual(calls, [
221
+ {
222
+ url: 'https://api.example.com/api-core-bot/front/s3/get-presigned-post',
223
+ method: 'POST'
224
+ },
225
+ {
226
+ url: 'https://upload.example.com',
227
+ method: 'POST'
228
+ }
229
+ ]);
230
+
231
+ const listed = await listArtifacts({ refresh: false }, context) as {
232
+ items: Array<{
233
+ relativePath: string;
234
+ storageStatus: string;
235
+ fileUrl: string | null;
236
+ objectKey: string | null;
237
+ }>;
238
+ };
239
+ assert.equal(listed.items.length, 1);
240
+ assert.equal(listed.items[0]?.relativePath, 'exports/me.pdf');
241
+ assert.equal(listed.items[0]?.storageStatus, 'uploaded');
242
+ assert.equal(listed.items[0]?.fileUrl, 'https://cdn.example.com/artifacts/me.pdf');
243
+ assert.equal(listed.items[0]?.objectKey, 'artifacts/me.pdf');
244
+ });
245
+
246
+ test('ensureUploaded reuses existing uploaded artifact without fetching', async () => {
247
+ const context = await createMethodContext();
248
+ const workspaceRoot = path.join(context.openclawRoot, 'workspace');
249
+ const artifactPath = path.join(workspaceRoot, 'exports', 'me.pdf');
250
+
251
+ await fs.mkdir(path.dirname(artifactPath), { recursive: true });
252
+ await fs.writeFile(artifactPath, 'pdf-data', 'utf8');
253
+
254
+ const refreshed = await refreshArtifacts({}, context) as {
255
+ items: Array<{ id: string }>;
256
+ };
257
+ const artifactId = refreshed.items[0]?.id;
258
+ assert.ok(artifactId);
259
+
260
+ await markArtifactUploaded({
261
+ artifactId,
262
+ objectKey: 'artifacts/me.pdf',
263
+ fileUrl: 'https://cdn.example.com/artifacts/me.pdf'
264
+ }, context);
265
+
266
+ globalThis.fetch = (async () => {
267
+ throw new Error('fetch should not be called for existing uploaded artifact');
268
+ }) as typeof globalThis.fetch;
269
+
270
+ const ensured = await ensureArtifactUploaded({ artifactId }, context) as {
271
+ ok: boolean;
272
+ uploaded: boolean;
273
+ downloadUrl: string;
274
+ item: {
275
+ storageStatus: string;
276
+ fileUrl: string | null;
277
+ };
278
+ };
279
+
280
+ assert.equal(ensured.ok, true);
281
+ assert.equal(ensured.uploaded, false);
282
+ assert.equal(ensured.downloadUrl, 'https://cdn.example.com/artifacts/me.pdf');
283
+ assert.equal(ensured.item.storageStatus, 'uploaded');
284
+ assert.equal(ensured.item.fileUrl, 'https://cdn.example.com/artifacts/me.pdf');
285
+ });
286
+ });
287
+
288
+ async function createMethodContext(): Promise<{ projectRoot: string; openclawRoot: string }> {
289
+ const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'artifacts-task-scope-'));
290
+ tempDirs.push(rootDir);
291
+
292
+ const openclawRoot = path.join(rootDir, '.openclaw');
293
+ const workspaceRoot = path.join(openclawRoot, 'workspace');
294
+ await fs.mkdir(workspaceRoot, { recursive: true });
295
+
296
+ return {
297
+ projectRoot: rootDir,
298
+ openclawRoot
299
+ };
300
+ }
301
+
302
+ async function writeApiCoreBotConfig(openclawRoot: string, baseUrl: string): Promise<void> {
303
+ await fs.writeFile(
304
+ path.join(openclawRoot, 'openclaw.json'),
305
+ JSON.stringify({
306
+ plugins: {
307
+ entries: {
308
+ 'rol-websocket-channel': {
309
+ config: {
310
+ apiCoreBot: {
311
+ baseUrl
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+ }, null, 2),
318
+ 'utf8'
319
+ );
320
+ }