rol-websocket-channel 1.0.9 → 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.
@@ -0,0 +1,977 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { ensureDir, pathExists, readJsonFile } from '../lib/fs.ts';
6
+ import { ensureInside } from '../lib/paths.ts';
7
+ import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.ts';
8
+ import type { JsonValue, MethodContext, MethodHandler } from '../types.ts';
9
+
10
+ type ArtifactCategory =
11
+ | 'image'
12
+ | 'video'
13
+ | 'document'
14
+ | 'archive'
15
+ | 'other';
16
+
17
+ type ArtifactStorageStatus = 'local_only' | 'uploaded';
18
+
19
+ interface ArtifactRecord {
20
+ id: string;
21
+ fileName: string;
22
+ relativePath: string;
23
+ localPath: string;
24
+ category: ArtifactCategory;
25
+ mimeType: string;
26
+ ext: string | null;
27
+ sizeBytes: number;
28
+ storageStatus: ArtifactStorageStatus;
29
+ source: string;
30
+ previewable: boolean;
31
+ createdAt: string;
32
+ updatedAt: string;
33
+ objectKey?: string | null;
34
+ fileUrl?: string | null;
35
+ uploadedAt?: string | null;
36
+ record?: JsonValue;
37
+ }
38
+
39
+ interface ArtifactScanParams {
40
+ refresh?: boolean;
41
+ }
42
+
43
+ interface ArtifactGetContentParams {
44
+ artifactId?: string;
45
+ relativePath?: string;
46
+ maxInlineBytes?: number;
47
+ }
48
+
49
+ interface ArtifactEnsureUploadedParams {
50
+ artifactId?: string;
51
+ relativePath?: string;
52
+ presignedPostBody?: Record<string, JsonValue>;
53
+ baseUrl?: string;
54
+ authToken?: string;
55
+ }
56
+
57
+ interface ArtifactPresignedPostParams {
58
+ body: Record<string, JsonValue>;
59
+ baseUrl?: string;
60
+ authToken?: string;
61
+ }
62
+
63
+ interface ArtifactCreateRecordParams {
64
+ endpoint: string;
65
+ body: Record<string, JsonValue>;
66
+ baseUrl?: string;
67
+ authToken?: string;
68
+ }
69
+
70
+ interface ArtifactMarkUploadedParams {
71
+ artifactId?: string;
72
+ relativePath?: string;
73
+ objectKey?: string;
74
+ fileUrl?: string;
75
+ record?: JsonValue;
76
+ }
77
+
78
+ interface OpenClawConfig {
79
+ plugins?: {
80
+ entries?: Record<string, {
81
+ config?: {
82
+ apiCoreBot?: {
83
+ baseUrl?: string;
84
+ authToken?: string;
85
+ };
86
+ };
87
+ }>;
88
+ };
89
+ }
90
+
91
+ const INLINE_CONTENT_LIMIT_BYTES = 10 * 1024 * 1024;
92
+ const MIN_ARTIFACT_SIZE_BYTES = 1;
93
+ const ARTIFACT_MANIFEST_FILE = 'artifacts.json';
94
+ const IGNORE_EXTENSIONS = new Set(['.tmp', '.part', '.crdownload']);
95
+ const IGNORE_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', ARTIFACT_MANIFEST_FILE]);
96
+ const IGNORE_DIRECTORY_NAMES = new Set([
97
+ '.git',
98
+ '.cache',
99
+ 'cache',
100
+ 'caches',
101
+ 'tmp',
102
+ 'temp',
103
+ 'logs',
104
+ 'log',
105
+ 'node_modules',
106
+ 'sessions',
107
+ 'session',
108
+ 'history'
109
+ ]);
110
+
111
+ const CATEGORY_BY_EXTENSION: Record<string, ArtifactCategory> = {
112
+ '.png': 'image',
113
+ '.jpg': 'image',
114
+ '.jpeg': 'image',
115
+ '.webp': 'image',
116
+ '.gif': 'image',
117
+ '.svg': 'image',
118
+ '.mp4': 'video',
119
+ '.mov': 'video',
120
+ '.webm': 'video',
121
+ '.avi': 'video',
122
+ '.mkv': 'video',
123
+ '.m4v': 'video',
124
+ '.pdf': 'document',
125
+ '.doc': 'document',
126
+ '.docx': 'document',
127
+ '.zip': 'archive',
128
+ '.7z': 'archive',
129
+ '.tar': 'archive',
130
+ '.gz': 'archive',
131
+ '.rar': 'archive'
132
+ };
133
+
134
+ const MIME_BY_EXTENSION: Record<string, string> = {
135
+ '.png': 'image/png',
136
+ '.jpg': 'image/jpeg',
137
+ '.jpeg': 'image/jpeg',
138
+ '.webp': 'image/webp',
139
+ '.gif': 'image/gif',
140
+ '.svg': 'image/svg+xml',
141
+ '.mp4': 'video/mp4',
142
+ '.mov': 'video/quicktime',
143
+ '.webm': 'video/webm',
144
+ '.avi': 'video/x-msvideo',
145
+ '.mkv': 'video/x-matroska',
146
+ '.m4v': 'video/x-m4v',
147
+ '.pdf': 'application/pdf',
148
+ '.doc': 'application/msword',
149
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
150
+ '.zip': 'application/zip',
151
+ '.7z': 'application/x-7z-compressed',
152
+ '.tar': 'application/x-tar',
153
+ '.gz': 'application/gzip',
154
+ '.rar': 'application/vnd.rar'
155
+ };
156
+
157
+ interface WorkspacePaths {
158
+ workspaceRoot: string;
159
+ manifestPath: string;
160
+ }
161
+
162
+ interface PresignedPostUploadTarget {
163
+ uploadUrl: string;
164
+ fields: Record<string, string>;
165
+ objectKey: string | null;
166
+ fileUrl: string | null;
167
+ }
168
+
169
+ export const listArtifacts: MethodHandler = async (params, context): Promise<JsonValue> => {
170
+ const objectParams = expectOptionalObject(params) as unknown as ArtifactScanParams;
171
+ const refresh = objectParams.refresh !== false;
172
+ const manifest = refresh
173
+ ? await refreshArtifactManifest(context)
174
+ : await readOrRefreshArtifactManifest(context);
175
+
176
+ return {
177
+ scope: 'workspace',
178
+ count: manifest.items.length,
179
+ manifestPath: manifest.manifestPath,
180
+ workspaceRoot: manifest.workspacePaths.workspaceRoot,
181
+ items: manifest.items.map(artifactToJsonValue)
182
+ };
183
+ };
184
+
185
+ export const refreshArtifacts: MethodHandler = async (params, context): Promise<JsonValue> => {
186
+ const objectParams = expectOptionalObject(params) as unknown as ArtifactScanParams;
187
+ const manifest = await refreshArtifactManifest(context);
188
+
189
+ return {
190
+ ok: true,
191
+ scope: 'workspace',
192
+ count: manifest.items.length,
193
+ manifestPath: manifest.manifestPath,
194
+ workspaceRoot: manifest.workspacePaths.workspaceRoot,
195
+ items: manifest.items.map(artifactToJsonValue)
196
+ };
197
+ };
198
+
199
+ export const getArtifactContent: MethodHandler = async (params, context): Promise<JsonValue> => {
200
+ const objectParams = expectObject(params) as unknown as ArtifactGetContentParams;
201
+ const maxInlineBytes = normalizeMaxInlineBytes(objectParams.maxInlineBytes);
202
+ const artifact = await resolveArtifact(objectParams, context);
203
+
204
+ if (artifact.sizeBytes > maxInlineBytes) {
205
+ throw new JsonRpcException(
206
+ JSON_RPC_ERRORS.invalidParams,
207
+ 'Artifact is too large for inline transfer',
208
+ {
209
+ artifactId: artifact.id,
210
+ sizeBytes: artifact.sizeBytes,
211
+ maxInlineBytes
212
+ }
213
+ );
214
+ }
215
+
216
+ const content = await fs.readFile(artifact.localPath);
217
+
218
+ return {
219
+ scope: 'workspace',
220
+ artifact: {
221
+ ...artifactToJsonValue(artifact),
222
+ contentBase64: content.toString('base64')
223
+ }
224
+ };
225
+ };
226
+
227
+ export const ensureArtifactUploaded: MethodHandler = async (params, context): Promise<JsonValue> => {
228
+ const objectParams = expectObject(params) as unknown as ArtifactEnsureUploadedParams;
229
+ const artifact = await resolveArtifact(objectParams, context);
230
+
231
+ if (artifact.storageStatus === 'uploaded' && artifact.fileUrl) {
232
+ return {
233
+ ok: true,
234
+ scope: 'workspace',
235
+ uploaded: false,
236
+ artifactId: artifact.id,
237
+ objectKey: artifact.objectKey ?? null,
238
+ downloadUrl: artifact.fileUrl,
239
+ item: artifactToJsonValue(artifact)
240
+ };
241
+ }
242
+
243
+ const endpointConfig = await resolveApiCoreBotEndpoint(
244
+ context.openclawRoot,
245
+ objectParams.baseUrl,
246
+ objectParams.authToken
247
+ );
248
+ const presignedPostBody = buildPresignedPostBody(objectParams.presignedPostBody, artifact);
249
+ const presignedResponse = await postJson(
250
+ `${endpointConfig.baseUrl}/api-core-bot/front/s3/get-presigned-post`,
251
+ presignedPostBody,
252
+ endpointConfig.authToken
253
+ );
254
+ const uploadTarget = resolvePresignedPostUploadTarget(presignedResponse);
255
+ await uploadArtifactToPresignedPost(uploadTarget, artifact);
256
+
257
+ const objectKey = uploadTarget.objectKey ?? artifact.objectKey ?? null;
258
+ const fileUrl = uploadTarget.fileUrl ?? artifact.fileUrl ?? buildPublicFileUrl(uploadTarget.uploadUrl, objectKey);
259
+ if (!fileUrl) {
260
+ throw new JsonRpcException(
261
+ JSON_RPC_ERRORS.internalError,
262
+ 'Unable to determine uploaded artifact download URL',
263
+ {
264
+ artifactId: artifact.id,
265
+ objectKey
266
+ }
267
+ );
268
+ }
269
+
270
+ const manifest = await readOrRefreshArtifactManifest(context);
271
+ const updated = await persistUploadedArtifact(
272
+ manifest.manifestPath,
273
+ manifest.items,
274
+ artifact.id,
275
+ {
276
+ objectKey,
277
+ fileUrl,
278
+ record: artifact.record
279
+ }
280
+ );
281
+
282
+ return {
283
+ ok: true,
284
+ scope: 'workspace',
285
+ uploaded: true,
286
+ artifactId: artifact.id,
287
+ objectKey,
288
+ downloadUrl: fileUrl,
289
+ item: artifactToJsonValue(updated)
290
+ };
291
+ };
292
+
293
+ export const getArtifactPresignedPost: MethodHandler = async (params, context): Promise<JsonValue> => {
294
+ const objectParams = expectObject(params) as unknown as ArtifactPresignedPostParams;
295
+ const body = isObject(objectParams.body as JsonValue) ? objectParams.body : null;
296
+ if (!body) {
297
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Missing required parameter: body');
298
+ }
299
+
300
+ const endpointConfig = await resolveApiCoreBotEndpoint(context.openclawRoot, objectParams.baseUrl, objectParams.authToken);
301
+ const response = await postJson(
302
+ `${endpointConfig.baseUrl}/api-core-bot/front/s3/get-presigned-post`,
303
+ body,
304
+ endpointConfig.authToken
305
+ );
306
+
307
+ return {
308
+ ok: true,
309
+ endpoint: '/api-core-bot/front/s3/get-presigned-post',
310
+ data: response
311
+ };
312
+ };
313
+
314
+ export const createArtifactRecord: MethodHandler = async (params, context): Promise<JsonValue> => {
315
+ const objectParams = expectObject(params) as unknown as ArtifactCreateRecordParams;
316
+ const body = isObject(objectParams.body as JsonValue) ? objectParams.body : null;
317
+ if (!body) {
318
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Missing required parameter: body');
319
+ }
320
+
321
+ const endpoint = expectString(objectParams.endpoint, 'endpoint');
322
+ if (!endpoint.startsWith('/api-core-bot/front/')) {
323
+ throw new JsonRpcException(
324
+ JSON_RPC_ERRORS.invalidParams,
325
+ 'endpoint must start with /api-core-bot/front/'
326
+ );
327
+ }
328
+
329
+ const endpointConfig = await resolveApiCoreBotEndpoint(context.openclawRoot, objectParams.baseUrl, objectParams.authToken);
330
+ const response = await postJson(
331
+ `${endpointConfig.baseUrl}${endpoint}`,
332
+ body,
333
+ endpointConfig.authToken
334
+ );
335
+
336
+ return {
337
+ ok: true,
338
+ endpoint,
339
+ data: response
340
+ };
341
+ };
342
+
343
+ export const markArtifactUploaded: MethodHandler = async (params, context): Promise<JsonValue> => {
344
+ const objectParams = expectObject(params) as unknown as ArtifactMarkUploadedParams;
345
+ const objectKey = optionalTrimmedString(objectParams.objectKey);
346
+ const fileUrl = optionalTrimmedString(objectParams.fileUrl);
347
+
348
+ if (!objectKey && !fileUrl) {
349
+ throw new JsonRpcException(
350
+ JSON_RPC_ERRORS.invalidParams,
351
+ 'At least one of objectKey or fileUrl is required'
352
+ );
353
+ }
354
+
355
+ const manifest = await readOrRefreshArtifactManifest(context);
356
+ const artifact = findArtifact(manifest.items, objectParams);
357
+ if (!artifact) {
358
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Artifact not found', {
359
+ artifactId: objectParams.artifactId,
360
+ relativePath: objectParams.relativePath
361
+ });
362
+ }
363
+
364
+ const updated = await persistUploadedArtifact(
365
+ manifest.manifestPath,
366
+ manifest.items,
367
+ artifact.id,
368
+ {
369
+ objectKey: objectKey ?? artifact.objectKey ?? null,
370
+ fileUrl: fileUrl ?? artifact.fileUrl ?? null,
371
+ record: objectParams.record === undefined ? artifact.record : objectParams.record
372
+ }
373
+ );
374
+
375
+ return {
376
+ ok: true,
377
+ scope: 'workspace',
378
+ artifactId: artifact.id,
379
+ item: artifactToJsonValue(updated)
380
+ };
381
+ };
382
+
383
+ export function classifyArtifactCategory(fileName: string): ArtifactCategory {
384
+ const ext = path.extname(fileName).toLowerCase();
385
+ return CATEGORY_BY_EXTENSION[ext] ?? 'other';
386
+ }
387
+
388
+ export function shouldIgnoreArtifactFile(fileName: string): boolean {
389
+ const normalized = fileName.trim().toLowerCase();
390
+ if (!normalized) {
391
+ return true;
392
+ }
393
+
394
+ if (normalized.startsWith('.')) {
395
+ return true;
396
+ }
397
+
398
+ if (IGNORE_FILE_NAMES.has(normalized)) {
399
+ return true;
400
+ }
401
+
402
+ const ext = path.extname(normalized);
403
+ return IGNORE_EXTENSIONS.has(ext);
404
+ }
405
+
406
+ async function refreshArtifactManifest(
407
+ context: MethodContext
408
+ ): Promise<{ manifestPath: string; items: ArtifactRecord[]; workspacePaths: WorkspacePaths }> {
409
+ const workspacePaths = await ensureWorkspacePaths(context.openclawRoot);
410
+ const { workspaceRoot, manifestPath } = workspacePaths;
411
+
412
+ const existing = await readExistingManifest(manifestPath);
413
+ const existingByPath = new Map(existing.map((item) => [item.relativePath, item]));
414
+ const files = await collectArtifactFiles(workspaceRoot);
415
+ const items: ArtifactRecord[] = [];
416
+
417
+ for (const fullPath of files) {
418
+ const stat = await fs.stat(fullPath);
419
+ if (!stat.isFile() || stat.size < MIN_ARTIFACT_SIZE_BYTES) {
420
+ continue;
421
+ }
422
+
423
+ const relativePath = path.relative(workspaceRoot, fullPath).replace(/\\/g, '/');
424
+ const existingItem = existingByPath.get(relativePath);
425
+ const fileName = path.basename(fullPath);
426
+ if (!shouldIncludeArtifactFile(fileName)) {
427
+ continue;
428
+ }
429
+
430
+ const ext = normalizeExtension(fileName);
431
+ const createdAt = normalizeTimestamp(stat.birthtime, stat.mtime);
432
+ const updatedAt = stat.mtime.toISOString();
433
+
434
+ items.push({
435
+ id: existingItem?.id ?? buildArtifactId(relativePath),
436
+ fileName,
437
+ relativePath,
438
+ localPath: fullPath,
439
+ category: classifyArtifactCategory(fileName),
440
+ mimeType: MIME_BY_EXTENSION[ext ?? ''] ?? 'application/octet-stream',
441
+ ext,
442
+ sizeBytes: stat.size,
443
+ storageStatus: existingItem?.storageStatus ?? 'local_only',
444
+ source: existingItem?.source ?? 'generated',
445
+ previewable: isPreviewableCategory(classifyArtifactCategory(fileName)),
446
+ createdAt: existingItem?.createdAt ?? createdAt,
447
+ updatedAt,
448
+ objectKey: existingItem?.objectKey ?? null,
449
+ fileUrl: existingItem?.fileUrl ?? null,
450
+ uploadedAt: existingItem?.uploadedAt ?? null,
451
+ record: existingItem?.record
452
+ });
453
+ }
454
+
455
+ items.sort((a, b) => a.fileName.localeCompare(b.fileName));
456
+ await writeArtifactManifest(manifestPath, items);
457
+
458
+ return { manifestPath, items, workspacePaths };
459
+ }
460
+
461
+ async function readOrRefreshArtifactManifest(
462
+ context: MethodContext
463
+ ): Promise<{ manifestPath: string; items: ArtifactRecord[]; workspacePaths: WorkspacePaths }> {
464
+ const workspacePaths = await ensureWorkspacePaths(context.openclawRoot);
465
+ const { manifestPath } = workspacePaths;
466
+ if (!(await pathExists(manifestPath))) {
467
+ return await refreshArtifactManifest(context);
468
+ }
469
+
470
+ const items = await readExistingManifest(manifestPath);
471
+ return { manifestPath, items, workspacePaths };
472
+ }
473
+
474
+ async function readExistingManifest(manifestPath: string): Promise<ArtifactRecord[]> {
475
+ if (!(await pathExists(manifestPath))) {
476
+ return [];
477
+ }
478
+
479
+ const parsed = await readJsonFile<JsonValue>(manifestPath);
480
+ if (!Array.isArray(parsed)) {
481
+ return [];
482
+ }
483
+
484
+ const items: ArtifactRecord[] = [];
485
+ for (const item of parsed) {
486
+ if (isArtifactRecord(item)) {
487
+ items.push(item);
488
+ }
489
+ }
490
+
491
+ return items;
492
+ }
493
+
494
+ async function writeArtifactManifest(manifestPath: string, items: ArtifactRecord[]): Promise<void> {
495
+ await fs.writeFile(
496
+ manifestPath,
497
+ JSON.stringify(items.map((item) => artifactToJsonValue(item)), null, 2),
498
+ 'utf8'
499
+ );
500
+ }
501
+
502
+ async function persistUploadedArtifact(
503
+ manifestPath: string,
504
+ items: ArtifactRecord[],
505
+ artifactId: string,
506
+ updates: {
507
+ objectKey?: string | null;
508
+ fileUrl?: string | null;
509
+ record?: JsonValue;
510
+ }
511
+ ): Promise<ArtifactRecord> {
512
+ const now = new Date().toISOString();
513
+ let updatedArtifact: ArtifactRecord | null = null;
514
+
515
+ const updatedItems = items.map((item) => {
516
+ if (!isArtifactRecord(item) || item.id !== artifactId) {
517
+ return item;
518
+ }
519
+
520
+ updatedArtifact = {
521
+ ...item,
522
+ storageStatus: 'uploaded',
523
+ objectKey: updates.objectKey ?? item.objectKey ?? null,
524
+ fileUrl: updates.fileUrl ?? item.fileUrl ?? null,
525
+ uploadedAt: now,
526
+ updatedAt: now,
527
+ ...(updates.record === undefined ? {} : { record: updates.record })
528
+ };
529
+
530
+ return updatedArtifact;
531
+ });
532
+
533
+ await writeArtifactManifest(manifestPath, updatedItems);
534
+
535
+ if (!updatedArtifact) {
536
+ throw new JsonRpcException(JSON_RPC_ERRORS.internalError, 'Artifact not found while persisting upload state', {
537
+ artifactId
538
+ });
539
+ }
540
+
541
+ return updatedArtifact;
542
+ }
543
+
544
+ async function collectArtifactFiles(rootDir: string): Promise<string[]> {
545
+ const files: string[] = [];
546
+ const entries = await fs.readdir(rootDir, { withFileTypes: true });
547
+
548
+ for (const entry of entries) {
549
+ if (entry.isDirectory() && shouldIgnoreArtifactDirectory(entry.name)) {
550
+ continue;
551
+ }
552
+
553
+ if (entry.isFile() && shouldIgnoreArtifactFile(entry.name)) {
554
+ continue;
555
+ }
556
+
557
+ const fullPath = path.join(rootDir, entry.name);
558
+ if (entry.isDirectory()) {
559
+ files.push(...await collectArtifactFiles(fullPath));
560
+ continue;
561
+ }
562
+
563
+ if (entry.isFile()) {
564
+ files.push(fullPath);
565
+ }
566
+ }
567
+
568
+ return files;
569
+ }
570
+
571
+ async function resolveArtifact(
572
+ params: { artifactId?: string; relativePath?: string },
573
+ context: MethodContext
574
+ ): Promise<ArtifactRecord> {
575
+ const manifest = await readOrRefreshArtifactManifest(context);
576
+ const artifact = findArtifact(manifest.items, params);
577
+ if (!artifact) {
578
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Artifact not found', {
579
+ artifactId: params.artifactId,
580
+ relativePath: params.relativePath
581
+ });
582
+ }
583
+
584
+ const checkedPath = ensureInside(manifest.workspacePaths.workspaceRoot, artifact.localPath);
585
+ if (!(await pathExists(checkedPath))) {
586
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Artifact file does not exist', {
587
+ localPath: checkedPath
588
+ });
589
+ }
590
+
591
+ return {
592
+ ...artifact,
593
+ localPath: checkedPath
594
+ };
595
+ }
596
+
597
+ function findArtifact(
598
+ items: ArtifactRecord[],
599
+ selector: { artifactId?: string; relativePath?: string }
600
+ ): ArtifactRecord | null {
601
+ const artifactId = optionalTrimmedString(selector.artifactId);
602
+ const relativePath = normalizeRelativePath(optionalTrimmedString(selector.relativePath));
603
+
604
+ if (!artifactId && !relativePath) {
605
+ throw new JsonRpcException(
606
+ JSON_RPC_ERRORS.invalidParams,
607
+ 'Either artifactId or relativePath is required'
608
+ );
609
+ }
610
+
611
+ return items.find((item) => {
612
+ if (artifactId && item.id === artifactId) {
613
+ return true;
614
+ }
615
+
616
+ return Boolean(relativePath && item.relativePath === relativePath);
617
+ }) ?? null;
618
+ }
619
+
620
+ function resolveWorkspaceRoot(openclawRoot: string): string {
621
+ const workspaceRoot = path.join(openclawRoot, 'workspace');
622
+ return ensureInside(openclawRoot, workspaceRoot);
623
+ }
624
+
625
+ async function ensureWorkspacePaths(openclawRoot: string): Promise<WorkspacePaths> {
626
+ const workspaceRoot = resolveWorkspaceRoot(openclawRoot);
627
+ const manifestPath = ensureInside(workspaceRoot, path.join(workspaceRoot, ARTIFACT_MANIFEST_FILE));
628
+
629
+ await ensureDir(workspaceRoot);
630
+
631
+ return {
632
+ workspaceRoot,
633
+ manifestPath
634
+ };
635
+ }
636
+
637
+ function buildArtifactId(relativePath: string): string {
638
+ return `art_${createHash('sha1').update(relativePath).digest('hex').slice(0, 12)}`;
639
+ }
640
+
641
+ function normalizeExtension(fileName: string): string | null {
642
+ const ext = path.extname(fileName).toLowerCase();
643
+ return ext || null;
644
+ }
645
+
646
+ function normalizeRelativePath(value: string | null): string | null {
647
+ if (!value) {
648
+ return null;
649
+ }
650
+
651
+ return value.replace(/\\/g, '/').replace(/^\/+/, '');
652
+ }
653
+
654
+ function normalizeTimestamp(created: Date, fallback: Date): string {
655
+ return Number.isNaN(created.getTime()) ? fallback.toISOString() : created.toISOString();
656
+ }
657
+
658
+ function normalizeMaxInlineBytes(value: number | undefined): number {
659
+ if (value === undefined) {
660
+ return INLINE_CONTENT_LIMIT_BYTES;
661
+ }
662
+
663
+ if (!Number.isFinite(value) || value <= 0) {
664
+ throw new JsonRpcException(
665
+ JSON_RPC_ERRORS.invalidParams,
666
+ 'maxInlineBytes must be a positive number'
667
+ );
668
+ }
669
+
670
+ return Math.floor(value);
671
+ }
672
+
673
+ function isPreviewableCategory(category: ArtifactCategory): boolean {
674
+ return category === 'image' || category === 'video' || category === 'document';
675
+ }
676
+
677
+ function shouldIgnoreArtifactDirectory(dirName: string): boolean {
678
+ const normalized = dirName.trim().toLowerCase();
679
+ return !normalized || normalized.startsWith('.') || IGNORE_DIRECTORY_NAMES.has(normalized);
680
+ }
681
+
682
+ function shouldIncludeArtifactFile(fileName: string): boolean {
683
+ if (shouldIgnoreArtifactFile(fileName)) {
684
+ return false;
685
+ }
686
+
687
+ return classifyArtifactCategory(fileName) !== 'other';
688
+ }
689
+
690
+ function isArtifactRecord(value: unknown): value is ArtifactRecord {
691
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
692
+ return false;
693
+ }
694
+
695
+ const objectValue = value as Record<string, unknown>;
696
+ return (
697
+ typeof objectValue.id === 'string' &&
698
+ typeof objectValue.fileName === 'string' &&
699
+ typeof objectValue.relativePath === 'string' &&
700
+ typeof objectValue.localPath === 'string'
701
+ );
702
+ }
703
+
704
+ function artifactToJsonValue(item: ArtifactRecord): { [key: string]: JsonValue } {
705
+ return {
706
+ id: item.id,
707
+ fileName: item.fileName,
708
+ relativePath: item.relativePath,
709
+ localPath: item.localPath,
710
+ category: item.category,
711
+ mimeType: item.mimeType,
712
+ ext: item.ext,
713
+ sizeBytes: item.sizeBytes,
714
+ storageStatus: item.storageStatus,
715
+ source: item.source,
716
+ previewable: item.previewable,
717
+ createdAt: item.createdAt,
718
+ updatedAt: item.updatedAt,
719
+ objectKey: item.objectKey ?? null,
720
+ fileUrl: item.fileUrl ?? null,
721
+ uploadedAt: item.uploadedAt ?? null,
722
+ record: item.record ?? null
723
+ };
724
+ }
725
+
726
+ function expectObject(value: JsonValue | undefined): Record<string, JsonValue> {
727
+ if (!value || Array.isArray(value) || typeof value !== 'object') {
728
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Params must be an object');
729
+ }
730
+
731
+ return value as Record<string, JsonValue>;
732
+ }
733
+
734
+ function expectOptionalObject(value: JsonValue | undefined): Record<string, JsonValue> {
735
+ if (value === undefined) {
736
+ return {};
737
+ }
738
+
739
+ return expectObject(value);
740
+ }
741
+
742
+ function buildPresignedPostBody(
743
+ providedBody: Record<string, JsonValue> | undefined,
744
+ artifact: ArtifactRecord
745
+ ): Record<string, JsonValue> {
746
+ const body = isObject(providedBody) ? { ...providedBody } : {};
747
+
748
+ if (body.filename === undefined) {
749
+ body.filename = artifact.fileName;
750
+ }
751
+
752
+ return body;
753
+ }
754
+
755
+ function expectString(value: JsonValue | undefined, fieldName: string): string {
756
+ if (typeof value !== 'string' || value.trim().length === 0) {
757
+ throw new JsonRpcException(
758
+ JSON_RPC_ERRORS.invalidParams,
759
+ `Field '${fieldName}' must be a non-empty string`
760
+ );
761
+ }
762
+
763
+ return value.trim();
764
+ }
765
+
766
+ function optionalTrimmedString(value: JsonValue | undefined): string | null {
767
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
768
+ }
769
+
770
+ function isObject(value: JsonValue | undefined): value is Record<string, JsonValue> {
771
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
772
+ }
773
+
774
+ async function resolveApiCoreBotEndpoint(
775
+ openclawRoot: string,
776
+ overrideBaseUrl?: string,
777
+ overrideAuthToken?: string
778
+ ): Promise<{ baseUrl: string; authToken?: string }> {
779
+ const config = await readJsonFile<OpenClawConfig>(path.join(openclawRoot, 'openclaw.json'));
780
+ const pluginConfig = config.plugins?.entries?.['rol-websocket-channel']?.config?.apiCoreBot ?? {};
781
+ const baseUrl = (overrideBaseUrl && overrideBaseUrl.trim()) || pluginConfig.baseUrl;
782
+
783
+ if (!baseUrl || !baseUrl.trim()) {
784
+ throw new JsonRpcException(
785
+ JSON_RPC_ERRORS.invalidParams,
786
+ 'apiCoreBot.baseUrl is not configured'
787
+ );
788
+ }
789
+
790
+ const authToken = (overrideAuthToken && overrideAuthToken.trim()) || pluginConfig.authToken;
791
+ return {
792
+ baseUrl: baseUrl.replace(/\/+$/, ''),
793
+ authToken
794
+ };
795
+ }
796
+
797
+ async function postJson(
798
+ url: string,
799
+ body: Record<string, JsonValue>,
800
+ authToken?: string
801
+ ): Promise<JsonValue> {
802
+ const headers: Record<string, string> = {
803
+ 'Content-Type': 'application/json'
804
+ };
805
+
806
+ if (authToken && authToken.trim()) {
807
+ headers.Authorization = `Bearer ${authToken.trim()}`;
808
+ }
809
+
810
+ const response = await fetch(url, {
811
+ method: 'POST',
812
+ headers,
813
+ body: JSON.stringify(body)
814
+ });
815
+
816
+ const payload = await response.json().catch(async () => await response.text()) as JsonValue;
817
+ if (!response.ok) {
818
+ throw new JsonRpcException(
819
+ JSON_RPC_ERRORS.internalError,
820
+ `Request failed: ${response.status}`,
821
+ {
822
+ url,
823
+ status: response.status,
824
+ payload
825
+ }
826
+ );
827
+ }
828
+
829
+ if (isObject(payload) && payload.success === false) {
830
+ throw new JsonRpcException(
831
+ JSON_RPC_ERRORS.internalError,
832
+ pickStringValue(payload, ['message', 'msg']) ?? 'Request failed',
833
+ {
834
+ url,
835
+ payload
836
+ }
837
+ );
838
+ }
839
+
840
+ return payload;
841
+ }
842
+
843
+ function resolvePresignedPostUploadTarget(payload: JsonValue): PresignedPostUploadTarget {
844
+ for (const candidate of collectPresignedPostCandidates(payload)) {
845
+ const uploadUrl = pickStringValue(candidate, ['url', 'uploadUrl', 'postUrl', 'presignedPostUrl']);
846
+ const rawFields = candidate.fields ?? candidate.formData ?? candidate.form ?? candidate.params;
847
+ const fields = toStringRecord(rawFields);
848
+
849
+ if (!uploadUrl || !fields) {
850
+ continue;
851
+ }
852
+
853
+ return {
854
+ uploadUrl,
855
+ fields,
856
+ objectKey: pickStringValue(candidate, ['objectKey', 'fileKey', 'file_key', 'source_file_key', 'key']) ?? fields.key ?? null,
857
+ fileUrl: pickStringValue(candidate, ['fileUrl', 'file_url', 'downloadUrl', 'source_file_url', 'location']) ?? null
858
+ };
859
+ }
860
+
861
+ throw new JsonRpcException(
862
+ JSON_RPC_ERRORS.internalError,
863
+ 'Presigned post response is missing upload url or fields',
864
+ { payload }
865
+ );
866
+ }
867
+
868
+ function collectPresignedPostCandidates(payload: JsonValue): Array<Record<string, JsonValue>> {
869
+ const candidates: Array<Record<string, JsonValue>> = [];
870
+ const queue: JsonValue[] = [payload];
871
+
872
+ while (queue.length > 0) {
873
+ const current = queue.shift();
874
+ if (!isObject(current)) {
875
+ continue;
876
+ }
877
+
878
+ candidates.push(current);
879
+
880
+ for (const key of ['data', 'result', 'presignedPost']) {
881
+ const nested = current[key];
882
+ if (isObject(nested)) {
883
+ queue.push(nested);
884
+ }
885
+ }
886
+ }
887
+
888
+ return candidates;
889
+ }
890
+
891
+ function pickStringValue(
892
+ source: Record<string, JsonValue>,
893
+ keys: string[]
894
+ ): string | null {
895
+ for (const key of keys) {
896
+ const value = source[key];
897
+ if (typeof value === 'string' && value.trim().length > 0) {
898
+ return value.trim();
899
+ }
900
+ }
901
+
902
+ return null;
903
+ }
904
+
905
+ function toStringRecord(value: JsonValue | undefined): Record<string, string> | null {
906
+ if (!isObject(value)) {
907
+ return null;
908
+ }
909
+
910
+ const result: Record<string, string> = {};
911
+ for (const [key, item] of Object.entries(value)) {
912
+ if (item === null) {
913
+ continue;
914
+ }
915
+
916
+ if (typeof item === 'string') {
917
+ result[key] = item;
918
+ continue;
919
+ }
920
+
921
+ if (typeof item === 'number' || typeof item === 'boolean') {
922
+ result[key] = String(item);
923
+ continue;
924
+ }
925
+
926
+ return null;
927
+ }
928
+
929
+ return result;
930
+ }
931
+
932
+ async function uploadArtifactToPresignedPost(
933
+ target: PresignedPostUploadTarget,
934
+ artifact: ArtifactRecord
935
+ ): Promise<void> {
936
+ const formData = new FormData();
937
+ for (const [key, value] of Object.entries(target.fields)) {
938
+ formData.append(key, value);
939
+ }
940
+
941
+ const content = await fs.readFile(artifact.localPath);
942
+ formData.append('file', new Blob([content], { type: artifact.mimeType }), artifact.fileName);
943
+
944
+ const response = await fetch(target.uploadUrl, {
945
+ method: 'POST',
946
+ body: formData
947
+ });
948
+
949
+ if (!response.ok) {
950
+ const payload = await response.text().catch(() => '');
951
+ throw new JsonRpcException(
952
+ JSON_RPC_ERRORS.internalError,
953
+ `Artifact upload failed: ${response.status}`,
954
+ {
955
+ url: target.uploadUrl,
956
+ status: response.status,
957
+ payload
958
+ }
959
+ );
960
+ }
961
+ }
962
+
963
+ function buildPublicFileUrl(uploadUrl: string, objectKey: string | null): string | null {
964
+ if (!objectKey) {
965
+ return null;
966
+ }
967
+
968
+ try {
969
+ const url = new URL(uploadUrl);
970
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/${objectKey.replace(/^\/+/, '')}`;
971
+ url.search = '';
972
+ url.hash = '';
973
+ return url.toString();
974
+ } catch {
975
+ return null;
976
+ }
977
+ }