rol-websocket-channel 1.7.3 → 1.7.5

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.
Files changed (32) hide show
  1. package/dist/index.js +45 -6
  2. package/dist/src/admin/lib/paths.js +10 -10
  3. package/dist/src/admin/methods/pairing.js +66 -62
  4. package/dist/src/admin/methods/sessions-extended.js +19 -13
  5. package/dist/src/admin/methods/system.js +116 -3
  6. package/index.ts +54 -7
  7. package/package.json +1 -1
  8. package/src/admin/lib/paths.ts +10 -10
  9. package/src/admin/methods/pairing.ts +79 -66
  10. package/src/admin/methods/sessions-extended.ts +25 -16
  11. package/src/admin/methods/system.ts +149 -3
  12. package/tsconfig.json +1 -1
  13. package/dist/src/admin/cli-manifest.test.js +0 -122
  14. package/dist/src/admin/lib/openclaw-bin.test.js +0 -37
  15. package/dist/src/admin/methods/admin.test.js +0 -61
  16. package/dist/src/admin/methods/artifacts.test.js +0 -304
  17. package/dist/src/admin/methods/index.test.js +0 -27
  18. package/dist/src/admin/methods/mem9.test.js +0 -259
  19. package/dist/src/admin/methods/pairing.test.js +0 -114
  20. package/dist/src/admin/tools/artifacts-tools.test.js +0 -163
  21. package/dist/src/mqtt/mqtt.test.js +0 -418
  22. package/src/admin/cli-manifest.test.ts +0 -135
  23. package/src/admin/lib/openclaw-bin.test.ts +0 -38
  24. package/src/admin/methods/admin.test.ts +0 -91
  25. package/src/admin/methods/artifacts.test.ts +0 -486
  26. package/src/admin/methods/index.test.ts +0 -34
  27. package/src/admin/methods/mem9.test.ts +0 -318
  28. package/src/admin/methods/pairing.test.ts +0 -149
  29. package/src/admin/tools/artifacts-tools.test.ts +0 -215
  30. package/src/mqtt/mqtt.test.ts +0 -670
  31. package/test/admin/methods/models-extended.test.ts +0 -49
  32. package/test/message-handler-artifacts.test.ts +0 -11
package/index.ts CHANGED
@@ -67,6 +67,20 @@ export function getPluginRuntime(): any {
67
67
  return pluginRuntime;
68
68
  }
69
69
 
70
+ // Chinese translations keyed by error code, so CLI failures are returned
71
+ // bilingually (English `message` + Chinese `messageZh`).
72
+ const ERROR_MESSAGES_ZH: Record<string, string> = {
73
+ PAIR_KEY_REQUIRED: "配对码不能为空",
74
+ PAIR_CONFIG_NOT_FOUND:
75
+ "找不到 openclaw.json 配置文件(请确认插件位于 OpenClaw 安装目录内,或设置环境变量 OPENCLAW_HOME 指向 .openclaw 目录)",
76
+ PAIR_EXCHANGE_REQUEST_FAILED:
77
+ "配对请求发送失败(无法连接配对服务,请检查网络、DNS 或代理)",
78
+ PAIR_EXCHANGE_FAILED:
79
+ "配对兑换被拒绝(配对码无效、已过期或已被使用;请向签发服务获取新配对码,并在 10 分钟内尽快完成配对)",
80
+ PAIR_CHANNEL_CONFIG_INVALID:
81
+ "配对返回的频道配置无效(缺少 mqttUrl 或 mqttTopic)",
82
+ };
83
+
70
84
  export function formatCliErrorPayload(error: unknown): {
71
85
  ok: false;
72
86
  error: Record<string, unknown>;
@@ -78,11 +92,17 @@ export function formatCliErrorPayload(error: unknown): {
78
92
  : {};
79
93
  const code =
80
94
  dataObject.code ?? (error as { code?: unknown } | null | undefined)?.code;
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ // Fall back to the English message when no Chinese translation exists for
97
+ // the code, so both fields are always present.
98
+ const messageZh =
99
+ (typeof code === "string" && ERROR_MESSAGES_ZH[code]) || message;
81
100
 
82
101
  return {
83
102
  ok: false,
84
103
  error: {
85
- message: error instanceof Error ? error.message : String(error),
104
+ message,
105
+ messageZh,
86
106
  ...dataObject,
87
107
  ...(code === undefined ? {} : { code }),
88
108
  },
@@ -117,6 +137,15 @@ const WebSocketChannel: any = {
117
137
  },
118
138
  },
119
139
 
140
+ // 让配对写入的 channels.rol-websocket-channel 变更被判为 hot:
141
+ // OpenClaw 只热重载本 channel(进程内 stop→start,加载新 MQTT 连接),
142
+ // 不整机重启、不等 health-monitor。配置写完即在 gateway 进程内生效。
143
+ // (plugins.entries.rol-websocket-channel.* 已由 OpenClaw 内置的
144
+ // channel-plugin-state 规则自动判为 hot+restart-channel,无需在此声明。)
145
+ reload: {
146
+ configPrefixes: ["channels.rol-websocket-channel"],
147
+ },
148
+
120
149
  configSchema: {
121
150
  schema: {
122
151
  type: "object",
@@ -388,7 +417,7 @@ const WebSocketChannel: any = {
388
417
 
389
418
  const text = typeof params.message === "string" ? params.message
390
419
  : typeof params.text === "string" ? params.text
391
- : "";
420
+ : "";
392
421
 
393
422
  const conn = ConnectionManager.getGlobalConnection();
394
423
  if (!conn || !conn.ws || !conn.ws.connected) {
@@ -599,6 +628,20 @@ async function handleIncomingMessage(
599
628
  return;
600
629
  }
601
630
 
631
+ // 收到 sender 消息时,先回复一个确认
632
+ publishCustomMessageResponse({
633
+ type: "receiver",
634
+ trace_id: traceId,
635
+ source: "system",
636
+ timestamp: Date.now(),
637
+ success: true,
638
+ data: {
639
+ ok: true,
640
+ status: "received",
641
+ message: "已收到消息"
642
+ }
643
+ }, innerData, mqttTopic);
644
+
602
645
  // 构造标准消息格式
603
646
  const attachments = (innerData.attachments || []).map((att: any) => ({
604
647
  url: att.url,
@@ -689,9 +732,9 @@ async function handleIncomingMessage(
689
732
  let targetTopic = mqttTopic;
690
733
  const sourceType = innerData?.source_type;
691
734
  if (targetTopic.endsWith("#")) {
692
- const replacement = sourceType === "device" ? "device" : "bot";
693
- targetTopic = targetTopic.slice(0, -1) + replacement;
735
+ targetTopic = targetTopic.slice(0, -1) + 'app';
694
736
  }
737
+ targetTopic = targetTopic.replace('device', 'app').replace('bot', 'app');
695
738
 
696
739
  conn.ws.publish(targetTopic, JSON.stringify(replyMessage));
697
740
  },
@@ -867,9 +910,9 @@ function publishCustomMessageResponse(response: any, innerData: any, mqttTopic:
867
910
  let targetTopic = mqttTopic;
868
911
  const sourceType = innerData?.source_type;
869
912
  if (targetTopic.endsWith("#")) {
870
- const replacement = sourceType === "device" ? "device" : "bot";
871
- targetTopic = targetTopic.slice(0, -1) + replacement;
913
+ targetTopic = targetTopic.slice(0, -1) + 'app';
872
914
  }
915
+ targetTopic = targetTopic.replace('device', 'app').replace('bot', 'app');
873
916
 
874
917
  conn.ws.publish(targetTopic, JSON.stringify(response));
875
918
  }
@@ -997,7 +1040,11 @@ function registerAdminBridgeCli(api: OpenClawPluginApi) {
997
1040
  );
998
1041
  } catch (error) {
999
1042
  process.exitCode = 1;
1000
- process.stderr.write(
1043
+ // Emit the structured error on stdout (same channel as the
1044
+ // success payload) so callers that only capture stdout still
1045
+ // receive the full diagnostic — message, code, status,
1046
+ // serviceMessage — instead of just exit code 1.
1047
+ process.stdout.write(
1001
1048
  JSON.stringify(formatCliErrorPayload(error), null, 2) + "\n",
1002
1049
  );
1003
1050
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rol-websocket-channel",
3
- "version": "1.7.3",
3
+ "version": "1.7.5",
4
4
  "description": "Unified OpenClaw plugin: MQTT Channel + Admin Bridge for remote management",
5
5
  "license": "MIT",
6
6
  "author": "nixgnehc",
@@ -13,45 +13,45 @@ export function getProjectRoot(): string {
13
13
  root = path.dirname(root);
14
14
  }
15
15
 
16
- console.log('[paths] __dirname:', __dirname);
17
- console.log('[paths] getProjectRoot:', root);
16
+ console.error('[paths] __dirname:', __dirname);
17
+ console.error('[paths] getProjectRoot:', root);
18
18
  return root;
19
19
  }
20
20
 
21
21
  export function getOpenClawRoot(): string {
22
22
  if (process.env.OPENCLAW_HOME) {
23
- console.log('[paths] getOpenClawRoot from env:', process.env.OPENCLAW_HOME);
23
+ console.error('[paths] getOpenClawRoot from env:', process.env.OPENCLAW_HOME);
24
24
  return path.resolve(process.env.OPENCLAW_HOME);
25
25
  }
26
26
 
27
27
  const projectRoot = getProjectRoot();
28
28
  const configuredRoot = findNearestOpenClawConfigRoot(projectRoot);
29
29
  if (configuredRoot) {
30
- console.log('[paths] getOpenClawRoot from discovered openclaw.json:', configuredRoot);
30
+ console.error('[paths] getOpenClawRoot from discovered openclaw.json:', configuredRoot);
31
31
  return configuredRoot;
32
32
  }
33
33
 
34
34
  const openclawDir = findAncestorNamed(projectRoot, '.openclaw');
35
35
  if (openclawDir) {
36
- console.log('[paths] getOpenClawRoot from .openclaw ancestor:', openclawDir);
36
+ console.error('[paths] getOpenClawRoot from .openclaw ancestor:', openclawDir);
37
37
  return openclawDir;
38
38
  }
39
39
 
40
40
  const parentDir = path.dirname(projectRoot);
41
41
  const parentName = path.basename(parentDir);
42
42
 
43
- console.log('[paths] projectRoot:', projectRoot);
44
- console.log('[paths] parentDir:', parentDir);
45
- console.log('[paths] parentName:', parentName);
43
+ console.error('[paths] projectRoot:', projectRoot);
44
+ console.error('[paths] parentDir:', parentDir);
45
+ console.error('[paths] parentName:', parentName);
46
46
 
47
47
  if (parentName === 'extensions') {
48
48
  const openclawRoot = path.dirname(parentDir);
49
- console.log('[paths] getOpenClawRoot (extensions):', openclawRoot);
49
+ console.error('[paths] getOpenClawRoot (extensions):', openclawRoot);
50
50
  return openclawRoot;
51
51
  }
52
52
 
53
53
  const fallbackRoot = path.resolve(projectRoot, '..');
54
- console.log('[paths] getOpenClawRoot (fallback):', fallbackRoot);
54
+ console.error('[paths] getOpenClawRoot (fallback):', fallbackRoot);
55
55
  return fallbackRoot;
56
56
  }
57
57
 
@@ -1,18 +1,12 @@
1
- import { exec, execFile } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { promisify } from 'node:util';
4
2
 
5
3
  import { pathExists, readJsonFile, writeJsonFile } from '../lib/fs.js';
6
4
  import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
7
5
  import type { JsonValue, MethodContext } from '../types.js';
8
6
 
9
- const execFileAsync = promisify(execFile);
10
- const execAsync = promisify(exec);
11
-
12
7
  const DEFAULT_PLUGIN_ID = 'rol-websocket-channel';
13
8
  const DEFAULT_PRODUCTION_BASE_URL = 'https://api.deotaland.ai';
14
9
  const PAIR_ENDPOINT_PATH = '/api-core-bot/front/agent/agent/key/query';
15
- const GATEWAY_SERVICE = 'openclaw-gateway.service';
16
10
 
17
11
  interface PairingCommandOptions {
18
12
  key: string;
@@ -73,7 +67,6 @@ export async function pairWithKey(
73
67
  const payload = await exchangePairKey(key, endpoint, options.auth, existingMqttUrl);
74
68
  applyPairingConfig(config, key, payload);
75
69
  await writeJsonFile(configPath, config);
76
- const restart = await restartGateway(context.projectRoot);
77
70
 
78
71
  return {
79
72
  ok: true,
@@ -87,7 +80,18 @@ export async function pairWithKey(
87
80
  `channels.${payload.pluginId}`
88
81
  ],
89
82
  channel: payload.channel,
90
- restart
83
+ // No gateway restart is dispatched here on purpose. Writing openclaw.json is
84
+ // enough: OpenClaw's config watcher detects the `channels` change, plans a
85
+ // gateway restart, and defers it until in-flight operations/replies/embedded
86
+ // runs drain before restarting with the new config. Spawning our own
87
+ // `openclaw gateway restart` would restart immediately and jump ahead of that
88
+ // deferral, killing the gateway mid-operation (the original pair-hang bug).
89
+ reload: {
90
+ dispatched: false,
91
+ handledBy: 'openclaw-config-watcher',
92
+ mode: 'deferred-until-idle',
93
+ note: 'OpenClaw restarts the gateway automatically after the config write.'
94
+ }
91
95
  };
92
96
  }
93
97
 
@@ -114,11 +118,24 @@ async function exchangePairKey(
114
118
  headers.Authorization = auth;
115
119
  }
116
120
 
117
- const response = await fetch(endpoint, {
118
- method: 'POST',
119
- headers,
120
- body: JSON.stringify({ key })
121
- });
121
+ let response: Response;
122
+ try {
123
+ response = await fetch(endpoint, {
124
+ method: 'POST',
125
+ headers,
126
+ body: JSON.stringify({ key })
127
+ });
128
+ } catch (error) {
129
+ throw new JsonRpcException(
130
+ JSON_RPC_ERRORS.internalError,
131
+ `pair exchange request failed: ${error instanceof Error ? error.message : String(error)}`,
132
+ {
133
+ code: 'PAIR_EXCHANGE_REQUEST_FAILED',
134
+ endpoint,
135
+ cause: describeErrorCause(error)
136
+ }
137
+ );
138
+ }
122
139
 
123
140
  const rawText = await response.text();
124
141
  const payload = tryParseJson(rawText);
@@ -135,9 +152,31 @@ async function exchangePairKey(
135
152
  );
136
153
  }
137
154
 
155
+ ensurePairExchangeSucceeded(payload, endpoint, response.status);
156
+
138
157
  return normalizePairingPayload(payload, endpoint, existingMqttUrl);
139
158
  }
140
159
 
160
+ function ensurePairExchangeSucceeded(raw: unknown, endpoint: string, status: number): void {
161
+ if (!isRecord(raw) || raw.success !== false) {
162
+ return;
163
+ }
164
+
165
+ const serviceMessage = pickString(raw.message) ?? pickString(raw.msg);
166
+ throw new JsonRpcException(
167
+ JSON_RPC_ERRORS.internalError,
168
+ `pair exchange failed${serviceMessage ? `: ${serviceMessage}` : ''}`,
169
+ {
170
+ code: 'PAIR_EXCHANGE_FAILED',
171
+ endpoint,
172
+ status,
173
+ serviceCode: raw.code,
174
+ success: false,
175
+ ...(serviceMessage ? { serviceMessage } : {})
176
+ }
177
+ );
178
+ }
179
+
141
180
  function normalizePairingPayload(
142
181
  raw: unknown,
143
182
  endpoint: string,
@@ -364,59 +403,6 @@ function resolveExistingMqttUrl(config: OpenClawConfig): string | null {
364
403
  return pickString(channelConfig.mqttUrl);
365
404
  }
366
405
 
367
- async function restartGateway(cwd: string): Promise<JsonValue> {
368
- const attempts: JsonValue[] = [];
369
-
370
- try {
371
- const { stdout, stderr } = await execAsync('openclaw gateway restart', { cwd });
372
- return {
373
- attempted: true,
374
- success: true,
375
- command: 'openclaw gateway restart',
376
- stdout: stdout.trim(),
377
- stderr: stderr.trim()
378
- };
379
- } catch (error: any) {
380
- attempts.push(formatRestartAttempt('openclaw gateway restart', error));
381
- }
382
-
383
- if (process.platform === 'win32') {
384
- return {
385
- attempted: true,
386
- success: false,
387
- message: 'openclaw gateway restart failed',
388
- attempts
389
- };
390
- }
391
-
392
- try {
393
- await execFileAsync('systemctl', ['--user', 'restart', GATEWAY_SERVICE], { cwd });
394
- return {
395
- attempted: true,
396
- success: true,
397
- command: `systemctl --user restart ${GATEWAY_SERVICE}`,
398
- attempts
399
- };
400
- } catch (error: any) {
401
- attempts.push(formatRestartAttempt(`systemctl --user restart ${GATEWAY_SERVICE}`, error));
402
- return {
403
- attempted: true,
404
- success: false,
405
- message: 'gateway restart failed',
406
- attempts
407
- };
408
- }
409
- }
410
-
411
- function formatRestartAttempt(command: string, error: any): JsonValue {
412
- return {
413
- command,
414
- message: error instanceof Error ? error.message : String(error),
415
- stdout: typeof error?.stdout === 'string' ? error.stdout.trim() : '',
416
- stderr: typeof error?.stderr === 'string' ? error.stderr.trim() : ''
417
- };
418
- }
419
-
420
406
  function normalizeGroupPolicy(value: string): 'pairing' | 'allowlist' | 'open' | 'disabled' {
421
407
  if (value === 'pairing' || value === 'allowlist' || value === 'open' || value === 'disabled') {
422
408
  return value;
@@ -448,6 +434,33 @@ function isRecord(value: unknown): value is Record<string, any> {
448
434
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
449
435
  }
450
436
 
437
+ function describeErrorCause(error: unknown): Record<string, unknown> {
438
+ const detail: Record<string, unknown> = {};
439
+ if (error instanceof Error) {
440
+ detail.name = error.name;
441
+ detail.message = error.message;
442
+ } else {
443
+ detail.message = String(error);
444
+ }
445
+
446
+ const cause = (error as { cause?: unknown } | null | undefined)?.cause;
447
+ if (isRecord(cause)) {
448
+ const causeDetail: Record<string, unknown> = {};
449
+ for (const key of ['name', 'message', 'code', 'errno', 'syscall', 'hostname', 'address', 'port']) {
450
+ const value = cause[key];
451
+ if (['string', 'number', 'boolean'].includes(typeof value)) {
452
+ causeDetail[key] = value;
453
+ }
454
+ }
455
+
456
+ if (Object.keys(causeDetail).length > 0) {
457
+ detail.cause = causeDetail;
458
+ }
459
+ }
460
+
461
+ return detail;
462
+ }
463
+
451
464
  function throwPairingError(code: string, message: string, debug?: PairingPayloadDebug): never {
452
465
  throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, message, {
453
466
  code,
@@ -26,9 +26,10 @@ export const getSession: MethodHandler = async (
26
26
  : undefined;
27
27
  const sessionId = typeof objectParams.sessionId === 'string' ? objectParams.sessionId : null;
28
28
  const requestedLimit = typeof objectParams.limit === 'number' ? objectParams.limit : MAX_SESSION_MESSAGES;
29
- const requestedOffset = typeof objectParams.offset === 'number' ? objectParams.offset : 0;
29
+ const beforeId = typeof objectParams.beforeId === 'string' && objectParams.beforeId.trim()
30
+ ? objectParams.beforeId.trim()
31
+ : undefined;
30
32
  const limit = normalizePageSize(requestedLimit, MAX_SESSION_MESSAGES);
31
- const offset = normalizeOffset(requestedOffset);
32
33
 
33
34
  if (!sessionId) {
34
35
  throw new JsonRpcException(
@@ -52,7 +53,7 @@ export const getSession: MethodHandler = async (
52
53
  'sessions',
53
54
  `${session.sessionId ?? sessionId}.jsonl`
54
55
  );
55
- const messages = await readSessionMessages(sessionFile, limit, offset);
56
+ const messages = await readSessionMessages(sessionFile, limit, beforeId);
56
57
 
57
58
  return {
58
59
  agentId: session.agentId,
@@ -69,7 +70,8 @@ export const getSession: MethodHandler = async (
69
70
  messages: {
70
71
  total: messages.total,
71
72
  limit,
72
- offset,
73
+ hasMore: messages.hasMore,
74
+ nextBeforeId: messages.nextBeforeId,
73
75
  items: messages.items
74
76
  }
75
77
  };
@@ -203,21 +205,13 @@ function normalizePageSize(value: number, max: number): number {
203
205
  return Math.min(normalized, max);
204
206
  }
205
207
 
206
- function normalizeOffset(value: number): number {
207
- if (!Number.isFinite(value)) {
208
- return 0;
209
- }
210
-
211
- return Math.max(0, Math.floor(value));
212
- }
213
-
214
208
  async function readSessionMessages(
215
209
  filePath: string,
216
210
  limit: number,
217
- offset: number
218
- ): Promise<{ total: number; items: SessionMessage[] }> {
211
+ beforeId?: string
212
+ ): Promise<{ total: number; hasMore: boolean; nextBeforeId: string | null; items: SessionMessage[] }> {
219
213
  if (!(await pathExists(filePath))) {
220
- return { total: 0, items: [] };
214
+ return { total: 0, hasMore: false, nextBeforeId: null, items: [] };
221
215
  }
222
216
 
223
217
  const messages: SessionMessage[] = [];
@@ -239,9 +233,24 @@ async function readSessionMessages(
239
233
  }
240
234
  }
241
235
 
236
+ const endExclusive = beforeId ? messages.findIndex((message) => message.id === beforeId) : messages.length;
237
+ if (endExclusive < 0) {
238
+ throw new JsonRpcException(
239
+ JSON_RPC_ERRORS.invalidParams,
240
+ `beforeId not found: ${beforeId}`
241
+ );
242
+ }
243
+
244
+ const start = Math.max(0, endExclusive - limit);
245
+ const items = messages.slice(start, endExclusive);
246
+ const hasMore = start > 0;
247
+ const firstItemId = typeof items[0]?.id === 'string' ? items[0].id : null;
248
+
242
249
  return {
243
250
  total: messages.length,
244
- items: messages.slice(offset, offset + limit)
251
+ hasMore,
252
+ nextBeforeId: hasMore ? firstItemId : null,
253
+ items
245
254
  };
246
255
  }
247
256
 
@@ -11,6 +11,7 @@ const execFileAsync = promisify(execFile);
11
11
  const UPDATE_COMMAND_TIMEOUT_MS = 10 * 60 * 1000;
12
12
  const UPDATE_COMMAND_MAX_BUFFER = 10 * 1024 * 1024;
13
13
  const CHANNEL_FALLBACK_VERSION = '1.5.9';
14
+ const MIN_OPENCLAW_PLUGIN_UPDATE_VERSION = '2026.5.6';
14
15
 
15
16
  export const ping: MethodHandler = async (): Promise<JsonValue> => {
16
17
  return {
@@ -81,6 +82,19 @@ export const doctorFix: MethodHandler = async (_params, context: MethodContext):
81
82
  };
82
83
 
83
84
  export const pluginSelfUpdate: MethodHandler = async (_params, context: MethodContext): Promise<JsonValue> => {
85
+ const versionCheck = await checkOpenClawVersionForPluginUpdate(context);
86
+ if (!versionCheck.supported) {
87
+ return {
88
+ ok: false,
89
+ action: 'pluginSelfUpdate',
90
+ plugin: 'rol-websocket-channel',
91
+ skipped: true,
92
+ reason: versionCheck.reason,
93
+ message: versionCheck.message,
94
+ restartRecommended: false
95
+ };
96
+ }
97
+
84
98
  const result = await runOpenClawCommand(
85
99
  ['plugins', 'update', 'rol-websocket-channel'],
86
100
  context,
@@ -110,6 +124,61 @@ export const pluginSelfUpdate: MethodHandler = async (_params, context: MethodCo
110
124
  };
111
125
  };
112
126
 
127
+ type PluginUpdateVersionCheck =
128
+ | {
129
+ supported: true;
130
+ currentVersion: string;
131
+ output: string;
132
+ }
133
+ | {
134
+ supported: false;
135
+ reason: string;
136
+ message: string;
137
+ currentVersion: string | null;
138
+ output: string;
139
+ };
140
+
141
+ async function checkOpenClawVersionForPluginUpdate(context: MethodContext): Promise<PluginUpdateVersionCheck> {
142
+ try {
143
+ const result = await runOpenClawCommand(['--version'], context, 'pluginSelfUpdate.versionCheck');
144
+ const output = `${result.stdout}\n${result.stderr}`.trim();
145
+ const currentVersion = extractOpenClawVersion(output);
146
+ if (!currentVersion) {
147
+ return {
148
+ supported: false,
149
+ reason: 'openclaw-version-unreadable',
150
+ message: `Unable to detect OpenClaw version. Please install or upgrade OpenClaw to ${MIN_OPENCLAW_PLUGIN_UPDATE_VERSION} or newer before updating rol-websocket-channel.`,
151
+ currentVersion: null,
152
+ output
153
+ };
154
+ }
155
+
156
+ if (!isOpenClawVersionAtLeast(currentVersion, MIN_OPENCLAW_PLUGIN_UPDATE_VERSION)) {
157
+ return {
158
+ supported: false,
159
+ reason: 'openclaw-version-too-old',
160
+ message: `OpenClaw ${currentVersion} is too old for rol-websocket-channel self update. Please install or upgrade OpenClaw to ${MIN_OPENCLAW_PLUGIN_UPDATE_VERSION} or newer.`,
161
+ currentVersion,
162
+ output
163
+ };
164
+ }
165
+
166
+ return {
167
+ supported: true,
168
+ currentVersion,
169
+ output
170
+ };
171
+ } catch (error) {
172
+ return {
173
+ supported: false,
174
+ reason: 'openclaw-version-check-failed',
175
+ message: `Unable to check OpenClaw version. Please install or upgrade OpenClaw to ${MIN_OPENCLAW_PLUGIN_UPDATE_VERSION} or newer before updating rol-websocket-channel.`,
176
+ currentVersion: null,
177
+ output: error instanceof Error ? error.message : String(error)
178
+ };
179
+ }
180
+ }
181
+
113
182
  export const currentVersion: MethodHandler = async (_params, context: MethodContext): Promise<JsonValue> => {
114
183
  const [channelPackage, registryInfo] = await Promise.all([
115
184
  readJsonFile<{ name?: string; version?: string }>(path.join(context.projectRoot, 'package.json')),
@@ -140,6 +209,34 @@ export const currentVersion: MethodHandler = async (_params, context: MethodCont
140
209
  };
141
210
 
142
211
  export const logs: MethodHandler = async (params: any, context: MethodContext): Promise<JsonValue> => {
212
+ const limit = 10;
213
+ const maxBytes = normalizePositiveInteger(params?.maxBytes, 250000);
214
+
215
+ try {
216
+ const result = await runOpenClawCommand(
217
+ ['logs', '--json', '--limit', String(limit), '--max-bytes', String(maxBytes)],
218
+ context,
219
+ 'logs'
220
+ );
221
+ return {
222
+ ok: true,
223
+ source: 'openclaw logs',
224
+ nextOffset: null,
225
+ lines: parseOpenClawLogOutput(result.stdout).slice(-limit).reverse()
226
+ };
227
+ } catch (officialError) {
228
+ console.error(
229
+ `[system] openclaw logs failed, falling back to local file scan: ${officialError instanceof Error ? officialError.message : String(officialError)}`
230
+ );
231
+ return await readLocalLogFiles(context, limit, maxBytes);
232
+ }
233
+ };
234
+
235
+ async function readLocalLogFiles(
236
+ context: MethodContext,
237
+ limit: number,
238
+ maxBytes: number
239
+ ): Promise<JsonValue> {
143
240
  try {
144
241
  // 根据实际情况可能需要调整,这里默认尝试 project root 或 user home 下的 .openclaw/logs
145
242
  // 很多时候全局日志位于 ~/.openclaw/logs/gateway.log 或工程目录下的 .openclaw 文件夹中
@@ -201,8 +298,6 @@ export const logs: MethodHandler = async (params: any, context: MethodContext):
201
298
  return { ok: false, error: `No .log files found in directory: ${logDir}` };
202
299
  }
203
300
 
204
- const limit = 10;
205
- const maxBytes = params?.maxBytes ?? 250000;
206
301
  let offset: number | undefined = undefined;
207
302
 
208
303
  // 获取所有候选日志的详细信息并排序(对应 ls -t)
@@ -267,7 +362,34 @@ export const logs: MethodHandler = async (params: any, context: MethodContext):
267
362
  error: error instanceof Error ? error.message : String(error)
268
363
  };
269
364
  }
270
- };
365
+ }
366
+
367
+ function parseOpenClawLogOutput(output: string): JsonValue[] {
368
+ return output
369
+ .split(/\r?\n/)
370
+ .map((line) => line.trim())
371
+ .filter((line) => line.length > 0)
372
+ .map((line) => {
373
+ try {
374
+ return JSON.parse(line) as JsonValue;
375
+ } catch {
376
+ return line;
377
+ }
378
+ })
379
+ .filter((entry) => !(isJsonObject(entry) && entry.type === 'meta'));
380
+ }
381
+
382
+ function normalizePositiveInteger(value: unknown, fallback: number): number {
383
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
384
+ return fallback;
385
+ }
386
+
387
+ return Math.floor(value);
388
+ }
389
+
390
+ function isJsonObject(value: JsonValue): value is { [key: string]: JsonValue } {
391
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
392
+ }
271
393
 
272
394
  async function runOpenClawCommand(
273
395
  args: string[],
@@ -432,6 +554,30 @@ function normalizeVersion(value: string | undefined): string {
432
554
  return value.trim().replace(/^v/i, '');
433
555
  }
434
556
 
557
+ function isOpenClawVersionAtLeast(current: string, minimum: string): boolean {
558
+ const currentVersion = extractOpenClawVersion(current);
559
+ const minimumVersion = extractOpenClawVersion(minimum);
560
+ if (!currentVersion || !minimumVersion) {
561
+ return false;
562
+ }
563
+
564
+ const currentParts = currentVersion.split('.').map(Number);
565
+ const minimumParts = minimumVersion.split('.').map(Number);
566
+ for (let i = 0; i < Math.max(currentParts.length, minimumParts.length); i += 1) {
567
+ const currentPart = currentParts[i] ?? 0;
568
+ const minimumPart = minimumParts[i] ?? 0;
569
+ if (currentPart > minimumPart) return true;
570
+ if (currentPart < minimumPart) return false;
571
+ }
572
+
573
+ return true;
574
+ }
575
+
576
+ function extractOpenClawVersion(value: string): string | null {
577
+ const match = value.match(/\b(\d{4}\.\d+\.\d+)\b/);
578
+ return match ? match[1] : null;
579
+ }
580
+
435
581
  function isPathSourceUpdateSkip(output: string): boolean {
436
582
  return /Skipping\s+"?rol-websocket-channel"?\s+\(source:\s*path\)/i.test(output);
437
583
  }
package/tsconfig.json CHANGED
@@ -13,5 +13,5 @@
13
13
  "rootDir": "."
14
14
  },
15
15
  "include": ["index.ts", "src/**/*.ts", "types/**/*.d.ts"],
16
- "exclude": ["node_modules", "dist"]
16
+ "exclude": ["node_modules", "dist", "test", "**/*.test.ts", "**/*.spec.ts"]
17
17
  }