chatccc 0.2.202 → 0.2.204
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/README.md +20 -14
- package/config.sample.json +8 -5
- package/package.json +1 -1
- package/src/__tests__/config-reload.test.ts +19 -6
- package/src/__tests__/config-sample.test.ts +10 -1
- package/src/__tests__/startup-lifecycle.test.ts +98 -0
- package/src/__tests__/update-command-guard.test.ts +144 -0
- package/src/__tests__/web-ui.test.ts +59 -0
- package/src/config.ts +19 -11
- package/src/index.ts +107 -28
- package/src/orchestrator.ts +54 -21
- package/src/startup-lifecycle.ts +96 -0
- package/src/update-command-guard.ts +165 -0
- package/src/web-ui.ts +185 -127
package/src/index.ts
CHANGED
|
@@ -28,12 +28,18 @@ import { WSClient, EventDispatcher, Domain } from "@larksuiteoapi/node-sdk";
|
|
|
28
28
|
import WebSocket from "ws";
|
|
29
29
|
|
|
30
30
|
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, waitForPortFree } from "./shared.ts";
|
|
31
|
-
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
31
|
+
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
|
|
32
|
+
import {
|
|
33
|
+
buildWebUiUrl,
|
|
34
|
+
openWebUiInDefaultBrowser,
|
|
35
|
+
shouldAutoOpenWebUi,
|
|
36
|
+
} from "./startup-lifecycle.ts";
|
|
32
37
|
import { buildPlatformStartupPlan } from "./platform-startup.ts";
|
|
33
38
|
import { makeTraceId, logTrace } from "./trace.ts";
|
|
34
39
|
import {
|
|
35
|
-
CHATCCC_PORT,
|
|
36
|
-
|
|
40
|
+
CHATCCC_PORT,
|
|
41
|
+
config,
|
|
42
|
+
APP_ID,
|
|
37
43
|
APP_SECRET,
|
|
38
44
|
FEISHU_ENABLED,
|
|
39
45
|
FEISHU_PLATFORM_TYPE,
|
|
@@ -189,16 +195,21 @@ function getInnerEvent(data: Evt): InnerEvent {
|
|
|
189
195
|
return (data.event ?? data) as InnerEvent;
|
|
190
196
|
}
|
|
191
197
|
|
|
192
|
-
import { formatMessageContent } from "./format-message.ts";
|
|
198
|
+
import { formatMessageContent } from "./format-message.ts";
|
|
199
|
+
import {
|
|
200
|
+
buildUpdateCommandId,
|
|
201
|
+
extractFeishuEventId,
|
|
202
|
+
} from "./update-command-guard.ts";
|
|
193
203
|
|
|
194
204
|
// ---------------------------------------------------------------------------
|
|
195
205
|
// Card action helper: parse button click into text command
|
|
196
206
|
// ---------------------------------------------------------------------------
|
|
197
207
|
|
|
198
|
-
interface CardActionResult {
|
|
199
|
-
text: string;
|
|
200
|
-
chatId: string;
|
|
201
|
-
openId: string;
|
|
208
|
+
interface CardActionResult {
|
|
209
|
+
text: string;
|
|
210
|
+
chatId: string;
|
|
211
|
+
openId: string;
|
|
212
|
+
commandId?: string;
|
|
202
213
|
}
|
|
203
214
|
|
|
204
215
|
function parseCardAction(data: unknown): CardActionResult | null {
|
|
@@ -237,7 +248,12 @@ function parseCardAction(data: unknown): CardActionResult | null {
|
|
|
237
248
|
((raw as Record<string, unknown>).operator as Record<string, unknown>)?.open_id as string ??
|
|
238
249
|
"";
|
|
239
250
|
|
|
240
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
text,
|
|
253
|
+
chatId,
|
|
254
|
+
openId,
|
|
255
|
+
commandId: buildUpdateCommandId("card", extractFeishuEventId(data)),
|
|
256
|
+
};
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
// ---------------------------------------------------------------------------
|
|
@@ -432,7 +448,18 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
432
448
|
const delayToken = await getTenantAccessToken();
|
|
433
449
|
await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
|
|
434
450
|
}
|
|
435
|
-
|
|
451
|
+
// 仅 `/update` 会使用这个稳定 ID 做跨重启幂等;其他命令仍沿用
|
|
452
|
+
// processedMessages 的进程内去重。
|
|
453
|
+
await handleCommand(
|
|
454
|
+
feishuPlatform,
|
|
455
|
+
text,
|
|
456
|
+
chatId,
|
|
457
|
+
openId,
|
|
458
|
+
msgTimestamp,
|
|
459
|
+
chatType,
|
|
460
|
+
traceId,
|
|
461
|
+
buildUpdateCommandId("message", messageId),
|
|
462
|
+
);
|
|
436
463
|
} catch (err) {
|
|
437
464
|
logTrace(traceId, "ERROR", { message: (err as Error).message });
|
|
438
465
|
console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
|
|
@@ -483,7 +510,16 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
483
510
|
const result = parseCardAction(data);
|
|
484
511
|
if (!result) return;
|
|
485
512
|
console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
|
|
486
|
-
handleCommand(
|
|
513
|
+
handleCommand(
|
|
514
|
+
feishuPlatform,
|
|
515
|
+
result.text,
|
|
516
|
+
result.chatId,
|
|
517
|
+
result.openId,
|
|
518
|
+
Date.now(),
|
|
519
|
+
"group",
|
|
520
|
+
undefined,
|
|
521
|
+
result.commandId,
|
|
522
|
+
).catch((err) =>
|
|
487
523
|
console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
|
|
488
524
|
);
|
|
489
525
|
} catch (err) {
|
|
@@ -508,7 +544,16 @@ async function startBotServiceCore(): Promise<void> {
|
|
|
508
544
|
const data = JSON.parse(raw.toString()) as Evt;
|
|
509
545
|
const action = parseCardAction(data);
|
|
510
546
|
if (action) {
|
|
511
|
-
handleCommand(
|
|
547
|
+
handleCommand(
|
|
548
|
+
feishuPlatform,
|
|
549
|
+
action.text,
|
|
550
|
+
action.chatId,
|
|
551
|
+
action.openId,
|
|
552
|
+
Date.now(),
|
|
553
|
+
"group",
|
|
554
|
+
undefined,
|
|
555
|
+
action.commandId,
|
|
556
|
+
).catch((err) =>
|
|
512
557
|
console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
|
|
513
558
|
);
|
|
514
559
|
return;
|
|
@@ -681,11 +726,17 @@ async function startConfiguredPlatforms(
|
|
|
681
726
|
// Main
|
|
682
727
|
// ---------------------------------------------------------------------------
|
|
683
728
|
|
|
684
|
-
async function main(): Promise<void> {
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
729
|
+
async function main(): Promise<void> {
|
|
730
|
+
// 用户直接运行 chatccc 时打开系统默认浏览器;由 `/restart`、`/update`
|
|
731
|
+
// 或 Web UI 拉起的替代进程会携带内部标记,不重复打扰用户。
|
|
732
|
+
const autoOpenWebUi = shouldAutoOpenWebUi({
|
|
733
|
+
openOnStart: config.webUi.openOnStart,
|
|
734
|
+
});
|
|
735
|
+
appendStartupTrace("main: entered", {
|
|
736
|
+
argv: process.argv.join(" ").slice(0, 400),
|
|
737
|
+
CHATCCC_PORT,
|
|
738
|
+
PROJECT_ROOT,
|
|
739
|
+
autoOpenWebUi,
|
|
689
740
|
});
|
|
690
741
|
|
|
691
742
|
// 黑匣子:所有未捕获异常 / 信号 / beforeExit 都同步写入 startup-trace.log(appendFileSync)。
|
|
@@ -741,9 +792,18 @@ async function main(): Promise<void> {
|
|
|
741
792
|
console.log(`${"=".repeat(60)}`);
|
|
742
793
|
console.log(` 发送消息: POST http://127.0.0.1:${SIM_PORT}/api/sim/inject-message`);
|
|
743
794
|
console.log(` 消息日志: ~/.chatccc/sim/messages.jsonl`);
|
|
744
|
-
console.log(`${"=".repeat(60)}\n`);
|
|
745
|
-
|
|
746
|
-
|
|
795
|
+
console.log(`${"=".repeat(60)}\n`);
|
|
796
|
+
|
|
797
|
+
if (autoOpenWebUi) {
|
|
798
|
+
const url = buildWebUiUrl(SIM_PORT);
|
|
799
|
+
const opened = openWebUiInDefaultBrowser(SIM_PORT);
|
|
800
|
+
appendStartupTrace(
|
|
801
|
+
opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable",
|
|
802
|
+
{ url },
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
installShutdownHandlers(simServer);
|
|
747
807
|
return;
|
|
748
808
|
}
|
|
749
809
|
|
|
@@ -791,8 +851,9 @@ async function main(): Promise<void> {
|
|
|
791
851
|
if (!APP_ID.trim() || !APP_SECRET.trim()) {
|
|
792
852
|
// 凭证不全:进 setup 向导。注入 onActivate 回调让用户点"保存并启动"
|
|
793
853
|
// 时,原地(同进程)调用 startBotService,复用 setup HTTP server。
|
|
794
|
-
startSetupMode(CHATCCC_PORT, {
|
|
795
|
-
|
|
854
|
+
startSetupMode(CHATCCC_PORT, {
|
|
855
|
+
openBrowser: autoOpenWebUi,
|
|
856
|
+
onActivate: async (httpServer: Server) => {
|
|
796
857
|
reloadRuntimeConfig("setup-activate");
|
|
797
858
|
appendStartupTrace("setup-activate: reloaded config from disk", {
|
|
798
859
|
appIdMaskAfterReload: maskAppId(APP_ID),
|
|
@@ -828,17 +889,35 @@ async function main(): Promise<void> {
|
|
|
828
889
|
await waitForPortFree(CHATCCC_PORT);
|
|
829
890
|
appendStartupTrace("main: port free confirmed", { CHATCCC_PORT });
|
|
830
891
|
}
|
|
831
|
-
const httpServer = createServer(createUiRouter());
|
|
832
|
-
await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
|
|
892
|
+
const httpServer = createServer(createUiRouter());
|
|
893
|
+
await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err: NodeJS.ErrnoException) => {
|
|
833
894
|
console.error(`\n[启动] 本地中继 WebSocket 监听失败:端口 ${CHATCCC_PORT}(${err.code ?? "?"} — ${err.message})`);
|
|
834
895
|
console.error(
|
|
835
896
|
" 处理建议: 关闭占用该端口的其它程序,或在 config.json 的 port 字段里改成其它未占用端口(如 18081)。"
|
|
836
897
|
);
|
|
837
898
|
printServiceDidNotStart(`本地中继端口 ${CHATCCC_PORT} 无法监听(${err.code ?? "?"} — ${err.message})`);
|
|
838
|
-
process.exit(1);
|
|
839
|
-
});
|
|
840
|
-
|
|
841
|
-
|
|
899
|
+
process.exit(1);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// 必须等 HTTP server 真正监听后再发起打开请求,避免浏览器先到一步看到
|
|
903
|
+
// ERR_CONNECTION_REFUSED。Chrome CDP 守护仍保持自己原有的独立行为。
|
|
904
|
+
if (autoOpenWebUi) {
|
|
905
|
+
const url = buildWebUiUrl(CHATCCC_PORT);
|
|
906
|
+
const opened = openWebUiInDefaultBrowser(CHATCCC_PORT);
|
|
907
|
+
if (opened) {
|
|
908
|
+
console.log(`[WEB-UI] 已请求系统默认浏览器打开: ${url}`);
|
|
909
|
+
appendStartupTrace("web-ui: opening default browser", { url });
|
|
910
|
+
} else {
|
|
911
|
+
appendStartupTrace("web-ui: default browser unavailable", { url });
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
appendStartupTrace("web-ui: default browser skipped by lifecycle or preference", {
|
|
915
|
+
url: buildWebUiUrl(CHATCCC_PORT),
|
|
916
|
+
openOnStart: config.webUi.openOnStart,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
|
|
842
921
|
|
|
843
922
|
installShutdownHandlers(httpServer);
|
|
844
923
|
}
|
package/src/orchestrator.ts
CHANGED
|
@@ -90,6 +90,8 @@ import { getChatGptSubscriptionStatus, type ChatGptSubscriptionResult } from "./
|
|
|
90
90
|
import { applySharedPrefix } from "./shared-prefix.ts";
|
|
91
91
|
import { cwdDisplayName, sessionChatName } from "./session-name.ts";
|
|
92
92
|
import { reloadRuntimeConfig } from "./runtime-reload.ts";
|
|
93
|
+
import { acquireUpdateCommandGuard } from "./update-command-guard.ts";
|
|
94
|
+
import { createInternalRestartEnv } from "./startup-lifecycle.ts";
|
|
93
95
|
export { type PlatformAdapter } from "./platform-adapter.ts";
|
|
94
96
|
import type { ChatAvatarUsageHints, PlatformAdapter } from "./platform-adapter.ts";
|
|
95
97
|
import type { CodexUsageSummary } from "./feishu-api.ts";
|
|
@@ -479,7 +481,12 @@ function syncUpdateAndRestart(): void {
|
|
|
479
481
|
|
|
480
482
|
// 3. spawn new chatccc
|
|
481
483
|
try {
|
|
482
|
-
const child = spawn(binPath, [], {
|
|
484
|
+
const child = spawn(binPath, [], {
|
|
485
|
+
detached: true,
|
|
486
|
+
stdio: "ignore",
|
|
487
|
+
shell: true,
|
|
488
|
+
env: createInternalRestartEnv(),
|
|
489
|
+
});
|
|
483
490
|
child.unref();
|
|
484
491
|
updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${binPath}`);
|
|
485
492
|
appendStartupTrace("update: spawn OK", { childPid: child.pid, binPath });
|
|
@@ -497,15 +504,16 @@ function syncUpdateAndRestart(): void {
|
|
|
497
504
|
// handleCommand — 平台无关的命令分发
|
|
498
505
|
// ---------------------------------------------------------------------------
|
|
499
506
|
|
|
500
|
-
export async function handleCommand(
|
|
501
|
-
platform: PlatformAdapter,
|
|
502
|
-
text: string,
|
|
503
|
-
chatId: string,
|
|
504
|
-
openId: string,
|
|
505
|
-
msgTimestamp: number,
|
|
506
|
-
chatType = "group",
|
|
507
|
-
traceId?: string,
|
|
508
|
-
|
|
507
|
+
export async function handleCommand(
|
|
508
|
+
platform: PlatformAdapter,
|
|
509
|
+
text: string,
|
|
510
|
+
chatId: string,
|
|
511
|
+
openId: string,
|
|
512
|
+
msgTimestamp: number,
|
|
513
|
+
chatType = "group",
|
|
514
|
+
traceId?: string,
|
|
515
|
+
commandId?: string,
|
|
516
|
+
): Promise<void> {
|
|
509
517
|
const tid = traceId ?? makeTraceId();
|
|
510
518
|
const sharedPrefix = applySharedPrefix(text);
|
|
511
519
|
const promptText = sharedPrefix.text;
|
|
@@ -543,9 +551,10 @@ export async function handleCommand(
|
|
|
543
551
|
appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
|
|
544
552
|
const child = spawn("npx", ["tsx", "src/index.ts"], {
|
|
545
553
|
cwd: PROJECT_ROOT,
|
|
546
|
-
detached: true,
|
|
547
|
-
stdio: "ignore",
|
|
548
|
-
shell: true,
|
|
554
|
+
detached: true,
|
|
555
|
+
stdio: "ignore",
|
|
556
|
+
shell: true,
|
|
557
|
+
env: createInternalRestartEnv(),
|
|
549
558
|
});
|
|
550
559
|
|
|
551
560
|
child.on("error", (err) => {
|
|
@@ -570,16 +579,40 @@ export async function handleCommand(
|
|
|
570
579
|
return;
|
|
571
580
|
}
|
|
572
581
|
|
|
573
|
-
if (isCommandText && textLower === "/update") {
|
|
574
|
-
logTrace(tid, "BRANCH", { cmd: "/update" });
|
|
575
|
-
const isGlobal = isRunningFromGlobalNpm();
|
|
576
|
-
appendStartupTrace("update: command received", { isGlobal, chatId });
|
|
582
|
+
if (isCommandText && textLower === "/update") {
|
|
583
|
+
logTrace(tid, "BRANCH", { cmd: "/update" });
|
|
584
|
+
const isGlobal = isRunningFromGlobalNpm();
|
|
585
|
+
appendStartupTrace("update: command received", { isGlobal, chatId });
|
|
577
586
|
if (!isGlobal) {
|
|
578
587
|
await platform.sendText(chatId, "当前进程非 npm 全局安装,无法使用 /update 更新。请通过 npm install -g chatccc 安装后使用。").catch(() => {});
|
|
579
|
-
logTrace(tid, "DONE", { outcome: "update_not_global" });
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
|
|
588
|
+
logTrace(tid, "DONE", { outcome: "update_not_global" });
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// `/update` 会主动重启进程,内存 processedMessages 随之丢失。必须在发送
|
|
593
|
+
// “正在更新”以及执行 npm 命令之前同步落盘,才能挡住新进程收到的飞书重投。
|
|
594
|
+
// 该护栏只位于此分支,不改变普通消息和 `/restart` 的现有去重行为。
|
|
595
|
+
const updateGuard = acquireUpdateCommandGuard({ commandId });
|
|
596
|
+
appendStartupTrace("update: command guard checked", {
|
|
597
|
+
allowed: updateGuard.allowed,
|
|
598
|
+
reason: updateGuard.reason,
|
|
599
|
+
hasCommandId: Boolean(commandId),
|
|
600
|
+
});
|
|
601
|
+
if (!updateGuard.allowed) {
|
|
602
|
+
if (updateGuard.reason === "duplicate_id") {
|
|
603
|
+
// 同一条飞书消息的重投静默丢弃,避免用户再次看到重复提示。
|
|
604
|
+
logTrace(tid, "DONE", { outcome: "update_duplicate_id" });
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
await platform.sendText(
|
|
608
|
+
chatId,
|
|
609
|
+
"无法写入更新保护状态。为避免连续更新和重启,本次 /update 未执行。",
|
|
610
|
+
).catch(() => {});
|
|
611
|
+
logTrace(tid, "DONE", { outcome: "update_guard_write_failed" });
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => {});
|
|
583
616
|
logTrace(tid, "DONE", { outcome: "update" });
|
|
584
617
|
appendStartupTrace("update: sync update begin", { fromPid: process.pid });
|
|
585
618
|
syncUpdateAndRestart();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChatCCC 自己拉起替代进程时使用的内部标记。
|
|
5
|
+
*
|
|
6
|
+
* 不能用“是否已有配置”判断是否打开控制台:首次配置和日常直接启动都应该
|
|
7
|
+
* 打开,而 `/restart`、`/update` 和 Web UI 重启都不应该打扰用户。环境变量
|
|
8
|
+
* 会自然穿过 cmd/bash/npx 这几层启动器,因此也适用于 Windows 与 Linux。
|
|
9
|
+
*/
|
|
10
|
+
export const INTERNAL_RESTART_ENV_VAR = "CHATCCC_INTERNAL_RESTART";
|
|
11
|
+
|
|
12
|
+
type Environment = Record<string, string | undefined>;
|
|
13
|
+
|
|
14
|
+
export function createInternalRestartEnv(
|
|
15
|
+
inherited: Environment = process.env,
|
|
16
|
+
): NodeJS.ProcessEnv {
|
|
17
|
+
return {
|
|
18
|
+
...inherited,
|
|
19
|
+
[INTERNAL_RESTART_ENV_VAR]: "1",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 用户直接启动时打开;ChatCCC 内部重启产生的替代进程不打开。 */
|
|
24
|
+
interface AutoOpenWebUiOptions {
|
|
25
|
+
env?: Environment;
|
|
26
|
+
openOnStart?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function shouldAutoOpenWebUi(options: AutoOpenWebUiOptions = {}): boolean {
|
|
30
|
+
const env = options.env ?? process.env;
|
|
31
|
+
return options.openOnStart !== false && env[INTERNAL_RESTART_ENV_VAR] !== "1";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Web UI 始终使用 localhost,并跟随实际配置端口。 */
|
|
35
|
+
export function buildWebUiUrl(port: number): string {
|
|
36
|
+
return `http://localhost:${port}/`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OpenBrowserDeps {
|
|
40
|
+
platform?: NodeJS.Platform;
|
|
41
|
+
env?: Environment;
|
|
42
|
+
spawnImpl?: typeof spawn;
|
|
43
|
+
onError?: (message: string) => void;
|
|
44
|
+
onInfo?: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 调用操作系统默认浏览器打开 Web UI,与 Chrome CDP 守护功能完全独立。
|
|
49
|
+
* 返回值仅表示打开请求是否成功发起;浏览器是否复用标签页由系统浏览器决定。
|
|
50
|
+
*/
|
|
51
|
+
export function openWebUiInDefaultBrowser(
|
|
52
|
+
port: number,
|
|
53
|
+
deps: OpenBrowserDeps = {},
|
|
54
|
+
): boolean {
|
|
55
|
+
const url = buildWebUiUrl(port);
|
|
56
|
+
const platform = deps.platform ?? process.platform;
|
|
57
|
+
const env = deps.env ?? process.env;
|
|
58
|
+
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
59
|
+
const onError = deps.onError ?? ((message: string) => console.error(message));
|
|
60
|
+
const onInfo = deps.onInfo ?? ((message: string) => console.log(message));
|
|
61
|
+
|
|
62
|
+
// Linux 服务器通常没有图形会话。此时调用 xdg-open 只会制造噪音;
|
|
63
|
+
// 直接给出可复制的 SSH 隧道命令,让用户从自己的电脑访问本地 Web UI。
|
|
64
|
+
if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
65
|
+
onInfo(
|
|
66
|
+
`[WEB-UI] 未检测到 Linux 图形桌面,跳过自动打开浏览器。` +
|
|
67
|
+
`可在本机执行 ssh -L ${port}:127.0.0.1:${port} <user>@<server>,` +
|
|
68
|
+
`然后访问 ${url}`,
|
|
69
|
+
);
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
let child: ChildProcess;
|
|
75
|
+
if (platform === "win32") {
|
|
76
|
+
// `start` 会把第一个带引号的参数当窗口标题,空字符串是必要占位符。
|
|
77
|
+
child = spawnImpl("cmd.exe", ["/c", "start", "", url], {
|
|
78
|
+
detached: true,
|
|
79
|
+
stdio: "ignore",
|
|
80
|
+
windowsHide: true,
|
|
81
|
+
});
|
|
82
|
+
} else if (platform === "darwin") {
|
|
83
|
+
child = spawnImpl("open", [url], { detached: true, stdio: "ignore" });
|
|
84
|
+
} else {
|
|
85
|
+
child = spawnImpl("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
86
|
+
}
|
|
87
|
+
child.on("error", (err) => {
|
|
88
|
+
onError(`[WEB-UI] 自动打开浏览器失败: ${err.message}`);
|
|
89
|
+
});
|
|
90
|
+
child.unref();
|
|
91
|
+
return true;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
onError(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
rmSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const UPDATE_COMMAND_GUARD_FILE = join(
|
|
13
|
+
homedir(),
|
|
14
|
+
".chatccc",
|
|
15
|
+
"state",
|
|
16
|
+
"update-command-guard.json",
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const UPDATE_COMMAND_GUARD_VERSION = 1;
|
|
20
|
+
const DEFAULT_MAX_PROCESSED_IDS = 100;
|
|
21
|
+
|
|
22
|
+
interface ProcessedUpdateCommand {
|
|
23
|
+
id: string;
|
|
24
|
+
recordedAt: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface UpdateCommandGuardState {
|
|
28
|
+
version: 1;
|
|
29
|
+
processed: ProcessedUpdateCommand[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type UpdateCommandGuardResult =
|
|
33
|
+
| { allowed: true; reason: "accepted" | "missing_id" }
|
|
34
|
+
| { allowed: false; reason: "duplicate_id" | "state_write_failed" };
|
|
35
|
+
|
|
36
|
+
export interface AcquireUpdateCommandGuardOptions {
|
|
37
|
+
filePath?: string;
|
|
38
|
+
commandId?: string;
|
|
39
|
+
now?: number;
|
|
40
|
+
maxEntries?: number;
|
|
41
|
+
warn?: (message: string) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
45
|
+
return typeof value === "object" && value !== null
|
|
46
|
+
? value as Record<string, unknown>
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 从飞书事件信封中读取重投时保持不变的 event_id。 */
|
|
51
|
+
export function extractFeishuEventId(data: unknown): string | undefined {
|
|
52
|
+
const envelope = asRecord(data);
|
|
53
|
+
const event = asRecord(envelope?.event);
|
|
54
|
+
const header = asRecord(envelope?.header) ?? asRecord(event?.header);
|
|
55
|
+
const context = asRecord(event?.context);
|
|
56
|
+
const candidates = [header?.event_id, envelope?.event_id, event?.event_id, context?.event_id];
|
|
57
|
+
for (const candidate of candidates) {
|
|
58
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 分隔文字消息和卡片回调 ID 的命名空间。 */
|
|
64
|
+
export function buildUpdateCommandId(
|
|
65
|
+
source: "message" | "card",
|
|
66
|
+
id: string | undefined,
|
|
67
|
+
): string | undefined {
|
|
68
|
+
const normalized = id?.trim();
|
|
69
|
+
return normalized ? `${source}:${normalized}` : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function emptyState(): UpdateCommandGuardState {
|
|
73
|
+
return { version: UPDATE_COMMAND_GUARD_VERSION, processed: [] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseState(raw: string, maxEntries: number): UpdateCommandGuardState {
|
|
77
|
+
const parsed = JSON.parse(raw) as Partial<UpdateCommandGuardState>;
|
|
78
|
+
if (parsed.version !== UPDATE_COMMAND_GUARD_VERSION || !Array.isArray(parsed.processed)) {
|
|
79
|
+
throw new Error("invalid update command guard schema");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const processed = parsed.processed.map((entry) => {
|
|
83
|
+
if (
|
|
84
|
+
typeof entry !== "object"
|
|
85
|
+
|| entry === null
|
|
86
|
+
|| typeof entry.id !== "string"
|
|
87
|
+
|| entry.id.length === 0
|
|
88
|
+
|| typeof entry.recordedAt !== "number"
|
|
89
|
+
|| !Number.isFinite(entry.recordedAt)
|
|
90
|
+
|| entry.recordedAt < 0
|
|
91
|
+
) {
|
|
92
|
+
throw new Error("invalid processed update command entry");
|
|
93
|
+
}
|
|
94
|
+
return { id: entry.id, recordedAt: entry.recordedAt };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
version: UPDATE_COMMAND_GUARD_VERSION,
|
|
99
|
+
processed: processed.slice(-maxEntries),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function loadState(
|
|
104
|
+
filePath: string,
|
|
105
|
+
maxEntries: number,
|
|
106
|
+
warn: (message: string) => void,
|
|
107
|
+
): UpdateCommandGuardState {
|
|
108
|
+
if (!existsSync(filePath)) return emptyState();
|
|
109
|
+
try {
|
|
110
|
+
return parseState(readFileSync(filePath, "utf8"), maxEntries);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
warn(`[UPDATE-GUARD] 状态文件损坏,将重建 ${filePath}: ${(err as Error).message}`);
|
|
113
|
+
return emptyState();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 原子写入更新命令 ID。写失败时调用方必须拒绝更新:只有先落盘,
|
|
119
|
+
* 新进程才能识别飞书在旧进程退出后重投的同一条 `/update`。
|
|
120
|
+
*/
|
|
121
|
+
function persistState(filePath: string, state: UpdateCommandGuardState): void {
|
|
122
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
123
|
+
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
124
|
+
try {
|
|
125
|
+
writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
126
|
+
renameSync(tempPath, filePath);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
try { rmSync(tempPath, { force: true }); } catch {}
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 获取 `/update` 执行资格。只比较稳定消息/事件 ID,因此用户主动发送的
|
|
135
|
+
* 不同 `/update` 消息仍可立即执行。
|
|
136
|
+
*/
|
|
137
|
+
export function acquireUpdateCommandGuard(
|
|
138
|
+
options: AcquireUpdateCommandGuardOptions = {},
|
|
139
|
+
): UpdateCommandGuardResult {
|
|
140
|
+
const filePath = options.filePath ?? UPDATE_COMMAND_GUARD_FILE;
|
|
141
|
+
const commandId = options.commandId?.trim() || undefined;
|
|
142
|
+
const now = options.now ?? Date.now();
|
|
143
|
+
const maxEntries = Number.isInteger(options.maxEntries) && (options.maxEntries ?? 0) > 0
|
|
144
|
+
? options.maxEntries!
|
|
145
|
+
: DEFAULT_MAX_PROCESSED_IDS;
|
|
146
|
+
const warn = options.warn ?? ((message: string) => console.warn(message));
|
|
147
|
+
|
|
148
|
+
// 模拟注入等没有稳定事件 ID 的入口无法做跨重启判断,保持原有行为。
|
|
149
|
+
if (!commandId) return { allowed: true, reason: "missing_id" };
|
|
150
|
+
|
|
151
|
+
const state = loadState(filePath, maxEntries, warn);
|
|
152
|
+
if (state.processed.some((entry) => entry.id === commandId)) {
|
|
153
|
+
return { allowed: false, reason: "duplicate_id" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
state.processed.push({ id: commandId, recordedAt: now });
|
|
157
|
+
state.processed = state.processed.slice(-maxEntries);
|
|
158
|
+
try {
|
|
159
|
+
persistState(filePath, state);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
warn(`[UPDATE-GUARD] 无法写入状态文件 ${filePath}: ${(err as Error).message}`);
|
|
162
|
+
return { allowed: false, reason: "state_write_failed" };
|
|
163
|
+
}
|
|
164
|
+
return { allowed: true, reason: "accepted" };
|
|
165
|
+
}
|