rol-websocket-channel 1.5.7 → 1.5.9
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/MQTT-API System.md +100 -0
- package/dist/index.js +22 -4
- package/dist/message-handler.js +7 -1
- package/dist/src/admin/cli-manifest.test.js +40 -3
- package/dist/src/admin/methods/admin.js +56 -1
- package/dist/src/admin/methods/admin.test.js +64 -0
- package/dist/src/admin/methods/artifacts.js +121 -2
- package/dist/src/admin/methods/artifacts.test.js +85 -1
- package/dist/src/admin/methods/index.js +5 -2
- package/dist/src/admin/methods/index.test.js +15 -0
- package/dist/src/admin/methods/mem9.js +143 -81
- package/dist/src/admin/methods/pairing.js +38 -4
- package/dist/src/admin/tools/artifacts-tools.js +101 -0
- package/dist/src/admin/tools/artifacts-tools.test.js +163 -0
- package/index.ts +42 -18
- package/message-handler.ts +8 -1
- package/openclaw.plugin.json +3 -0
- package/package.json +15 -3
- package/src/admin/cli-manifest.test.ts +44 -3
- package/src/admin/methods/admin.test.ts +103 -0
- package/src/admin/methods/admin.ts +69 -2
- package/src/admin/methods/artifacts.test.ts +120 -0
- package/src/admin/methods/artifacts.ts +189 -2
- package/src/admin/methods/index.test.ts +18 -0
- package/src/admin/methods/index.ts +6 -1
- package/src/admin/methods/mem9.ts +165 -95
- package/src/admin/methods/pairing.ts +41 -4
- package/src/admin/tools/artifacts-tools.test.ts +215 -0
- package/src/admin/tools/artifacts-tools.ts +133 -0
- package/tsconfig.json +1 -1
- package/types/openclaw.d.ts +137 -74
package/MQTT-API System.md
CHANGED
|
@@ -1908,3 +1908,103 @@ MQTT 请求示例:
|
|
|
1908
1908
|
}
|
|
1909
1909
|
}
|
|
1910
1910
|
```
|
|
1911
|
+
|
|
1912
|
+
---
|
|
1913
|
+
|
|
1914
|
+
## apiCoreBot 配置与 Artifact 发布(新增 configSetApiCoreBot;上传链接可返回会话)
|
|
1915
|
+
|
|
1916
|
+
### configSetApiCoreBot
|
|
1917
|
+
|
|
1918
|
+
作用:前端在需要开启文件上传/下载链接能力时主动调用,写入当前插件的 `apiCoreBot` 配置。这个配置不是配对时写入,只有收到该 MQTT 消息后才会更新 `openclaw.json`。
|
|
1919
|
+
|
|
1920
|
+
写入位置:
|
|
1921
|
+
|
|
1922
|
+
```json
|
|
1923
|
+
{
|
|
1924
|
+
"plugins": {
|
|
1925
|
+
"entries": {
|
|
1926
|
+
"rol-websocket-channel": {
|
|
1927
|
+
"config": {
|
|
1928
|
+
"apiCoreBot": {
|
|
1929
|
+
"baseUrl": "http://192.168.1.23:9092",
|
|
1930
|
+
"authToken": "123"
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
```
|
|
1938
|
+
|
|
1939
|
+
默认值:
|
|
1940
|
+
|
|
1941
|
+
- `baseUrl`: `http://192.168.1.23:9092`
|
|
1942
|
+
- `authToken`: `123`
|
|
1943
|
+
|
|
1944
|
+
MQTT 请求示例:
|
|
1945
|
+
|
|
1946
|
+
```json
|
|
1947
|
+
{
|
|
1948
|
+
"type": "configSetApiCoreBot",
|
|
1949
|
+
"trace_id": "config-api-core-bot-001",
|
|
1950
|
+
"data": {}
|
|
1951
|
+
}
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
如果前端需要覆盖默认值,也可以显式传入:
|
|
1955
|
+
|
|
1956
|
+
```json
|
|
1957
|
+
{
|
|
1958
|
+
"type": "configSetApiCoreBot",
|
|
1959
|
+
"trace_id": "config-api-core-bot-002",
|
|
1960
|
+
"data": {
|
|
1961
|
+
"baseUrl": "http://192.168.1.23:9092",
|
|
1962
|
+
"authToken": "123"
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
```
|
|
1966
|
+
|
|
1967
|
+
成功返回示例:
|
|
1968
|
+
|
|
1969
|
+
```json
|
|
1970
|
+
{
|
|
1971
|
+
"type": "receiver",
|
|
1972
|
+
"trace_id": "config-api-core-bot-001",
|
|
1973
|
+
"source": "system",
|
|
1974
|
+
"success": true,
|
|
1975
|
+
"data": {
|
|
1976
|
+
"ok": true,
|
|
1977
|
+
"configFile": "/home/woowonjae/.openclaw/openclaw.json",
|
|
1978
|
+
"updated": [
|
|
1979
|
+
"plugins.entries.rol-websocket-channel.config.apiCoreBot.baseUrl",
|
|
1980
|
+
"plugins.entries.rol-websocket-channel.config.apiCoreBot.authToken"
|
|
1981
|
+
],
|
|
1982
|
+
"apiCoreBot": {
|
|
1983
|
+
"baseUrl": "http://192.168.1.23:9092",
|
|
1984
|
+
"authToken": "********"
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
```
|
|
1989
|
+
|
|
1990
|
+
### Artifact 发布链路
|
|
1991
|
+
|
|
1992
|
+
`configSetApiCoreBot` 写入成功后,Artifact 发布方法会从 `openclaw.json` 读取 `apiCoreBot.baseUrl` 和 `apiCoreBot.authToken`,再调用上传接口。小龙虾会话如果调用插件工具 `rol_artifacts_publish`,成功后会拿到可返回给用户的下载链接。
|
|
1993
|
+
|
|
1994
|
+
成功返回中的核心字段:
|
|
1995
|
+
|
|
1996
|
+
```json
|
|
1997
|
+
{
|
|
1998
|
+
"ok": true,
|
|
1999
|
+
"uploaded": true,
|
|
2000
|
+
"artifactId": "art_91f6d03e144c",
|
|
2001
|
+
"objectKey": "uploads/91a78677-5e79-4259-a28d-fa01f24d4848.svg",
|
|
2002
|
+
"downloadUrl": "https://draft-user.s3.us-east-2.amazonaws.com/uploads/91a78677-5e79-4259-a28d-fa01f24d4848.svg"
|
|
2003
|
+
}
|
|
2004
|
+
```
|
|
2005
|
+
|
|
2006
|
+
注意:
|
|
2007
|
+
|
|
2008
|
+
- 如果没有先调用 `configSetApiCoreBot`,发布时可能返回 `apiCoreBot.baseUrl is not configured`。
|
|
2009
|
+
- `authToken` 在返回值中会脱敏显示,实际写入 `openclaw.json` 的值是 `123` 或前端传入的值。
|
|
2010
|
+
- 生成文件本身仍然在 `.openclaw/workspace` 下,上传只在发布/下载链路触发时执行。
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// extensions/rol-websocket-channel/index.ts
|
|
2
2
|
// WebSocket Channel 插件实现
|
|
3
3
|
// 提供基于 WebSocket 的双向通信能力,支持 AI 回复和主动消息
|
|
4
|
+
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/channel-core";
|
|
4
5
|
import { messageHandler } from "./message-handler.js";
|
|
5
6
|
import { GlobalMqttClient } from "./src/mqtt/mqtt-client.js";
|
|
6
7
|
import * as ConnectionManager from "./src/mqtt/connection-manager.js";
|
|
8
|
+
import { registerArtifactsTools } from "./src/admin/tools/artifacts-tools.js";
|
|
7
9
|
// ============================================
|
|
8
10
|
// 3. 全局状态
|
|
9
11
|
// ============================================
|
|
@@ -558,14 +560,30 @@ async function handleCustomMessageType(msgType, innerData, traceId, accountId, m
|
|
|
558
560
|
// ============================================
|
|
559
561
|
// 6. 插件注册入口
|
|
560
562
|
// ============================================
|
|
561
|
-
|
|
562
|
-
|
|
563
|
+
export default defineChannelPluginEntry({
|
|
564
|
+
id: "rol-websocket-channel",
|
|
565
|
+
name: "WebSocket Channel",
|
|
566
|
+
description: "Unified plugin providing MQTT Channel and Admin Bridge capabilities for OpenClaw management",
|
|
567
|
+
plugin: WebSocketChannel,
|
|
568
|
+
setRuntime(runtime) {
|
|
569
|
+
pluginRuntime = runtime;
|
|
570
|
+
initializeContext();
|
|
571
|
+
},
|
|
572
|
+
registerCliMetadata(api) {
|
|
573
|
+
registerAdminBridgeCli(api);
|
|
574
|
+
},
|
|
575
|
+
registerFull(api) {
|
|
576
|
+
registerArtifactsTools(api);
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
let legacyIsPluginRegistered = false;
|
|
580
|
+
function legacyRegister(api) {
|
|
563
581
|
console.log("[mqtt] Register rol-websocket-channel");
|
|
564
|
-
if (
|
|
582
|
+
if (legacyIsPluginRegistered) {
|
|
565
583
|
return;
|
|
566
584
|
}
|
|
567
585
|
console.log("[mqtt] Register rol-websocket-channel real");
|
|
568
|
-
|
|
586
|
+
legacyIsPluginRegistered = true;
|
|
569
587
|
pluginRuntime = api.runtime;
|
|
570
588
|
// 初始化共享 context
|
|
571
589
|
initializeContext();
|
package/dist/message-handler.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { getContext } from './src/shared/context.js';
|
|
6
6
|
import { wrapAdminCall } from './src/shared/wrapper.js';
|
|
7
|
-
import { getAgents, getConfig } from './src/admin/methods/admin.js';
|
|
7
|
+
import { getAgents, getConfig, setApiCoreBotConfig } from './src/admin/methods/admin.js';
|
|
8
8
|
import { createAgent, deleteAgent, listAgents, updateAgent } from './src/admin/methods/agents-extended.js';
|
|
9
9
|
import { createArtifactRecord, ensureArtifactUploaded, getArtifactContent, getArtifactPresignedPost, listArtifacts, markArtifactUploaded, refreshArtifacts } from './src/admin/methods/artifacts.js';
|
|
10
10
|
import { addCron, disableCron, enableCron, getCronStatus, listCron, listCronRuns, renameCron, removeCron, rescheduleCron, runCron, setCronContent, updateCronMessage, updateCronSystemEvent } from './src/admin/methods/cron.js';
|
|
@@ -77,6 +77,12 @@ export class MessageHandler {
|
|
|
77
77
|
return await getConfig(data, context);
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
|
+
async configSetApiCoreBot(data) {
|
|
81
|
+
return wrapAdminCall(async () => {
|
|
82
|
+
const context = getContext();
|
|
83
|
+
return await setApiCoreBotConfig(data, context);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
80
86
|
async cronList(data) {
|
|
81
87
|
return wrapAdminCall(async () => {
|
|
82
88
|
const context = getContext();
|
|
@@ -1,19 +1,37 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import test from 'node:test';
|
|
4
|
-
import
|
|
4
|
+
import entry, { formatCliErrorPayload } from '../../index.js';
|
|
5
5
|
const manifest = JSON.parse(readFileSync(new URL('../../openclaw.plugin.json', import.meta.url), 'utf8'));
|
|
6
|
+
const packageJson = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
6
7
|
test('manifest declares OpenClaw 2026 command ownership for admin bridge CLI', () => {
|
|
7
8
|
assert.deepEqual(manifest.activation?.onCommands, ['admin-bridge', 'rol-websocket-channel']);
|
|
8
9
|
assert.deepEqual(manifest.commandAliases, [
|
|
9
10
|
{ name: 'admin-bridge' },
|
|
10
11
|
{ name: 'rol-websocket-channel' }
|
|
11
12
|
]);
|
|
13
|
+
assert.deepEqual(manifest.contracts?.tools, [
|
|
14
|
+
'rol_artifacts_find_latest',
|
|
15
|
+
'rol_artifacts_publish'
|
|
16
|
+
]);
|
|
17
|
+
});
|
|
18
|
+
test('package.json declares official OpenClaw runtime metadata', () => {
|
|
19
|
+
assert.deepEqual(packageJson.openclaw?.extensions, ['./index.ts']);
|
|
20
|
+
assert.deepEqual(packageJson.openclaw?.runtimeExtensions, ['./dist/index.js']);
|
|
21
|
+
assert.deepEqual(packageJson.openclaw?.compat, {
|
|
22
|
+
pluginApi: '>=2026.5.7',
|
|
23
|
+
minGatewayVersion: '2026.5.7'
|
|
24
|
+
});
|
|
25
|
+
assert.deepEqual(packageJson.openclaw?.build, {
|
|
26
|
+
openclawVersion: '2026.5.7',
|
|
27
|
+
pluginSdkVersion: '2026.5.7'
|
|
28
|
+
});
|
|
12
29
|
});
|
|
13
|
-
test('runtime
|
|
30
|
+
test('runtime entry exposes admin-bridge CLI roots and registers artifacts tools in full mode', () => {
|
|
14
31
|
const descriptors = [];
|
|
15
32
|
const commands = [];
|
|
16
33
|
const aliases = [];
|
|
34
|
+
const registeredTools = [];
|
|
17
35
|
const commandNode = {
|
|
18
36
|
alias(name) {
|
|
19
37
|
aliases.push(name);
|
|
@@ -37,14 +55,18 @@ test('runtime CLI registrar exposes admin-bridge and rol-websocket-channel comma
|
|
|
37
55
|
}
|
|
38
56
|
};
|
|
39
57
|
const api = {
|
|
58
|
+
registrationMode: 'full',
|
|
40
59
|
runtime: {},
|
|
41
60
|
registerChannel() { },
|
|
61
|
+
registerTool(tool) {
|
|
62
|
+
registeredTools.push(tool);
|
|
63
|
+
},
|
|
42
64
|
registerCli(callback, options) {
|
|
43
65
|
descriptors.push(...options.descriptors);
|
|
44
66
|
callback({ program: commandNode });
|
|
45
67
|
}
|
|
46
68
|
};
|
|
47
|
-
register(api);
|
|
69
|
+
entry.register(api);
|
|
48
70
|
assert.deepEqual(descriptors, [
|
|
49
71
|
{
|
|
50
72
|
name: 'admin-bridge',
|
|
@@ -59,6 +81,21 @@ test('runtime CLI registrar exposes admin-bridge and rol-websocket-channel comma
|
|
|
59
81
|
]);
|
|
60
82
|
assert.equal(commands[0], 'admin-bridge');
|
|
61
83
|
assert.equal(aliases[0], 'rol-websocket-channel');
|
|
84
|
+
assert.equal(registeredTools.length, 1);
|
|
85
|
+
});
|
|
86
|
+
test('CLI metadata mode skips full-only tool registration', () => {
|
|
87
|
+
let registerToolCalled = false;
|
|
88
|
+
const api = {
|
|
89
|
+
registrationMode: 'cli-metadata',
|
|
90
|
+
runtime: {},
|
|
91
|
+
registerChannel() { },
|
|
92
|
+
registerTool() {
|
|
93
|
+
registerToolCalled = true;
|
|
94
|
+
},
|
|
95
|
+
registerCli() { }
|
|
96
|
+
};
|
|
97
|
+
entry.register(api);
|
|
98
|
+
assert.equal(registerToolCalled, false);
|
|
62
99
|
});
|
|
63
100
|
test('CLI error formatter preserves diagnostic data for pairing failures', () => {
|
|
64
101
|
const error = new Error('mqttUrl is missing from pairing payload');
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { readJsonFile } from '../lib/fs.js';
|
|
2
|
+
import { readJsonFile, writeJsonFile } from '../lib/fs.js';
|
|
3
|
+
import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
|
|
4
|
+
const DEFAULT_PLUGIN_ID = 'rol-websocket-channel';
|
|
5
|
+
const DEFAULT_API_CORE_BOT_BASE_URL = 'http://192.168.1.23:9092';
|
|
6
|
+
const DEFAULT_API_CORE_BOT_AUTH_TOKEN = '123';
|
|
3
7
|
export const getAgents = async (_params, context) => {
|
|
4
8
|
const configPath = path.join(context.openclawRoot, 'openclaw.json');
|
|
5
9
|
const config = await readJsonFile(configPath);
|
|
@@ -28,6 +32,57 @@ export const getConfig = async (params, context) => {
|
|
|
28
32
|
sectionValue: value === undefined ? null : redactSecrets(value)
|
|
29
33
|
};
|
|
30
34
|
};
|
|
35
|
+
export const setApiCoreBotConfig = async (params, context) => {
|
|
36
|
+
const objectParams = isObject(params) ? params : {};
|
|
37
|
+
const baseUrl = typeof objectParams.baseUrl === 'string'
|
|
38
|
+
? objectParams.baseUrl.trim().replace(/\/+$/, '')
|
|
39
|
+
: DEFAULT_API_CORE_BOT_BASE_URL;
|
|
40
|
+
const authToken = typeof objectParams.authToken === 'string' && objectParams.authToken.trim()
|
|
41
|
+
? objectParams.authToken.trim()
|
|
42
|
+
: DEFAULT_API_CORE_BOT_AUTH_TOKEN;
|
|
43
|
+
if (!baseUrl) {
|
|
44
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'apiCoreBot.baseUrl is required');
|
|
45
|
+
}
|
|
46
|
+
const configPath = path.join(context.openclawRoot, 'openclaw.json');
|
|
47
|
+
const config = await readJsonFile(configPath);
|
|
48
|
+
if (!config.plugins)
|
|
49
|
+
config.plugins = {};
|
|
50
|
+
if (!config.plugins.entries || typeof config.plugins.entries !== 'object') {
|
|
51
|
+
config.plugins.entries = {};
|
|
52
|
+
}
|
|
53
|
+
const existingEntry = isObject(config.plugins.entries[DEFAULT_PLUGIN_ID])
|
|
54
|
+
? config.plugins.entries[DEFAULT_PLUGIN_ID]
|
|
55
|
+
: {};
|
|
56
|
+
const existingPluginConfig = isObject(existingEntry.config) ? existingEntry.config : {};
|
|
57
|
+
const existingApiCoreBot = isObject(existingPluginConfig.apiCoreBot)
|
|
58
|
+
? existingPluginConfig.apiCoreBot
|
|
59
|
+
: {};
|
|
60
|
+
const nextApiCoreBot = {
|
|
61
|
+
...existingApiCoreBot,
|
|
62
|
+
baseUrl
|
|
63
|
+
};
|
|
64
|
+
if (authToken) {
|
|
65
|
+
nextApiCoreBot.authToken = authToken;
|
|
66
|
+
}
|
|
67
|
+
config.plugins.entries[DEFAULT_PLUGIN_ID] = {
|
|
68
|
+
...existingEntry,
|
|
69
|
+
enabled: existingEntry.enabled === false ? false : true,
|
|
70
|
+
config: {
|
|
71
|
+
...existingPluginConfig,
|
|
72
|
+
apiCoreBot: nextApiCoreBot
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
await writeJsonFile(configPath, config);
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
configFile: configPath,
|
|
79
|
+
updated: [
|
|
80
|
+
`plugins.entries.${DEFAULT_PLUGIN_ID}.config.apiCoreBot.baseUrl`,
|
|
81
|
+
...(authToken ? [`plugins.entries.${DEFAULT_PLUGIN_ID}.config.apiCoreBot.authToken`] : [])
|
|
82
|
+
],
|
|
83
|
+
apiCoreBot: redactSecrets(nextApiCoreBot)
|
|
84
|
+
};
|
|
85
|
+
};
|
|
31
86
|
function isObject(value) {
|
|
32
87
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
33
88
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
import { setApiCoreBotConfig } from './admin.js';
|
|
7
|
+
test('setApiCoreBotConfig writes apiCoreBot endpoint without changing pairing or channel config', async () => {
|
|
8
|
+
const context = await createMethodContext();
|
|
9
|
+
const configPath = path.join(context.openclawRoot, 'openclaw.json');
|
|
10
|
+
await fs.writeFile(configPath, JSON.stringify({
|
|
11
|
+
plugins: {
|
|
12
|
+
entries: {
|
|
13
|
+
'rol-websocket-channel': {
|
|
14
|
+
enabled: true,
|
|
15
|
+
config: {
|
|
16
|
+
pairing: {
|
|
17
|
+
paired: true,
|
|
18
|
+
pairingKeyLast4: '7a46'
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
channels: {
|
|
25
|
+
'rol-websocket-channel': {
|
|
26
|
+
enabled: true,
|
|
27
|
+
config: {
|
|
28
|
+
mqttUrl: 'ws://mqtt.example.test:8083/mqtt'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}, null, 2));
|
|
33
|
+
const result = await setApiCoreBotConfig({
|
|
34
|
+
baseUrl: 'https://api.example.test/',
|
|
35
|
+
authToken: 'secret-token'
|
|
36
|
+
}, context);
|
|
37
|
+
const savedConfig = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
|
38
|
+
assert.equal(result.ok, true);
|
|
39
|
+
assert.equal(result.apiCoreBot.baseUrl, 'https://api.example.test');
|
|
40
|
+
assert.equal(result.apiCoreBot.authToken, 'secr***oken');
|
|
41
|
+
assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.baseUrl, 'https://api.example.test');
|
|
42
|
+
assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.authToken, 'secret-token');
|
|
43
|
+
assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.pairing.pairingKeyLast4, '7a46');
|
|
44
|
+
assert.equal(savedConfig.channels['rol-websocket-channel'].config.mqttUrl, 'ws://mqtt.example.test:8083/mqtt');
|
|
45
|
+
});
|
|
46
|
+
test('setApiCoreBotConfig uses default apiCoreBot values when omitted', async () => {
|
|
47
|
+
const context = await createMethodContext();
|
|
48
|
+
await fs.writeFile(path.join(context.openclawRoot, 'openclaw.json'), '{}');
|
|
49
|
+
const result = await setApiCoreBotConfig({}, context);
|
|
50
|
+
const savedConfig = JSON.parse(await fs.readFile(path.join(context.openclawRoot, 'openclaw.json'), 'utf8'));
|
|
51
|
+
assert.equal(result.apiCoreBot.baseUrl, 'http://192.168.1.23:9092');
|
|
52
|
+
assert.equal(result.apiCoreBot.authToken, '********');
|
|
53
|
+
assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.baseUrl, 'http://192.168.1.23:9092');
|
|
54
|
+
assert.equal(savedConfig.plugins.entries['rol-websocket-channel'].config.apiCoreBot.authToken, '123');
|
|
55
|
+
});
|
|
56
|
+
async function createMethodContext() {
|
|
57
|
+
const projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'api-core-bot-config-test-'));
|
|
58
|
+
const openclawRoot = path.join(projectRoot, '.openclaw');
|
|
59
|
+
await fs.mkdir(openclawRoot, { recursive: true });
|
|
60
|
+
return {
|
|
61
|
+
projectRoot,
|
|
62
|
+
openclawRoot
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -169,6 +169,45 @@ export const ensureArtifactUploaded = async (params, context) => {
|
|
|
169
169
|
item: artifactToJsonValue(updated)
|
|
170
170
|
};
|
|
171
171
|
};
|
|
172
|
+
export const publishArtifact = async (params, context) => {
|
|
173
|
+
const objectParams = expectObject(params);
|
|
174
|
+
const refresh = objectParams.refresh !== false;
|
|
175
|
+
const artifact = await resolveArtifact(objectParams, context, {
|
|
176
|
+
refreshManifest: refresh
|
|
177
|
+
});
|
|
178
|
+
const uploadParams = {
|
|
179
|
+
artifactId: artifact.id
|
|
180
|
+
};
|
|
181
|
+
if (objectParams.presignedPostBody !== undefined) {
|
|
182
|
+
uploadParams.presignedPostBody = objectParams.presignedPostBody;
|
|
183
|
+
}
|
|
184
|
+
if (objectParams.baseUrl !== undefined) {
|
|
185
|
+
uploadParams.baseUrl = objectParams.baseUrl;
|
|
186
|
+
}
|
|
187
|
+
if (objectParams.authToken !== undefined) {
|
|
188
|
+
uploadParams.authToken = objectParams.authToken;
|
|
189
|
+
}
|
|
190
|
+
return await ensureArtifactUploaded(uploadParams, context);
|
|
191
|
+
};
|
|
192
|
+
export const findLatestArtifacts = async (params, context) => {
|
|
193
|
+
const objectParams = expectOptionalObject(params);
|
|
194
|
+
const refresh = objectParams.refresh !== false;
|
|
195
|
+
const manifest = refresh
|
|
196
|
+
? await refreshArtifactManifest(context)
|
|
197
|
+
: await readOrRefreshArtifactManifest(context);
|
|
198
|
+
const filters = normalizeArtifactFindFilters(objectParams);
|
|
199
|
+
const items = manifest.items
|
|
200
|
+
.filter((item) => matchesArtifactFindFilters(item, filters))
|
|
201
|
+
.sort(compareArtifactsByRecency)
|
|
202
|
+
.slice(0, filters.limit)
|
|
203
|
+
.map(artifactToJsonValue);
|
|
204
|
+
return {
|
|
205
|
+
ok: true,
|
|
206
|
+
scope: 'workspace',
|
|
207
|
+
count: items.length,
|
|
208
|
+
items
|
|
209
|
+
};
|
|
210
|
+
};
|
|
172
211
|
export const getArtifactPresignedPost = async (params, context) => {
|
|
173
212
|
const objectParams = expectObject(params);
|
|
174
213
|
const body = isObject(objectParams.body) ? objectParams.body : null;
|
|
@@ -403,8 +442,10 @@ async function collectArtifactFiles(rootDir) {
|
|
|
403
442
|
}
|
|
404
443
|
return files;
|
|
405
444
|
}
|
|
406
|
-
async function resolveArtifact(params, context) {
|
|
407
|
-
const manifest =
|
|
445
|
+
async function resolveArtifact(params, context, options) {
|
|
446
|
+
const manifest = options?.refreshManifest
|
|
447
|
+
? await refreshArtifactManifest(context)
|
|
448
|
+
: await readOrRefreshArtifactManifest(context);
|
|
408
449
|
const artifact = findArtifact(manifest.items, params);
|
|
409
450
|
if (!artifact) {
|
|
410
451
|
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Artifact not found', {
|
|
@@ -590,6 +631,84 @@ function expectString(value, fieldName) {
|
|
|
590
631
|
function optionalTrimmedString(value) {
|
|
591
632
|
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
|
592
633
|
}
|
|
634
|
+
function normalizeArtifactFindFilters(params) {
|
|
635
|
+
return {
|
|
636
|
+
category: normalizeArtifactCategory(optionalTrimmedString(params.category)),
|
|
637
|
+
mimeType: normalizeOptionalLowercaseString(params.mimeType),
|
|
638
|
+
fileName: normalizeOptionalLowercaseString(params.fileName),
|
|
639
|
+
relativePathPrefix: normalizeRelativePath(optionalTrimmedString(params.relativePathPrefix)),
|
|
640
|
+
createdAfterMs: normalizeOptionalTimestamp(params.createdAfter),
|
|
641
|
+
limit: normalizeArtifactLimit(params.limit)
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
function normalizeArtifactCategory(value) {
|
|
645
|
+
if (!value) {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
if (value === 'image' ||
|
|
649
|
+
value === 'video' ||
|
|
650
|
+
value === 'document' ||
|
|
651
|
+
value === 'archive' ||
|
|
652
|
+
value === 'other') {
|
|
653
|
+
return value;
|
|
654
|
+
}
|
|
655
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, `Unsupported artifact category: ${value}`);
|
|
656
|
+
}
|
|
657
|
+
function normalizeOptionalLowercaseString(value) {
|
|
658
|
+
const normalized = optionalTrimmedString(value);
|
|
659
|
+
return normalized ? normalized.toLowerCase() : null;
|
|
660
|
+
}
|
|
661
|
+
function normalizeOptionalTimestamp(value) {
|
|
662
|
+
if (value === undefined) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
if (typeof value !== 'string' || value.trim().length === 0) {
|
|
666
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'createdAfter must be a non-empty ISO timestamp');
|
|
667
|
+
}
|
|
668
|
+
const timestamp = Date.parse(value);
|
|
669
|
+
if (Number.isNaN(timestamp)) {
|
|
670
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'createdAfter must be a valid ISO timestamp');
|
|
671
|
+
}
|
|
672
|
+
return timestamp;
|
|
673
|
+
}
|
|
674
|
+
function normalizeArtifactLimit(value) {
|
|
675
|
+
if (value === undefined) {
|
|
676
|
+
return 5;
|
|
677
|
+
}
|
|
678
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
679
|
+
throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'limit must be a positive number');
|
|
680
|
+
}
|
|
681
|
+
return Math.min(50, Math.floor(value));
|
|
682
|
+
}
|
|
683
|
+
function matchesArtifactFindFilters(item, filters) {
|
|
684
|
+
if (filters.category && item.category !== filters.category) {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
if (filters.mimeType && item.mimeType.toLowerCase() !== filters.mimeType) {
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
if (filters.fileName && item.fileName.toLowerCase() !== filters.fileName) {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
if (filters.relativePathPrefix && !item.relativePath.startsWith(filters.relativePathPrefix)) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
if (filters.createdAfterMs !== null && Date.parse(item.createdAt) <= filters.createdAfterMs) {
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
function compareArtifactsByRecency(left, right) {
|
|
702
|
+
const createdDelta = Date.parse(right.createdAt) - Date.parse(left.createdAt);
|
|
703
|
+
if (createdDelta !== 0) {
|
|
704
|
+
return createdDelta;
|
|
705
|
+
}
|
|
706
|
+
const updatedDelta = Date.parse(right.updatedAt) - Date.parse(left.updatedAt);
|
|
707
|
+
if (updatedDelta !== 0) {
|
|
708
|
+
return updatedDelta;
|
|
709
|
+
}
|
|
710
|
+
return left.fileName.localeCompare(right.fileName);
|
|
711
|
+
}
|
|
593
712
|
function isObject(value) {
|
|
594
713
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
595
714
|
}
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import fs from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { ensureArtifactUploaded, listArtifacts, markArtifactUploaded, refreshArtifacts } from './artifacts.js';
|
|
6
|
+
import { ensureArtifactUploaded, findLatestArtifacts, listArtifacts, markArtifactUploaded, publishArtifact, refreshArtifacts } from './artifacts.js';
|
|
7
7
|
const tempDirs = [];
|
|
8
8
|
const originalFetch = globalThis.fetch;
|
|
9
9
|
afterEach(async () => {
|
|
@@ -181,6 +181,90 @@ describe('artifacts workspace scope', () => {
|
|
|
181
181
|
assert.equal(ensured.item.storageStatus, 'uploaded');
|
|
182
182
|
assert.equal(ensured.item.fileUrl, 'https://cdn.example.com/artifacts/me.pdf');
|
|
183
183
|
});
|
|
184
|
+
test('findLatest returns newest matching artifacts with filters', async () => {
|
|
185
|
+
const context = await createMethodContext();
|
|
186
|
+
const workspaceRoot = path.join(context.openclawRoot, 'workspace');
|
|
187
|
+
await fs.mkdir(path.join(workspaceRoot, 'exports'), { recursive: true });
|
|
188
|
+
await fs.writeFile(path.join(workspaceRoot, 'exports', 'first.pdf'), 'pdf-data', 'utf8');
|
|
189
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
190
|
+
await fs.writeFile(path.join(workspaceRoot, 'exports', 'second.png'), 'png-data', 'utf8');
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
192
|
+
await fs.writeFile(path.join(workspaceRoot, 'exports', 'third.docx'), 'docx-data', 'utf8');
|
|
193
|
+
const latest = await findLatestArtifacts({
|
|
194
|
+
category: 'document',
|
|
195
|
+
relativePathPrefix: 'exports/',
|
|
196
|
+
limit: 2
|
|
197
|
+
}, context);
|
|
198
|
+
assert.equal(latest.ok, true);
|
|
199
|
+
assert.equal(latest.count, 2);
|
|
200
|
+
assert.deepEqual(latest.items.map((item) => item.fileName), ['third.docx', 'first.pdf']);
|
|
201
|
+
assert.deepEqual(latest.items.map((item) => item.category), ['document', 'document']);
|
|
202
|
+
assert.deepEqual(latest.items.map((item) => item.relativePath), ['exports/third.docx', 'exports/first.pdf']);
|
|
203
|
+
});
|
|
204
|
+
test('publish refreshes manifest for a new file and uploads it by relativePath', async () => {
|
|
205
|
+
const context = await createMethodContext();
|
|
206
|
+
const workspaceRoot = path.join(context.openclawRoot, 'workspace');
|
|
207
|
+
const artifactPath = path.join(workspaceRoot, 'generated', 'result.pdf');
|
|
208
|
+
await writeApiCoreBotConfig(context.openclawRoot, 'https://api.example.com');
|
|
209
|
+
await fs.mkdir(path.dirname(artifactPath), { recursive: true });
|
|
210
|
+
await fs.writeFile(artifactPath, 'pdf-data', 'utf8');
|
|
211
|
+
const calls = [];
|
|
212
|
+
globalThis.fetch = (async (input, init) => {
|
|
213
|
+
const url = typeof input === 'string'
|
|
214
|
+
? input
|
|
215
|
+
: input instanceof URL
|
|
216
|
+
? input.toString()
|
|
217
|
+
: input.url;
|
|
218
|
+
const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
|
|
219
|
+
calls.push({ url, method });
|
|
220
|
+
if (url === 'https://api.example.com/api-core-bot/front/s3/get-presigned-post') {
|
|
221
|
+
const parsedBody = JSON.parse(String(init?.body));
|
|
222
|
+
assert.equal(parsedBody.filename, 'result.pdf');
|
|
223
|
+
assert.equal(parsedBody.dir, 'generated/');
|
|
224
|
+
return new Response(JSON.stringify({
|
|
225
|
+
success: true,
|
|
226
|
+
data: {
|
|
227
|
+
url: 'https://upload.example.com',
|
|
228
|
+
fields: {
|
|
229
|
+
key: 'generated/result.pdf',
|
|
230
|
+
policy: 'policy-token'
|
|
231
|
+
},
|
|
232
|
+
file_url: 'https://cdn.example.com/generated/result.pdf'
|
|
233
|
+
}
|
|
234
|
+
}), {
|
|
235
|
+
status: 200,
|
|
236
|
+
headers: { 'Content-Type': 'application/json' }
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (url === 'https://upload.example.com') {
|
|
240
|
+
assert.ok(init?.body instanceof FormData);
|
|
241
|
+
return new Response(null, { status: 204 });
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`Unexpected fetch call: ${url}`);
|
|
244
|
+
});
|
|
245
|
+
const published = await publishArtifact({
|
|
246
|
+
relativePath: 'generated/result.pdf',
|
|
247
|
+
presignedPostBody: {
|
|
248
|
+
dir: 'generated/'
|
|
249
|
+
}
|
|
250
|
+
}, context);
|
|
251
|
+
assert.equal(published.ok, true);
|
|
252
|
+
assert.equal(published.uploaded, true);
|
|
253
|
+
assert.equal(published.downloadUrl, 'https://cdn.example.com/generated/result.pdf');
|
|
254
|
+
assert.equal(published.item.relativePath, 'generated/result.pdf');
|
|
255
|
+
assert.equal(published.item.storageStatus, 'uploaded');
|
|
256
|
+
assert.match(published.artifactId, /^art_/);
|
|
257
|
+
assert.deepEqual(calls, [
|
|
258
|
+
{
|
|
259
|
+
url: 'https://api.example.com/api-core-bot/front/s3/get-presigned-post',
|
|
260
|
+
method: 'POST'
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
url: 'https://upload.example.com',
|
|
264
|
+
method: 'POST'
|
|
265
|
+
}
|
|
266
|
+
]);
|
|
267
|
+
});
|
|
184
268
|
});
|
|
185
269
|
async function createMethodContext() {
|
|
186
270
|
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'artifacts-task-scope-'));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
|
|
2
|
-
import { getAgents, getConfig } from './admin.js';
|
|
2
|
+
import { getAgents, getConfig, setApiCoreBotConfig } from './admin.js';
|
|
3
3
|
import { createAgent, deleteAgent, listAgents, updateAgent } from './agents-extended.js';
|
|
4
|
-
import { createArtifactRecord, ensureArtifactUploaded, getArtifactContent, getArtifactPresignedPost, listArtifacts, markArtifactUploaded, refreshArtifacts } from './artifacts.js';
|
|
4
|
+
import { createArtifactRecord, ensureArtifactUploaded, findLatestArtifacts, getArtifactContent, getArtifactPresignedPost, listArtifacts, markArtifactUploaded, publishArtifact, refreshArtifacts } from './artifacts.js';
|
|
5
5
|
import { addCron, disableCron, enableCron, getCronStatus, listCron, listCronRuns, renameCron, removeCron, rescheduleCron, runCron, setCronContent, updateCronMessage, updateCronSystemEvent } from './cron.js';
|
|
6
6
|
import { backupMemory, createMemoryBackupRecord, exportMemoryZip, getMemoryPresignedPost, getMemoryFile, importMemoryZip, listMemoryFiles } from './memory.js';
|
|
7
7
|
import { getMem9Config, installMem9, reconnectMem9 } from './mem9.js';
|
|
@@ -30,6 +30,7 @@ const methods = new Map([
|
|
|
30
30
|
['agents.update', updateAgent],
|
|
31
31
|
// Config
|
|
32
32
|
['config.get', getConfig],
|
|
33
|
+
['config.setApiCoreBot', setApiCoreBotConfig],
|
|
33
34
|
// Cron
|
|
34
35
|
['cron.list', listCron],
|
|
35
36
|
['cron.status', getCronStatus],
|
|
@@ -82,6 +83,8 @@ const methods = new Map([
|
|
|
82
83
|
['artifacts.refresh', refreshArtifacts],
|
|
83
84
|
['artifacts.getContent', getArtifactContent],
|
|
84
85
|
['artifacts.ensureUploaded', ensureArtifactUploaded],
|
|
86
|
+
['artifacts.findLatest', findLatestArtifacts],
|
|
87
|
+
['artifacts.publish', publishArtifact],
|
|
85
88
|
['artifacts.getPresignedPost', getArtifactPresignedPost],
|
|
86
89
|
['artifacts.createRecord', createArtifactRecord],
|
|
87
90
|
['artifacts.markUploaded', markArtifactUploaded],
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { findLatestArtifacts, publishArtifact } from './artifacts.js';
|
|
4
|
+
import { setApiCoreBotConfig } from './admin.js';
|
|
5
|
+
import { getMethod, listMethods } from './index.js';
|
|
6
|
+
test('methods registry exposes local artifact discovery and publish methods', () => {
|
|
7
|
+
assert.equal(getMethod('artifacts.findLatest'), findLatestArtifacts);
|
|
8
|
+
assert.equal(getMethod('artifacts.publish'), publishArtifact);
|
|
9
|
+
assert.ok(listMethods().includes('artifacts.findLatest'));
|
|
10
|
+
assert.ok(listMethods().includes('artifacts.publish'));
|
|
11
|
+
});
|
|
12
|
+
test('methods registry exposes apiCoreBot config writer', () => {
|
|
13
|
+
assert.equal(getMethod('config.setApiCoreBot'), setApiCoreBotConfig);
|
|
14
|
+
assert.ok(listMethods().includes('config.setApiCoreBot'));
|
|
15
|
+
});
|