chatccc 0.2.202 → 0.2.203

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 CHANGED
@@ -352,10 +352,12 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
352
352
  | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
353
353
  | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
354
354
  | `/restart` | 重启机器人进程 |
355
- | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
356
- | `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
357
-
358
- > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
355
+ | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程;同一飞书事件跨重启去重) |
356
+ | `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
357
+
358
+ `/update` 会在执行 npm 更新前把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新 `/update` 因事件 ID 不同,仍可立即执行。该保护仅作用于 `/update`,普通消息与 `/restart` 的处理不变。
359
+
360
+ > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
359
361
 
360
362
  ---
361
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.202",
3
+ "version": "0.2.203",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,144 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import {
7
+ acquireUpdateCommandGuard,
8
+ buildUpdateCommandId,
9
+ extractFeishuEventId,
10
+ } from "../update-command-guard.ts";
11
+
12
+ describe("Feishu update command IDs", () => {
13
+ it("extracts and namespaces a card callback event_id", () => {
14
+ const eventId = extractFeishuEventId({
15
+ schema: "2.0",
16
+ header: { event_id: "evt_update_001" },
17
+ event: { action: { value: { action: "update" } } },
18
+ });
19
+
20
+ expect(buildUpdateCommandId("card", eventId)).toBe("card:evt_update_001");
21
+ expect(buildUpdateCommandId("message", "om_update_001")).toBe("message:om_update_001");
22
+ });
23
+ });
24
+
25
+ describe("acquireUpdateCommandGuard", () => {
26
+ let tempDir: string;
27
+ let guardFile: string;
28
+
29
+ beforeEach(async () => {
30
+ tempDir = await mkdtemp(join(tmpdir(), "chatccc-update-guard-"));
31
+ guardFile = join(tempDir, "state", "update-command-guard.json");
32
+ });
33
+
34
+ afterEach(async () => {
35
+ await rm(tempDir, { recursive: true, force: true });
36
+ });
37
+
38
+ it("persists an accepted update ID before returning", async () => {
39
+ const result = acquireUpdateCommandGuard({
40
+ filePath: guardFile,
41
+ commandId: "message:om_update_001",
42
+ now: 1_000,
43
+ });
44
+
45
+ expect(result).toEqual({ allowed: true, reason: "accepted" });
46
+ const saved = JSON.parse(await readFile(guardFile, "utf8"));
47
+ expect(saved).toEqual({
48
+ version: 1,
49
+ processed: [{ id: "message:om_update_001", recordedAt: 1_000 }],
50
+ });
51
+ });
52
+
53
+ it("rejects the same ID after a simulated process restart", () => {
54
+ expect(acquireUpdateCommandGuard({
55
+ filePath: guardFile,
56
+ commandId: "message:om_update_001",
57
+ now: 1_000,
58
+ }).allowed).toBe(true);
59
+
60
+ // 第二次调用不共享任何内存状态,只通过落盘文件模拟新进程重启后的判断。
61
+ expect(acquireUpdateCommandGuard({
62
+ filePath: guardFile,
63
+ commandId: "message:om_update_001",
64
+ now: 9_999_999,
65
+ })).toEqual({ allowed: false, reason: "duplicate_id" });
66
+ });
67
+
68
+ it("accepts different IDs immediately without a cooldown", () => {
69
+ expect(acquireUpdateCommandGuard({
70
+ filePath: guardFile,
71
+ commandId: "message:om_update_001",
72
+ now: 1_000,
73
+ }).allowed).toBe(true);
74
+
75
+ expect(acquireUpdateCommandGuard({
76
+ filePath: guardFile,
77
+ commandId: "message:om_update_002",
78
+ now: 1_001,
79
+ })).toEqual({ allowed: true, reason: "accepted" });
80
+ });
81
+
82
+ it("repairs a corrupt state file and warns without creating an update loop", async () => {
83
+ await mkdir(join(tempDir, "state"), { recursive: true });
84
+ await writeFile(guardFile, "not-json", "utf8");
85
+ const warn = vi.fn();
86
+
87
+ expect(acquireUpdateCommandGuard({
88
+ filePath: guardFile,
89
+ commandId: "message:om_update_001",
90
+ now: 1_000,
91
+ warn,
92
+ })).toEqual({ allowed: true, reason: "accepted" });
93
+ expect(warn).toHaveBeenCalledOnce();
94
+
95
+ // 损坏文件已被有效状态覆盖,因此重启后的重复投递仍会被拦截。
96
+ expect(acquireUpdateCommandGuard({
97
+ filePath: guardFile,
98
+ commandId: "message:om_update_001",
99
+ now: 2_000,
100
+ warn,
101
+ })).toEqual({ allowed: false, reason: "duplicate_id" });
102
+ });
103
+
104
+ it("fails closed when the accepted ID cannot be persisted", async () => {
105
+ const blocker = join(tempDir, "not-a-directory");
106
+ await writeFile(blocker, "block", "utf8");
107
+ const warn = vi.fn();
108
+
109
+ expect(acquireUpdateCommandGuard({
110
+ filePath: join(blocker, "update-command-guard.json"),
111
+ commandId: "message:om_update_001",
112
+ now: 1_000,
113
+ warn,
114
+ })).toEqual({ allowed: false, reason: "state_write_failed" });
115
+ expect(warn).toHaveBeenCalledOnce();
116
+ });
117
+
118
+ it("allows sources without a stable ID without writing a misleading record", async () => {
119
+ expect(acquireUpdateCommandGuard({
120
+ filePath: guardFile,
121
+ commandId: undefined,
122
+ now: 1_000,
123
+ })).toEqual({ allowed: true, reason: "missing_id" });
124
+ await expect(readFile(guardFile, "utf8")).rejects.toMatchObject({ code: "ENOENT" });
125
+ });
126
+
127
+ it("keeps only the newest configured number of update IDs", async () => {
128
+ for (let i = 0; i < 4; i++) {
129
+ expect(acquireUpdateCommandGuard({
130
+ filePath: guardFile,
131
+ commandId: `message:om_update_${i}`,
132
+ now: i,
133
+ maxEntries: 3,
134
+ }).allowed).toBe(true);
135
+ }
136
+
137
+ const saved = JSON.parse(await readFile(guardFile, "utf8"));
138
+ expect(saved.processed.map((entry: { id: string }) => entry.id)).toEqual([
139
+ "message:om_update_1",
140
+ "message:om_update_2",
141
+ "message:om_update_3",
142
+ ]);
143
+ });
144
+ });
package/src/index.ts CHANGED
@@ -189,16 +189,21 @@ function getInnerEvent(data: Evt): InnerEvent {
189
189
  return (data.event ?? data) as InnerEvent;
190
190
  }
191
191
 
192
- import { formatMessageContent } from "./format-message.ts";
192
+ import { formatMessageContent } from "./format-message.ts";
193
+ import {
194
+ buildUpdateCommandId,
195
+ extractFeishuEventId,
196
+ } from "./update-command-guard.ts";
193
197
 
194
198
  // ---------------------------------------------------------------------------
195
199
  // Card action helper: parse button click into text command
196
200
  // ---------------------------------------------------------------------------
197
201
 
198
- interface CardActionResult {
199
- text: string;
200
- chatId: string;
201
- openId: string;
202
+ interface CardActionResult {
203
+ text: string;
204
+ chatId: string;
205
+ openId: string;
206
+ commandId?: string;
202
207
  }
203
208
 
204
209
  function parseCardAction(data: unknown): CardActionResult | null {
@@ -237,7 +242,12 @@ function parseCardAction(data: unknown): CardActionResult | null {
237
242
  ((raw as Record<string, unknown>).operator as Record<string, unknown>)?.open_id as string ??
238
243
  "";
239
244
 
240
- return { text, chatId, openId };
245
+ return {
246
+ text,
247
+ chatId,
248
+ openId,
249
+ commandId: buildUpdateCommandId("card", extractFeishuEventId(data)),
250
+ };
241
251
  }
242
252
 
243
253
  // ---------------------------------------------------------------------------
@@ -432,7 +442,18 @@ async function startBotServiceCore(): Promise<void> {
432
442
  const delayToken = await getTenantAccessToken();
433
443
  await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
434
444
  }
435
- await handleCommand(feishuPlatform, text, chatId, openId, msgTimestamp, chatType, traceId);
445
+ // `/update` 会使用这个稳定 ID 做跨重启幂等;其他命令仍沿用
446
+ // processedMessages 的进程内去重。
447
+ await handleCommand(
448
+ feishuPlatform,
449
+ text,
450
+ chatId,
451
+ openId,
452
+ msgTimestamp,
453
+ chatType,
454
+ traceId,
455
+ buildUpdateCommandId("message", messageId),
456
+ );
436
457
  } catch (err) {
437
458
  logTrace(traceId, "ERROR", { message: (err as Error).message });
438
459
  console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
@@ -483,7 +504,16 @@ async function startBotServiceCore(): Promise<void> {
483
504
  const result = parseCardAction(data);
484
505
  if (!result) return;
485
506
  console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
486
- handleCommand(feishuPlatform, result.text, result.chatId, result.openId, Date.now()).catch((err) =>
507
+ handleCommand(
508
+ feishuPlatform,
509
+ result.text,
510
+ result.chatId,
511
+ result.openId,
512
+ Date.now(),
513
+ "group",
514
+ undefined,
515
+ result.commandId,
516
+ ).catch((err) =>
487
517
  console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
488
518
  );
489
519
  } catch (err) {
@@ -508,7 +538,16 @@ async function startBotServiceCore(): Promise<void> {
508
538
  const data = JSON.parse(raw.toString()) as Evt;
509
539
  const action = parseCardAction(data);
510
540
  if (action) {
511
- handleCommand(feishuPlatform, action.text, action.chatId, action.openId, Date.now()).catch((err) =>
541
+ handleCommand(
542
+ feishuPlatform,
543
+ action.text,
544
+ action.chatId,
545
+ action.openId,
546
+ Date.now(),
547
+ "group",
548
+ undefined,
549
+ action.commandId,
550
+ ).catch((err) =>
512
551
  console.error(`[${ts()}] [BTN] handleCommand failed: ${(err as Error).message}`)
513
552
  );
514
553
  return;
@@ -90,6 +90,7 @@ 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";
93
94
  export { type PlatformAdapter } from "./platform-adapter.ts";
94
95
  import type { ChatAvatarUsageHints, PlatformAdapter } from "./platform-adapter.ts";
95
96
  import type { CodexUsageSummary } from "./feishu-api.ts";
@@ -497,15 +498,16 @@ function syncUpdateAndRestart(): void {
497
498
  // handleCommand — 平台无关的命令分发
498
499
  // ---------------------------------------------------------------------------
499
500
 
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> {
501
+ export async function handleCommand(
502
+ platform: PlatformAdapter,
503
+ text: string,
504
+ chatId: string,
505
+ openId: string,
506
+ msgTimestamp: number,
507
+ chatType = "group",
508
+ traceId?: string,
509
+ commandId?: string,
510
+ ): Promise<void> {
509
511
  const tid = traceId ?? makeTraceId();
510
512
  const sharedPrefix = applySharedPrefix(text);
511
513
  const promptText = sharedPrefix.text;
@@ -570,16 +572,40 @@ export async function handleCommand(
570
572
  return;
571
573
  }
572
574
 
573
- if (isCommandText && textLower === "/update") {
574
- logTrace(tid, "BRANCH", { cmd: "/update" });
575
- const isGlobal = isRunningFromGlobalNpm();
576
- appendStartupTrace("update: command received", { isGlobal, chatId });
575
+ if (isCommandText && textLower === "/update") {
576
+ logTrace(tid, "BRANCH", { cmd: "/update" });
577
+ const isGlobal = isRunningFromGlobalNpm();
578
+ appendStartupTrace("update: command received", { isGlobal, chatId });
577
579
  if (!isGlobal) {
578
580
  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(() => {});
581
+ logTrace(tid, "DONE", { outcome: "update_not_global" });
582
+ return;
583
+ }
584
+
585
+ // `/update` 会主动重启进程,内存 processedMessages 随之丢失。必须在发送
586
+ // “正在更新”以及执行 npm 命令之前同步落盘,才能挡住新进程收到的飞书重投。
587
+ // 该护栏只位于此分支,不改变普通消息和 `/restart` 的现有去重行为。
588
+ const updateGuard = acquireUpdateCommandGuard({ commandId });
589
+ appendStartupTrace("update: command guard checked", {
590
+ allowed: updateGuard.allowed,
591
+ reason: updateGuard.reason,
592
+ hasCommandId: Boolean(commandId),
593
+ });
594
+ if (!updateGuard.allowed) {
595
+ if (updateGuard.reason === "duplicate_id") {
596
+ // 同一条飞书消息的重投静默丢弃,避免用户再次看到重复提示。
597
+ logTrace(tid, "DONE", { outcome: "update_duplicate_id" });
598
+ return;
599
+ }
600
+ await platform.sendText(
601
+ chatId,
602
+ "无法写入更新保护状态。为避免连续更新和重启,本次 /update 未执行。",
603
+ ).catch(() => {});
604
+ logTrace(tid, "DONE", { outcome: "update_guard_write_failed" });
605
+ return;
606
+ }
607
+
608
+ await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => {});
583
609
  logTrace(tid, "DONE", { outcome: "update" });
584
610
  appendStartupTrace("update: sync update begin", { fromPid: process.pid });
585
611
  syncUpdateAndRestart();
@@ -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
+ }