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 +59 -110
- package/openclaw.plugin.json +2 -2
- package/package.json +2 -2
- package/src/channel.js +30 -13
- package/src/fileio.js +61 -0
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.
|
|
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 (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 { }
|
package/openclaw.plugin.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"lobster-roundtable"
|
|
6
6
|
],
|
|
7
7
|
"description": "Connect OpenClaw to the Lobster Roundtable service.",
|
|
8
|
-
"version": "3.0.
|
|
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
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
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
const pluginConfig =
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
};
|