palz-connector 1.5.6 → 1.5.7
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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/activity.js +1 -1
- package/src/agent-logger.js +84 -0
- package/src/bot.js +21 -22
- package/src/channel.js +44 -11
- package/src/config.js +19 -14
- package/src/connection-manager.js +131 -0
- package/src/media.js +7 -7
- package/src/message-utils.js +12 -0
- package/src/monitor.js +32 -10
- package/src/outbound.js +13 -1
- package/src/reply-dispatcher.js +2 -0
- package/src/send.js +7 -5
- package/src/workspace-scanner.js +93 -0
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/activity.js
CHANGED
|
@@ -30,7 +30,7 @@ export async function reportPalzActivity(params) {
|
|
|
30
30
|
if (config.activityReportEnabled !== true)
|
|
31
31
|
return;
|
|
32
32
|
const baseUrl = config.clawGatewayUrl?.trim();
|
|
33
|
-
const releaseName = (
|
|
33
|
+
const releaseName = (config.botId || "").trim();
|
|
34
34
|
if (!baseUrl || !releaseName) {
|
|
35
35
|
const warnKey = `${accountId ?? ""}:${baseUrl ? "hasBaseUrl" : "missingBaseUrl"}:${releaseName ? "hasRelease" : "missingRelease"}`;
|
|
36
36
|
if (!warnedMissingConfig.has(warnKey)) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
const DEFAULT_OPENCLAW_DIR = path.join(os.homedir(), ".openclaw");
|
|
5
|
+
const LOG_DIR_NAME = "palz-log";
|
|
6
|
+
function todayStr() {
|
|
7
|
+
const d = new Date();
|
|
8
|
+
const y = d.getFullYear();
|
|
9
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
10
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
11
|
+
return `${y}-${m}-${day}`;
|
|
12
|
+
}
|
|
13
|
+
function formatTimestamp() {
|
|
14
|
+
const d = new Date();
|
|
15
|
+
const offset = -d.getTimezoneOffset();
|
|
16
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
17
|
+
const absOff = Math.abs(offset);
|
|
18
|
+
const hh = String(Math.floor(absOff / 60)).padStart(2, "0");
|
|
19
|
+
const mm = String(absOff % 60).padStart(2, "0");
|
|
20
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
21
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}${sign}${hh}:${mm}`;
|
|
22
|
+
}
|
|
23
|
+
function argsToString(args) {
|
|
24
|
+
return args.map((a) => (typeof a === "string" ? a : String(a))).join(" ");
|
|
25
|
+
}
|
|
26
|
+
export function createUserLogger(userId, openclawDir) {
|
|
27
|
+
const baseDir = openclawDir || DEFAULT_OPENCLAW_DIR;
|
|
28
|
+
const logDir = path.join(baseDir, userId, LOG_DIR_NAME);
|
|
29
|
+
let currentDate = "";
|
|
30
|
+
let fd = null;
|
|
31
|
+
function ensureFd() {
|
|
32
|
+
const date = todayStr();
|
|
33
|
+
if (fd !== null && date === currentDate)
|
|
34
|
+
return fd;
|
|
35
|
+
if (fd !== null) {
|
|
36
|
+
try {
|
|
37
|
+
fs.closeSync(fd);
|
|
38
|
+
}
|
|
39
|
+
catch { }
|
|
40
|
+
fd = null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
44
|
+
const filePath = path.join(logDir, `palz-${date}.log`);
|
|
45
|
+
fd = fs.openSync(filePath, "a");
|
|
46
|
+
currentDate = date;
|
|
47
|
+
return fd;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeLine(level, args) {
|
|
54
|
+
const fileFd = ensureFd();
|
|
55
|
+
if (fileFd !== null) {
|
|
56
|
+
const line = `${formatTimestamp()} [${level}] ${argsToString(args)}\n`;
|
|
57
|
+
try {
|
|
58
|
+
fs.writeSync(fileFd, line);
|
|
59
|
+
}
|
|
60
|
+
catch { }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const logger = {
|
|
64
|
+
log: (...args) => {
|
|
65
|
+
console.log(...args);
|
|
66
|
+
writeLine("INFO", args);
|
|
67
|
+
},
|
|
68
|
+
error: (...args) => {
|
|
69
|
+
console.error(...args);
|
|
70
|
+
writeLine("ERROR", args);
|
|
71
|
+
},
|
|
72
|
+
close: () => {
|
|
73
|
+
if (fd !== null) {
|
|
74
|
+
try {
|
|
75
|
+
fs.closeSync(fd);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
fd = null;
|
|
79
|
+
currentDate = "";
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
return logger;
|
|
84
|
+
}
|
package/src/bot.js
CHANGED
|
@@ -15,20 +15,8 @@ import { tryClaimMessage } from "./dedup.js";
|
|
|
15
15
|
import { createPalzReplyDispatcher } from "./reply-dispatcher.js";
|
|
16
16
|
import { resolvePalzMediaList, resolveMediaLocalRoots } from "./media.js";
|
|
17
17
|
import { sendToPalzIM } from "./send.js";
|
|
18
|
+
import { buildPassthroughFromMsg } from "./message-utils.js";
|
|
18
19
|
import { tracer, trace, context, SpanStatusCode } from "./tracing.js";
|
|
19
|
-
// ============ 原始消息透传字段 ============
|
|
20
|
-
const PASSTHROUGH_EXCLUDE = new Set(["event", "content", "timestamp"]);
|
|
21
|
-
function buildPassthroughFromMsg(msg) {
|
|
22
|
-
const out = {};
|
|
23
|
-
for (const [k, v] of Object.entries(msg)) {
|
|
24
|
-
if (PASSTHROUGH_EXCLUDE.has(k))
|
|
25
|
-
continue;
|
|
26
|
-
if (v === undefined)
|
|
27
|
-
continue;
|
|
28
|
-
out[k] = v;
|
|
29
|
-
}
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
20
|
// ============ 文本提取工具 ============
|
|
33
21
|
function extractPlainText(content) {
|
|
34
22
|
if (typeof content === "string")
|
|
@@ -210,7 +198,7 @@ export async function handlePalzMessage(params) {
|
|
|
210
198
|
span.setAttribute("msg_id", msg.msg_id);
|
|
211
199
|
span.setAttribute("sender_id", msg.sender_id);
|
|
212
200
|
span.setAttribute("conversation_type", msg.conversation_type);
|
|
213
|
-
span.setAttribute("agent_id",
|
|
201
|
+
span.setAttribute("agent_id", params.agentId);
|
|
214
202
|
span.setAttribute("is_group", msg.conversation_type === "group");
|
|
215
203
|
try {
|
|
216
204
|
await _handlePalzMessageInner(params);
|
|
@@ -225,12 +213,12 @@ export async function handlePalzMessage(params) {
|
|
|
225
213
|
});
|
|
226
214
|
}
|
|
227
215
|
async function _handlePalzMessageInner(params) {
|
|
228
|
-
const { cfg, msg, runtime, accountId } = params;
|
|
216
|
+
const { cfg, msg, runtime, accountId, agentId } = params;
|
|
229
217
|
const log = typeof runtime?.log === "function" ? runtime.log : console.log;
|
|
230
218
|
const error = typeof runtime?.error === "function" ? runtime.error : console.error;
|
|
231
219
|
const tag = `palz[${accountId}]`;
|
|
232
220
|
const isGroup = msg.conversation_type === "group";
|
|
233
|
-
const effectiveAgentId =
|
|
221
|
+
const effectiveAgentId = agentId;
|
|
234
222
|
const step1Filter = `[STEP 1/6 入站过滤] msg_id=${msg.msg_id} sender=${msg.sender_id} conv=${msg.conversation_id} type=${msg.conversation_type} agent=${effectiveAgentId}`;
|
|
235
223
|
log(`${tag}: ${step1Filter}`);
|
|
236
224
|
const span = trace.getActiveSpan();
|
|
@@ -348,7 +336,7 @@ async function _handlePalzMessageInner(params) {
|
|
|
348
336
|
span?.addEvent(waitLog);
|
|
349
337
|
}
|
|
350
338
|
try {
|
|
351
|
-
await context.with(capturedCtx, () => dispatchPalzMessage({ cfg, msg, runtime, accountId }));
|
|
339
|
+
await context.with(capturedCtx, () => dispatchPalzMessage({ cfg, msg, runtime, accountId, agentId }));
|
|
352
340
|
const doneLog = `[完成] msg_id=${msg.msg_id} 总耗时=${Date.now() - startMs}ms queueWait=${queueWaitMs}ms`;
|
|
353
341
|
log(`${tag}: ${doneLog}`);
|
|
354
342
|
span?.addEvent(doneLog);
|
|
@@ -380,7 +368,7 @@ async function dispatchPalzMessage(params) {
|
|
|
380
368
|
});
|
|
381
369
|
}
|
|
382
370
|
async function _dispatchPalzMessageInner(params) {
|
|
383
|
-
const { cfg, msg, runtime, accountId } = params;
|
|
371
|
+
const { cfg, msg, runtime, accountId, agentId } = params;
|
|
384
372
|
const log = typeof runtime?.log === "function" ? runtime.log : console.log;
|
|
385
373
|
const tag = `palz[${accountId}]`;
|
|
386
374
|
const core = getPalzRuntime();
|
|
@@ -426,13 +414,12 @@ async function _dispatchPalzMessageInner(params) {
|
|
|
426
414
|
accountId,
|
|
427
415
|
peer: { kind: peerKind, id: peerId },
|
|
428
416
|
});
|
|
429
|
-
|
|
430
|
-
const effectiveAgentId = msg.agent_id || "main";
|
|
417
|
+
const effectiveAgentId = agentId;
|
|
431
418
|
if (route.agentId !== effectiveAgentId) {
|
|
432
419
|
const oldAgentId = route.agentId;
|
|
433
420
|
route.agentId = effectiveAgentId;
|
|
434
421
|
route.sessionKey = route.sessionKey.replace(`agent:${oldAgentId}:`, `agent:${effectiveAgentId}:`);
|
|
435
|
-
const step5Override = `[STEP 5 覆盖] agentId: ${oldAgentId} -> ${effectiveAgentId}, sessionKey=${route.sessionKey}
|
|
422
|
+
const step5Override = `[STEP 5 覆盖] agentId: ${oldAgentId} -> ${effectiveAgentId}, sessionKey=${route.sessionKey}`;
|
|
436
423
|
log(`${tag}: ${step5Override}`);
|
|
437
424
|
span?.addEvent(step5Override);
|
|
438
425
|
}
|
|
@@ -704,7 +691,7 @@ async function _dispatchPalzMessageInner(params) {
|
|
|
704
691
|
msgId: msg.msg_id,
|
|
705
692
|
msgType: msg.msg_type,
|
|
706
693
|
groupId,
|
|
707
|
-
mediaLocalRoots: resolveMediaLocalRoots(effectiveAgentId),
|
|
694
|
+
mediaLocalRoots: resolveMediaLocalRoots(effectiveAgentId, accountId),
|
|
708
695
|
showProcess,
|
|
709
696
|
sessionKey: route.sessionKey,
|
|
710
697
|
passthrough: buildPassthroughFromMsg(msg),
|
|
@@ -759,3 +746,15 @@ async function _dispatchPalzMessageInner(params) {
|
|
|
759
746
|
log(`${tag}: [TRACE] 解析 sessionFile 失败: ${String(err)}`);
|
|
760
747
|
}
|
|
761
748
|
}
|
|
749
|
+
export function cleanupAgentCaches(agentIdToClean) {
|
|
750
|
+
const prefix = `${agentIdToClean}:`;
|
|
751
|
+
for (const key of chatHistories.keys()) {
|
|
752
|
+
if (key.startsWith(prefix))
|
|
753
|
+
chatHistories.delete(key);
|
|
754
|
+
}
|
|
755
|
+
for (const key of reasoningActivated.keys()) {
|
|
756
|
+
if (key.includes(`:agent:${agentIdToClean}:`))
|
|
757
|
+
reasoningActivated.delete(key);
|
|
758
|
+
}
|
|
759
|
+
reasoningScannedAgents.delete(agentIdToClean);
|
|
760
|
+
}
|
package/src/channel.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* 实现 OpenClaw ChannelPlugin 接口,与飞书插件采用相同的架构模式。
|
|
5
5
|
* 包含 config、messaging、outbound、gateway 等全套适配器。
|
|
6
6
|
*/
|
|
7
|
-
import { listPalzAccountIds, resolvePalzAccount, resolveDefaultPalzAccountId, } from "./config.js";
|
|
7
|
+
import { listPalzAccountIds, resolvePalzAccount, resolveDefaultPalzAccountId, resolvePalzConfig, PALZ_MANAGER_ACCOUNT_ID, } from "./config.js";
|
|
8
8
|
import { palzOutbound } from "./outbound.js";
|
|
9
9
|
import { normalizePalzTarget, looksLikePalzId } from "./targets.js";
|
|
10
|
+
import { ConnectionManager } from "./connection-manager.js";
|
|
11
|
+
import { startUserDirScanner } from "./workspace-scanner.js";
|
|
10
12
|
export const palzPlugin = {
|
|
11
13
|
id: "palz-connector",
|
|
12
14
|
meta: {
|
|
@@ -69,22 +71,53 @@ export const palzPlugin = {
|
|
|
69
71
|
startAccount: async (ctx) => {
|
|
70
72
|
const log = typeof ctx.runtime?.log === "function" ? ctx.runtime.log : console.log;
|
|
71
73
|
const logInfo = typeof ctx.log?.info === "function" ? ctx.log.info.bind(ctx.log) : log;
|
|
74
|
+
const error = typeof ctx.runtime?.error === "function" ? ctx.runtime.error : console.error;
|
|
72
75
|
logInfo("palz-gateway: [startAccount] 输入: accountId=" + JSON.stringify(ctx.accountId));
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
if (ctx.accountId !== PALZ_MANAGER_ACCOUNT_ID) {
|
|
77
|
+
logInfo("palz-gateway: [startAccount] non-manager account " + JSON.stringify(ctx.accountId) + ", returning abort-pending promise");
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
if (ctx.abortSignal?.aborted) {
|
|
80
|
+
resolve();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
ctx.abortSignal?.addEventListener("abort", () => resolve(), { once: true });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const baseConfig = resolvePalzConfig(ctx.cfg);
|
|
87
|
+
if (!baseConfig.streamUrl || !baseConfig.apiBaseUrl) {
|
|
88
|
+
const errMsg = "Palz manager not configured (missing streamUrl/apiBaseUrl)";
|
|
78
89
|
logInfo("palz-gateway: [startAccount] 失败: " + errMsg);
|
|
79
90
|
throw new Error(errMsg);
|
|
80
91
|
}
|
|
81
|
-
logInfo("palz-gateway: [startAccount] 启动
|
|
82
|
-
|
|
92
|
+
logInfo("palz-gateway: [startAccount] 启动 manager, streamUrl=" + baseConfig.streamUrl);
|
|
93
|
+
const manager = new ConnectionManager({
|
|
83
94
|
cfg: ctx.cfg,
|
|
84
|
-
|
|
95
|
+
baseConfig,
|
|
85
96
|
runtime: ctx.runtime,
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
log: logInfo,
|
|
98
|
+
error,
|
|
99
|
+
});
|
|
100
|
+
const scanner = startUserDirScanner({
|
|
101
|
+
onSnapshot: (userIds) => {
|
|
102
|
+
manager.reconcile(userIds).catch((err) => {
|
|
103
|
+
error("palz-gateway: reconcile error: " + String(err));
|
|
104
|
+
});
|
|
105
|
+
},
|
|
106
|
+
log: logInfo,
|
|
107
|
+
error,
|
|
108
|
+
});
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const onAbort = () => {
|
|
111
|
+
logInfo("palz-gateway: [startAccount] abort received, shutting down manager");
|
|
112
|
+
scanner.stop();
|
|
113
|
+
manager.shutdown();
|
|
114
|
+
resolve();
|
|
115
|
+
};
|
|
116
|
+
if (ctx.abortSignal?.aborted) {
|
|
117
|
+
onAbort();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
ctx.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
88
121
|
});
|
|
89
122
|
},
|
|
90
123
|
},
|
package/src/config.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from "path";
|
|
|
12
12
|
import { fileURLToPath } from "url";
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
14
|
const __dirname = path.dirname(__filename);
|
|
15
|
-
const
|
|
15
|
+
export const PALZ_MANAGER_ACCOUNT_ID = "palz-manager";
|
|
16
16
|
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
17
17
|
const DEFAULT_CONFIG_FILENAME = "palz-connector.config.json";
|
|
18
18
|
let _cachedFileConfig = null;
|
|
@@ -55,11 +55,11 @@ function loadConfigFile() {
|
|
|
55
55
|
_cachedFileConfig = {};
|
|
56
56
|
return _cachedFileConfig;
|
|
57
57
|
}
|
|
58
|
-
export function resolvePalzConfig(_cfg) {
|
|
58
|
+
export function resolvePalzConfig(_cfg, botId) {
|
|
59
59
|
const file = loadConfigFile();
|
|
60
60
|
const config = {
|
|
61
61
|
enabled: file.enabled !== false,
|
|
62
|
-
botId:
|
|
62
|
+
botId: botId ?? "",
|
|
63
63
|
streamUrl: file.streamUrl || "",
|
|
64
64
|
apiBaseUrl: file.apiBaseUrl || "",
|
|
65
65
|
clawGatewayUrl: file.clawGatewayUrl || "",
|
|
@@ -70,41 +70,46 @@ export function resolvePalzConfig(_cfg) {
|
|
|
70
70
|
};
|
|
71
71
|
if (!_configLoggedOnce) {
|
|
72
72
|
_configLoggedOnce = true;
|
|
73
|
-
console.log("palz-config: [resolve] input:
|
|
73
|
+
console.log("palz-config: [resolve] input: botId=" + JSON.stringify(botId) + " configFile=" + JSON.stringify(file));
|
|
74
74
|
console.log("palz-config: [resolve] output: " + JSON.stringify(config));
|
|
75
75
|
}
|
|
76
76
|
return config;
|
|
77
77
|
}
|
|
78
78
|
export function listPalzAccountIds(_cfg) {
|
|
79
79
|
const config = resolvePalzConfig();
|
|
80
|
-
const ids =
|
|
80
|
+
const ids = config.enabled ? [PALZ_MANAGER_ACCOUNT_ID] : [];
|
|
81
81
|
if (!_configAdapterLoggedOnce) {
|
|
82
82
|
console.log("palz-config: [listAccountIds] output: " + JSON.stringify(ids));
|
|
83
83
|
}
|
|
84
84
|
return ids;
|
|
85
85
|
}
|
|
86
86
|
export function resolveDefaultPalzAccountId(_cfg) {
|
|
87
|
-
const config = resolvePalzConfig();
|
|
88
|
-
const id = config.botId || DEFAULT_ACCOUNT_ID;
|
|
89
87
|
if (!_configAdapterLoggedOnce) {
|
|
90
|
-
console.log("palz-config: [defaultAccountId] output: " + JSON.stringify(
|
|
88
|
+
console.log("palz-config: [defaultAccountId] output: " + JSON.stringify(PALZ_MANAGER_ACCOUNT_ID));
|
|
91
89
|
}
|
|
92
|
-
return
|
|
90
|
+
return PALZ_MANAGER_ACCOUNT_ID;
|
|
93
91
|
}
|
|
94
92
|
export function resolvePalzAccount(params) {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
93
|
+
const rawAccountId = params.accountId?.trim() || "";
|
|
94
|
+
const isManager = !rawAccountId || rawAccountId === PALZ_MANAGER_ACCOUNT_ID;
|
|
95
|
+
const accountId = isManager ? PALZ_MANAGER_ACCOUNT_ID : rawAccountId;
|
|
96
|
+
const botId = isManager ? "" : rawAccountId;
|
|
97
|
+
const config = resolvePalzConfig(params.cfg, botId);
|
|
97
98
|
const result = {
|
|
98
99
|
accountId,
|
|
99
100
|
enabled: config.enabled !== false,
|
|
100
|
-
configured:
|
|
101
|
-
|
|
101
|
+
configured: isManager
|
|
102
|
+
? Boolean(config.streamUrl && config.apiBaseUrl)
|
|
103
|
+
: Boolean(botId && config.streamUrl && config.apiBaseUrl),
|
|
104
|
+
name: isManager
|
|
105
|
+
? "Palz Connector (manager)"
|
|
106
|
+
: "Palz Connector (" + botId + ")",
|
|
102
107
|
config,
|
|
103
108
|
};
|
|
104
109
|
if (!_configAdapterLoggedOnce) {
|
|
105
110
|
_configAdapterLoggedOnce = true;
|
|
106
111
|
console.log("palz-config: [resolveAccount] input: accountId=" + JSON.stringify(params.accountId));
|
|
107
|
-
console.log("palz-config: [resolveAccount] output: " + JSON.stringify({ accountId: result.accountId, enabled: result.enabled, configured: result.configured, name: result.name }));
|
|
112
|
+
console.log("palz-config: [resolveAccount] output: " + JSON.stringify({ accountId: result.accountId, enabled: result.enabled, configured: result.configured, name: result.name, botId }));
|
|
108
113
|
}
|
|
109
114
|
return result;
|
|
110
115
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { monitorPalzProvider } from "./monitor.js";
|
|
2
|
+
import { createUserLogger } from "./agent-logger.js";
|
|
3
|
+
const BATCH_SIZE = 10;
|
|
4
|
+
const BATCH_DELAY_MS = 500;
|
|
5
|
+
function sleep(ms) {
|
|
6
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
|
+
}
|
|
8
|
+
export class ConnectionManager {
|
|
9
|
+
connections = new Map();
|
|
10
|
+
cfg;
|
|
11
|
+
baseConfig;
|
|
12
|
+
runtime;
|
|
13
|
+
log;
|
|
14
|
+
error;
|
|
15
|
+
reconciling = false;
|
|
16
|
+
shutdownCalled = false;
|
|
17
|
+
constructor(params) {
|
|
18
|
+
this.cfg = params.cfg;
|
|
19
|
+
this.baseConfig = params.baseConfig;
|
|
20
|
+
this.runtime = params.runtime;
|
|
21
|
+
this.log = params.log ?? console.log;
|
|
22
|
+
this.error = params.error ?? console.error;
|
|
23
|
+
}
|
|
24
|
+
async reconcile(desiredUserIds) {
|
|
25
|
+
if (this.shutdownCalled)
|
|
26
|
+
return;
|
|
27
|
+
if (this.reconciling) {
|
|
28
|
+
this.log("connection-manager: reconcile skipped (already in progress)");
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.reconciling = true;
|
|
32
|
+
try {
|
|
33
|
+
const desired = new Set(desiredUserIds);
|
|
34
|
+
const current = new Set(this.connections.keys());
|
|
35
|
+
const toRemove = [...current].filter((name) => !desired.has(name));
|
|
36
|
+
const toAdd = [...desired].filter((name) => !current.has(name));
|
|
37
|
+
for (const name of toRemove) {
|
|
38
|
+
this.removeUser(name);
|
|
39
|
+
}
|
|
40
|
+
for (let i = 0; i < toAdd.length; i++) {
|
|
41
|
+
if (this.shutdownCalled)
|
|
42
|
+
break;
|
|
43
|
+
this.addUser(toAdd[i]);
|
|
44
|
+
if (i > 0 && i % BATCH_SIZE === 0 && i < toAdd.length - 1) {
|
|
45
|
+
this.log(`connection-manager: batch pause after ${i} connections`);
|
|
46
|
+
await sleep(BATCH_DELAY_MS);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (toAdd.length > 0 || toRemove.length > 0) {
|
|
50
|
+
this.log(`connection-manager: reconcile done, added=${toAdd.length} removed=${toRemove.length} active=${this.connections.size}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
this.error(`connection-manager: reconcile error: ${err}`);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
this.reconciling = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
addUser(userId) {
|
|
61
|
+
if (this.connections.has(userId))
|
|
62
|
+
return;
|
|
63
|
+
if (this.shutdownCalled)
|
|
64
|
+
return;
|
|
65
|
+
const abortController = new AbortController();
|
|
66
|
+
const config = { ...this.baseConfig, botId: userId };
|
|
67
|
+
const logger = createUserLogger(userId);
|
|
68
|
+
const userRuntime = { ...this.runtime, log: logger.log, error: logger.error };
|
|
69
|
+
this.log(`connection-manager: adding user "${userId}"`);
|
|
70
|
+
const promise = monitorPalzProvider({
|
|
71
|
+
cfg: this.cfg,
|
|
72
|
+
config,
|
|
73
|
+
runtime: userRuntime,
|
|
74
|
+
abortSignal: abortController.signal,
|
|
75
|
+
// accountId 在 connector 内部层表示 WS 连接标识(= userId = 目录名),
|
|
76
|
+
// 与 OpenClaw 框架层的 accountId("palz-manager")不同。
|
|
77
|
+
accountId: userId,
|
|
78
|
+
});
|
|
79
|
+
const entry = {
|
|
80
|
+
userId,
|
|
81
|
+
abortController,
|
|
82
|
+
promise,
|
|
83
|
+
startedAt: Date.now(),
|
|
84
|
+
logger,
|
|
85
|
+
};
|
|
86
|
+
this.connections.set(userId, entry);
|
|
87
|
+
promise
|
|
88
|
+
.catch((err) => {
|
|
89
|
+
this.error(`connection-manager: user "${userId}" exited with error: ${err}`);
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
if (this.connections.get(userId) === entry) {
|
|
93
|
+
this.connections.delete(userId);
|
|
94
|
+
this.log(`connection-manager: user "${userId}" cleaned up from map`);
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
removeUser(userId) {
|
|
99
|
+
const entry = this.connections.get(userId);
|
|
100
|
+
if (!entry)
|
|
101
|
+
return;
|
|
102
|
+
this.log(`connection-manager: removing user "${userId}"`);
|
|
103
|
+
this.connections.delete(userId);
|
|
104
|
+
entry.abortController.abort();
|
|
105
|
+
entry.logger.close();
|
|
106
|
+
// 不主动清理 agent 缓存:userId 不是 agentId,无法确定该 user 下有哪些 agent。
|
|
107
|
+
// 依赖 dedup TTL (5min) 和 chatHistories MAX_HISTORY_KEYS (3000) 自动淘汰。
|
|
108
|
+
}
|
|
109
|
+
getActiveUsers() {
|
|
110
|
+
return [...this.connections.keys()].sort();
|
|
111
|
+
}
|
|
112
|
+
shutdown() {
|
|
113
|
+
if (this.shutdownCalled)
|
|
114
|
+
return;
|
|
115
|
+
this.shutdownCalled = true;
|
|
116
|
+
this.log(`connection-manager: shutting down ${this.connections.size} connection(s)`);
|
|
117
|
+
for (const [name, entry] of this.connections) {
|
|
118
|
+
try {
|
|
119
|
+
entry.abortController.abort();
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
this.error(`connection-manager: abort error for "${name}": ${err}`);
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
entry.logger.close();
|
|
126
|
+
}
|
|
127
|
+
catch { }
|
|
128
|
+
}
|
|
129
|
+
this.connections.clear();
|
|
130
|
+
}
|
|
131
|
+
}
|
package/src/media.js
CHANGED
|
@@ -16,16 +16,16 @@ const STATE_DIR = path.join(os.homedir(), ".openclaw");
|
|
|
16
16
|
* 根据 agentId 构造媒体文件搜索根路径列表,
|
|
17
17
|
* 与 OpenClaw 的 getAgentScopedMediaLocalRoots 逻辑对齐。
|
|
18
18
|
*/
|
|
19
|
-
export function resolveMediaLocalRoots(agentId) {
|
|
19
|
+
export function resolveMediaLocalRoots(agentId, accountId) {
|
|
20
20
|
const roots = [MEDIA_DIR, path.join(STATE_DIR, "workspace"), path.join(STATE_DIR, "sandboxes")];
|
|
21
|
+
// user 目录下的 agent workspace: ~/.openclaw/{userId}/workspace-{agentId}
|
|
22
|
+
if (accountId?.startsWith("user-") && agentId?.trim()) {
|
|
23
|
+
roots.push(path.join(STATE_DIR, accountId, `workspace-${agentId}`));
|
|
24
|
+
}
|
|
21
25
|
if (agentId?.trim()) {
|
|
22
|
-
|
|
23
|
-
const workspaceDirById = path.join(STATE_DIR, `workspace-${agentId}`);
|
|
24
|
-
if (!roots.includes(workspaceDirById)) {
|
|
25
|
-
roots.push(workspaceDirById);
|
|
26
|
-
}
|
|
26
|
+
roots.push(path.join(STATE_DIR, `workspace-${agentId}`));
|
|
27
27
|
}
|
|
28
|
-
return roots;
|
|
28
|
+
return [...new Set(roots)];
|
|
29
29
|
}
|
|
30
30
|
/**
|
|
31
31
|
* 将原始文件名清洁化:
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const PASSTHROUGH_EXCLUDE = new Set(["event", "content", "timestamp"]);
|
|
2
|
+
export function buildPassthroughFromMsg(msg) {
|
|
3
|
+
const out = {};
|
|
4
|
+
for (const [k, v] of Object.entries(msg)) {
|
|
5
|
+
if (PASSTHROUGH_EXCLUDE.has(k))
|
|
6
|
+
continue;
|
|
7
|
+
if (v === undefined)
|
|
8
|
+
continue;
|
|
9
|
+
out[k] = v;
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
package/src/monitor.js
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import WebSocket from "ws";
|
|
8
8
|
import { handlePalzMessage } from "./bot.js";
|
|
9
|
+
import { sendToPalzIM } from "./send.js";
|
|
10
|
+
import { buildPassthroughFromMsg } from "./message-utils.js";
|
|
9
11
|
import { reportPalzActivity } from "./activity.js";
|
|
10
12
|
import { tracer, extractTraceparentContext, SpanStatusCode } from "./tracing.js";
|
|
11
13
|
const WS_CONNECT_TIMEOUT_MS = 15_000;
|
|
12
|
-
const WS_PING_INTERVAL_MS =
|
|
14
|
+
const WS_PING_INTERVAL_MS = 15_000;
|
|
13
15
|
const WS_PONG_TIMEOUT_MS = 70_000;
|
|
14
16
|
export async function monitorPalzProvider(params) {
|
|
15
17
|
const { cfg, config, runtime, abortSignal, accountId } = params;
|
|
@@ -47,9 +49,8 @@ export async function monitorPalzProvider(params) {
|
|
|
47
49
|
currentWs.close();
|
|
48
50
|
}
|
|
49
51
|
else if (currentWs.readyState === WebSocket.CONNECTING) {
|
|
50
|
-
// WebSocket 还在连接中,等 open 后再关,或者连接失败自动清理
|
|
51
52
|
currentWs.once("open", () => currentWs?.close());
|
|
52
|
-
currentWs.once("error", () => { });
|
|
53
|
+
currentWs.once("error", () => { });
|
|
53
54
|
}
|
|
54
55
|
currentWs = null;
|
|
55
56
|
}
|
|
@@ -127,7 +128,6 @@ export async function monitorPalzProvider(params) {
|
|
|
127
128
|
return;
|
|
128
129
|
const wasConnected = connectedAt > 0;
|
|
129
130
|
const stableMs = wasConnected ? Date.now() - connectedAt : 0;
|
|
130
|
-
// 4002: 被新连接替代,带退避重试
|
|
131
131
|
if (code === 4002) {
|
|
132
132
|
consecutive4002++;
|
|
133
133
|
if (consecutive4002 >= MAX_CONSECUTIVE_4002) {
|
|
@@ -142,14 +142,12 @@ export async function monitorPalzProvider(params) {
|
|
|
142
142
|
scheduleReconnect(delay, false);
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
|
-
// 4001: bot_id 缺失,不重连
|
|
146
145
|
if (code === 4001) {
|
|
147
146
|
error(`palz[${accountId}]: bot_id missing (code=4001), not reconnecting`);
|
|
148
147
|
cleanup();
|
|
149
148
|
resolve();
|
|
150
149
|
return;
|
|
151
150
|
}
|
|
152
|
-
// 其他断开:指数退避重连
|
|
153
151
|
if (wasConnected && stableMs > 10_000)
|
|
154
152
|
reconnectDelay = 1000;
|
|
155
153
|
log(`palz[${accountId}]: disconnected (code=${code}, reason=${reasonStr}, uptime=${Math.round(stableMs / 1000)}s, msgs=${messageCount}), reconnecting in ${reconnectDelay}ms`);
|
|
@@ -183,7 +181,6 @@ export async function monitorPalzProvider(params) {
|
|
|
183
181
|
try {
|
|
184
182
|
lastPingAt = Date.now();
|
|
185
183
|
ws.ping();
|
|
186
|
-
log(`palz[${accountId}]: [WS_HEARTBEAT] ping sent, last_pong_ago=${timeSinceLastPong}ms`);
|
|
187
184
|
}
|
|
188
185
|
catch (err) {
|
|
189
186
|
error(`palz[${accountId}]: [WS_HEARTBEAT] ping failed: ${err.message ?? err}`);
|
|
@@ -193,9 +190,11 @@ export async function monitorPalzProvider(params) {
|
|
|
193
190
|
});
|
|
194
191
|
ws.on("pong", () => {
|
|
195
192
|
const now = Date.now();
|
|
196
|
-
const rttMs = lastPingAt > 0 ? now - lastPingAt :
|
|
193
|
+
const rttMs = lastPingAt > 0 ? now - lastPingAt : 0;
|
|
197
194
|
lastPongAt = now;
|
|
198
|
-
|
|
195
|
+
if (rttMs > 1000) {
|
|
196
|
+
log(`palz[${accountId}]: [WS_HEARTBEAT] slow pong, rtt=${rttMs}ms`);
|
|
197
|
+
}
|
|
199
198
|
});
|
|
200
199
|
ws.on("message", (data) => {
|
|
201
200
|
const receivedAt = new Date();
|
|
@@ -207,7 +206,6 @@ export async function monitorPalzProvider(params) {
|
|
|
207
206
|
return;
|
|
208
207
|
}
|
|
209
208
|
messageCount++;
|
|
210
|
-
// 从 IM 上游的 traceparent 恢复 trace context
|
|
211
209
|
const parentCtx = extractTraceparentContext(msg.traceparent);
|
|
212
210
|
tracer.startActiveSpan("palz.ws_recv", {}, parentCtx, (span) => {
|
|
213
211
|
try {
|
|
@@ -222,11 +220,35 @@ export async function monitorPalzProvider(params) {
|
|
|
222
220
|
}).catch((err) => {
|
|
223
221
|
error(`palz[${accountId}]: [ACTIVITY_REPORT] unhandled error: ${err.message ?? err}`);
|
|
224
222
|
});
|
|
223
|
+
// agent_id 必填:从消息中取,缺失时回复 IM 错误并跳过 AI 分发
|
|
224
|
+
const messageAgentId = typeof msg.agent_id === "string" ? msg.agent_id.trim() : "";
|
|
225
|
+
if (!messageAgentId) {
|
|
226
|
+
error(`palz[${accountId}]: missing agent_id, skipping message msg_id=${msg.msg_id}`);
|
|
227
|
+
sendToPalzIM({
|
|
228
|
+
config,
|
|
229
|
+
conversationId: msg.conversation_id,
|
|
230
|
+
content: "消息缺少 agent_id,无法路由到 Agent。",
|
|
231
|
+
conversationType: msg.conversation_type || "direct",
|
|
232
|
+
msgId: msg.msg_id,
|
|
233
|
+
senderId: msg.sender_id,
|
|
234
|
+
msgType: msg.msg_type,
|
|
235
|
+
groupId: msg.conversation_type === "group" ? msg.conversation_id : undefined,
|
|
236
|
+
lobsterId: msg.lobster_id,
|
|
237
|
+
palzMsgType: "error",
|
|
238
|
+
passthrough: buildPassthroughFromMsg(msg),
|
|
239
|
+
log,
|
|
240
|
+
error,
|
|
241
|
+
}).catch((err) => {
|
|
242
|
+
error(`palz[${accountId}]: missing agent_id error reply failed: ${err.message ?? err}`);
|
|
243
|
+
});
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
225
246
|
handlePalzMessage({
|
|
226
247
|
cfg,
|
|
227
248
|
msg,
|
|
228
249
|
runtime,
|
|
229
250
|
accountId,
|
|
251
|
+
agentId: messageAgentId,
|
|
230
252
|
}).catch((err) => {
|
|
231
253
|
error(`palz[${accountId}]: [WS_RECV] handlePalzMessage unhandled error: ${err.message ?? err}`);
|
|
232
254
|
});
|
package/src/outbound.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* 实现 ChannelOutboundAdapter 接口,供 OpenClaw Runtime 发送主动消息
|
|
5
5
|
* (如定时任务 cron delivery、跨渠道路由等)。
|
|
6
6
|
*/
|
|
7
|
-
import { resolvePalzAccount } from "./config.js";
|
|
7
|
+
import { resolvePalzAccount, PALZ_MANAGER_ACCOUNT_ID } from "./config.js";
|
|
8
8
|
import { sendToPalzIM } from "./send.js";
|
|
9
9
|
import { loadMediaAsOssUrl } from "./media.js";
|
|
10
10
|
import { parsePalzTarget } from "./targets.js";
|
|
@@ -25,6 +25,11 @@ export const palzOutbound = {
|
|
|
25
25
|
const { cfg, to, text, accountId } = ctx;
|
|
26
26
|
const log = typeof ctx.log === "function" ? ctx.log : console.log;
|
|
27
27
|
log(`palz-outbound: [sendText] 输入: to="${to}" accountId="${accountId}" textLen=${text?.length || 0} text="${(text || "").slice(0, 120)}"`);
|
|
28
|
+
if (accountId === PALZ_MANAGER_ACCOUNT_ID) {
|
|
29
|
+
const err = new Error("Cannot send messages with manager account " + PALZ_MANAGER_ACCOUNT_ID);
|
|
30
|
+
log(`palz-outbound: [sendText] 拒绝: ${err.message}`);
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
28
33
|
const account = resolvePalzAccount({ cfg, accountId });
|
|
29
34
|
const { senderId, lobsterId, conversationId, conversationType } = parsePalzTarget(to);
|
|
30
35
|
log(`palz-outbound: [sendText] 解析: senderId="${senderId}" lobsterId="${lobsterId}" conversationId="${conversationId}" conversationType="${conversationType}" botId=${account.config.botId}`);
|
|
@@ -37,6 +42,7 @@ export const palzOutbound = {
|
|
|
37
42
|
senderId,
|
|
38
43
|
conversationType,
|
|
39
44
|
lobsterId,
|
|
45
|
+
log,
|
|
40
46
|
});
|
|
41
47
|
const output = { channel: "palz-connector", messageId: Date.now().toString() };
|
|
42
48
|
log(`palz-outbound: [sendText] 输出: ${JSON.stringify(output)} sendResult=${JSON.stringify(result)}`);
|
|
@@ -45,6 +51,11 @@ export const palzOutbound = {
|
|
|
45
51
|
sendMedia: async (ctx) => {
|
|
46
52
|
const { cfg, to, text, mediaUrl, accountId, mediaLocalRoots } = ctx;
|
|
47
53
|
const log = typeof ctx.log === "function" ? ctx.log : console.log;
|
|
54
|
+
if (accountId === PALZ_MANAGER_ACCOUNT_ID) {
|
|
55
|
+
const err = new Error("Cannot send media with manager account " + PALZ_MANAGER_ACCOUNT_ID);
|
|
56
|
+
log(`palz-outbound: [sendMedia] 拒绝: ${err.message}`);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
48
59
|
const account = resolvePalzAccount({ cfg, accountId });
|
|
49
60
|
const { senderId, lobsterId, conversationId, conversationType } = parsePalzTarget(to);
|
|
50
61
|
log(`palz-outbound: [sendMedia] 输入: to="${to}" accountId="${accountId}" textLen=${text?.length || 0} mediaUrl="${(mediaUrl || "").slice(0, 200)}"`);
|
|
@@ -72,6 +83,7 @@ export const palzOutbound = {
|
|
|
72
83
|
senderId,
|
|
73
84
|
conversationType,
|
|
74
85
|
lobsterId,
|
|
86
|
+
log,
|
|
75
87
|
});
|
|
76
88
|
const output = { channel: "palz-connector", messageId: Date.now().toString() };
|
|
77
89
|
log(`palz-outbound: [sendMedia] 输出: ${JSON.stringify(output)} sendResult=${JSON.stringify(result)}`);
|
package/src/reply-dispatcher.js
CHANGED
package/src/send.js
CHANGED
|
@@ -36,7 +36,9 @@ function normalizeContent(content) {
|
|
|
36
36
|
return content;
|
|
37
37
|
}
|
|
38
38
|
async function _sendToPalzIMInner(params) {
|
|
39
|
-
const { config, conversationId, content: rawContent, conversationType, msgId, senderId, stream, msgType, groupId, lobsterId, palzMsgType, toolContent, passthrough } = params;
|
|
39
|
+
const { config, conversationId, content: rawContent, conversationType, msgId, senderId, stream, msgType, groupId, lobsterId, palzMsgType, toolContent, passthrough, log: paramLog, error: paramError } = params;
|
|
40
|
+
const log = paramLog ?? console.log;
|
|
41
|
+
const error = paramError ?? console.error;
|
|
40
42
|
const content = normalizeContent(rawContent);
|
|
41
43
|
const url = `${config.apiBaseUrl}/bot/send`;
|
|
42
44
|
const resolvedMsgId = msgId || nextMsgId();
|
|
@@ -75,7 +77,7 @@ async function _sendToPalzIMInner(params) {
|
|
|
75
77
|
}
|
|
76
78
|
const reqBodyStr = JSON.stringify(reqBody);
|
|
77
79
|
const reqLog = `[HTTP_REQ] POST ${url} body_length=${reqBodyStr.length}\n request_body=${reqBodyStr}`;
|
|
78
|
-
|
|
80
|
+
log(`palz-send: ${reqLog}`);
|
|
79
81
|
span?.addEvent(reqLog);
|
|
80
82
|
// 构建请求 headers,注入 Traceparent
|
|
81
83
|
const headers = { "Content-Type": "application/json" };
|
|
@@ -99,7 +101,7 @@ async function _sendToPalzIMInner(params) {
|
|
|
99
101
|
const elapsedMs = Date.now() - startMs;
|
|
100
102
|
const reason = err?.name === "AbortError" ? `timeout ${SEND_TIMEOUT_MS}ms` : (err?.message ?? String(err));
|
|
101
103
|
const failLog = `[HTTP_RES] ERROR elapsed=${elapsedMs}ms reason=${reason}`;
|
|
102
|
-
|
|
104
|
+
error(`palz-send: ${failLog}`);
|
|
103
105
|
span?.addEvent(failLog);
|
|
104
106
|
throw err?.name === "AbortError"
|
|
105
107
|
? new Error(`Palz send timeout after ${SEND_TIMEOUT_MS}ms`)
|
|
@@ -117,12 +119,12 @@ async function _sendToPalzIMInner(params) {
|
|
|
117
119
|
catch { }
|
|
118
120
|
if (!response.ok) {
|
|
119
121
|
const failLog = `[HTTP_RES] FAILED status=${response.status} elapsed=${elapsedMs}ms\n response_headers=${JSON.stringify(Object.fromEntries(response.headers.entries()))}\n response_body=${rawText.slice(0, 500)}`;
|
|
120
|
-
|
|
122
|
+
error(`palz-send: ${failLog}`);
|
|
121
123
|
span?.addEvent(failLog);
|
|
122
124
|
throw new Error(`Palz send failed: ${response.status} ${rawText}`);
|
|
123
125
|
}
|
|
124
126
|
const okLog = `[HTTP_RES] OK status=${response.status} elapsed=${elapsedMs}ms msg_id=${resolvedMsgId}${traceparent ? ` Traceparent=${traceparent}` : ""}\n response_body=${rawText.slice(0, 500)}`;
|
|
125
|
-
|
|
127
|
+
log(`palz-send: ${okLog}`);
|
|
126
128
|
span?.addEvent(okLog);
|
|
127
129
|
return body;
|
|
128
130
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
const DEFAULT_OPENCLAW_DIR = path.join(os.homedir(), ".openclaw");
|
|
5
|
+
const USER_PREFIX = "user-";
|
|
6
|
+
const DEFAULT_SCAN_INTERVAL_MS = 5000;
|
|
7
|
+
export function scanUserDirs(openclawDir) {
|
|
8
|
+
const dir = openclawDir || DEFAULT_OPENCLAW_DIR;
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const userIds = [];
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (!entry.isDirectory()) {
|
|
19
|
+
if (entry.isSymbolicLink()) {
|
|
20
|
+
try {
|
|
21
|
+
const fullPath = path.join(dir, entry.name);
|
|
22
|
+
const stat = fs.statSync(fullPath);
|
|
23
|
+
if (!stat.isDirectory())
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (!entry.name.startsWith(USER_PREFIX))
|
|
35
|
+
continue;
|
|
36
|
+
if (entry.name.length <= USER_PREFIX.length)
|
|
37
|
+
continue;
|
|
38
|
+
userIds.push(entry.name);
|
|
39
|
+
}
|
|
40
|
+
return userIds.sort();
|
|
41
|
+
}
|
|
42
|
+
export function startUserDirScanner(opts) {
|
|
43
|
+
const dir = opts.openclawDir || DEFAULT_OPENCLAW_DIR;
|
|
44
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_SCAN_INTERVAL_MS;
|
|
45
|
+
const log = opts.log ?? console.log;
|
|
46
|
+
const error = opts.error ?? console.error;
|
|
47
|
+
let stopped = false;
|
|
48
|
+
let scanning = false;
|
|
49
|
+
let timer = null;
|
|
50
|
+
function doScan() {
|
|
51
|
+
if (stopped)
|
|
52
|
+
return [];
|
|
53
|
+
if (scanning)
|
|
54
|
+
return [];
|
|
55
|
+
scanning = true;
|
|
56
|
+
try {
|
|
57
|
+
const names = scanUserDirs(dir);
|
|
58
|
+
try {
|
|
59
|
+
opts.onSnapshot(names);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
error(`user-dir-scanner: onSnapshot error: ${err}`);
|
|
63
|
+
}
|
|
64
|
+
return names;
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
error(`user-dir-scanner: scan error: ${err}`);
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
scanning = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
log(`user-dir-scanner: starting, dir=${dir} interval=${intervalMs}ms`);
|
|
75
|
+
const initialNames = doScan();
|
|
76
|
+
log(`user-dir-scanner: initial scan found ${initialNames.length} user(s): [${initialNames.join(", ")}]`);
|
|
77
|
+
timer = setInterval(doScan, intervalMs);
|
|
78
|
+
return {
|
|
79
|
+
stop() {
|
|
80
|
+
if (stopped)
|
|
81
|
+
return;
|
|
82
|
+
stopped = true;
|
|
83
|
+
if (timer) {
|
|
84
|
+
clearInterval(timer);
|
|
85
|
+
timer = null;
|
|
86
|
+
}
|
|
87
|
+
log("user-dir-scanner: stopped");
|
|
88
|
+
},
|
|
89
|
+
scanNow() {
|
|
90
|
+
return doScan();
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|