chatccc 0.2.215 → 0.2.217

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.215",
3
+ "version": "0.2.217",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -2,7 +2,8 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import sharp from "sharp";
6
7
 
7
8
  const mockConfig = {
8
9
  cursor: {
@@ -68,14 +69,18 @@ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void
68
69
  }));
69
70
  }
70
71
 
71
- function mockAvatarUploadOnlyFetch(uploadedNames: string[]): ReturnType<typeof vi.fn> {
72
- const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
73
- const urlText = String(url);
74
- if (urlText === "https://open.feishu.test/im/v1/images") {
75
- const form = init?.body as FormData;
76
- const image = form.get("image") as File;
77
- uploadedNames.push(image.name);
78
- return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
72
+ function mockAvatarUploadOnlyFetch(
73
+ uploadedNames: string[],
74
+ uploadedImages: Buffer[] = [],
75
+ ): ReturnType<typeof vi.fn> {
76
+ const fetchMock = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
77
+ const urlText = String(url);
78
+ if (urlText === "https://open.feishu.test/im/v1/images") {
79
+ const form = init?.body as FormData;
80
+ const image = form.get("image") as File;
81
+ uploadedNames.push(image.name);
82
+ uploadedImages.push(Buffer.from(await image.arrayBuffer()));
83
+ return new Response(JSON.stringify({ code: 0, data: { image_key: "img_test" } }), { status: 200 });
79
84
  }
80
85
  if (urlText === "https://open.feishu.test/im/v1/chats/chat_1") {
81
86
  return new Response(JSON.stringify({ code: 0 }), { status: 200 });
@@ -197,7 +202,7 @@ describe("Codex avatar usage battery", () => {
197
202
  }
198
203
  });
199
204
 
200
- it("uses provided Codex usage without fetching usage again", async () => {
205
+ it("uses provided Codex usage without fetching usage again", async () => {
201
206
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
202
207
  const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
203
208
  const uploadedNames: string[] = [];
@@ -220,7 +225,65 @@ describe("Codex avatar usage battery", () => {
220
225
  await rm(homeDir, { recursive: true, force: true });
221
226
  await rm(userDataDir, { recursive: true, force: true });
222
227
  }
223
- });
228
+ });
229
+
230
+ it("isolates Fast and standard Codex avatars in the upload cache", async () => {
231
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
232
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
233
+ const uploadedNames: string[] = [];
234
+ mockAvatarUploadOnlyFetch(uploadedNames);
235
+
236
+ try {
237
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
238
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
239
+ codexUsage: null,
240
+ fastMode: false,
241
+ });
242
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
243
+ codexUsage: null,
244
+ fastMode: true,
245
+ });
246
+
247
+ expect(uploadedNames).toEqual([
248
+ "avatar_codex_idle.jpg",
249
+ "avatar_codex_idle_fast.jpg",
250
+ ]);
251
+ const cacheRaw = await readFile(join(userDataDir, "state", "avatar-image-keys.json"), "utf-8");
252
+ const cache = JSON.parse(cacheRaw) as Record<string, string>;
253
+ expect(cache["codex:idle:plain"]).toBe("img_test");
254
+ expect(cache["codex:idle:plain:fast-champagne-frame-v1"]).toBe("img_test");
255
+ } finally {
256
+ await rm(homeDir, { recursive: true, force: true });
257
+ await rm(userDataDir, { recursive: true, force: true });
258
+ }
259
+ });
260
+
261
+ it("renders a thick champagne frame above the Codex badge in Fast mode", async () => {
262
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
263
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
264
+ const uploadedNames: string[] = [];
265
+ const uploadedImages: Buffer[] = [];
266
+ mockAvatarUploadOnlyFetch(uploadedNames, uploadedImages);
267
+
268
+ try {
269
+ const { setChatAvatar } = await loadFeishuApiWithHome(homeDir, userDataDir);
270
+ await setChatAvatar("tenant-token", "chat_1", "codex", "idle", {
271
+ codexUsage: null,
272
+ fastMode: true,
273
+ });
274
+
275
+ expect(uploadedNames).toEqual(["avatar_codex_idle_fast.jpg"]);
276
+ const { data, info } = await sharp(uploadedImages[0]).raw().toBuffer({ resolveWithObject: true });
277
+ const pixelOffset = (149 * info.width + 200) * info.channels;
278
+ const [red, green, blue] = data.subarray(pixelOffset, pixelOffset + 3);
279
+ expect(red).toBeGreaterThan(220);
280
+ expect(green).toBeGreaterThan(150);
281
+ expect(blue).toBeLessThan(180);
282
+ } finally {
283
+ await rm(homeDir, { recursive: true, force: true });
284
+ await rm(userDataDir, { recursive: true, force: true });
285
+ }
286
+ });
224
287
 
225
288
  it("returns both usage windows and available reset credit expiries", async () => {
226
289
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
@@ -0,0 +1,138 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import {
8
+ FeishuMessageLedger,
9
+ createAckFirstEventHandler,
10
+ } from "../feishu-message-ingress.ts";
11
+
12
+ const tempDirs: string[] = [];
13
+
14
+ async function createLedger(maxEntries = 5): Promise<FeishuMessageLedger> {
15
+ const dir = await mkdtemp(join(tmpdir(), "chatccc-feishu-ingress-"));
16
+ tempDirs.push(dir);
17
+ return new FeishuMessageLedger(join(dir, "messages.json"), maxEntries);
18
+ }
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
22
+ });
23
+
24
+ describe("createAckFirstEventHandler", () => {
25
+ it("returns before starting the asynchronous event worker", async () => {
26
+ let scheduled: (() => void) | undefined;
27
+ const schedule = vi.fn((task: () => void) => {
28
+ scheduled = task;
29
+ });
30
+ const worker = vi.fn(async () => {});
31
+ const onError = vi.fn();
32
+ const handler = createAckFirstEventHandler(worker, onError, schedule);
33
+
34
+ await expect(handler({ message: "test" })).resolves.toBeUndefined();
35
+ expect(schedule).toHaveBeenCalledOnce();
36
+ expect(worker).not.toHaveBeenCalled();
37
+
38
+ scheduled?.();
39
+ await vi.waitFor(() => expect(worker).toHaveBeenCalledWith({ message: "test" }));
40
+ expect(onError).not.toHaveBeenCalled();
41
+ });
42
+
43
+ it("reports detached worker failures without rejecting the SDK callback", async () => {
44
+ const error = new Error("worker failed");
45
+ const onError = vi.fn();
46
+ const handler = createAckFirstEventHandler(
47
+ async () => {
48
+ throw error;
49
+ },
50
+ onError,
51
+ (task) => task(),
52
+ );
53
+
54
+ await expect(handler({})).resolves.toBeUndefined();
55
+ await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error));
56
+ });
57
+ });
58
+
59
+ describe("FeishuMessageLedger", () => {
60
+ it("rejects an already accepted message after a process restart", async () => {
61
+ const first = await createLedger();
62
+ await first.load();
63
+
64
+ expect(await first.accept({
65
+ messageId: "om_001",
66
+ chatId: "oc_chat",
67
+ createTime: 100,
68
+ })).toBe("accepted");
69
+
70
+ const restarted = new FeishuMessageLedger(first.filePath, 5);
71
+ await restarted.load();
72
+
73
+ expect(await restarted.accept({
74
+ messageId: "om_001",
75
+ chatId: "oc_chat",
76
+ createTime: 100,
77
+ })).toBe("duplicate");
78
+ });
79
+
80
+ it("rejects a unique old event after restoring the chat high-water mark", async () => {
81
+ const first = await createLedger();
82
+ await first.load();
83
+
84
+ expect(await first.accept({
85
+ messageId: "om_new",
86
+ chatId: "oc_chat",
87
+ createTime: 200,
88
+ })).toBe("accepted");
89
+
90
+ const restarted = new FeishuMessageLedger(first.filePath, 5);
91
+ await restarted.load();
92
+
93
+ expect(await restarted.accept({
94
+ messageId: "om_old",
95
+ chatId: "oc_chat",
96
+ createTime: 100,
97
+ })).toBe("stale");
98
+ });
99
+
100
+ it("accepts distinct messages with the same create timestamp", async () => {
101
+ const ledger = await createLedger();
102
+ await ledger.load();
103
+
104
+ expect(await ledger.accept({
105
+ messageId: "om_001",
106
+ chatId: "oc_chat",
107
+ createTime: 100,
108
+ })).toBe("accepted");
109
+ expect(await ledger.accept({
110
+ messageId: "om_002",
111
+ chatId: "oc_chat",
112
+ createTime: 100,
113
+ })).toBe("accepted");
114
+ });
115
+
116
+ it("keeps only the configured number of recent message IDs", async () => {
117
+ const ledger = await createLedger(2);
118
+ await ledger.load();
119
+
120
+ await ledger.accept({ messageId: "om_001", chatId: "oc_chat", createTime: 100 });
121
+ await ledger.accept({ messageId: "om_002", chatId: "oc_chat", createTime: 200 });
122
+ await ledger.accept({ messageId: "om_003", chatId: "oc_chat", createTime: 300 });
123
+
124
+ const restarted = new FeishuMessageLedger(ledger.filePath, 2);
125
+ await restarted.load();
126
+
127
+ expect(await restarted.accept({
128
+ messageId: "om_001",
129
+ chatId: "oc_other",
130
+ createTime: 100,
131
+ })).toBe("accepted");
132
+ expect(await restarted.accept({
133
+ messageId: "om_003",
134
+ chatId: "oc_chat",
135
+ createTime: 300,
136
+ })).toBe("duplicate");
137
+ });
138
+ });
@@ -681,9 +681,21 @@ describe("handleCommand WeChat processing ack", () => {
681
681
 
682
682
  await handleCommand(platform, "/fast on", "feishu-codex", "ou-user", Date.now(), "p2p");
683
683
  expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(true);
684
+ expect(platform.setChatAvatar).toHaveBeenLastCalledWith(
685
+ "feishu-codex",
686
+ "codex",
687
+ "idle",
688
+ { fastMode: true },
689
+ );
684
690
 
685
691
  await handleCommand(platform, "/fast off", "feishu-codex", "ou-user", Date.now(), "p2p");
686
692
  expect(getEffectiveFastModeForTool("codex", "sid-codex-fast")).toBe(false);
693
+ expect(platform.setChatAvatar).toHaveBeenLastCalledWith(
694
+ "feishu-codex",
695
+ "codex",
696
+ "idle",
697
+ { fastMode: false },
698
+ );
687
699
  card = JSON.parse(
688
700
  vi.mocked(platform.sendRawCard).mock.calls.at(-1)?.[1] ?? "{}",
689
701
  ) as { elements?: Array<{ tag: string; text?: { content: string } }> };
@@ -73,9 +73,10 @@ describe("SimulatedPlatform", () => {
73
73
  });
74
74
 
75
75
  it("纯函数 formatDelayNotice 正常工作", () => {
76
- const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
77
- expect(notice).toBeDefined();
78
- expect(notice).toContain("延迟送达");
76
+ const notice = SimulatedPlatform.formatDelayNotice(Date.now() - 20 * 60 * 1000, "测试消息");
77
+ expect(notice).toBeDefined();
78
+ expect(notice).toContain("延迟送达");
79
+ expect(notice).not.toContain("因服务离线");
79
80
  // 近期消息不触发
80
81
  expect(SimulatedPlatform.formatDelayNotice(Date.now(), "test")).toBeNull();
81
82
  });
@@ -3,7 +3,13 @@ import { resolve } from "node:path";
3
3
  import { sessionPrefixForTool, toolDisplayName, ts } from "./config.ts";
4
4
  import { setDefaultCwd } from "./config.ts";
5
5
  import type { PlatformAdapter } from "./platform-adapter.ts";
6
- import { initClaudeSession, recordSessionRegistry, resumeAndPrompt, saveSessionTool } from "./session.ts";
6
+ import {
7
+ getEffectiveFastModeForTool,
8
+ initClaudeSession,
9
+ recordSessionRegistry,
10
+ resumeAndPrompt,
11
+ saveSessionTool,
12
+ } from "./session.ts";
7
13
  import { bindChatToSession } from "./session-chat-binding.ts";
8
14
  import { sessionChatName } from "./session-name.ts";
9
15
 
@@ -64,7 +70,11 @@ export async function delegateAgentTask(input: DelegateAgentTaskInput): Promise<
64
70
  `下面会自动把任务作为第一句话发送给 ${toolLabel}。`,
65
71
  "green",
66
72
  ).catch(() => {});
67
- input.platform.setChatAvatar(chatId, input.tool, "new").catch(() => {});
73
+ const fastMode = getEffectiveFastModeForTool(input.tool, sessionId);
74
+ const avatarUpdate = fastMode
75
+ ? input.platform.setChatAvatar(chatId, input.tool, "new", { fastMode: true })
76
+ : input.platform.setChatAvatar(chatId, input.tool, "new");
77
+ avatarUpdate.catch(() => {});
68
78
 
69
79
  await resumeAndPrompt(
70
80
  sessionId,
package/src/feishu-api.ts CHANGED
@@ -324,6 +324,7 @@ const AVATAR_BADGE_SIZE = 92;
324
324
  const AVATAR_BADGE_MARGIN = 10;
325
325
  const PLAIN_AVATAR_TOOL = "plain";
326
326
  const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-window-aware-v14";
327
+ const CODEX_FAST_FRAME_STYLE_VERSION = "fast-champagne-frame-v1";
327
328
  const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
328
329
 
329
330
  export interface CodexUsageBalance {
@@ -371,18 +372,20 @@ function avatarCombinationPath(tool: string, status: string): string {
371
372
  return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
372
373
  }
373
374
 
374
- function avatarCacheKey(
375
- tool: string,
376
- status: string,
377
- codexUsage: CodexUsageSummary | null = null,
378
- cursorBatteryPercent: number | null = null,
379
- ): string {
375
+ function avatarCacheKey(
376
+ tool: string,
377
+ status: string,
378
+ codexUsage: CodexUsageSummary | null = null,
379
+ cursorBatteryPercent: number | null = null,
380
+ fastMode = false,
381
+ ): string {
380
382
  const normalizedTool = normalizeAvatarTool(tool);
381
383
  const normalizedStatus = normalizeAvatarStatus(status);
382
384
  if (normalizedTool === "codex") {
383
- if (!codexUsage?.weekly) return `${normalizedTool}:${normalizedStatus}:plain`;
385
+ const fastKey = fastMode ? `:${CODEX_FAST_FRAME_STYLE_VERSION}` : "";
386
+ if (!codexUsage?.weekly) return `${normalizedTool}:${normalizedStatus}:plain${fastKey}`;
384
387
  const ringKey = codexUsage.fiveHour ? `:5h-ring:${codexUsage.fiveHour.remainingPercent}` : "";
385
- return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}`;
388
+ return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}${fastKey}`;
386
389
  }
387
390
  if (normalizedTool === "cursor") {
388
391
  return cursorBatteryPercent !== null
@@ -757,7 +760,7 @@ function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
757
760
  </svg>`);
758
761
  }
759
762
 
760
- async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
763
+ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
761
764
  const badge = await sharp(AVATAR_BADGES[tool])
762
765
  .resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
763
766
  fit: "contain",
@@ -770,24 +773,50 @@ async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOption
770
773
  input: badge,
771
774
  left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
772
775
  top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
773
- };
774
- }
775
-
776
- async function renderAvatar(
777
- tool: string,
778
- status: string,
779
- codexUsage: CodexUsageSummary | null = null,
780
- cursorBatteryPercent: number | null = null,
781
- ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
776
+ };
777
+ }
778
+
779
+ function buildCodexFastFrameOverlay(): sharp.OverlayOptions {
780
+ const size = AVATAR_BADGE_SIZE + 16;
781
+ const innerOffset = 7;
782
+ const innerSize = size - innerOffset * 2;
783
+ const frame = Buffer.from(`
784
+ <svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
785
+ <defs>
786
+ <linearGradient id="champagne" x1="0" y1="0" x2="${size}" y2="${size}" gradientUnits="userSpaceOnUse">
787
+ <stop offset="0" stop-color="#FFF0BE"/>
788
+ <stop offset="0.55" stop-color="#FFD56A"/>
789
+ <stop offset="1" stop-color="#EFA923"/>
790
+ </linearGradient>
791
+ </defs>
792
+ <rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="25" fill="url(#champagne)"/>
793
+ <rect x="${innerOffset}" y="${innerOffset}" width="${innerSize}" height="${innerSize}" rx="21" fill="#fffdf4"/>
794
+ </svg>`);
795
+ return {
796
+ input: frame,
797
+ left: AVATAR_SIZE - size - 2,
798
+ top: AVATAR_SIZE - size - 2,
799
+ };
800
+ }
801
+
802
+ async function renderAvatar(
803
+ tool: string,
804
+ status: string,
805
+ codexUsage: CodexUsageSummary | null = null,
806
+ cursorBatteryPercent: number | null = null,
807
+ fastMode = false,
808
+ ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
782
809
  const normalizedTool = normalizeAvatarTool(tool);
783
810
  const normalizedStatus = normalizeAvatarStatus(status);
784
811
  const composites: sharp.OverlayOptions[] = [];
785
812
  const hasAgentBadge = normalizedTool !== PLAIN_AVATAR_TOOL;
813
+ const useFastCodexAvatar = normalizedTool === "codex" && fastMode;
786
814
 
787
815
  const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly ?? null : null;
788
816
  const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage !== null && codexWeeklyUsage !== null;
789
817
  const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
790
- const basePath = useDynamicCodexAvatar || useDynamicCursorAvatar
818
+ const useDynamicBadgeAvatar = useDynamicCodexAvatar || useDynamicCursorAvatar || useFastCodexAvatar;
819
+ const basePath = useDynamicBadgeAvatar
791
820
  ? AVATAR_SOURCES[normalizedStatus]
792
821
  : hasAgentBadge
793
822
  ? avatarCombinationPath(normalizedTool, normalizedStatus)
@@ -797,16 +826,15 @@ async function renderAvatar(
797
826
  if (codexUsage.fiveHour) {
798
827
  composites.push({ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 });
799
828
  }
800
- composites.push(
801
- { input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 },
802
- await buildAgentBadgeOverlay(normalizedTool),
803
- );
804
- } else if (useDynamicCursorAvatar) {
805
- composites.push(
806
- { input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 },
807
- await buildAgentBadgeOverlay(normalizedTool),
808
- );
809
- }
829
+ composites.push({ input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 });
830
+ } else if (useDynamicCursorAvatar) {
831
+ composites.push({ input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 });
832
+ }
833
+
834
+ if (useDynamicBadgeAvatar) {
835
+ if (useFastCodexAvatar) composites.push(buildCodexFastFrameOverlay());
836
+ composites.push(await buildAgentBadgeOverlay(normalizedTool));
837
+ }
810
838
 
811
839
  let pipeline = sharp(await readFile(basePath))
812
840
  .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
@@ -824,21 +852,22 @@ async function renderAvatar(
824
852
  buffer: jpeg,
825
853
  contentType: "image/jpeg",
826
854
  filename: normalizedTool === "codex" && codexUsage?.weekly
827
- ? `avatar_${normalizedTool}_${normalizedStatus}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
855
+ ? `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
828
856
  : normalizedTool === "cursor" && cursorBatteryPercent !== null
829
857
  ? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
830
- : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
858
+ : `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}.jpg`,
831
859
  };
832
860
  }
833
861
 
834
- async function uploadImage(
835
- token: string,
836
- tool: string,
837
- status: string,
838
- codexUsage: CodexUsageSummary | null = null,
839
- cursorBatteryPercent: number | null = null,
840
- ): Promise<string> {
841
- const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent);
862
+ async function uploadImage(
863
+ token: string,
864
+ tool: string,
865
+ status: string,
866
+ codexUsage: CodexUsageSummary | null = null,
867
+ cursorBatteryPercent: number | null = null,
868
+ fastMode = false,
869
+ ): Promise<string> {
870
+ const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent, fastMode);
842
871
  const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
843
872
  const form = new FormData();
844
873
  form.append("image_type", "avatar");
@@ -869,13 +898,14 @@ async function getOrUploadAvatarKey(
869
898
  const normalizedTool = normalizeAvatarTool(tool);
870
899
  const normalizedStatus = normalizeAvatarStatus(status);
871
900
  const codexUsage = normalizedTool === "codex" ? await resolveCodexAvatarUsage(usageHints.codexUsage) : null;
872
- const cursorBatteryPercent = normalizedTool === "cursor"
873
- ? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
874
- : null;
875
- const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
876
- const cached = avatarKeyCache.get(keyName);
877
- if (cached) return cached;
878
- const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent);
901
+ const cursorBatteryPercent = normalizedTool === "cursor"
902
+ ? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
903
+ : null;
904
+ const fastMode = normalizedTool === "codex" && usageHints.fastMode === true;
905
+ const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
906
+ const cached = avatarKeyCache.get(keyName);
907
+ if (cached) return cached;
908
+ const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
879
909
  avatarKeyCache.set(keyName, key);
880
910
  await persistAvatarKeyCache().catch((err) => {
881
911
  console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
@@ -1023,7 +1053,7 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
1023
1053
  }
1024
1054
 
1025
1055
  const contentLine = messageText ? `\n> 原始内容:${messageText.slice(0, 200)}` : "";
1026
- return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达${contentLine}`;
1056
+ return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,现延迟约 ${delayStr}后送达${contentLine}`;
1027
1057
  }
1028
1058
 
1029
1059
  /**
@@ -0,0 +1,195 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, join } from "node:path";
3
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+
5
+ export const MAX_PROCESSED = 5000;
6
+
7
+ export type FeishuMessageDisposition = "accepted" | "duplicate" | "stale";
8
+
9
+ export interface FeishuMessageIdentity {
10
+ messageId?: string;
11
+ chatId: string;
12
+ createTime: number;
13
+ }
14
+
15
+ interface PersistedMessageEntry {
16
+ messageId: string;
17
+ chatId: string;
18
+ createTime: number;
19
+ }
20
+
21
+ interface PersistedMessageLedger {
22
+ version: 1;
23
+ entries: PersistedMessageEntry[];
24
+ }
25
+
26
+ type ScheduleTask = (task: () => void) => void;
27
+
28
+ const defaultSchedule: ScheduleTask = (task) => {
29
+ setImmediate(task);
30
+ };
31
+
32
+ /**
33
+ * The Feishu SDK waits for an event handler's return value before sending its
34
+ * WebSocket response. Schedule the real work for the next event-loop turn so
35
+ * the SDK callback can return and acknowledge the event immediately.
36
+ */
37
+ export function createAckFirstEventHandler<T>(
38
+ worker: (data: T) => Promise<void>,
39
+ onError: (error: unknown) => void,
40
+ schedule: ScheduleTask = defaultSchedule,
41
+ ): (data: T) => Promise<void> {
42
+ return async (data) => {
43
+ schedule(() => {
44
+ void worker(data).catch(onError);
45
+ });
46
+ };
47
+ }
48
+
49
+ function isPersistedEntry(value: unknown): value is PersistedMessageEntry {
50
+ if (!value || typeof value !== "object") return false;
51
+ const entry = value as Record<string, unknown>;
52
+ return typeof entry.messageId === "string"
53
+ && entry.messageId.length > 0
54
+ && typeof entry.chatId === "string"
55
+ && typeof entry.createTime === "number"
56
+ && Number.isFinite(entry.createTime);
57
+ }
58
+
59
+ export class FeishuMessageLedger {
60
+ private readonly messageIds: Set<string>;
61
+ private entries: PersistedMessageEntry[] = [];
62
+ private latestCreateTimeByChat = new Map<string, number>();
63
+ private persistTail: Promise<void> = Promise.resolve();
64
+
65
+ constructor(
66
+ public readonly filePath: string,
67
+ private readonly maxEntries = MAX_PROCESSED,
68
+ messageIds?: Set<string>,
69
+ ) {
70
+ this.messageIds = messageIds ?? new Set<string>();
71
+ }
72
+
73
+ async load(): Promise<void> {
74
+ this.clearMemory();
75
+
76
+ try {
77
+ const raw = await readFile(this.filePath, "utf-8");
78
+ const parsed = JSON.parse(raw) as Partial<PersistedMessageLedger>;
79
+ const entries = Array.isArray(parsed.entries)
80
+ ? parsed.entries.filter(isPersistedEntry)
81
+ : [];
82
+ this.entries = entries.slice(-this.maxEntries);
83
+ this.rebuildIndexes();
84
+
85
+ if (entries.length > this.maxEntries) {
86
+ await this.persist();
87
+ }
88
+ } catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
90
+ console.error(
91
+ `[FEISHU-DEDUP] Failed to load ${this.filePath}: ${(error as Error).message}`,
92
+ );
93
+ }
94
+ }
95
+ }
96
+
97
+ async accept(identity: FeishuMessageIdentity): Promise<FeishuMessageDisposition> {
98
+ const { messageId, chatId, createTime } = identity;
99
+
100
+ if (messageId && this.messageIds.has(messageId)) {
101
+ return "duplicate";
102
+ }
103
+
104
+ const latestCreateTime = this.latestCreateTimeByChat.get(chatId);
105
+ if (latestCreateTime !== undefined && createTime < latestCreateTime) {
106
+ return "stale";
107
+ }
108
+
109
+ if (!messageId) {
110
+ this.recordLatestCreateTime(chatId, createTime);
111
+ return "accepted";
112
+ }
113
+
114
+ this.entries.push({ messageId, chatId, createTime });
115
+ this.messageIds.add(messageId);
116
+ this.recordLatestCreateTime(chatId, createTime);
117
+
118
+ if (this.entries.length > this.maxEntries) {
119
+ this.entries = this.entries.slice(-this.maxEntries);
120
+ this.rebuildIndexes();
121
+ }
122
+
123
+ try {
124
+ await this.persist();
125
+ } catch (error) {
126
+ console.error(
127
+ `[FEISHU-DEDUP] Failed to persist ${this.filePath}: ${(error as Error).message}`,
128
+ );
129
+ }
130
+ return "accepted";
131
+ }
132
+
133
+ clearMemory(): void {
134
+ this.entries = [];
135
+ this.messageIds.clear();
136
+ this.latestCreateTimeByChat.clear();
137
+ this.persistTail = Promise.resolve();
138
+ }
139
+
140
+ private recordLatestCreateTime(chatId: string, createTime: number): void {
141
+ const current = this.latestCreateTimeByChat.get(chatId);
142
+ if (current === undefined || createTime > current) {
143
+ this.latestCreateTimeByChat.set(chatId, createTime);
144
+ }
145
+ }
146
+
147
+ private rebuildIndexes(): void {
148
+ this.messageIds.clear();
149
+ this.latestCreateTimeByChat.clear();
150
+ for (const entry of this.entries) {
151
+ this.messageIds.add(entry.messageId);
152
+ this.recordLatestCreateTime(entry.chatId, entry.createTime);
153
+ }
154
+ }
155
+
156
+ private persist(): Promise<void> {
157
+ const snapshot: PersistedMessageLedger = {
158
+ version: 1,
159
+ entries: this.entries.map((entry) => ({ ...entry })),
160
+ };
161
+
162
+ const nextPersist = this.persistTail
163
+ .catch(() => {})
164
+ .then(async () => {
165
+ await mkdir(dirname(this.filePath), { recursive: true });
166
+ const tempPath = `${this.filePath}.${process.pid}.tmp`;
167
+ try {
168
+ await writeFile(tempPath, JSON.stringify(snapshot), "utf-8");
169
+ await rename(tempPath, this.filePath);
170
+ } finally {
171
+ await rm(tempPath, { force: true }).catch(() => {});
172
+ }
173
+ });
174
+ this.persistTail = nextPersist;
175
+ return nextPersist;
176
+ }
177
+ }
178
+
179
+ const defaultLedgerPath = join(
180
+ homedir(),
181
+ ".chatccc",
182
+ "state",
183
+ "feishu-message-ledger.json",
184
+ );
185
+
186
+ export const processedMessages = new Set<string>();
187
+ export const feishuMessageLedger = new FeishuMessageLedger(
188
+ defaultLedgerPath,
189
+ MAX_PROCESSED,
190
+ processedMessages,
191
+ );
192
+
193
+ export function clearFeishuMessageLedgerMemory(): void {
194
+ feishuMessageLedger.clearMemory();
195
+ }
package/src/index.ts CHANGED
@@ -96,11 +96,9 @@ import {
96
96
  sendCardKitMessage,
97
97
  updateCardKitCard,
98
98
  } from "./cardkit.ts";
99
- import {
100
- MAX_PROCESSED,
101
- loadSessionRegistryForBinding,
102
- processedMessages,
103
- rebuildBindingsFromRegistry,
99
+ import {
100
+ loadSessionRegistryForBinding,
101
+ rebuildBindingsFromRegistry,
104
102
  resetState,
105
103
  setSessionPlatform,
106
104
  startUnifiedDisplayLoop,
@@ -115,7 +113,11 @@ import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
115
113
  import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
116
114
  import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
117
115
  import { resolveFeishuCardActionChatType } from "./card-action-routing.ts";
118
- import { reloadRuntimeConfig } from "./runtime-reload.ts";
116
+ import { reloadRuntimeConfig } from "./runtime-reload.ts";
117
+ import {
118
+ createAckFirstEventHandler,
119
+ feishuMessageLedger,
120
+ } from "./feishu-message-ingress.ts";
119
121
 
120
122
  // ---------------------------------------------------------------------------
121
123
  // Feishu 平台适配器
@@ -262,12 +264,105 @@ function parseCardAction(data: unknown): CardActionResult | null {
262
264
  // WebSocket relay broadcast
263
265
  // ---------------------------------------------------------------------------
264
266
 
265
- let broadcastToRelay: (data: unknown) => void = () => {};
266
- const wechatSignal = { stopped: false };
267
-
268
- // ---------------------------------------------------------------------------
269
- // Simulate mode: inject message via HTTP
270
- // ---------------------------------------------------------------------------
267
+ let broadcastToRelay: (data: unknown) => void = () => {};
268
+ const wechatSignal = { stopped: false };
269
+
270
+ async function processFeishuMessageEvent(data: Evt): Promise<void> {
271
+ const traceId = makeTraceId();
272
+ try {
273
+ broadcastToRelay(data);
274
+
275
+ const event = getInnerEvent(data);
276
+ const message = event.message;
277
+ if (!message) return;
278
+
279
+ const messageId = message.message_id;
280
+ const chatId = message.chat_id ?? "";
281
+ const chatType = message.chat_type ?? "group";
282
+ const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
283
+ const disposition = await feishuMessageLedger.accept({
284
+ messageId,
285
+ chatId,
286
+ createTime: msgTimestamp,
287
+ });
288
+
289
+ if (disposition === "duplicate") {
290
+ console.log(`[MSG] Duplicate message ignored: ${messageId ?? "(missing id)"}`);
291
+ return;
292
+ }
293
+ if (disposition === "stale") {
294
+ logTrace(traceId, "DONE", {
295
+ outcome: "skip_stale_feishu_message",
296
+ messageId,
297
+ chatId,
298
+ msgTimestamp,
299
+ });
300
+ console.log(
301
+ `[${ts()}] [SKIP] Stale Feishu message ignored: messageId=${messageId ?? "(missing id)"} chatId=${chatId} createTime=${msgTimestamp}`,
302
+ );
303
+ return;
304
+ }
305
+
306
+ const text = await formatMessageContent(message);
307
+ const openId = event.sender?.sender_id?.open_id ?? "";
308
+
309
+ console.log(
310
+ `[MSG] id=${messageId ?? "(missing)"} sender=${openId} chat=${chatId} type=${chatType} text="${text}"`,
311
+ );
312
+ appendChatLog(chatId, openId, text);
313
+
314
+ if (messageId) {
315
+ getTenantAccessToken().then((freshToken) =>
316
+ addReaction(freshToken, messageId).catch((err) =>
317
+ console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
318
+ )
319
+ ).catch((err) =>
320
+ console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
321
+ );
322
+ }
323
+
324
+ if (!text) return;
325
+ logTrace(traceId, "RECV", {
326
+ messageId,
327
+ chatId,
328
+ chatType,
329
+ text: text.slice(0, 100),
330
+ });
331
+
332
+ const delayNotice = formatDelayNotice(msgTimestamp, text);
333
+ if (delayNotice) {
334
+ const delayToken = await getTenantAccessToken();
335
+ await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
336
+ }
337
+
338
+ await handleCommand(
339
+ feishuPlatform,
340
+ text,
341
+ chatId,
342
+ openId,
343
+ msgTimestamp,
344
+ chatType,
345
+ traceId,
346
+ buildUpdateCommandId("message", messageId),
347
+ );
348
+ } catch (err) {
349
+ logTrace(traceId, "ERROR", { message: (err as Error).message });
350
+ console.error(
351
+ `[${ts()}] [FATAL] im.message.receive_v1 worker crashed: ${(err as Error).message}`,
352
+ );
353
+ }
354
+ }
355
+
356
+ const handleFeishuMessageEventAckFirst = createAckFirstEventHandler(
357
+ processFeishuMessageEvent,
358
+ (err) => console.error(
359
+ `[${ts()}] [FATAL] Detached Feishu event worker failed: ${(err as Error).message}`,
360
+ ),
361
+ );
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // Simulate mode: inject message via HTTP
365
+ // ---------------------------------------------------------------------------
271
366
 
272
367
  async function handleSimInjectMessage(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
273
368
  const url = new URL(req.url ?? "/", "http://127.0.0.1");
@@ -399,74 +494,9 @@ async function startBotServiceCore(): Promise<void> {
399
494
 
400
495
  console.log(`[${ts()}] [AUTH] Token obtained`);
401
496
 
402
- const eventDispatcher = new EventDispatcher({});
403
- eventDispatcher.register({
404
- "im.message.receive_v1": async (data: Evt) => {
405
- const traceId = makeTraceId();
406
- try {
407
- broadcastToRelay(data);
408
-
409
- const event = getInnerEvent(data);
410
- const message = event.message;
411
- if (!message) return;
412
-
413
- const messageId = message.message_id;
414
- if (messageId) {
415
- if (processedMessages.has(messageId)) {
416
- console.log(`[MSG] Duplicate message ignored: ${messageId}`);
417
- return;
418
- }
419
- processedMessages.add(messageId);
420
- if (processedMessages.size > MAX_PROCESSED) {
421
- const it = processedMessages.values();
422
- for (let i = 0; i < 1000; i++) processedMessages.delete(it.next().value as string);
423
- }
424
- }
425
-
426
- const text = await formatMessageContent(message);
427
- const sender = event.sender;
428
- const openId = sender?.sender_id?.open_id ?? "";
429
- const chatId = message.chat_id ?? "";
430
- const chatType = message.chat_type ?? "group";
431
-
432
- console.log(`[MSG] sender=${openId} chat=${chatId} type=${chatType} text="${text}"`);
433
- appendChatLog(chatId, openId, text);
434
-
435
- if (messageId) {
436
- getTenantAccessToken().then((freshToken) =>
437
- addReaction(freshToken, messageId).catch((err) =>
438
- console.error(`[${ts()}] Reaction failed: ${(err as Error).message}`)
439
- )
440
- ).catch((err) =>
441
- console.error(`[${ts()}] Reaction token failed: ${(err as Error).message}`)
442
- );
443
- }
444
-
445
- if (!text) return;
446
- const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
447
- logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
448
- const delayNotice = formatDelayNotice(msgTimestamp, text);
449
- if (delayNotice) {
450
- const delayToken = await getTenantAccessToken();
451
- await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
452
- }
453
- // 仅 `/update` 会使用这个稳定 ID 做跨重启幂等;其他命令仍沿用
454
- // processedMessages 的进程内去重。
455
- await handleCommand(
456
- feishuPlatform,
457
- text,
458
- chatId,
459
- openId,
460
- msgTimestamp,
461
- chatType,
462
- traceId,
463
- buildUpdateCommandId("message", messageId),
464
- );
465
- } catch (err) {
466
- logTrace(traceId, "ERROR", { message: (err as Error).message });
467
- console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
468
- }
469
- },
497
+ const eventDispatcher = new EventDispatcher({});
498
+ eventDispatcher.register({
499
+ "im.message.receive_v1": handleFeishuMessageEventAckFirst,
470
500
 
471
501
  "card.action.trigger": async (data: Evt) => {
472
502
  try {
@@ -593,7 +623,8 @@ async function startBotServiceCore(): Promise<void> {
593
623
  } else {
594
624
  // 进程首次启动:此时所有 Map 都是空的,resetState 主要是打个 LOG 标识"开始
595
625
  // 干净状态"。修正残留的 running stream-state 并重建 session→chat 映射。
596
- resetState();
626
+ resetState();
627
+ await feishuMessageLedger.load();
597
628
  startUnifiedDisplayLoop();
598
629
  fixStaleStreamStates().then(async () => {
599
630
  const registry = await loadSessionRegistryForBinding();
@@ -340,6 +340,22 @@ function fastHelpAfterModel(tool: string): string {
340
340
  : "";
341
341
  }
342
342
 
343
+ function setChatAvatarForSession(
344
+ platform: PlatformAdapter,
345
+ chatId: string,
346
+ tool: string,
347
+ status: string,
348
+ sessionId?: string,
349
+ usageHints?: ChatAvatarUsageHints,
350
+ ): Promise<void> {
351
+ const fastMode = getEffectiveFastModeForTool(tool, sessionId);
352
+ if (!usageHints && !fastMode) return platform.setChatAvatar(chatId, tool, status);
353
+ return platform.setChatAvatar(chatId, tool, status, {
354
+ ...usageHints,
355
+ ...(fastMode ? { fastMode: true } : {}),
356
+ });
357
+ }
358
+
343
359
  async function sendFastModeStatus(
344
360
  platform: PlatformAdapter,
345
361
  chatId: string,
@@ -369,24 +385,26 @@ async function resolveUsageTarget(chatId: string): Promise<{ tool: "codex" | "cu
369
385
  }
370
386
  }
371
387
 
372
- function refreshUsageAvatar(
373
- platform: PlatformAdapter,
374
- chatId: string,
375
- tool: "codex" | "cursor",
376
- status: "busy" | "idle",
377
- usageHints: ChatAvatarUsageHints,
378
- ): void {
379
- platform.setChatAvatar(chatId, tool, status, usageHints).catch((err) => {
388
+ function refreshUsageAvatar(
389
+ platform: PlatformAdapter,
390
+ chatId: string,
391
+ tool: "codex" | "cursor",
392
+ status: "busy" | "idle",
393
+ usageHints: ChatAvatarUsageHints,
394
+ sessionId?: string,
395
+ ): void {
396
+ setChatAvatarForSession(platform, chatId, tool, status, sessionId, usageHints).catch((err) => {
380
397
  console.warn(`[${ts()}] [AVATAR] usage refresh failed: chatId=${chatId} tool=${tool} ${(err as Error).message}`);
381
398
  });
382
399
  }
383
400
 
384
- async function sendUsageSummary(
385
- platform: PlatformAdapter,
386
- chatId: string,
387
- tool: "codex" | "cursor",
388
- avatarStatus: "busy" | "idle" = "idle",
389
- ): Promise<void> {
401
+ async function sendUsageSummary(
402
+ platform: PlatformAdapter,
403
+ chatId: string,
404
+ tool: "codex" | "cursor",
405
+ avatarStatus: "busy" | "idle" = "idle",
406
+ sessionId?: string,
407
+ ): Promise<void> {
390
408
  if (tool === "cursor") {
391
409
  const usage = await getCursorUsageSummary();
392
410
  const content = formatCursorUsageSummary(usage);
@@ -395,7 +413,7 @@ async function sendUsageSummary(
395
413
  } else {
396
414
  await platform.sendCard(chatId, "Cursor Usage", content, "blue");
397
415
  }
398
- refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage });
416
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage }, sessionId);
399
417
  return;
400
418
  }
401
419
 
@@ -411,7 +429,7 @@ async function sendUsageSummary(
411
429
  } else {
412
430
  await platform.sendCard(chatId, "Codex Usage", content, "blue");
413
431
  }
414
- refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage });
432
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage }, sessionId);
415
433
  }
416
434
 
417
435
  async function sendUsageError(platform: PlatformAdapter, chatId: string, tool: "codex" | "cursor", err: unknown): Promise<void> {
@@ -585,7 +603,7 @@ async function resolveFeishuP2pAgent(
585
603
  `检测到默认 Agent 已变化:**${previousLabel} → ${desiredLabel}**。\n\n已创建新的空白 ${desiredLabel} 私聊会话,并从本条消息开始使用。`,
586
604
  "green",
587
605
  ).catch(() => {});
588
- platform.setChatAvatar(chatId, desiredTool, "new").catch(() => {});
606
+ setChatAvatarForSession(platform, chatId, desiredTool, "new", init.sessionId).catch(() => {});
589
607
  return { kind: "ready", sessionId: init.sessionId, tool: desiredTool };
590
608
  } catch (err) {
591
609
  return {
@@ -817,7 +835,7 @@ export async function handleCommand(
817
835
  const avatarStatus = usageTarget.sessionId && isSessionRunning(usageTarget.sessionId) ? "busy" : "idle";
818
836
  logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
819
837
  try {
820
- await sendUsageSummary(platform, chatId, usageTool, avatarStatus);
838
+ await sendUsageSummary(platform, chatId, usageTool, avatarStatus, usageTarget.sessionId);
821
839
  logTrace(tid, "DONE", { outcome: "usage", tool: usageTool });
822
840
  } catch (err) {
823
841
  await sendUsageError(platform, chatId, usageTool, err);
@@ -1149,7 +1167,7 @@ export async function handleCommand(
1149
1167
  sessionId,
1150
1168
  tool,
1151
1169
  });
1152
- platform.setChatAvatar(newChatId, tool, "new").catch(() => {});
1170
+ setChatAvatarForSession(platform, newChatId, tool, "new", sessionId).catch(() => {});
1153
1171
  console.log(`${"=".repeat(60)}`);
1154
1172
  return;
1155
1173
  }
@@ -1544,9 +1562,7 @@ export async function handleCommand(
1544
1562
  );
1545
1563
  }
1546
1564
 
1547
- platform
1548
- .setChatAvatar(chatId, descriptionTool, "new")
1549
- .catch(() => {});
1565
+ setChatAvatarForSession(platform, chatId, descriptionTool, "new", newSessionId).catch(() => {});
1550
1566
 
1551
1567
  await platform.sendCard(
1552
1568
  chatId,
@@ -1716,7 +1732,7 @@ export async function handleCommand(
1716
1732
  );
1717
1733
  }
1718
1734
 
1719
- platform.setChatAvatar(chatId, target.tool, "new").catch(() => {});
1735
+ setChatAvatarForSession(platform, chatId, target.tool, "new", target.sessionId).catch(() => {});
1720
1736
 
1721
1737
  const targetToolLabel = toolDisplayName(target.tool);
1722
1738
  const busyNote = isSessionRunning(target.sessionId)
@@ -1772,6 +1788,12 @@ export async function handleCommand(
1772
1788
  }
1773
1789
  const enabled = getEffectiveFastModeForTool("codex", sessionId);
1774
1790
  await sendFastModeStatus(platform, chatId, enabled).catch(() => {});
1791
+ if (fastArg) {
1792
+ const avatarStatus = isSessionRunning(sessionId) ? "busy" : "idle";
1793
+ await platform.setChatAvatar(chatId, "codex", avatarStatus, { fastMode: enabled }).catch((err) => {
1794
+ console.warn(`[${ts()}] [AVATAR] Fast mode refresh failed: chatId=${chatId} ${(err as Error).message}`);
1795
+ });
1796
+ }
1775
1797
  logTrace(tid, "DONE", {
1776
1798
  outcome: fastArg ? "fast_switched" : "fast_query",
1777
1799
  enabled,
@@ -8,10 +8,11 @@
8
8
  import type { CursorUsageSummary } from "./cursor-usage.ts";
9
9
  import type { CodexUsageSummary } from "./feishu-api.ts";
10
10
 
11
- export interface ChatAvatarUsageHints {
12
- codexUsage?: CodexUsageSummary | null;
13
- cursorUsage?: CursorUsageSummary | null;
14
- }
11
+ export interface ChatAvatarUsageHints {
12
+ codexUsage?: CodexUsageSummary | null;
13
+ cursorUsage?: CursorUsageSummary | null;
14
+ fastMode?: boolean;
15
+ }
15
16
 
16
17
  export interface PlatformAdapter {
17
18
  /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
@@ -66,4 +67,4 @@ export interface PlatformAdapter {
66
67
 
67
68
  /** 更新已发送的进度展示,sequence 保证有序 */
68
69
  cardUpdate(cardId: string, cardJson: string, sequence: number): Promise<void>;
69
- }
70
+ }
package/src/session.ts CHANGED
@@ -42,6 +42,13 @@ import { resourceMonitor, registerProcess, unregisterProcess } from "./adapters/
42
42
  import { buildImSkillsPromptCached, exportSkillSubDocs, clearImSkillsPromptCache } from "./im-skills.ts";
43
43
  import type { PlatformAdapter } from "./platform-adapter.ts";
44
44
  import { hasResponseStalled, observeResponseProgress } from "./response-stall.ts";
45
+ import {
46
+ MAX_PROCESSED,
47
+ clearFeishuMessageLedgerMemory,
48
+ processedMessages,
49
+ } from "./feishu-message-ingress.ts";
50
+
51
+ export { MAX_PROCESSED, processedMessages };
45
52
 
46
53
  // 微信显示循环压缩:头5 + ... + 尾5,避免在最后一步 sendText 中压缩指令回复
47
54
  function compressWechatDisplayText(text: string): string {
@@ -131,9 +138,6 @@ async function createVisibleProgressCard(
131
138
  // Shared state (imported by index.ts)
132
139
  // ---------------------------------------------------------------------------
133
140
 
134
- export const processedMessages = new Set<string>();
135
- export const MAX_PROCESSED = 5000;
136
-
137
141
  /** 每个 chatId 上一次已处理消息的时间戳,用于拦截延迟送达的旧消息 */
138
142
  export const lastMsgTimestamps = new Map<string, number>();
139
143
 
@@ -463,7 +467,7 @@ export function resetState(): void {
463
467
  }
464
468
  chatSessionMap.clear();
465
469
  sessionInfoMap.clear();
466
- processedMessages.clear();
470
+ clearFeishuMessageLedgerMemory();
467
471
  lastMsgTimestamps.clear();
468
472
  chatPlatformMap.clear();
469
473
  for (const prompt of activePrompts.values()) {
@@ -534,6 +538,18 @@ export function getEffectiveFastModeForTool(tool: string, sessionId?: string): b
534
538
  }
535
539
  return config.codex.fastMode;
536
540
  }
541
+
542
+ function setSessionChatAvatar(
543
+ platform: PlatformAdapter,
544
+ chatId: string,
545
+ tool: string,
546
+ status: string,
547
+ sessionId: string,
548
+ ): Promise<void> {
549
+ return getEffectiveFastModeForTool(tool, sessionId)
550
+ ? platform.setChatAvatar(chatId, tool, status, { fastMode: true })
551
+ : platform.setChatAvatar(chatId, tool, status);
552
+ }
537
553
 
538
554
  /** 为指定 session 设置模型覆盖(/model <name>) */
539
555
  export function setSessionModelOverride(sessionId: string, model: string): void {
@@ -1286,7 +1302,7 @@ export async function runAgentSession(
1286
1302
  if (displayCards.get(displayChatId) !== display) {
1287
1303
  const finalStatus = turnFinalStatus(prevState.status);
1288
1304
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
1289
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
1305
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1290
1306
  } else {
1291
1307
  const nextSeq = display.sequence + 1;
1292
1308
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
@@ -1306,7 +1322,7 @@ export async function runAgentSession(
1306
1322
  if (prevTerminalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
1307
1323
  await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevTerminalReply);
1308
1324
  }
1309
- pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
1325
+ setSessionChatAvatar(pp, displayChatId, prevState.tool, "idle", sessionId).catch(() => {});
1310
1326
  }
1311
1327
  } else if (pp && prevTerminalReply && !isFinalReplySentForTurn(prevState)) {
1312
1328
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
@@ -1371,7 +1387,7 @@ export async function runAgentSession(
1371
1387
  // 设置最后活跃群头像为 busy
1372
1388
  const activeCid = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1373
1389
  if (activeCid) {
1374
- platform.setChatAvatar(activeCid, tool, "busy").catch(() => {});
1390
+ setSessionChatAvatar(platform, activeCid, tool, "busy", sessionId).catch(() => {});
1375
1391
  }
1376
1392
 
1377
1393
  const state: AccumulatorState = {
@@ -1661,7 +1677,7 @@ export async function runAgentSession(
1661
1677
  const active1 = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1662
1678
  if (active1) {
1663
1679
  await platform.sendText(active1, "会话已停止。").catch(() => {});
1664
- platform.setChatAvatar(active1, tool, "idle").catch(() => {});
1680
+ setSessionChatAvatar(platform, active1, tool, "idle", sessionId).catch(() => {});
1665
1681
  }
1666
1682
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1667
1683
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
@@ -1691,7 +1707,7 @@ export async function runAgentSession(
1691
1707
  formatAutoEndedReply(finalReplyToWrite),
1692
1708
  );
1693
1709
  }
1694
- pp.setChatAvatar(activeAutoEnded, tool, "idle").catch(() => {});
1710
+ setSessionChatAvatar(pp, activeAutoEnded, tool, "idle", sessionId).catch(() => {});
1695
1711
 
1696
1712
  if (wasAutoRecovery) {
1697
1713
  // 这是紧接第一次停滞而启动的恢复轮;再次发生相同停滞即终止
@@ -1726,7 +1742,7 @@ export async function runAgentSession(
1726
1742
  });
1727
1743
  }
1728
1744
  const activeErr = getLastActiveChat(sessionId) ?? finalizationChatIds[0];
1729
- if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1745
+ if (activeErr) setSessionChatAvatar(platform, activeErr, tool, "idle", sessionId).catch(() => {});
1730
1746
  console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1731
1747
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1732
1748
  } else {
@@ -1749,7 +1765,7 @@ export async function runAgentSession(
1749
1765
  const pp = platformForChat(active2) ?? platform;
1750
1766
  await sendFinalReplyTextOnce(pp, active2, sessionId, nextTurnCount, finalReply);
1751
1767
  }
1752
- platform.setChatAvatar(active2, tool, "idle").catch(() => {});
1768
+ setSessionChatAvatar(platform, active2, tool, "idle", sessionId).catch(() => {});
1753
1769
  }
1754
1770
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
1755
1771
  if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
@@ -1992,7 +2008,7 @@ export function startUnifiedDisplayLoop(): void {
1992
2008
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1993
2009
  displayCards.delete(chatId);
1994
2010
  }
1995
- p.setChatAvatar(chatId, state.tool, "idle").catch(() => {});
2011
+ setSessionChatAvatar(p, chatId, state.tool, "idle", sessionId).catch(() => {});
1996
2012
  console.log(`[${ts()}] [DISPLAY] unified loop deleted display for ${chatId} (terminal: ${state.status})`);
1997
2013
  } else {
1998
2014
  // running: 创建或更新展示