chatccc 0.2.214 → 0.2.216

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/cards.ts CHANGED
@@ -553,8 +553,12 @@ export function buildModelCard(
553
553
  lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
554
554
  }
555
555
 
556
- const buttons: ButtonDef[] = [];
557
- for (const m of models.slice(0, 20)) {
556
+ if (tool === "codex") {
557
+ lines.push("输入 `/fast` 查看或切换当前会话的 Fast 模式");
558
+ }
559
+
560
+ const buttons: ButtonDef[] = [];
561
+ for (const m of models.slice(0, 20)) {
558
562
  const shortName = m.includes("/") ? m.slice(m.lastIndexOf("/") + 1) : m;
559
563
  buttons.push({
560
564
  text: `/model ${shortName}`,
@@ -574,6 +578,43 @@ export function buildModelCard(
574
578
  });
575
579
  }
576
580
 
581
+ export function buildFastModeCard(enabled: boolean): string {
582
+ const mode = enabled ? "ON (Fast)" : "OFF (Standard)";
583
+ return JSON.stringify({
584
+ config: { wide_screen_mode: true },
585
+ header: {
586
+ template: enabled ? "green" : "blue",
587
+ title: { content: "Codex Fast 模式", tag: "plain_text" },
588
+ },
589
+ elements: [
590
+ {
591
+ tag: "div",
592
+ text: {
593
+ tag: "lark_md",
594
+ content: [
595
+ `**当前模式:** ${mode}`,
596
+ "",
597
+ "切换将在下一条消息生效,当前生成不中断。",
598
+ ].join("\n"),
599
+ },
600
+ },
601
+ { tag: "hr" },
602
+ buildButtons([
603
+ {
604
+ text: "ON",
605
+ value: JSON.stringify({ cmd: "/fast on" }),
606
+ type: enabled ? "primary" : "default",
607
+ },
608
+ {
609
+ text: "OFF",
610
+ value: JSON.stringify({ cmd: "/fast off" }),
611
+ type: enabled ? "default" : "primary",
612
+ },
613
+ ]),
614
+ ],
615
+ });
616
+ }
617
+
577
618
  export function buildEffortCard(
578
619
  currentEffort: string,
579
620
  efforts: string[],
package/src/config.ts CHANGED
@@ -97,8 +97,10 @@ export interface CodexConfig {
97
97
  path: string;
98
98
  model: string;
99
99
  /** /model 可切换的单个备选模型;留空则不加入候选列表 */
100
- alternativeModel: string;
100
+ alternativeModel: string;
101
101
  effort: string;
102
+ /** Codex Priority service tier. False explicitly forces the standard tier. */
103
+ fastMode: boolean;
102
104
  }
103
105
 
104
106
  export interface CccConfig {
@@ -442,7 +444,7 @@ function loadConfig(): AppConfig {
442
444
  avatarBatteryMode: "apiPercent",
443
445
  onDemandMonthlyBudget: 1000,
444
446
  },
445
- codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "" },
447
+ codex: { enabled: false, defaultAgent: false, path: "", model: "", alternativeModel: "", effort: "", fastMode: false },
446
448
  ccc: { DEEPSEEK_API_KEY: "", DEEPSEEK_BASE_URL: DEFAULT_CCC_DEEPSEEK_BASE_URL, model: DEFAULT_CCC_MODEL },
447
449
  };
448
450
 
@@ -495,7 +497,7 @@ function loadConfig(): AppConfig {
495
497
  avatarBatteryMode?: unknown;
496
498
  onDemandMonthlyBudget?: unknown;
497
499
  };
498
- codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown };
500
+ codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; alternativeModel?: unknown; effort?: unknown; fastMode?: unknown };
499
501
  ccc?: { DEEPSEEK_API_KEY?: unknown; DEEPSEEK_BASE_URL?: unknown; model?: unknown };
500
502
  webUi?: { openOnStart?: unknown };
501
503
  chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
@@ -555,9 +557,10 @@ function loadConfig(): AppConfig {
555
557
  Boolean(
556
558
  (typeof codexRaw.path === "string" && codexRaw.path.trim()) ||
557
559
  (typeof codexRaw.command === "string" && (codexRaw.command as string).trim()) ||
558
- (typeof codexRaw.model === "string" && (codexRaw.model as string).trim()) ||
559
- (typeof codexRaw.alternativeModel === "string" && (codexRaw.alternativeModel as string).trim()) ||
560
- (typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()),
560
+ (typeof codexRaw.model === "string" && (codexRaw.model as string).trim()) ||
561
+ (typeof codexRaw.alternativeModel === "string" && (codexRaw.alternativeModel as string).trim()) ||
562
+ (typeof codexRaw.effort === "string" && (codexRaw.effort as string).trim()) ||
563
+ codexRaw.fastMode === true,
561
564
  );
562
565
 
563
566
  const claudeEnabled = resolveEnabled(claude.enabled, claudeNonEmpty);
@@ -649,6 +652,7 @@ function loadConfig(): AppConfig {
649
652
  model: normalizeOptionalConfigField(codexRaw.model, { label: "codex.model" }),
650
653
  alternativeModel: normalizeOptionalConfigField(codexRaw.alternativeModel, { label: "codex.alternativeModel" }),
651
654
  effort: normalizeOptionalConfigField(codexRaw.effort, { label: "codex.effort" }),
655
+ fastMode: codexRaw.fastMode === true,
652
656
  },
653
657
  ccc: {
654
658
  DEEPSEEK_API_KEY: normalizeOptionalConfigField(cccRaw.DEEPSEEK_API_KEY, { label: "ccc.DEEPSEEK_API_KEY" }),
package/src/feishu-api.ts CHANGED
@@ -1023,7 +1023,7 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
1023
1023
  }
1024
1024
 
1025
1025
  const contentLine = messageText ? `\n> 原始内容:${messageText.slice(0, 200)}` : "";
1026
- return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达${contentLine}`;
1026
+ return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,现延迟约 ${delayStr}后送达${contentLine}`;
1027
1027
  }
1028
1028
 
1029
1029
  /**
@@ -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();