chatccc 0.2.180 → 0.2.181

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.
@@ -65,8 +65,8 @@ vi.mock("../cursor-usage.ts", () => ({
65
65
  }));
66
66
 
67
67
  import { handleCommand } from "../orchestrator.ts";
68
- import {
69
- _clearAdapterCacheForTest,
68
+ import {
69
+ _clearAdapterCacheForTest,
70
70
  _resetSessionRegistryFileForTest,
71
71
  _resetSessionToolsFileForTest,
72
72
  _setAdapterForToolForTest,
@@ -74,10 +74,10 @@ import {
74
74
  _setSessionToolsFileForTest,
75
75
  loadSessionRegistryForBinding,
76
76
  recordSessionRegistry,
77
- resetState,
78
- } from "../session.ts";
79
- import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
80
- import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
77
+ resetState,
78
+ } from "../session.ts";
79
+ import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
80
+ import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
81
81
 
82
82
  function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
83
83
  return {
@@ -132,6 +132,8 @@ describe("handleCommand WeChat processing ack", () => {
132
132
  mockGetCodexUsageSummary.mockResolvedValue({
133
133
  fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
134
134
  weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
135
+ rateLimitResetCreditsAvailable: null,
136
+ rateLimitResetCredits: null,
135
137
  });
136
138
  mockGetCursorUsageSummary.mockResolvedValue({
137
139
  billingCycleStart: "1779357999000",
@@ -198,8 +200,8 @@ describe("handleCommand WeChat processing ack", () => {
198
200
  expect(platform.sendCard).not.toHaveBeenCalled();
199
201
  });
200
202
 
201
- it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
202
- const platform = mockPlatform();
203
+ it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
204
+ const platform = mockPlatform();
203
205
  await recordSessionRegistry({
204
206
  chatId: "wx-chat",
205
207
  sessionId: "sid-wechat",
@@ -209,44 +211,44 @@ describe("handleCommand WeChat processing ack", () => {
209
211
  });
210
212
 
211
213
  await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
212
-
213
- expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
214
- });
215
-
216
- it("treats /abd as a shared prompt prefix in an existing session", async () => {
217
- const platform = mockPlatform();
218
- const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
219
- yield {
220
- type: "assistant" as const,
221
- blocks: [{ type: "text" as const, text: "done" }],
222
- };
223
- });
224
- _setAdapterForToolForTest("claude", {
225
- displayName: "Claude",
226
- sessionDescPrefix: "Claude Session:",
227
- createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
228
- prompt,
229
- getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
230
- sessionId,
231
- cwd: "F:\\repo",
232
- }),
233
- closeSession: async () => {},
234
- });
235
- await recordSessionRegistry({
236
- chatId: "wx-chat",
237
- sessionId: "sid-wechat",
238
- tool: "claude",
239
- chatName: "ready-session",
240
- running: false,
241
- });
242
-
243
- await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
244
-
245
- expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
246
- const userText = prompt.mock.calls[0][1];
247
- expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
248
- expect(userText).not.toContain("/abd");
249
- });
214
+
215
+ expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
216
+ });
217
+
218
+ it("treats /abd as a shared prompt prefix in an existing session", async () => {
219
+ const platform = mockPlatform();
220
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
221
+ yield {
222
+ type: "assistant" as const,
223
+ blocks: [{ type: "text" as const, text: "done" }],
224
+ };
225
+ });
226
+ _setAdapterForToolForTest("claude", {
227
+ displayName: "Claude",
228
+ sessionDescPrefix: "Claude Session:",
229
+ createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
230
+ prompt,
231
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
232
+ sessionId,
233
+ cwd: "F:\\repo",
234
+ }),
235
+ closeSession: async () => {},
236
+ });
237
+ await recordSessionRegistry({
238
+ chatId: "wx-chat",
239
+ sessionId: "sid-wechat",
240
+ tool: "claude",
241
+ chatName: "ready-session",
242
+ running: false,
243
+ });
244
+
245
+ await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
246
+
247
+ expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
248
+ const userText = prompt.mock.calls[0][1];
249
+ expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
250
+ expect(userText).not.toContain("/abd");
251
+ });
250
252
 
251
253
  it("does not send the stopped success text until the running prompt really exits", async () => {
252
254
  const platform = mockPlatform();
@@ -268,7 +270,7 @@ describe("handleCommand WeChat processing ack", () => {
268
270
  expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "会话已停止。");
269
271
  });
270
272
 
271
- it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
273
+ it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
272
274
  const platform = mockPlatform("feishu");
273
275
  const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
274
276
  yield {
@@ -313,40 +315,40 @@ describe("handleCommand WeChat processing ack", () => {
313
315
 
314
316
  const registry = await loadSessionRegistryForBinding();
315
317
  expect(registry["feishu-p2p"]).toBeUndefined();
316
- expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
317
- });
318
-
319
- it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
320
- const platform = mockPlatform("feishu");
321
- const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
322
- yield {
323
- type: "assistant" as const,
324
- blocks: [{ type: "text" as const, text: "done" }],
325
- };
326
- });
327
- _setAdapterForToolForTest("claude", {
328
- displayName: "Claude",
329
- sessionDescPrefix: "Claude Session:",
330
- createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
331
- prompt,
332
- getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
333
- sessionId,
334
- cwd: "F:\\repo",
335
- }),
336
- closeSession: async () => {},
337
- });
338
-
339
- await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
340
-
341
- expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
342
- expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
343
- const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
344
- expect(updateCall[1]).not.toContain("---");
345
- expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
346
- const userText = prompt.mock.calls[0][1];
347
- expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
348
- expect(userText).not.toContain("/abd");
349
- });
318
+ expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
319
+ });
320
+
321
+ it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
322
+ const platform = mockPlatform("feishu");
323
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
324
+ yield {
325
+ type: "assistant" as const,
326
+ blocks: [{ type: "text" as const, text: "done" }],
327
+ };
328
+ });
329
+ _setAdapterForToolForTest("claude", {
330
+ displayName: "Claude",
331
+ sessionDescPrefix: "Claude Session:",
332
+ createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
333
+ prompt,
334
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
335
+ sessionId,
336
+ cwd: "F:\\repo",
337
+ }),
338
+ closeSession: async () => {},
339
+ });
340
+
341
+ await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
342
+
343
+ expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
344
+ expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
345
+ const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
346
+ expect(updateCall[1]).not.toContain("---");
347
+ expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
348
+ const userText = prompt.mock.calls[0][1];
349
+ expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
350
+ expect(userText).not.toContain("/abd");
351
+ });
350
352
 
351
353
  it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
352
354
  const platform = mockPlatform("feishu");
@@ -371,47 +373,31 @@ describe("handleCommand WeChat processing ack", () => {
371
373
  mockGetCodexUsageSummary.mockResolvedValue({
372
374
  fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
373
375
  weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
376
+ rateLimitResetCreditsAvailable: 2,
377
+ rateLimitResetCredits: [
378
+ { grantedAt: "2026-06-12T04:01:47.770016Z", expiresAt: "2026-07-12T04:01:47.770016Z" },
379
+ { grantedAt: "2026-06-18T00:44:23.904386Z", expiresAt: "2026-07-18T00:44:23.904386Z" },
380
+ ],
374
381
  });
375
382
 
376
383
  await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
377
384
 
378
385
  expect(platform.createGroup).not.toHaveBeenCalled();
379
- expect(platform.sendCard).toHaveBeenCalledWith(
380
- "feishu-p2p",
381
- "Codex Usage",
382
- expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
383
- "blue",
384
- );
385
- expect(platform.sendCard).toHaveBeenCalledWith(
386
- "feishu-p2p",
387
- "Codex Usage",
388
- expect.stringContaining("约 2小时52分钟后"),
389
- "blue",
390
- );
391
- expect(platform.sendCard).toHaveBeenCalledWith(
392
- "feishu-p2p",
393
- "Codex Usage",
394
- expect.stringContaining("[███████░░░░░░░░░░░░░]"),
395
- "blue",
396
- );
397
- expect(platform.sendCard).toHaveBeenCalledWith(
398
- "feishu-p2p",
399
- "Codex Usage",
400
- expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
401
- "blue",
402
- );
403
- expect(platform.sendCard).toHaveBeenCalledWith(
404
- "feishu-p2p",
405
- "Codex Usage",
406
- expect.stringContaining("约 3天18小时17分钟后"),
407
- "blue",
408
- );
409
- expect(platform.sendCard).toHaveBeenCalledWith(
410
- "feishu-p2p",
411
- "Codex Usage",
412
- expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
413
- "blue",
414
- );
386
+ expect(platform.sendCard).not.toHaveBeenCalled();
387
+ expect(platform.sendRawCard).toHaveBeenCalledTimes(1);
388
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
389
+ expect(card.header.title.content).toBe("Codex Usage");
390
+ expect(card.elements[0].text.content).toContain("2026-07-12");
391
+ expect(card.elements[0].text.content).toContain("2026-07-18");
392
+ expect(card.elements[0].text.content).toContain("**主动重置:** 剩余 2 次");
393
+ expect(card.elements[0].text.content).toContain("**5h:** 已用 37%,剩余 63%,重置:");
394
+ expect(card.elements[0].text.content).toContain(" 2小时52分钟后");
395
+ expect(card.elements[0].text.content).toContain("[███████░░░░░░░░░░░░░]");
396
+ expect(card.elements[0].text.content).toContain("**周:** 已用 12%,剩余 88%,重置:");
397
+ expect(card.elements[0].text.content).toContain("约 3天18小时17分钟后");
398
+ expect(card.elements[0].text.content).toContain("[██░░░░░░░░░░░░░░░░░░]");
399
+ expect(card.elements[2].actions[0].text.content).toBe("发起重置");
400
+ expect(card.elements[2].actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
415
401
  });
416
402
 
417
403
  it("handles /usage as Cursor usage in Cursor chats", async () => {
@@ -452,7 +438,7 @@ describe("handleCommand WeChat processing ack", () => {
452
438
  expect(codexPlatform.sendCard).toHaveBeenCalledWith(
453
439
  "feishu-group",
454
440
  "Codex Session Ready",
455
- expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
441
+ expect.stringContaining("发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡。"),
456
442
  "green",
457
443
  );
458
444
 
@@ -1,36 +1,36 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import {
4
- ABD_APPEND_PROMPT,
5
- applySharedPrefix,
6
- } from "../shared-prefix.ts";
7
-
8
- describe("applySharedPrefix", () => {
9
- it("leaves normal messages unchanged", () => {
10
- expect(applySharedPrefix("帮我分析")).toEqual({
11
- matched: false,
12
- text: "帮我分析",
13
- body: "帮我分析",
14
- });
15
- });
16
-
17
- it("removes /abd without requiring a space", () => {
18
- const result = applySharedPrefix("/abd帮我分析");
19
-
20
- expect(result.matched).toBe(true);
21
- expect(result.body).toBe("帮我分析");
22
- expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
23
- });
24
-
25
- it("removes whitespace after /abd", () => {
26
- const result = applySharedPrefix("/abd 帮我分析");
27
-
28
- expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
29
- });
30
-
31
- it("keeps the appendix when the user body is empty", () => {
32
- const result = applySharedPrefix("/abd ");
33
-
34
- expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
35
- });
36
- });
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ ABD_APPEND_PROMPT,
5
+ applySharedPrefix,
6
+ } from "../shared-prefix.ts";
7
+
8
+ describe("applySharedPrefix", () => {
9
+ it("leaves normal messages unchanged", () => {
10
+ expect(applySharedPrefix("帮我分析")).toEqual({
11
+ matched: false,
12
+ text: "帮我分析",
13
+ body: "帮我分析",
14
+ });
15
+ });
16
+
17
+ it("removes /abd without requiring a space", () => {
18
+ const result = applySharedPrefix("/abd帮我分析");
19
+
20
+ expect(result.matched).toBe(true);
21
+ expect(result.body).toBe("帮我分析");
22
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
23
+ });
24
+
25
+ it("removes whitespace after /abd", () => {
26
+ const result = applySharedPrefix("/abd 帮我分析");
27
+
28
+ expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
29
+ });
30
+
31
+ it("keeps the appendix when the user body is empty", () => {
32
+ const result = applySharedPrefix("/abd ");
33
+
34
+ expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
35
+ });
36
+ });
package/src/cards.ts CHANGED
@@ -1,10 +1,10 @@
1
- // ---------------------------------------------------------------------------
2
- // Button helpers
3
- // ---------------------------------------------------------------------------
4
-
5
- import { ABD_HELP_LINE } from "./shared-prefix.ts";
6
-
7
- export interface ButtonDef {
1
+ // ---------------------------------------------------------------------------
2
+ // Button helpers
3
+ // ---------------------------------------------------------------------------
4
+
5
+ import { ABD_HELP_LINE } from "./shared-prefix.ts";
6
+
7
+ export interface ButtonDef {
8
8
  text: string;
9
9
  value: string;
10
10
  type?: "primary" | "default" | "danger";
@@ -137,11 +137,11 @@ export function buildHelpCard(
137
137
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
138
138
  "发送 **/plan** 以规划模式提问(只读,不执行写操作)",
139
139
  "发送 **/ask** 以问答模式提问(只读,不执行写操作)",
140
- "发送 **/usage** 查看 Codex 5h 和周用量",
141
- "发送 **/restart** 重启 ChatCCC 进程",
142
- "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
143
- ABD_HELP_LINE,
144
- ].join("\n");
140
+ "发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡",
141
+ "发送 **/restart** 重启 ChatCCC 进程",
142
+ "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
143
+ ABD_HELP_LINE,
144
+ ].join("\n");
145
145
  return JSON.stringify({
146
146
  config: { wide_screen_mode: true },
147
147
  header: { template: "blue", title: { content: "ChatCCC", tag: "plain_text" } },
@@ -430,6 +430,72 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
430
430
  });
431
431
  }
432
432
 
433
+ export function buildCodexUsageCard(content: string, resetCreditsAvailable: number | null): string {
434
+ const elements: object[] = [
435
+ { tag: "div", text: { tag: "lark_md", content } },
436
+ ];
437
+ if (resetCreditsAvailable !== null && resetCreditsAvailable > 0) {
438
+ elements.push({ tag: "hr" });
439
+ elements.push({
440
+ tag: "action",
441
+ actions: [{
442
+ tag: "button",
443
+ text: { tag: "plain_text", content: "发起重置" },
444
+ type: "primary",
445
+ value: { action: "codex_reset_request", availableCount: resetCreditsAvailable },
446
+ }],
447
+ });
448
+ }
449
+
450
+ return JSON.stringify({
451
+ config: { wide_screen_mode: true },
452
+ header: { template: "blue", title: { content: "Codex Usage", tag: "plain_text" } },
453
+ elements,
454
+ });
455
+ }
456
+
457
+ export function buildCodexResetConfirmCard(params: {
458
+ availableCount: number;
459
+ parentMessageId: string;
460
+ requestId: string;
461
+ }): string {
462
+ const valueBase = {
463
+ parentMessageId: params.parentMessageId,
464
+ requestId: params.requestId,
465
+ };
466
+ return JSON.stringify({
467
+ config: { wide_screen_mode: true },
468
+ header: { template: "yellow", title: { content: "确认 Codex 主动重置", tag: "plain_text" } },
469
+ elements: [
470
+ {
471
+ tag: "div",
472
+ text: {
473
+ tag: "lark_md",
474
+ content: `当前可用主动重置次数:**${params.availableCount}**。\n\n确认后会消耗 1 次主动重置,并重置当前可重置的 Codex 用量窗口。`,
475
+ },
476
+ },
477
+ { tag: "hr" },
478
+ {
479
+ tag: "action",
480
+ actions: [
481
+ {
482
+ tag: "button",
483
+ text: { tag: "plain_text", content: "是,发起重置" },
484
+ type: "danger",
485
+ value: { action: "codex_reset_confirm", decision: "yes", ...valueBase },
486
+ },
487
+ {
488
+ tag: "button",
489
+ text: { tag: "plain_text", content: "否" },
490
+ type: "default",
491
+ value: { action: "codex_reset_confirm", decision: "no", ...valueBase },
492
+ },
493
+ ],
494
+ },
495
+ ],
496
+ });
497
+ }
498
+
433
499
  /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
434
500
  export function buildModelCard(
435
501
  currentModel: string,
@@ -0,0 +1,171 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import { buildCodexResetConfirmCard } from "./cards.ts";
4
+ import type { CodexResetConsumeResult } from "./feishu-api.ts";
5
+
6
+ type CodexResetDecision = "yes" | "no";
7
+
8
+ interface PendingCodexResetRequest {
9
+ chatId: string;
10
+ parentMessageId: string;
11
+ status: "pending" | "handled";
12
+ }
13
+
14
+ export interface CodexResetActionDeps {
15
+ getTenantAccessToken(): Promise<string>;
16
+ sendRawCard(token: string, chatId: string, cardJson: string): Promise<boolean>;
17
+ sendCardReply(token: string, chatId: string, title: string, content: string, template?: string): Promise<boolean>;
18
+ updateCardMessage(token: string, messageId: string, content: string): Promise<boolean>;
19
+ consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult>;
20
+ createRequestId?: () => string;
21
+ }
22
+
23
+ const pendingCodexResetRequests = new Map<string, PendingCodexResetRequest>();
24
+
25
+ function rawEvent(data: unknown): Record<string, unknown> {
26
+ return ((data as Record<string, unknown>)?.event ?? data) as Record<string, unknown>;
27
+ }
28
+
29
+ function actionValue(raw: Record<string, unknown>): Record<string, unknown> | null {
30
+ const action = raw.action as { value?: unknown } | undefined;
31
+ const value = action?.value;
32
+ if (!value || typeof value !== "object") return null;
33
+ return value as Record<string, unknown>;
34
+ }
35
+
36
+ function eventMessageId(raw: Record<string, unknown>): string {
37
+ return typeof raw.open_message_id === "string"
38
+ ? raw.open_message_id
39
+ : typeof (raw.context as Record<string, unknown> | undefined)?.open_message_id === "string"
40
+ ? (raw.context as Record<string, string>).open_message_id
41
+ : "";
42
+ }
43
+
44
+ function eventChatId(raw: Record<string, unknown>): string {
45
+ return typeof raw.open_chat_id === "string"
46
+ ? raw.open_chat_id
47
+ : typeof (raw.context as Record<string, unknown> | undefined)?.open_chat_id === "string"
48
+ ? (raw.context as Record<string, string>).open_chat_id
49
+ : typeof (raw.message as Record<string, unknown> | undefined)?.chat_id === "string"
50
+ ? (raw.message as Record<string, string>).chat_id
51
+ : "";
52
+ }
53
+
54
+ function collapsedCard(): string {
55
+ return JSON.stringify({
56
+ config: { wide_screen_mode: true },
57
+ elements: [{ tag: "markdown", content: " " }],
58
+ });
59
+ }
60
+
61
+ function resetResultMessage(result: CodexResetConsumeResult): { content: string; template: string } {
62
+ if (result.code === "reset") {
63
+ return {
64
+ content: `重置成功。已重置 ${result.windowsReset} 个 Codex 用量窗口。`,
65
+ template: "green",
66
+ };
67
+ }
68
+ if (result.code === "nothing_to_reset") {
69
+ return {
70
+ content: "没有需要重置的 Codex 用量窗口。",
71
+ template: "yellow",
72
+ };
73
+ }
74
+ if (result.code === "no_credit") {
75
+ return {
76
+ content: "没有可用的 Codex 主动重置次数。",
77
+ template: "red",
78
+ };
79
+ }
80
+ return {
81
+ content: "这次 Codex 主动重置请求已经处理过。",
82
+ template: "yellow",
83
+ };
84
+ }
85
+
86
+ async function sendResetRequestConfirmation(
87
+ raw: Record<string, unknown>,
88
+ value: Record<string, unknown>,
89
+ deps: CodexResetActionDeps,
90
+ ): Promise<boolean> {
91
+ const parentMessageId = eventMessageId(raw);
92
+ const chatId = eventChatId(raw);
93
+ if (!parentMessageId || !chatId) return true;
94
+ const availableCount = Number(value.availableCount);
95
+
96
+ const requestId = (deps.createRequestId ?? randomUUID)();
97
+ pendingCodexResetRequests.set(requestId, {
98
+ chatId,
99
+ parentMessageId,
100
+ status: "pending",
101
+ });
102
+
103
+ const token = await deps.getTenantAccessToken();
104
+ await deps.sendRawCard(token, chatId, buildCodexResetConfirmCard({
105
+ availableCount: Number.isFinite(availableCount) ? Math.max(1, Math.trunc(availableCount)) : 1,
106
+ parentMessageId,
107
+ requestId,
108
+ }));
109
+ return true;
110
+ }
111
+
112
+ async function handleResetConfirmation(
113
+ raw: Record<string, unknown>,
114
+ value: Record<string, unknown>,
115
+ deps: CodexResetActionDeps,
116
+ ): Promise<boolean> {
117
+ const decision = value.decision;
118
+ const requestId = value.requestId;
119
+ const parentMessageId = value.parentMessageId;
120
+ if ((decision !== "yes" && decision !== "no") || typeof requestId !== "string" || typeof parentMessageId !== "string") {
121
+ return true;
122
+ }
123
+
124
+ const token = await deps.getTenantAccessToken();
125
+ const confirmMessageId = eventMessageId(raw);
126
+ if (confirmMessageId) {
127
+ await deps.updateCardMessage(token, confirmMessageId, collapsedCard());
128
+ }
129
+
130
+ const pending = pendingCodexResetRequests.get(requestId);
131
+ const chatId = pending?.chatId ?? eventChatId(raw);
132
+ if (!chatId) return true;
133
+
134
+ if (!pending || pending.status !== "pending" || pending.parentMessageId !== parentMessageId) {
135
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", "这张确认卡片已经失效或已处理。", "yellow");
136
+ return true;
137
+ }
138
+ pending.status = "handled";
139
+
140
+ if (decision === "no") {
141
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", "用户取消了重置。", "grey");
142
+ return true;
143
+ }
144
+
145
+ try {
146
+ const result = await deps.consumeCodexRateLimitResetCredit(requestId);
147
+ const message = resetResultMessage(result);
148
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", message.content, message.template);
149
+ } catch (err) {
150
+ await deps.sendCardReply(token, chatId, "Codex 主动重置", `重置失败:${(err as Error).message}`, "red");
151
+ }
152
+ return true;
153
+ }
154
+
155
+ export async function handleCodexResetCardAction(data: unknown, deps: CodexResetActionDeps): Promise<boolean> {
156
+ const raw = rawEvent(data);
157
+ const value = actionValue(raw);
158
+ if (!value) return false;
159
+ const action = value?.action;
160
+ if (action === "codex_reset_request") {
161
+ return sendResetRequestConfirmation(raw, value, deps);
162
+ }
163
+ if (action === "codex_reset_confirm") {
164
+ return handleResetConfirmation(raw, value, deps);
165
+ }
166
+ return false;
167
+ }
168
+
169
+ export function _resetCodexResetRequestsForTest(): void {
170
+ pendingCodexResetRequests.clear();
171
+ }