lobster-roundtable 3.0.4 → 3.0.6

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/main.js CHANGED
@@ -22,6 +22,7 @@ const {
22
22
  getOpenClawApiPort,
23
23
  getOpenClawApiToken,
24
24
  } = require("./src/env.js");
25
+ const { readTextSafe, parseJsonFileFlexible } = require("./src/fileio.js");
25
26
  // Node.js 原生 HTTP 请求工具(避免依赖外部命令)
26
27
  function httpRequest(urlStr, options = {}) {
27
28
  return new Promise((resolve, reject) => {
@@ -60,7 +61,7 @@ try {
60
61
  }
61
62
 
62
63
  const CHANNEL_ID = "lobster-roundtable";
63
- const PLUGIN_VERSION = "3.0.4";
64
+ const PLUGIN_VERSION = "3.0.6";
64
65
  const ENABLE_OPENCLAW_CONFIG_SYNC = isOpenClawConfigSyncEnabled();
65
66
  const OPENCLAW_CONFIG_ALLOWED_KEYS = new Set(["url", "token", "ownerToken", "name", "persona", "maxTokens"]);
66
67
 
@@ -111,64 +112,6 @@ function ensureDirForFile(filePath) {
111
112
  fsModule.mkdirSync(pathModule.dirname(filePath), { recursive: true });
112
113
  }
113
114
 
114
- function readTextSafe(filePath) {
115
- try {
116
- return fsModule.existsSync(filePath) ? String(fsModule.readFileSync(filePath, "utf-8") || "").trim() : "";
117
- } catch {
118
- return "";
119
- }
120
- }
121
-
122
- function decodeFileBuffer(buf) {
123
- if (!Buffer.isBuffer(buf) || buf.length === 0) return "";
124
- // UTF-8 BOM
125
- if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
126
- return buf.slice(3).toString("utf8");
127
- }
128
- // UTF-16 LE BOM
129
- if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
130
- return buf.slice(2).toString("utf16le");
131
- }
132
- // UTF-16 BE BOM
133
- if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
134
- const swapped = Buffer.allocUnsafe(buf.length - 2);
135
- for (let i = 2; i + 1 < buf.length; i += 2) {
136
- swapped[i - 2] = buf[i + 1];
137
- swapped[i - 1] = buf[i];
138
- }
139
- return swapped.toString("utf16le");
140
- }
141
- // Heuristic: UTF-16 LE without BOM (lots of NUL bytes in odd positions)
142
- const sample = buf.slice(0, Math.min(buf.length, 128));
143
- let nulOdd = 0;
144
- for (let i = 1; i < sample.length; i += 2) {
145
- if (sample[i] === 0) nulOdd++;
146
- }
147
- if (sample.length > 8 && nulOdd >= sample.length / 6) {
148
- return buf.toString("utf16le");
149
- }
150
- return buf.toString("utf8");
151
- }
152
-
153
- function parseJsonFileFlexible(filePath) {
154
- const raw = fsModule.readFileSync(filePath);
155
- let text = decodeFileBuffer(raw);
156
- if (!text) return {};
157
- text = String(text).replace(/^\uFEFF/, "").trim();
158
- try {
159
- return JSON.parse(text);
160
- } catch {
161
- // Recover from contaminated files like: log-prefix + {...json...} + log-suffix
162
- const first = text.indexOf("{");
163
- const last = text.lastIndexOf("}");
164
- if (first >= 0 && last > first) {
165
- const candidate = text.slice(first, last + 1).trim();
166
- return JSON.parse(candidate);
167
- }
168
- throw new Error("invalid_json");
169
- }
170
- }
171
-
172
115
  function writeJsonUtf8NoBomAtomic(filePath, value) {
173
116
  const tmp = `${filePath}.tmp`;
174
117
  const out = JSON.stringify(value, null, 2);
@@ -227,21 +170,6 @@ async function requestAutoRegister(httpBase, payload) {
227
170
  }
228
171
  }
229
172
 
230
- async function requestTokenInfo(httpBase, token) {
231
- const safeToken = String(token || "").trim();
232
- if (!safeToken) return null;
233
- try {
234
- const res = await httpRequest(`${httpBase}/api/tokens/${encodeURIComponent(safeToken)}`, {
235
- timeout: 12000,
236
- });
237
- const parsed = JSON.parse(String(res.data || "{}"));
238
- if (parsed && typeof parsed === "object") return parsed;
239
- return null;
240
- } catch {
241
- return null;
242
- }
243
- }
244
-
245
173
  function upsertOpenClawPluginConfig(ocHome, wsUrl, updates, logger) {
246
174
  const ocConfigPath = pathModule.join(ocHome, "openclaw.json");
247
175
  if (!fsModule.existsSync(ocConfigPath)) return;
@@ -347,43 +275,25 @@ module.exports = async function initRoundtable(api, core, hasRuntimeAPI) {
347
275
  writeTextSafe(instanceIdFile, instanceId);
348
276
 
349
277
  const cachedToken = String(readTextSafe(tokenCacheFile) || "").trim();
350
- if (cachedToken && token && token !== cachedToken) {
351
- let useConfiguredToken = false;
352
- let decisionReason = "";
353
- if (ownerToken) {
354
- useConfiguredToken = true;
355
- decisionReason = "配置携带 ownerToken(视为新的安装绑定)";
356
- } else {
357
- try {
358
- const info = await requestTokenInfo(httpBase, token);
359
- if (info?.token && String(info.token).trim() === token) {
360
- useConfiguredToken = true;
361
- decisionReason = "配置 token 在服务端存在(视为新安装)";
362
- }
363
- } catch {
364
- // 服务端不可达时保持缓存优先,避免旧配置回滚
365
- }
366
- }
367
-
368
- if (useConfiguredToken) {
369
- api.logger.warn(`[roundtable] 🔁 检测到新 token,采用配置 token(${decisionReason})`);
370
- writeTextSafe(tokenCacheFile, token);
371
- } else {
372
- api.logger.warn("[roundtable] ⚠️ 检测到配置 token 与本地缓存不一致,优先使用缓存 token");
373
- token = cachedToken;
374
- tokenSource = 'cache';
278
+ if (token) {
279
+ if (cachedToken && token !== cachedToken) {
280
+ api.logger.warn("[roundtable] 🔁 检测到配置 token 与本地缓存不一致,按配置 token 启动并覆盖缓存");
281
+ } else if (cachedToken && token === cachedToken) {
282
+ tokenSource = 'config+cache';
283
+ api.logger.info("[roundtable] 📦 命中本地 token 缓存");
375
284
  }
376
- } else if (cachedToken && !token) {
285
+ writeTextSafe(tokenCacheFile, token);
286
+ } else if (cachedToken) {
377
287
  api.logger.info("[roundtable] 📦 从本地缓存加载 token");
378
288
  token = cachedToken;
379
289
  tokenSource = 'cache';
380
- } else if (cachedToken && token === cachedToken) {
381
- api.logger.info("[roundtable] 📦 命中本地 token 缓存");
382
- tokenSource = 'config+cache';
383
290
  }
384
291
 
385
292
  const cachedOwnerToken = normalizeIdentityId(readTextSafe(ownerTokenCacheFile), 128);
386
- if (!ownerToken && cachedOwnerToken) {
293
+ if (ownerToken && cachedOwnerToken && ownerToken !== cachedOwnerToken) {
294
+ api.logger.warn("[roundtable] 🔁 检测到配置 ownerToken 与缓存不一致,按配置 ownerToken 覆盖缓存");
295
+ writeTextSafe(ownerTokenCacheFile, ownerToken);
296
+ } else if (!ownerToken && cachedOwnerToken) {
387
297
  ownerToken = cachedOwnerToken;
388
298
  api.logger.info("[roundtable] 🔗 从本地缓存恢复 ownerToken");
389
299
  }
@@ -487,6 +397,8 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
487
397
  const sessionId = normalizeIdentityId(identityCtx.sessionId, 120) || cryptoModule.randomBytes(12).toString("hex");
488
398
  const tokenSource = identityCtx.tokenSource || 'unknown';
489
399
  let recoveringToken = false;
400
+ let ackReceivedForSocket = false;
401
+ let awaitingAckTimer = null;
490
402
 
491
403
  // Token 冲突熔断器:防止无限重试导致日志爆炸和 CPU 空转
492
404
  const conflictBreaker = {
@@ -527,6 +439,29 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
527
439
  if (ownerToken) writeTextSafe(ownerTokenCacheFile, ownerToken);
528
440
  }
529
441
 
442
+ function clearAckTimer() {
443
+ if (awaitingAckTimer) {
444
+ clearTimeout(awaitingAckTimer);
445
+ awaitingAckTimer = null;
446
+ }
447
+ }
448
+
449
+ function armAckTimer() {
450
+ clearAckTimer();
451
+ if (!token || !ws || ws.readyState !== WebSocket.OPEN) return;
452
+ awaitingAckTimer = setTimeout(() => {
453
+ if (stopping || !ws || ws.readyState !== WebSocket.OPEN || ackReceivedForSocket) return;
454
+ api.logger.warn("[roundtable] ⚠️ 已连接 WS 但未收到 bot_ack,主动重连");
455
+ reportDiag("bot_ack_timeout", {
456
+ level: "warn",
457
+ message: "websocket open but bot_ack not received in time",
458
+ detail: { timeoutMs: 12000, tokenSource },
459
+ }, 0);
460
+ try { ws.close(4000, "bot-ack-timeout"); } catch { }
461
+ }, 12000);
462
+ if (awaitingAckTimer.unref) awaitingAckTimer.unref();
463
+ }
464
+
530
465
  function nextReconnectDelayMs() {
531
466
  const base = Math.min(30000, Math.round(2500 * Math.pow(1.6, reconnectAttempts)));
532
467
  reconnectAttempts += 1;
@@ -619,10 +554,17 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
619
554
  } catch { }
620
555
  }
621
556
 
622
- function safeDiagText(v, max = 280) {
623
- const s = String(v || "").trim();
624
- return s.length > max ? s.slice(0, max) : s;
625
- }
557
+ function safeDiagText(v, max = 280) {
558
+ const s = String(v || "").trim();
559
+ return s.length > max ? s.slice(0, max) : s;
560
+ }
561
+
562
+ function maskTokenForDiag(raw) {
563
+ const s = String(raw || "").trim();
564
+ if (!s) return "";
565
+ if (s.length <= 8) return s;
566
+ return `${s.slice(0, 6)}...${s.slice(-4)}`;
567
+ }
626
568
 
627
569
  function reportDiag(event, payload = {}, throttleMs = 8000) {
628
570
  if (!event) return;
@@ -647,7 +589,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
647
589
  roomMode: currentRoomMode || "",
648
590
  wsUrl,
649
591
  botName: myName || "",
650
- token: token || "",
592
+ tokenMasked: maskTokenForDiag(token),
651
593
  instanceId: instanceId || "",
652
594
  sessionId: sessionId || "",
653
595
  detail: payload.detail && typeof payload.detail === "object" ? payload.detail : null,
@@ -669,7 +611,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
669
611
  roomMode: currentRoomMode || "",
670
612
  wsUrl,
671
613
  botName: myName || "",
672
- token: token || "",
614
+ tokenMasked: maskTokenForDiag(token),
673
615
  instanceId: instanceId || "",
674
616
  sessionId: sessionId || "",
675
617
  stack: safeDiagText(payload.stack || "", 1200),
@@ -753,6 +695,8 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
753
695
 
754
696
  ws.onopen = () => {
755
697
  reconnectAttempts = 0;
698
+ ackReceivedForSocket = false;
699
+ clearAckTimer();
756
700
  reportDiag("ws_open", {
757
701
  message: token ? "connected with token" : "connected in observe mode",
758
702
  detail: { instanceId, sessionId },
@@ -781,6 +725,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
781
725
  pluginVersion: PLUGIN_VERSION,
782
726
  tokenSource: tokenSource || 'unknown',
783
727
  });
728
+ armAckTimer();
784
729
  api.logger.info(`[roundtable] 🔗 bot_connect 已发送 (token=${token.slice(0, 8)}... source=${tokenSource || 'unknown'})`);
785
730
  };
786
731
 
@@ -805,6 +750,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
805
750
  };
806
751
 
807
752
  ws.onclose = (evt) => {
753
+ clearAckTimer();
808
754
  // 断连后立即清理本地 room 状态,避免重连后状态错乱
809
755
  currentRoomId = null;
810
756
  currentRoomMode = null;
@@ -1600,6 +1546,8 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
1600
1546
 
1601
1547
  switch (msg.type) {
1602
1548
  case "bot_ack":
1549
+ ackReceivedForSocket = true;
1550
+ clearAckTimer();
1603
1551
  // 在大厅待机中
1604
1552
  if (msg.status === 'lobby') {
1605
1553
  myName = msg.name || myName;
@@ -1982,6 +1930,7 @@ function startBot(api, core, cfg, wsUrl, token, persona, maxTokens, tokenCacheFi
1982
1930
  if (heartbeatTimer) { clearInterval(heartbeatTimer); }
1983
1931
  // 停止重连
1984
1932
  if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
1933
+ clearAckTimer();
1985
1934
  // 关闭 WS
1986
1935
  if (ws) {
1987
1936
  try { ws.close(1000, "shutdown"); } catch { }
@@ -5,7 +5,7 @@
5
5
  "lobster-roundtable"
6
6
  ],
7
7
  "description": "Connect OpenClaw to the Lobster Roundtable service.",
8
- "version": "3.0.4",
8
+ "version": "3.0.6",
9
9
  "configSchema": {
10
10
  "type": "object",
11
11
  "additionalProperties": false,
@@ -67,4 +67,4 @@
67
67
  "placeholder": "龙虾"
68
68
  }
69
69
  }
70
- }
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lobster-roundtable",
3
- "version": "3.0.4",
3
+ "version": "3.0.6",
4
4
  "description": "🦞 龙虾圆桌 OpenClaw 标准 Channel 插件 - 让你的 AI 自动参与多智能体圆桌讨论",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -41,4 +41,4 @@
41
41
  "optional": true
42
42
  }
43
43
  }
44
- }
44
+ }
package/src/channel.js CHANGED
@@ -16,7 +16,22 @@ const CHANNEL_ID = "lobster-roundtable";
16
16
 
17
17
  function createOutboundMessageId() {
18
18
  return `rt-outbound-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
19
- }
19
+ }
20
+
21
+ function isExplicitConfigValue(value) {
22
+ if (value === undefined || value === null) return false;
23
+ if (typeof value === "string" && value.trim() === "") return false;
24
+ return true;
25
+ }
26
+
27
+ function mergeConfigPreferExplicitChannel(pluginEntryConfig, channelConfig) {
28
+ const merged = { ...(pluginEntryConfig || {}) };
29
+ for (const [key, value] of Object.entries(channelConfig || {})) {
30
+ if (!isExplicitConfigValue(value)) continue;
31
+ merged[key] = value;
32
+ }
33
+ return merged;
34
+ }
20
35
 
21
36
  /**
22
37
  * �?ChannelGatewayContext 适配�?main.js 所需�?api 兼容对象
@@ -27,12 +42,12 @@ function createOutboundMessageId() {
27
42
  * - api.runtime �?ctx.runtime
28
43
  * - api.config �?ctx.cfg (完�?OpenClawConfig,用�?dispatchReply �?cfg 参数�?
29
44
  */
30
- function buildApiAdapter(ctx) {
31
- // 双源读取:channels.<id>(官方标准路径)优先,plugins.entries(兼容现有安装)降级
32
- const channelConfig = ctx.cfg?.channels?.[CHANNEL_ID] || {};
33
- const pluginEntryConfig = ctx.cfg?.plugins?.entries?.[CHANNEL_ID]?.config || {};
34
- // channel 路径覆盖 plugin 路径(向标准迁移�?
35
- const pluginConfig = { ...pluginEntryConfig, ...channelConfig };
45
+ function buildApiAdapter(ctx) {
46
+ // 双源读取:channels.<id>(官方标准路径)优先,plugins.entries(兼容现有安装)降级。
47
+ // 关键:channels 中空字符串不覆盖 plugins.entries 中已有 token/url,避免“loaded 但离线”。
48
+ const channelConfig = ctx.cfg?.channels?.[CHANNEL_ID] || {};
49
+ const pluginEntryConfig = ctx.cfg?.plugins?.entries?.[CHANNEL_ID]?.config || {};
50
+ const pluginConfig = mergeConfigPreferExplicitChannel(pluginEntryConfig, channelConfig);
36
51
 
37
52
  return {
38
53
  // main.js �?api.logger.info/warn/error
@@ -76,12 +91,14 @@ const roundtablePlugin = {
76
91
  reload: { configPrefixes: [`channels.${CHANNEL_ID}`, `plugins.entries.${CHANNEL_ID}`] },
77
92
 
78
93
  config: {
79
- listAccountIds: () => ["default"],
80
- resolveAccount: (cfg, accountId) => {
81
- const pluginCfg = cfg?.plugins?.entries?.[CHANNEL_ID]?.config || {};
82
- return {
83
- accountId: accountId || "default",
84
- enabled: true,
94
+ listAccountIds: () => ["default"],
95
+ resolveAccount: (cfg, accountId) => {
96
+ const channelConfig = cfg?.channels?.[CHANNEL_ID] || {};
97
+ const pluginEntryConfig = cfg?.plugins?.entries?.[CHANNEL_ID]?.config || {};
98
+ const pluginCfg = mergeConfigPreferExplicitChannel(pluginEntryConfig, channelConfig);
99
+ return {
100
+ accountId: accountId || "default",
101
+ enabled: true,
85
102
  // main.js 支持默认 URL + 自动注册,因此始终视�?configured
86
103
  // 不拦截零配置启动,让 main.js 自己处理
87
104
  configured: true,
package/src/fileio.js ADDED
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+
5
+ function readTextSafe(filePath) {
6
+ try {
7
+ return fs.existsSync(filePath) ? String(fs.readFileSync(filePath, "utf-8") || "").trim() : "";
8
+ } catch {
9
+ return "";
10
+ }
11
+ }
12
+
13
+ function decodeFileBuffer(buf) {
14
+ if (!Buffer.isBuffer(buf) || buf.length === 0) return "";
15
+ if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
16
+ return buf.slice(3).toString("utf8");
17
+ }
18
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
19
+ return buf.slice(2).toString("utf16le");
20
+ }
21
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
22
+ const swapped = Buffer.allocUnsafe(buf.length - 2);
23
+ for (let i = 2; i + 1 < buf.length; i += 2) {
24
+ swapped[i - 2] = buf[i + 1];
25
+ swapped[i - 1] = buf[i];
26
+ }
27
+ return swapped.toString("utf16le");
28
+ }
29
+ const sample = buf.slice(0, Math.min(buf.length, 128));
30
+ let nulOdd = 0;
31
+ for (let i = 1; i < sample.length; i += 2) {
32
+ if (sample[i] === 0) nulOdd++;
33
+ }
34
+ if (sample.length > 8 && nulOdd >= sample.length / 6) {
35
+ return buf.toString("utf16le");
36
+ }
37
+ return buf.toString("utf8");
38
+ }
39
+
40
+ function parseJsonFileFlexible(filePath) {
41
+ const raw = fs.readFileSync(filePath);
42
+ let text = decodeFileBuffer(raw);
43
+ if (!text) return {};
44
+ text = String(text).replace(/^\uFEFF/, "").trim();
45
+ try {
46
+ return JSON.parse(text);
47
+ } catch {
48
+ const first = text.indexOf("{");
49
+ const last = text.lastIndexOf("}");
50
+ if (first >= 0 && last > first) {
51
+ const candidate = text.slice(first, last + 1).trim();
52
+ return JSON.parse(candidate);
53
+ }
54
+ throw new Error("invalid_json");
55
+ }
56
+ }
57
+
58
+ module.exports = {
59
+ readTextSafe,
60
+ parseJsonFileFlexible,
61
+ };