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/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
- APP_ID,
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 { text, chatId, openId };
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
- await handleCommand(feishuPlatform, text, chatId, openId, msgTimestamp, chatType, traceId);
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(feishuPlatform, result.text, result.chatId, result.openId, Date.now()).catch((err) =>
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(feishuPlatform, action.text, action.chatId, action.openId, Date.now()).catch((err) =>
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
- appendStartupTrace("main: entered", {
686
- argv: process.argv.join(" ").slice(0, 400),
687
- CHATCCC_PORT,
688
- PROJECT_ROOT,
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
- installShutdownHandlers(simServer);
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
- onActivate: async (httpServer: Server) => {
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
- await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
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
  }
@@ -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, [], { detached: true, stdio: "ignore", shell: true });
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
- ): Promise<void> {
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
- await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => {});
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
+ }