palz-connector 1.5.6 → 1.5.8

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "palz-connector",
3
3
  "name": "Palz Connector Channel",
4
- "version": "1.5.6",
4
+ "version": "1.5.8",
5
5
  "description": "Palz IM 接入 OpenClaw",
6
6
  "channels": [
7
7
  "palz-connector"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "palz-connector",
3
- "version": "1.5.6",
3
+ "version": "1.5.8",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "description": "Palz IM 接入 OpenClaw — 模块化架构,基于 OpenClaw Runtime 消息管道",
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 = (process.env.botID || config.botId || "").trim();
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", msg.agent_id || "main");
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 = msg.agent_id || "main";
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
- // IM 指定 agent_id 时走指定 agent,否则强制走 main
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} (${msg.agent_id ? "IM指定" : "默认main"})`;
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
- const { monitorPalzProvider } = await import("./monitor.js");
74
- const account = resolvePalzAccount({ cfg: ctx.cfg, accountId: ctx.accountId });
75
- logInfo("palz-gateway: [startAccount] account解析: " + JSON.stringify({ accountId: account.accountId, enabled: account.enabled, configured: account.configured, name: account.name, botId: account.config.botId, streamUrl: account.config.streamUrl }));
76
- if (!account.configured) {
77
- const errMsg = "Palz account " + JSON.stringify(ctx.accountId) + " not configured (missing botId/streamUrl/apiBaseUrl)";
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] 启动WebSocket监听 botId=" + account.config.botId + " streamUrl=" + account.config.streamUrl);
82
- return monitorPalzProvider({
92
+ logInfo("palz-gateway: [startAccount] 启动 manager, streamUrl=" + baseConfig.streamUrl);
93
+ const manager = new ConnectionManager({
83
94
  cfg: ctx.cfg,
84
- config: account.config,
95
+ baseConfig,
85
96
  runtime: ctx.runtime,
86
- abortSignal: ctx.abortSignal,
87
- accountId: ctx.accountId,
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 DEFAULT_ACCOUNT_ID = "__default__";
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: process.env.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: env.botID=" + JSON.stringify(process.env.botID) + " configFile=" + JSON.stringify(file));
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 = !config.enabled || !config.botId ? [] : [config.botId];
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(id));
88
+ console.log("palz-config: [defaultAccountId] output: " + JSON.stringify(PALZ_MANAGER_ACCOUNT_ID));
91
89
  }
92
- return id;
90
+ return PALZ_MANAGER_ACCOUNT_ID;
93
91
  }
94
92
  export function resolvePalzAccount(params) {
95
- const config = resolvePalzConfig();
96
- const accountId = params.accountId?.trim() || config.botId || DEFAULT_ACCOUNT_ID;
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: Boolean(config.botId && config.streamUrl && config.apiBaseUrl),
101
- name: "Palz Connector (" + (config.botId || "unconfigured") + ")",
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
- // workspace-{agentId} 形式(非 default agent,或 main 作为非 default 时)
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
@@ -9,7 +9,7 @@ import { handlePalzMessage } from "./bot.js";
9
9
  import { reportPalzActivity } from "./activity.js";
10
10
  import { tracer, extractTraceparentContext, SpanStatusCode } from "./tracing.js";
11
11
  const WS_CONNECT_TIMEOUT_MS = 15_000;
12
- const WS_PING_INTERVAL_MS = 30_000;
12
+ const WS_PING_INTERVAL_MS = 15_000;
13
13
  const WS_PONG_TIMEOUT_MS = 70_000;
14
14
  export async function monitorPalzProvider(params) {
15
15
  const { cfg, config, runtime, abortSignal, accountId } = params;
@@ -47,9 +47,8 @@ export async function monitorPalzProvider(params) {
47
47
  currentWs.close();
48
48
  }
49
49
  else if (currentWs.readyState === WebSocket.CONNECTING) {
50
- // WebSocket 还在连接中,等 open 后再关,或者连接失败自动清理
51
50
  currentWs.once("open", () => currentWs?.close());
52
- currentWs.once("error", () => { }); // 防止 unhandled error
51
+ currentWs.once("error", () => { });
53
52
  }
54
53
  currentWs = null;
55
54
  }
@@ -127,7 +126,6 @@ export async function monitorPalzProvider(params) {
127
126
  return;
128
127
  const wasConnected = connectedAt > 0;
129
128
  const stableMs = wasConnected ? Date.now() - connectedAt : 0;
130
- // 4002: 被新连接替代,带退避重试
131
129
  if (code === 4002) {
132
130
  consecutive4002++;
133
131
  if (consecutive4002 >= MAX_CONSECUTIVE_4002) {
@@ -142,14 +140,12 @@ export async function monitorPalzProvider(params) {
142
140
  scheduleReconnect(delay, false);
143
141
  return;
144
142
  }
145
- // 4001: bot_id 缺失,不重连
146
143
  if (code === 4001) {
147
144
  error(`palz[${accountId}]: bot_id missing (code=4001), not reconnecting`);
148
145
  cleanup();
149
146
  resolve();
150
147
  return;
151
148
  }
152
- // 其他断开:指数退避重连
153
149
  if (wasConnected && stableMs > 10_000)
154
150
  reconnectDelay = 1000;
155
151
  log(`palz[${accountId}]: disconnected (code=${code}, reason=${reasonStr}, uptime=${Math.round(stableMs / 1000)}s, msgs=${messageCount}), reconnecting in ${reconnectDelay}ms`);
@@ -183,7 +179,6 @@ export async function monitorPalzProvider(params) {
183
179
  try {
184
180
  lastPingAt = Date.now();
185
181
  ws.ping();
186
- log(`palz[${accountId}]: [WS_HEARTBEAT] ping sent, last_pong_ago=${timeSinceLastPong}ms`);
187
182
  }
188
183
  catch (err) {
189
184
  error(`palz[${accountId}]: [WS_HEARTBEAT] ping failed: ${err.message ?? err}`);
@@ -193,9 +188,11 @@ export async function monitorPalzProvider(params) {
193
188
  });
194
189
  ws.on("pong", () => {
195
190
  const now = Date.now();
196
- const rttMs = lastPingAt > 0 ? now - lastPingAt : null;
191
+ const rttMs = lastPingAt > 0 ? now - lastPingAt : 0;
197
192
  lastPongAt = now;
198
- log(`palz[${accountId}]: [WS_HEARTBEAT] pong received${rttMs === null ? "" : `, rtt=${rttMs}ms`}`);
193
+ if (rttMs > 1000) {
194
+ log(`palz[${accountId}]: [WS_HEARTBEAT] slow pong, rtt=${rttMs}ms`);
195
+ }
199
196
  });
200
197
  ws.on("message", (data) => {
201
198
  const receivedAt = new Date();
@@ -207,7 +204,6 @@ export async function monitorPalzProvider(params) {
207
204
  return;
208
205
  }
209
206
  messageCount++;
210
- // 从 IM 上游的 traceparent 恢复 trace context
211
207
  const parentCtx = extractTraceparentContext(msg.traceparent);
212
208
  tracer.startActiveSpan("palz.ws_recv", {}, parentCtx, (span) => {
213
209
  try {
@@ -222,11 +218,19 @@ export async function monitorPalzProvider(params) {
222
218
  }).catch((err) => {
223
219
  error(`palz[${accountId}]: [ACTIVITY_REPORT] unhandled error: ${err.message ?? err}`);
224
220
  });
221
+ // agent_id:从消息中取,缺失/空时默认为 {userId}-main
222
+ let messageAgentId = typeof msg.agent_id === "string" ? msg.agent_id.trim() : "";
223
+ if (!messageAgentId) {
224
+ messageAgentId = `${accountId}-main`;
225
+ msg.agent_id = messageAgentId;
226
+ log(`palz[${accountId}]: agent_id missing, defaulting to "${messageAgentId}" msg_id=${msg.msg_id}`);
227
+ }
225
228
  handlePalzMessage({
226
229
  cfg,
227
230
  msg,
228
231
  runtime,
229
232
  accountId,
233
+ agentId: messageAgentId,
230
234
  }).catch((err) => {
231
235
  error(`palz[${accountId}]: [WS_RECV] handlePalzMessage unhandled error: ${err.message ?? err}`);
232
236
  });
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)}`);
@@ -62,6 +62,8 @@ export function createPalzReplyDispatcher(params) {
62
62
  palzMsgType,
63
63
  toolContent,
64
64
  passthrough,
65
+ log,
66
+ error,
65
67
  });
66
68
  log(`${tag}: [DISPATCHER←sendToIM] 输出: ${JSON.stringify(result)}`);
67
69
  return result;
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
- console.log(`palz-send: ${reqLog}`);
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
- console.error(`palz-send: ${failLog}`);
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
- console.error(`palz-send: ${failLog}`);
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
- console.log(`palz-send: ${okLog}`);
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
+ }