@spzhongwin/skill-logger-plugin 1.0.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.
- package/dist/active-skills.js +67 -0
- package/dist/active-skills.test.js +29 -0
- package/dist/config-sync.js +439 -0
- package/dist/config-sync.test.js +145 -0
- package/dist/hooks.js +337 -0
- package/dist/hooks.test.js +123 -0
- package/dist/http.js +54 -0
- package/dist/identity.js +56 -0
- package/dist/index.js +2286 -0
- package/dist/index.test.js +39 -0
- package/dist/integration.test.js +102 -0
- package/dist/matcher.js +362 -0
- package/dist/matcher.test.js +139 -0
- package/dist/paths.js +62 -0
- package/dist/paths.test.js +49 -0
- package/dist/reporter.js +267 -0
- package/dist/reporter.test.js +128 -0
- package/dist/semver.js +64 -0
- package/dist/semver.test.js +21 -0
- package/dist/skill-version.js +23 -0
- package/dist/types.js +9 -0
- package/dist/updater.js +352 -0
- package/dist/updater.test.js +212 -0
- package/dist/ws-client.js +484 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +35 -0
- package/src/active-skills.test.ts +32 -0
- package/src/active-skills.ts +77 -0
- package/src/config-sync.test.ts +165 -0
- package/src/config-sync.ts +485 -0
- package/src/hooks.test.ts +156 -0
- package/src/hooks.ts +405 -0
- package/src/http.ts +61 -0
- package/src/identity.ts +64 -0
- package/src/index.test.ts +53 -0
- package/src/index.ts +226 -0
- package/src/integration.test.ts +119 -0
- package/src/matcher.test.ts +170 -0
- package/src/matcher.ts +393 -0
- package/src/paths.test.ts +57 -0
- package/src/paths.ts +84 -0
- package/src/reporter.test.ts +139 -0
- package/src/reporter.ts +298 -0
- package/src/sample-config.json +72 -0
- package/src/semver.test.ts +23 -0
- package/src/semver.ts +60 -0
- package/src/skill-version.ts +22 -0
- package/src/types.ts +198 -0
- package/src/updater.test.ts +237 -0
- package/src/updater.ts +400 -0
- package/src/ws-client.ts +516 -0
- package/test-ws.ts +17 -0
- package/tsconfig.json +14 -0
package/src/ws-client.ts
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs/promises";
|
|
4
|
+
import { SkillUpdater } from "./updater.ts";
|
|
5
|
+
import { openclawHome } from "./paths.ts";
|
|
6
|
+
|
|
7
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
8
|
+
const HEARTBEAT_ACK_TIMEOUT_MS = 75_000;
|
|
9
|
+
const AGENT_SCAN_INTERVAL_MS = 3 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export interface WsClientOptions {
|
|
12
|
+
serverUrl: string; // 例如: wss://api.aishuo.co/gateway/ws
|
|
13
|
+
authToken?: string; // 用于网关鉴权
|
|
14
|
+
gatewayId: string; // 当前网关宿主的标识,方便中控做集群分发
|
|
15
|
+
updater: SkillUpdater; // 传入原来已有的 updater 实例
|
|
16
|
+
enableFileLog?: boolean; // 文件日志开关
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class GatewayWsClient {
|
|
20
|
+
private ws: WebSocket | null = null;
|
|
21
|
+
private options: WsClientOptions;
|
|
22
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
23
|
+
private agentScanTimer: NodeJS.Timeout | null = null;
|
|
24
|
+
private pingTimer: NodeJS.Timeout | null = null;
|
|
25
|
+
private connectTimeoutTimer: NodeJS.Timeout | null = null;
|
|
26
|
+
private lastServerAckAt = 0;
|
|
27
|
+
private reconnectAttempts = 0;
|
|
28
|
+
private isDestroyed = false;
|
|
29
|
+
|
|
30
|
+
// 本地缓存的 agent ID 列表
|
|
31
|
+
private currentAgentIds = new Set<string>();
|
|
32
|
+
|
|
33
|
+
private appendLogToFile(level: string, category: string, message: string, payload?: any) {
|
|
34
|
+
if (!this.options.enableFileLog) return;
|
|
35
|
+
try {
|
|
36
|
+
const ts = new Date().toISOString();
|
|
37
|
+
let logLine = `[${ts}] [${level}] [${category}] ${message}`;
|
|
38
|
+
if (payload !== undefined && payload !== null) {
|
|
39
|
+
// 如果是 Error 对象,主动提取 stack
|
|
40
|
+
if (payload instanceof Error) {
|
|
41
|
+
logLine += `\n Stack: ${payload.stack || payload.message}`;
|
|
42
|
+
} else {
|
|
43
|
+
logLine += ` | Data: ${typeof payload === 'object' ? JSON.stringify(payload) : payload}`;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
logLine += '\n';
|
|
47
|
+
const logsDir = path.join(openclawHome(), "logs");
|
|
48
|
+
fs.mkdir(logsDir, { recursive: true }).then(() => {
|
|
49
|
+
const logPath = path.join(logsDir, "skill-logger.err");
|
|
50
|
+
fs.appendFile(logPath, logLine).catch(()=>{});
|
|
51
|
+
}).catch(()=>{});
|
|
52
|
+
} catch (e) {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
constructor(options: WsClientOptions) {
|
|
58
|
+
this.options = options;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public connect() {
|
|
62
|
+
if (this.isDestroyed) return;
|
|
63
|
+
if (this.ws && (
|
|
64
|
+
this.ws.readyState === WebSocket.OPEN ||
|
|
65
|
+
this.ws.readyState === WebSocket.CONNECTING
|
|
66
|
+
)) {
|
|
67
|
+
this.appendLogToFile("INFO", "Connection", "Connect skipped: websocket already active", { readyState: this.ws.readyState });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
this.clearConnectTimeout();
|
|
72
|
+
|
|
73
|
+
const msgConnect = `Connecting to Central Service: ${this.options.serverUrl}`;
|
|
74
|
+
console.log(`[skill-logger-plugin][WS] ${msgConnect}`);
|
|
75
|
+
this.appendLogToFile("INFO", "Connection", msgConnect);
|
|
76
|
+
|
|
77
|
+
const headers: Record<string, string> = {
|
|
78
|
+
"X-Gateway-Id": this.options.gatewayId,
|
|
79
|
+
};
|
|
80
|
+
if (this.options.authToken) {
|
|
81
|
+
headers["Authorization"] = this.options.authToken;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const ws = new WebSocket(this.options.serverUrl, { headers });
|
|
86
|
+
this.ws = ws;
|
|
87
|
+
this.connectTimeoutTimer = setTimeout(() => {
|
|
88
|
+
if (this.ws === ws && ws.readyState === WebSocket.CONNECTING) {
|
|
89
|
+
this.appendLogToFile("WARN", "Connection", "Connection timed out before open; terminating socket.");
|
|
90
|
+
ws.terminate();
|
|
91
|
+
}
|
|
92
|
+
}, 15000);
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error(`[skill-logger-plugin][WS] Sync init error:`, err);
|
|
95
|
+
this.appendLogToFile("ERROR", "Connection", "Sync initialization error", err);
|
|
96
|
+
this.scheduleReconnect();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ws = this.ws;
|
|
101
|
+
|
|
102
|
+
ws.on("open", async () => {
|
|
103
|
+
if (this.ws !== ws) return;
|
|
104
|
+
console.log(`[skill-logger-plugin][WS] Connected successfully!`);
|
|
105
|
+
this.appendLogToFile("INFO", "Connection", "Connected successfully!");
|
|
106
|
+
this.clearConnectTimeout();
|
|
107
|
+
this.reconnectAttempts = 0;
|
|
108
|
+
this.lastServerAckAt = Date.now();
|
|
109
|
+
this.clearReconnectTimer();
|
|
110
|
+
|
|
111
|
+
// 首次连接,全量扫描并上报,同时进行应用层握手,确保服务端能把 DB 在线态刷新成真实状态。
|
|
112
|
+
await this.scanAndReportAgents(true);
|
|
113
|
+
this.sendGatewayHello();
|
|
114
|
+
this.startHeartbeat();
|
|
115
|
+
|
|
116
|
+
// 开启 3 分钟定期的自动扫码增量同步
|
|
117
|
+
if (!this.agentScanTimer) {
|
|
118
|
+
this.agentScanTimer = setInterval(() => {
|
|
119
|
+
this.scanAndReportAgents(true);
|
|
120
|
+
}, AGENT_SCAN_INTERVAL_MS);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
ws.on("pong", () => {
|
|
125
|
+
this.lastServerAckAt = Date.now();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
ws.on("message", async (data) => {
|
|
129
|
+
if (this.ws !== ws) return;
|
|
130
|
+
try {
|
|
131
|
+
const msg = JSON.parse(data.toString());
|
|
132
|
+
if (msg?.type === "GATEWAY_HELLO_ACK" || msg?.type === "HEARTBEAT_ACK") {
|
|
133
|
+
this.lastServerAckAt = Date.now();
|
|
134
|
+
this.appendLogToFile("INFO", "Heartbeat", "Server heartbeat acknowledged", { type: msg.type, connectionId: msg.connectionId });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
await this.handleMessage(msg);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(`[skill-logger-plugin][WS] Failed to parse/handle message:`, err);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ws.on("close", () => {
|
|
144
|
+
console.warn(`[skill-logger-plugin][WS] Connection closed.`);
|
|
145
|
+
this.appendLogToFile("WARN", "Connection", "Connection closed. Starting reconnect timer.");
|
|
146
|
+
if (this.ws === ws) {
|
|
147
|
+
this.ws = null;
|
|
148
|
+
this.clearConnectTimeout();
|
|
149
|
+
this.clearAgentScanTimer();
|
|
150
|
+
this.clearHeartbeat();
|
|
151
|
+
this.scheduleReconnect();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
ws.on("error", (err) => {
|
|
156
|
+
console.error(`[skill-logger-plugin][WS] Connection error:`, err);
|
|
157
|
+
this.appendLogToFile("ERROR", "Connection", "Connection error", err);
|
|
158
|
+
if (this.ws === ws) {
|
|
159
|
+
ws.close(); // 触发 close 事件进行重连
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 扫描 OpenClaw 根目录下的 workspace-assistant-{userId} 目录
|
|
166
|
+
*/
|
|
167
|
+
private async scanAndReportAgents(isInitialReport: boolean) {
|
|
168
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const rootPath = openclawHome();
|
|
172
|
+
let entries: string[] = [];
|
|
173
|
+
try {
|
|
174
|
+
entries = await fs.readdir(rootPath);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
this.currentAgentIds = new Set();
|
|
177
|
+
this.appendLogToFile("WARN", "AgentScan", "OpenClaw home is not readable; reporting empty agent list", e);
|
|
178
|
+
this.sendAgentListReport("AGENT_LIST_REPORT");
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const newAgentIds = new Set<string>();
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
if (entry.startsWith("workspace-assistant-")) {
|
|
185
|
+
const suffix = entry.replace("workspace-assistant-", "").trim();
|
|
186
|
+
if (suffix && suffix === path.basename(suffix) && !suffix.startsWith(".")) {
|
|
187
|
+
newAgentIds.add(`assistant-${suffix}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let changed = false;
|
|
193
|
+
if (newAgentIds.size !== this.currentAgentIds.size) {
|
|
194
|
+
changed = true;
|
|
195
|
+
} else {
|
|
196
|
+
for (const id of newAgentIds) {
|
|
197
|
+
if (!this.currentAgentIds.has(id)) {
|
|
198
|
+
changed = true;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.currentAgentIds = newAgentIds;
|
|
205
|
+
|
|
206
|
+
if (isInitialReport) {
|
|
207
|
+
this.appendLogToFile("INFO", "AgentScan", `Reporting full agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
|
|
208
|
+
this.sendAgentListReport("AGENT_LIST_REPORT");
|
|
209
|
+
} else if (changed) {
|
|
210
|
+
this.appendLogToFile("INFO", "AgentScan", `Syncing changed agent list`, { count: this.currentAgentIds.size, agents: Array.from(this.currentAgentIds) });
|
|
211
|
+
this.sendAgentListReport("AGENT_LIST_SYNC");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
} catch (err: any) {
|
|
215
|
+
console.error(`[skill-logger-plugin][WS] Failed to scan agents:`, err);
|
|
216
|
+
this.appendLogToFile("ERROR", "AgentScan", `Failed to scan agents`, err);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private startHeartbeat() {
|
|
221
|
+
this.clearHeartbeat();
|
|
222
|
+
this.pingTimer = setInterval(() => {
|
|
223
|
+
const ws = this.ws;
|
|
224
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
225
|
+
|
|
226
|
+
const ackAge = Date.now() - this.lastServerAckAt;
|
|
227
|
+
if (ackAge > HEARTBEAT_ACK_TIMEOUT_MS) {
|
|
228
|
+
this.appendLogToFile("WARN", "Heartbeat", "Server heartbeat ACK timed out; terminating socket for reconnect", { ackAge });
|
|
229
|
+
ws.terminate();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
ws.ping();
|
|
234
|
+
this.sendClientHeartbeat();
|
|
235
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
236
|
+
this.sendClientHeartbeat();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private clearHeartbeat() {
|
|
240
|
+
if (this.pingTimer) {
|
|
241
|
+
clearInterval(this.pingTimer);
|
|
242
|
+
this.pingTimer = null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private scheduleReconnect() {
|
|
247
|
+
if (this.isDestroyed || this.reconnectTimer) return;
|
|
248
|
+
|
|
249
|
+
// 闭环完善:引入随机 Jitter 抖动,打散服务端重启时可能引发的瞬间重连风暴
|
|
250
|
+
const jitter = Math.floor(Math.random() * 5000);
|
|
251
|
+
const baseDelay = Math.min(30000, 2000 * Math.max(1, 2 ** this.reconnectAttempts));
|
|
252
|
+
const delay = baseDelay + jitter;
|
|
253
|
+
this.reconnectAttempts += 1;
|
|
254
|
+
|
|
255
|
+
console.log(`[skill-logger-plugin][WS] Reconnecting in ${delay}ms...`);
|
|
256
|
+
this.appendLogToFile("INFO", "Connection", "Reconnect scheduled", { delay, attempt: this.reconnectAttempts });
|
|
257
|
+
this.reconnectTimer = setTimeout(() => {
|
|
258
|
+
this.reconnectTimer = null;
|
|
259
|
+
this.connect();
|
|
260
|
+
}, delay);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private clearReconnectTimer() {
|
|
264
|
+
if (this.reconnectTimer) {
|
|
265
|
+
clearTimeout(this.reconnectTimer);
|
|
266
|
+
this.reconnectTimer = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private clearConnectTimeout() {
|
|
271
|
+
if (this.connectTimeoutTimer) {
|
|
272
|
+
clearTimeout(this.connectTimeoutTimer);
|
|
273
|
+
this.connectTimeoutTimer = null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private terminateCurrentSocket(reason: string, payload?: any) {
|
|
278
|
+
const ws = this.ws;
|
|
279
|
+
if (!ws) return;
|
|
280
|
+
this.appendLogToFile("WARN", "Connection", `Terminating websocket: ${reason}`, payload);
|
|
281
|
+
try {
|
|
282
|
+
ws.terminate();
|
|
283
|
+
} catch (err) {
|
|
284
|
+
this.appendLogToFile("WARN", "Connection", "Terminate websocket failed", err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private sendJson(payload: any, category: string) {
|
|
289
|
+
const ws = this.ws;
|
|
290
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
ws.send(JSON.stringify(payload), (err) => {
|
|
294
|
+
if (!err) return;
|
|
295
|
+
this.appendLogToFile("WARN", category, "WebSocket send failed", err);
|
|
296
|
+
if (this.ws === ws) {
|
|
297
|
+
this.terminateCurrentSocket("send_failed", { category, message: err.message });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
return true;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
this.appendLogToFile("WARN", category, "WebSocket send threw", err);
|
|
303
|
+
if (this.ws === ws) {
|
|
304
|
+
this.terminateCurrentSocket("send_threw", err);
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private sendGatewayHello() {
|
|
311
|
+
this.sendJson({
|
|
312
|
+
type: "GATEWAY_HELLO",
|
|
313
|
+
gatewayId: this.options.gatewayId,
|
|
314
|
+
agentIds: Array.from(this.currentAgentIds),
|
|
315
|
+
clientTime: Date.now(),
|
|
316
|
+
}, "Heartbeat");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private sendClientHeartbeat() {
|
|
320
|
+
this.sendJson({
|
|
321
|
+
type: "CLIENT_HEARTBEAT",
|
|
322
|
+
gatewayId: this.options.gatewayId,
|
|
323
|
+
agentIds: Array.from(this.currentAgentIds),
|
|
324
|
+
clientTime: Date.now(),
|
|
325
|
+
}, "Heartbeat");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private sendAgentListReport(type: "AGENT_LIST_REPORT" | "AGENT_LIST_SYNC") {
|
|
329
|
+
this.sendJson({
|
|
330
|
+
type,
|
|
331
|
+
gatewayId: this.options.gatewayId,
|
|
332
|
+
agentIds: Array.from(this.currentAgentIds),
|
|
333
|
+
clientTime: Date.now(),
|
|
334
|
+
}, "AgentScan");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private clearAgentScanTimer() {
|
|
338
|
+
if (this.agentScanTimer) {
|
|
339
|
+
clearInterval(this.agentScanTimer);
|
|
340
|
+
this.agentScanTimer = null;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* 核心指令分发中心:完全跳过沙盒,基于 userId 直接进行底层物理文件操作
|
|
346
|
+
*/
|
|
347
|
+
private async handleMessage(msg: any) {
|
|
348
|
+
const { action, userId, code, url, force, version, replyId } = msg;
|
|
349
|
+
this.appendLogToFile("INFO", "Command", `Received WS message`, { action, userId, code, version, replyId });
|
|
350
|
+
|
|
351
|
+
if (!action || !userId) {
|
|
352
|
+
this.appendLogToFile("WARN", "Command", `Message dropped: missing action or userId`, msg);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 闭环完善:强制清理 userId 和 code 字符串,防止恶意指令通过 '../' 引发宿主机目录穿越攻击
|
|
357
|
+
const safeUserId = path.basename(userId);
|
|
358
|
+
const safeCode = code ? path.basename(code) : undefined;
|
|
359
|
+
|
|
360
|
+
// 100% 确定性安全寻址 (去掉 userId 中可能带有的 assistant- 前缀,统一用 workspace-assistant- 拼接)
|
|
361
|
+
const pureId = safeUserId.replace(/^assistant-/, "");
|
|
362
|
+
const targetDir = path.join(openclawHome(), `workspace-assistant-${pureId}`, "skills");
|
|
363
|
+
|
|
364
|
+
try {
|
|
365
|
+
if (action === "INSTALL_SKILL") {
|
|
366
|
+
if (!safeCode) throw new Error("Missing code parameter");
|
|
367
|
+
console.log(`[skill-logger-plugin][WS] Executing INSTALL for user ${userId}, code: ${safeCode}`);
|
|
368
|
+
this.appendLogToFile("INFO", "Command", `INSTALL_SKILL received`, { userId, code: safeCode, version });
|
|
369
|
+
const result = await this.options.updater.manualInstall({
|
|
370
|
+
code: safeCode, url, version, force: force !== false, targetDir
|
|
371
|
+
});
|
|
372
|
+
this.reply(replyId, { success: result.success, message: result.message, action });
|
|
373
|
+
|
|
374
|
+
} else if (action === "UNINSTALL_SKILL") {
|
|
375
|
+
if (!safeCode) throw new Error("Missing code parameter");
|
|
376
|
+
console.log(`[skill-logger-plugin][WS] Executing UNINSTALL for user ${userId}, code: ${safeCode}`);
|
|
377
|
+
this.appendLogToFile("INFO", "Command", `UNINSTALL_SKILL received`, { userId, code: safeCode });
|
|
378
|
+
const skillPath = path.join(targetDir, safeCode);
|
|
379
|
+
await fs.rm(skillPath, { recursive: true, force: true });
|
|
380
|
+
this.reply(replyId, { success: true, message: `Skill ${safeCode} removed`, action });
|
|
381
|
+
|
|
382
|
+
} else if (action === "LIST_SKILLS") {
|
|
383
|
+
let list: any[] = [];
|
|
384
|
+
let targetDirExists = false;
|
|
385
|
+
try {
|
|
386
|
+
const targetStat = await fs.stat(targetDir);
|
|
387
|
+
targetDirExists = targetStat.isDirectory();
|
|
388
|
+
} catch (err: any) {
|
|
389
|
+
if (err?.code !== "ENOENT") throw err;
|
|
390
|
+
}
|
|
391
|
+
if (!targetDirExists) {
|
|
392
|
+
throw new Error(`Target skills directory does not exist: ${targetDir}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const entries = await fs.readdir(targetDir, { withFileTypes: true });
|
|
396
|
+
|
|
397
|
+
const dirs = entries.filter(e => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith("."));
|
|
398
|
+
|
|
399
|
+
for (const e of dirs) {
|
|
400
|
+
const skillDir = path.join(targetDir, e.name);
|
|
401
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const stat = await fs.stat(skillMdPath);
|
|
405
|
+
if (!stat.isFile()) continue;
|
|
406
|
+
} catch (err) {
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const metaPath = path.join(skillDir, '.meta.json');
|
|
411
|
+
let isPlatform = false;
|
|
412
|
+
let isBuiltIn = e.isSymbolicLink();
|
|
413
|
+
let metaData: any = null;
|
|
414
|
+
let name = e.name;
|
|
415
|
+
let description = "";
|
|
416
|
+
let skillVersion = "";
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const mdContent = await fs.readFile(skillMdPath, 'utf8');
|
|
420
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---/.exec(mdContent)?.[1] ?? "";
|
|
421
|
+
const parsedName = /(^|\n)name:\s*(.+)/.exec(fm)?.[2]?.trim();
|
|
422
|
+
if (parsedName) name = parsedName;
|
|
423
|
+
const parsedVersion = /(^|\n)version:\s*(.+)/i.exec(fm)?.[2]?.trim();
|
|
424
|
+
if (parsedVersion) skillVersion = parsedVersion.replace(/^['"]|['"]$/g, '').replace(/\s+#.*$/, '');
|
|
425
|
+
|
|
426
|
+
const descMatch = /(^|\n)description:\s*(?:>\s*\n\s*)?(.*?)(?=\n[a-z]+:|\n---|$)/is.exec(fm);
|
|
427
|
+
if (descMatch && descMatch[2]) {
|
|
428
|
+
description = descMatch[2].replace(/\n\s+/g, ' ').trim();
|
|
429
|
+
}
|
|
430
|
+
} catch (err) {}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const metaContent = await fs.readFile(metaPath, 'utf8');
|
|
434
|
+
const parsed = JSON.parse(metaContent);
|
|
435
|
+
if (parsed) {
|
|
436
|
+
if (parsed.ownerId === 'CMS' || parsed.ownerId === 'CMS_COMPAT') isPlatform = true;
|
|
437
|
+
if (parsed.isBuiltIn === true || parsed.ownerId === 'built-in') isBuiltIn = true;
|
|
438
|
+
metaData = parsed;
|
|
439
|
+
}
|
|
440
|
+
} catch (err) {}
|
|
441
|
+
|
|
442
|
+
if (isPlatform) {
|
|
443
|
+
list.push({
|
|
444
|
+
code: e.name,
|
|
445
|
+
isPlatform: true,
|
|
446
|
+
isBuiltIn: isBuiltIn,
|
|
447
|
+
version: metaData?.version || skillVersion,
|
|
448
|
+
name,
|
|
449
|
+
description,
|
|
450
|
+
publishedAt: metaData?.publishedAt
|
|
451
|
+
});
|
|
452
|
+
} else {
|
|
453
|
+
list.push({
|
|
454
|
+
code: e.name,
|
|
455
|
+
isPlatform: false,
|
|
456
|
+
isBuiltIn: isBuiltIn,
|
|
457
|
+
version: skillVersion,
|
|
458
|
+
name: name,
|
|
459
|
+
description: description
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
this.reply(replyId, { success: true, data: list, action });
|
|
464
|
+
|
|
465
|
+
} else if (action === "UPDATE_SKILL") {
|
|
466
|
+
if (!safeCode) throw new Error("Missing code parameter");
|
|
467
|
+
// 更新可能是大批量广播,增加防阻塞的随机延时 (0~5秒)
|
|
468
|
+
const delayMs = Math.random() * 5000;
|
|
469
|
+
console.log(`[skill-logger-plugin][WS] Scheduled UPDATE for user ${userId}, code: ${safeCode} in ${Math.round(delayMs)}ms`);
|
|
470
|
+
this.appendLogToFile("INFO", "Command", `Scheduled UPDATE_SKILL`, { userId, code: safeCode, version, delayMs: Math.round(delayMs) });
|
|
471
|
+
|
|
472
|
+
setTimeout(async () => {
|
|
473
|
+
try {
|
|
474
|
+
await this.options.updater.manualInstall({
|
|
475
|
+
code: safeCode, url, version, force: true, targetDir
|
|
476
|
+
});
|
|
477
|
+
// 广播更新一般无需强阻塞等待 replyId,若有需要也可以回传
|
|
478
|
+
if (replyId) {
|
|
479
|
+
this.reply(replyId, { success: true, message: `Skill ${safeCode} updated successfully`, action });
|
|
480
|
+
}
|
|
481
|
+
} catch (e: any) {
|
|
482
|
+
if (replyId) this.reply(replyId, { success: false, message: e.message, action });
|
|
483
|
+
}
|
|
484
|
+
}, delayMs);
|
|
485
|
+
|
|
486
|
+
} else {
|
|
487
|
+
console.warn(`[skill-logger-plugin][WS] Unknown action: ${action}`);
|
|
488
|
+
this.appendLogToFile("WARN", "Command", `Unknown action: ${action}`);
|
|
489
|
+
}
|
|
490
|
+
} catch (err: any) {
|
|
491
|
+
this.appendLogToFile("ERROR", "Command", `Error executing action ${action}`, err);
|
|
492
|
+
this.reply(replyId, { success: false, message: err.message, action });
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private reply(replyId: string, payload: any) {
|
|
497
|
+
this.appendLogToFile("INFO", "Command", `Replying to command`, { replyId, payload });
|
|
498
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN || !replyId) {
|
|
499
|
+
this.appendLogToFile("WARN", "Command", `Cannot reply: WS closed or missing replyId`, { replyId });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
this.ws.send(JSON.stringify({ type: "REPLY", replyId, ...payload }));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
public destroy() {
|
|
506
|
+
this.isDestroyed = true;
|
|
507
|
+
this.clearReconnectTimer();
|
|
508
|
+
this.clearAgentScanTimer();
|
|
509
|
+
this.clearHeartbeat();
|
|
510
|
+
this.clearConnectTimeout();
|
|
511
|
+
if (this.ws) {
|
|
512
|
+
this.ws.terminate(); // 强行销毁,斩断半开连接残留
|
|
513
|
+
this.ws = null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
package/test-ws.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GatewayWsClient } from './src/ws-client.ts';
|
|
2
|
+
|
|
3
|
+
const client = new GatewayWsClient({
|
|
4
|
+
serverUrl: 'wss://aishuo.co/gateway/ws',
|
|
5
|
+
gatewayId: 'test-local-gateway',
|
|
6
|
+
authToken: 'song3i3wnjwej0923iuwejewjwjkwekwejkew',
|
|
7
|
+
enableFileLog: true,
|
|
8
|
+
updater: {} as any
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
console.log("Connecting...");
|
|
12
|
+
client.connect();
|
|
13
|
+
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
console.log("Test finished.");
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}, 3000);
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"]
|
|
14
|
+
}
|