chatccc 0.2.180 → 0.2.182

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.180",
3
+ "version": "0.2.182",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -5,9 +5,11 @@ import {
5
5
  buildCdContent,
6
6
  buildCdCard,
7
7
  buildSessionsCard,
8
- buildStatusCard,
9
- buildButtons,
10
- truncateContent,
8
+ buildStatusCard,
9
+ buildCodexUsageCard,
10
+ buildCodexResetConfirmCard,
11
+ buildButtons,
12
+ truncateContent,
11
13
  getToolEmoji,
12
14
  } from "../cards.ts";
13
15
  import { ABD_HELP_LINE } from "../shared-prefix.ts";
@@ -165,6 +167,7 @@ describe("buildHelpCard", () => {
165
167
  const parsed = JSON.parse(card);
166
168
  const lines = parsed.elements[1].text.content.split("\n");
167
169
 
170
+ expect(lines).toContain("发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡");
168
171
  expect(lines.at(-1)).toBe(ABD_HELP_LINE);
169
172
  });
170
173
  });
@@ -422,7 +425,7 @@ describe("buildSessionsCard", () => {
422
425
  // buildStatusCard
423
426
  // ---------------------------------------------------------------------------
424
427
 
425
- describe("buildStatusCard", () => {
428
+ describe("buildStatusCard", () => {
426
429
  it("returns valid JSON with status text", () => {
427
430
  const card = buildStatusCard("一切正常");
428
431
  const parsed = JSON.parse(card);
@@ -442,11 +445,54 @@ describe("buildStatusCard", () => {
442
445
  const action = parsed.elements[2];
443
446
  expect(action.actions[0].text.content).toBe("收起");
444
447
  });
445
- });
446
-
447
- // ---------------------------------------------------------------------------
448
- // buildButtons
449
- // ---------------------------------------------------------------------------
448
+ });
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // buildCodexUsageCard / buildCodexResetConfirmCard
452
+ // ---------------------------------------------------------------------------
453
+
454
+ describe("Codex usage reset cards", () => {
455
+ it("shows the reset button only when reset credits are available", () => {
456
+ const card = buildCodexUsageCard("Codex 用量", 2);
457
+ const parsed = JSON.parse(card);
458
+ const action = parsed.elements.find((element: any) => element.tag === "action");
459
+ expect(action.actions[0].text.content).toBe("发起重置");
460
+ expect(action.actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
461
+
462
+ const noCreditCard = buildCodexUsageCard("Codex 用量", 0);
463
+ const noCreditParsed = JSON.parse(noCreditCard);
464
+ expect(noCreditParsed.elements.some((element: any) => element.tag === "action")).toBe(false);
465
+ });
466
+
467
+ it("builds yes/no confirmation buttons tied to the parent usage card", () => {
468
+ const card = buildCodexResetConfirmCard({
469
+ availableCount: 2,
470
+ parentMessageId: "usage-message",
471
+ requestId: "request-1",
472
+ });
473
+ const parsed = JSON.parse(card);
474
+ const action = parsed.elements.find((element: any) => element.tag === "action");
475
+
476
+ expect(action.actions[0].text.content).toBe("是,发起重置");
477
+ expect(action.actions[0].value).toEqual({
478
+ action: "codex_reset_confirm",
479
+ decision: "yes",
480
+ parentMessageId: "usage-message",
481
+ requestId: "request-1",
482
+ });
483
+ expect(action.actions[1].text.content).toBe("否");
484
+ expect(action.actions[1].value).toEqual({
485
+ action: "codex_reset_confirm",
486
+ decision: "no",
487
+ parentMessageId: "usage-message",
488
+ requestId: "request-1",
489
+ });
490
+ });
491
+ });
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // buildButtons
495
+ // ---------------------------------------------------------------------------
450
496
 
451
497
  describe("buildButtons", () => {
452
498
  it("returns action with buttons", () => {
@@ -0,0 +1,146 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ _resetCodexResetRequestsForTest,
5
+ handleCodexResetCardAction,
6
+ type CodexResetActionDeps,
7
+ } from "../codex-reset-actions.ts";
8
+
9
+ function deps(): CodexResetActionDeps & {
10
+ getTenantAccessToken: ReturnType<typeof vi.fn>;
11
+ sendRawCard: ReturnType<typeof vi.fn>;
12
+ sendTextReply: ReturnType<typeof vi.fn>;
13
+ sendCardReply: ReturnType<typeof vi.fn>;
14
+ updateCardMessage: ReturnType<typeof vi.fn>;
15
+ recallMessage: ReturnType<typeof vi.fn>;
16
+ consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>;
17
+ createRequestId: ReturnType<typeof vi.fn>;
18
+ } {
19
+ return {
20
+ getTenantAccessToken: vi.fn(async () => "tenant-token"),
21
+ sendRawCard: vi.fn(async () => true),
22
+ sendTextReply: vi.fn(async () => true),
23
+ sendCardReply: vi.fn(async () => true),
24
+ updateCardMessage: vi.fn(async () => true),
25
+ recallMessage: vi.fn(async () => true),
26
+ consumeCodexRateLimitResetCredit: vi.fn(async () => ({ code: "reset" as const, windowsReset: 2 })),
27
+ createRequestId: vi.fn(() => "request-1"),
28
+ };
29
+ }
30
+
31
+ function event(value: Record<string, unknown>, messageId: string) {
32
+ return {
33
+ event: {
34
+ open_message_id: messageId,
35
+ open_chat_id: "chat-1",
36
+ operator: { open_id: "ou-user" },
37
+ action: { value },
38
+ },
39
+ };
40
+ }
41
+
42
+ describe("Codex reset card actions", () => {
43
+ beforeEach(() => {
44
+ _resetCodexResetRequestsForTest();
45
+ });
46
+
47
+ it("sends a confirmation card tied to the clicked usage card", async () => {
48
+ const d = deps();
49
+
50
+ await expect(handleCodexResetCardAction(
51
+ event({ action: "codex_reset_request" }, "usage-message"),
52
+ d,
53
+ )).resolves.toBe(true);
54
+
55
+ expect(d.sendRawCard).toHaveBeenCalledTimes(1);
56
+ expect(d.sendRawCard.mock.calls[0][1]).toBe("chat-1");
57
+ const confirmCard = JSON.parse(d.sendRawCard.mock.calls[0][2]);
58
+ const action = confirmCard.elements.find((element: any) => element.tag === "action");
59
+ expect(action.actions[0].value).toEqual({
60
+ action: "codex_reset_confirm",
61
+ decision: "yes",
62
+ parentMessageId: "usage-message",
63
+ requestId: "request-1",
64
+ });
65
+ });
66
+
67
+ it("consumes a reset credit, sends the result, and collapses the confirmation card when user confirms", async () => {
68
+ const d = deps();
69
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
70
+
71
+ await expect(handleCodexResetCardAction(
72
+ event({
73
+ action: "codex_reset_confirm",
74
+ decision: "yes",
75
+ parentMessageId: "usage-message",
76
+ requestId: "request-1",
77
+ }, "confirm-message"),
78
+ d,
79
+ )).resolves.toBe(true);
80
+
81
+ expect(d.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith("request-1");
82
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
83
+ expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message");
84
+ expect(JSON.parse(d.updateCardMessage.mock.calls[0][2]).elements).toEqual([
85
+ { tag: "markdown", content: " " },
86
+ ]);
87
+ expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message");
88
+ expect(d.sendTextReply).toHaveBeenCalledWith(
89
+ "tenant-token",
90
+ "chat-1",
91
+ expect.stringContaining("重置成功"),
92
+ );
93
+ expect(d.sendCardReply).not.toHaveBeenCalled();
94
+ });
95
+
96
+ it("does not consume a reset credit and sends a cancellation message when user declines", async () => {
97
+ const d = deps();
98
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
99
+
100
+ await expect(handleCodexResetCardAction(
101
+ event({
102
+ action: "codex_reset_confirm",
103
+ decision: "no",
104
+ parentMessageId: "usage-message",
105
+ requestId: "request-1",
106
+ }, "confirm-message"),
107
+ d,
108
+ )).resolves.toBe(true);
109
+
110
+ expect(d.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled();
111
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
112
+ expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message");
113
+ expect(d.sendTextReply).toHaveBeenCalledWith(
114
+ "tenant-token",
115
+ "chat-1",
116
+ "Codex 主动重置:用户取消了重置。",
117
+ );
118
+ expect(d.sendCardReply).not.toHaveBeenCalled();
119
+ });
120
+
121
+ it("can collapse a confirmation card using context.message_id fallback", async () => {
122
+ const d = deps();
123
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
124
+
125
+ await expect(handleCodexResetCardAction({
126
+ event: {
127
+ context: {
128
+ message_id: "confirm-message-from-context",
129
+ open_chat_id: "chat-1",
130
+ },
131
+ action: {
132
+ value: {
133
+ action: "codex_reset_confirm",
134
+ decision: "no",
135
+ parentMessageId: "usage-message",
136
+ requestId: "request-1",
137
+ },
138
+ },
139
+ },
140
+ }, d)).resolves.toBe(true);
141
+
142
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
143
+ expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message-from-context");
144
+ expect(d.recallMessage).toHaveBeenCalledWith("tenant-token", "confirm-message-from-context");
145
+ });
146
+ });
@@ -39,20 +39,23 @@ async function loadFeishuApiWithHome(homeDir: string, userDataDir: string) {
39
39
  async function writeCodexAuth(homeDir: string): Promise<void> {
40
40
  const codexDir = join(homeDir, ".codex");
41
41
  await mkdir(codexDir, { recursive: true });
42
- await writeFile(
43
- join(codexDir, "auth.json"),
44
- JSON.stringify({ tokens: { access_token: "codex-access-token" } }),
45
- "utf-8",
46
- );
47
- }
42
+ await writeFile(
43
+ join(codexDir, "auth.json"),
44
+ JSON.stringify({ tokens: { access_token: "codex-access-token", account_id: "codex-account-id" } }),
45
+ "utf-8",
46
+ );
47
+ }
48
48
 
49
49
  function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void {
50
50
  vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
51
51
  const urlText = String(url);
52
- if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
53
- return usageResponse.clone();
54
- }
55
- if (urlText === "https://open.feishu.test/im/v1/images") {
52
+ if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
53
+ return usageResponse.clone();
54
+ }
55
+ if (urlText === "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") {
56
+ return new Response(JSON.stringify({ credits: [] }), { status: 200 });
57
+ }
58
+ if (urlText === "https://open.feishu.test/im/v1/images") {
56
59
  const form = init?.body as FormData;
57
60
  const image = form.get("image") as File;
58
61
  uploadedNames.push(image.name);
@@ -100,31 +103,109 @@ describe("Codex avatar usage battery", () => {
100
103
  }
101
104
  });
102
105
 
103
- it("returns both 5h and weekly Codex usage windows", async () => {
104
- const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
105
- const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
106
- const uploadedNames: string[] = [];
107
- await writeCodexAuth(homeDir);
108
- mockAvatarFetch(uploadedNames, new Response(JSON.stringify({
109
- rate_limit: {
110
- primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
111
- secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
112
- },
113
- }), { status: 200 }));
114
-
115
- try {
116
- const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
117
- await expect(getCodexUsageSummary()).resolves.toEqual({
118
- fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
119
- weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
120
- });
121
- } finally {
122
- await rm(homeDir, { recursive: true, force: true });
123
- await rm(userDataDir, { recursive: true, force: true });
106
+ it("returns both usage windows and available reset credit expiries", async () => {
107
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
108
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
109
+ await writeCodexAuth(homeDir);
110
+ const fetchMock = vi.fn(async (url: string | URL | Request) => {
111
+ const urlText = String(url);
112
+ if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
113
+ return new Response(JSON.stringify({
114
+ rate_limit: {
115
+ primary_window: { used_percent: 37, reset_after_seconds: 10349, reset_at: 1781528212 },
116
+ secondary_window: { used_percent: 12, reset_after_seconds: 325063, reset_at: 1781842926 },
117
+ },
118
+ rate_limit_reset_credits: { available_count: 1 },
119
+ }), { status: 200 });
120
+ }
121
+ if (urlText === "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") {
122
+ return new Response(JSON.stringify({
123
+ available_count: 2,
124
+ credits: [
125
+ {
126
+ status: "available",
127
+ granted_at: "2026-06-12T04:01:47.770016Z",
128
+ expires_at: "2026-07-12T04:01:47.770016Z",
129
+ },
130
+ {
131
+ status: "redeemed",
132
+ granted_at: "2026-06-13T04:01:47.770016Z",
133
+ expires_at: "2026-07-13T04:01:47.770016Z",
134
+ },
135
+ {
136
+ status: "available",
137
+ granted_at: "2026-06-18T00:44:23.904386Z",
138
+ expires_at: "2026-07-18T00:44:23.904386Z",
139
+ },
140
+ ],
141
+ }), { status: 200 });
142
+ }
143
+ throw new Error(`unexpected fetch: ${urlText}`);
144
+ });
145
+ vi.stubGlobal("fetch", fetchMock);
146
+
147
+ try {
148
+ const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
149
+ await expect(getCodexUsageSummary()).resolves.toEqual({
150
+ fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
151
+ weekly: { usedPercent: 12, remainingPercent: 88, resetAfterSeconds: 325063, resetAtEpochSeconds: 1781842926 },
152
+ rateLimitResetCreditsAvailable: 2,
153
+ rateLimitResetCredits: [
154
+ { grantedAt: "2026-06-12T04:01:47.770016Z", expiresAt: "2026-07-12T04:01:47.770016Z" },
155
+ { grantedAt: "2026-06-18T00:44:23.904386Z", expiresAt: "2026-07-18T00:44:23.904386Z" },
156
+ ],
157
+ });
158
+ expect(fetchMock).toHaveBeenCalledWith(
159
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits",
160
+ expect.objectContaining({
161
+ headers: expect.objectContaining({
162
+ Authorization: "Bearer codex-access-token",
163
+ "ChatGPT-Account-ID": "codex-account-id",
164
+ "OpenAI-Beta": "codex-1",
165
+ originator: "Codex Desktop",
166
+ }),
167
+ }),
168
+ );
169
+ } finally {
170
+ await rm(homeDir, { recursive: true, force: true });
171
+ await rm(userDataDir, { recursive: true, force: true });
124
172
  }
125
- });
126
-
127
- it("falls back to the pre-combined Codex avatar when usage lookup fails", async () => {
173
+ });
174
+
175
+ it("consumes a Codex rate-limit reset credit through the WHAM endpoint", async () => {
176
+ const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
177
+ const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
178
+ await writeCodexAuth(homeDir);
179
+ const fetchMock = vi.fn(async () => new Response(JSON.stringify({
180
+ code: "reset",
181
+ windows_reset: 2,
182
+ }), { status: 200 }));
183
+ vi.stubGlobal("fetch", fetchMock);
184
+
185
+ try {
186
+ const { consumeCodexRateLimitResetCredit } = await loadFeishuApiWithHome(homeDir, userDataDir);
187
+ await expect(consumeCodexRateLimitResetCredit("request-1")).resolves.toEqual({
188
+ code: "reset",
189
+ windowsReset: 2,
190
+ });
191
+ expect(fetchMock).toHaveBeenCalledWith(
192
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
193
+ expect.objectContaining({
194
+ method: "POST",
195
+ headers: expect.objectContaining({
196
+ Authorization: "Bearer codex-access-token",
197
+ "Content-Type": "application/json",
198
+ }),
199
+ body: JSON.stringify({ redeem_request_id: "request-1" }),
200
+ }),
201
+ );
202
+ } finally {
203
+ await rm(homeDir, { recursive: true, force: true });
204
+ await rm(userDataDir, { recursive: true, force: true });
205
+ }
206
+ });
207
+
208
+ it("falls back to the pre-combined Codex avatar when usage lookup fails", async () => {
128
209
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
129
210
  const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
130
211
  const uploadedNames: string[] = [];
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, getCodexUsageSummary, disbandChat, sendRestartCard, getMergeForwardMessages } from "../feishu-platform.ts";
2
+ import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, getCodexUsageSummary, consumeCodexRateLimitResetCredit, disbandChat, sendRestartCard, getMergeForwardMessages } from "../feishu-platform.ts";
3
3
  import type { FeishuPlatform } from "../feishu-platform.ts";
4
4
 
5
5
  const realPlatform = getPlatform();
@@ -31,7 +31,10 @@ describe("feishu-platform", () => {
31
31
  getCodexUsageSummary: async () => ({
32
32
  fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
33
33
  weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
34
+ rateLimitResetCreditsAvailable: 1,
35
+ rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
34
36
  }),
37
+ consumeCodexRateLimitResetCredit: async () => ({ code: "reset", windowsReset: 2 }),
35
38
  getOrDownloadImage: async () => "/tmp/img.png",
36
39
  verifyAllPermissions: async () => [],
37
40
  reportPermissionResults: realPlatform.reportPermissionResults,
@@ -55,7 +58,10 @@ describe("feishu-platform", () => {
55
58
  expect(await getCodexUsageSummary()).toEqual({
56
59
  fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
57
60
  weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
61
+ rateLimitResetCreditsAvailable: 1,
62
+ rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
58
63
  });
64
+ expect(await consumeCodexRateLimitResetCredit("request-1")).toEqual({ code: "reset", windowsReset: 2 });
59
65
  } finally {
60
66
  // 恢复到真实实现,避免影响后续测试
61
67
  setPlatform(realPlatform);
@@ -129,10 +129,12 @@ describe("handleCommand WeChat processing ack", () => {
129
129
  mockStreamStates.clear();
130
130
  mockGetCodexUsageSummary.mockReset();
131
131
  mockGetCursorUsageSummary.mockReset();
132
- mockGetCodexUsageSummary.mockResolvedValue({
133
- fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
134
- weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
135
- });
132
+ mockGetCodexUsageSummary.mockResolvedValue({
133
+ fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
134
+ weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
135
+ rateLimitResetCreditsAvailable: null,
136
+ rateLimitResetCredits: null,
137
+ });
136
138
  mockGetCursorUsageSummary.mockResolvedValue({
137
139
  billingCycleStart: "1779357999000",
138
140
  billingCycleEnd: "1782036399000",
@@ -368,51 +370,35 @@ describe("handleCommand WeChat processing ack", () => {
368
370
 
369
371
  it("handles /usage without creating a new Feishu group", async () => {
370
372
  const platform = mockPlatform("feishu");
371
- mockGetCodexUsageSummary.mockResolvedValue({
372
- fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
373
- weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
374
- });
375
-
376
- await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
373
+ mockGetCodexUsageSummary.mockResolvedValue({
374
+ fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
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
+ ],
381
+ });
377
382
 
378
- 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
- );
415
- });
383
+ await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
384
+
385
+ expect(platform.createGroup).not.toHaveBeenCalled();
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 });
401
+ });
416
402
 
417
403
  it("handles /usage as Cursor usage in Cursor chats", async () => {
418
404
  const platform = mockPlatform("feishu");
@@ -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
 
package/src/cards.ts CHANGED
@@ -137,7 +137,7 @@ export function buildHelpCard(
137
137
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
138
138
  "发送 **/plan** 以规划模式提问(只读,不执行写操作)",
139
139
  "发送 **/ask** 以问答模式提问(只读,不执行写操作)",
140
- "发送 **/usage** 查看 Codex 5h 和周用量",
140
+ "发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡",
141
141
  "发送 **/restart** 重启 ChatCCC 进程",
142
142
  "发送 **/update** 更新并重启(仅 npm 全局安装可用)",
143
143
  ABD_HELP_LINE,
@@ -410,11 +410,11 @@ export function buildQueueFullCard(): string {
410
410
  }
411
411
 
412
412
  // 状态卡片(带关闭按钮)
413
- export function buildStatusCard(statusText: string, template = "blue"): string {
414
- return JSON.stringify({
415
- config: { wide_screen_mode: true },
416
- header: { template, title: { content: "会话状态", tag: "plain_text" } },
417
- elements: [
413
+ export function buildStatusCard(statusText: string, template = "blue"): string {
414
+ return JSON.stringify({
415
+ config: { wide_screen_mode: true },
416
+ header: { template, title: { content: "会话状态", tag: "plain_text" } },
417
+ elements: [
418
418
  { tag: "div", text: { tag: "lark_md", content: statusText } },
419
419
  { tag: "hr" },
420
420
  {
@@ -426,12 +426,78 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
426
426
  value: { action: "close" },
427
427
  }],
428
428
  },
429
- ],
430
- });
431
- }
432
-
433
- /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
434
- export function buildModelCard(
429
+ ],
430
+ });
431
+ }
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
+
499
+ /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
500
+ export function buildModelCard(
435
501
  currentModel: string,
436
502
  models: string[],
437
503
  tool?: string,
@@ -0,0 +1,184 @@
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
+ sendTextReply(token: string, chatId: string, text: string): Promise<boolean>;
18
+ sendCardReply(token: string, chatId: string, title: string, content: string, template?: string): Promise<boolean>;
19
+ updateCardMessage(token: string, messageId: string, content: string): Promise<boolean>;
20
+ recallMessage(token: string, messageId: string): Promise<boolean>;
21
+ consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult>;
22
+ createRequestId?: () => string;
23
+ }
24
+
25
+ const pendingCodexResetRequests = new Map<string, PendingCodexResetRequest>();
26
+
27
+ function rawEvent(data: unknown): Record<string, unknown> {
28
+ return ((data as Record<string, unknown>)?.event ?? data) as Record<string, unknown>;
29
+ }
30
+
31
+ function actionValue(raw: Record<string, unknown>): Record<string, unknown> | null {
32
+ const action = raw.action as { value?: unknown } | undefined;
33
+ const value = action?.value;
34
+ if (!value || typeof value !== "object") return null;
35
+ return value as Record<string, unknown>;
36
+ }
37
+
38
+ function eventMessageId(raw: Record<string, unknown>): string {
39
+ return typeof raw.open_message_id === "string"
40
+ ? raw.open_message_id
41
+ : typeof (raw.context as Record<string, unknown> | undefined)?.open_message_id === "string"
42
+ ? (raw.context as Record<string, string>).open_message_id
43
+ : typeof raw.message_id === "string"
44
+ ? raw.message_id
45
+ : typeof (raw.context as Record<string, unknown> | undefined)?.message_id === "string"
46
+ ? (raw.context as Record<string, string>).message_id
47
+ : typeof (raw.message as Record<string, unknown> | undefined)?.message_id === "string"
48
+ ? (raw.message as Record<string, string>).message_id
49
+ : "";
50
+ }
51
+
52
+ function eventChatId(raw: Record<string, unknown>): string {
53
+ return typeof raw.open_chat_id === "string"
54
+ ? raw.open_chat_id
55
+ : typeof (raw.context as Record<string, unknown> | undefined)?.open_chat_id === "string"
56
+ ? (raw.context as Record<string, string>).open_chat_id
57
+ : typeof (raw.message as Record<string, unknown> | undefined)?.chat_id === "string"
58
+ ? (raw.message as Record<string, string>).chat_id
59
+ : "";
60
+ }
61
+
62
+ function collapsedCard(): string {
63
+ return JSON.stringify({
64
+ config: { wide_screen_mode: true },
65
+ elements: [{ tag: "markdown", content: " " }],
66
+ });
67
+ }
68
+
69
+ function resetResultMessage(result: CodexResetConsumeResult): { content: string; template: string } {
70
+ if (result.code === "reset") {
71
+ return {
72
+ content: `重置成功。已重置 ${result.windowsReset} 个 Codex 用量窗口。`,
73
+ template: "green",
74
+ };
75
+ }
76
+ if (result.code === "nothing_to_reset") {
77
+ return {
78
+ content: "没有需要重置的 Codex 用量窗口。",
79
+ template: "yellow",
80
+ };
81
+ }
82
+ if (result.code === "no_credit") {
83
+ return {
84
+ content: "没有可用的 Codex 主动重置次数。",
85
+ template: "red",
86
+ };
87
+ }
88
+ return {
89
+ content: "这次 Codex 主动重置请求已经处理过。",
90
+ template: "yellow",
91
+ };
92
+ }
93
+
94
+ async function sendResetRequestConfirmation(
95
+ raw: Record<string, unknown>,
96
+ value: Record<string, unknown>,
97
+ deps: CodexResetActionDeps,
98
+ ): Promise<boolean> {
99
+ const parentMessageId = eventMessageId(raw);
100
+ const chatId = eventChatId(raw);
101
+ if (!parentMessageId || !chatId) return true;
102
+ const availableCount = Number(value.availableCount);
103
+
104
+ const requestId = (deps.createRequestId ?? randomUUID)();
105
+ pendingCodexResetRequests.set(requestId, {
106
+ chatId,
107
+ parentMessageId,
108
+ status: "pending",
109
+ });
110
+
111
+ const token = await deps.getTenantAccessToken();
112
+ await deps.sendRawCard(token, chatId, buildCodexResetConfirmCard({
113
+ availableCount: Number.isFinite(availableCount) ? Math.max(1, Math.trunc(availableCount)) : 1,
114
+ parentMessageId,
115
+ requestId,
116
+ }));
117
+ return true;
118
+ }
119
+
120
+ async function handleResetConfirmation(
121
+ raw: Record<string, unknown>,
122
+ value: Record<string, unknown>,
123
+ deps: CodexResetActionDeps,
124
+ ): Promise<boolean> {
125
+ const decision = value.decision;
126
+ const requestId = value.requestId;
127
+ const parentMessageId = value.parentMessageId;
128
+ if ((decision !== "yes" && decision !== "no") || typeof requestId !== "string" || typeof parentMessageId !== "string") {
129
+ return true;
130
+ }
131
+
132
+ const token = await deps.getTenantAccessToken();
133
+ const confirmMessageId = eventMessageId(raw);
134
+ if (confirmMessageId) {
135
+ const collapsed = await deps.updateCardMessage(token, confirmMessageId, collapsedCard());
136
+ console.log(`[Codex Reset] collapse confirmation card ${collapsed ? "OK" : "FAILED"}: messageId=${confirmMessageId}`);
137
+ const recalled = await deps.recallMessage(token, confirmMessageId);
138
+ console.log(`[Codex Reset] recall confirmation card ${recalled ? "OK" : "FAILED"}: messageId=${confirmMessageId}`);
139
+ } else {
140
+ console.error("[Codex Reset] missing confirmation card message id; cannot collapse");
141
+ }
142
+
143
+ const pending = pendingCodexResetRequests.get(requestId);
144
+ const chatId = pending?.chatId ?? eventChatId(raw);
145
+ if (!chatId) return true;
146
+
147
+ if (!pending || pending.status !== "pending" || pending.parentMessageId !== parentMessageId) {
148
+ await deps.sendTextReply(token, chatId, "Codex 主动重置:这张确认卡片已经失效或已处理。");
149
+ return true;
150
+ }
151
+ pending.status = "handled";
152
+
153
+ if (decision === "no") {
154
+ await deps.sendTextReply(token, chatId, "Codex 主动重置:用户取消了重置。");
155
+ return true;
156
+ }
157
+
158
+ try {
159
+ const result = await deps.consumeCodexRateLimitResetCredit(requestId);
160
+ const message = resetResultMessage(result);
161
+ await deps.sendTextReply(token, chatId, `Codex 主动重置:${message.content}`);
162
+ } catch (err) {
163
+ await deps.sendTextReply(token, chatId, `Codex 主动重置失败:${(err as Error).message}`);
164
+ }
165
+ return true;
166
+ }
167
+
168
+ export async function handleCodexResetCardAction(data: unknown, deps: CodexResetActionDeps): Promise<boolean> {
169
+ const raw = rawEvent(data);
170
+ const value = actionValue(raw);
171
+ if (!value) return false;
172
+ const action = value?.action;
173
+ if (action === "codex_reset_request") {
174
+ return sendResetRequestConfirmation(raw, value, deps);
175
+ }
176
+ if (action === "codex_reset_confirm") {
177
+ return handleResetConfirmation(raw, value, deps);
178
+ }
179
+ return false;
180
+ }
181
+
182
+ export function _resetCodexResetRequestsForTest(): void {
183
+ pendingCodexResetRequests.clear();
184
+ }
package/src/feishu-api.ts CHANGED
@@ -302,8 +302,10 @@ const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
302
302
  const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
303
303
  const AVATAR_COMBINATIONS_DIR = resolvePath(AVATAR_DIR, "combinations");
304
304
  const AVATAR_KEY_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "avatar-image-keys.json");
305
- const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
306
- const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
305
+ const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
306
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
307
+ const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
308
+ const CODEX_RESET_CONSUME_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
307
309
  const AVATAR_SOURCES: Record<string, string> = {
308
310
  new: resolvePath(AVATAR_DIR, "status_new.png"),
309
311
  busy: resolvePath(AVATAR_DIR, "status_busy.png"),
@@ -320,17 +322,31 @@ const AVATAR_BADGE_MARGIN = 10;
320
322
  const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v13";
321
323
  const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
322
324
 
323
- export interface CodexUsageBalance {
324
- usedPercent: number;
325
- remainingPercent: number;
326
- resetAtEpochSeconds: number | null;
327
- resetAfterSeconds: number | null;
328
- }
329
-
330
- export interface CodexUsageSummary {
331
- fiveHour: CodexUsageBalance;
332
- weekly: CodexUsageBalance | null;
333
- }
325
+ export interface CodexUsageBalance {
326
+ usedPercent: number;
327
+ remainingPercent: number;
328
+ resetAtEpochSeconds: number | null;
329
+ resetAfterSeconds: number | null;
330
+ }
331
+
332
+ export interface CodexRateLimitResetCredit {
333
+ grantedAt: string | null;
334
+ expiresAt: string;
335
+ }
336
+
337
+ export interface CodexUsageSummary {
338
+ fiveHour: CodexUsageBalance;
339
+ weekly: CodexUsageBalance | null;
340
+ rateLimitResetCreditsAvailable: number | null;
341
+ rateLimitResetCredits: CodexRateLimitResetCredit[] | null;
342
+ }
343
+
344
+ export type CodexResetConsumeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
345
+
346
+ export interface CodexResetConsumeResult {
347
+ code: CodexResetConsumeCode;
348
+ windowsReset: number;
349
+ }
334
350
 
335
351
  const avatarKeyCache = new Map<string, string>();
336
352
  let avatarKeyCacheLoaded = false;
@@ -411,58 +427,167 @@ function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string)
411
427
  };
412
428
  }
413
429
 
414
- function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
430
+ function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
415
431
  for (const key of keys) {
416
432
  const raw = rateLimit[key];
417
433
  if (!raw || typeof raw !== "object") continue;
418
434
  if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
419
435
  return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
420
436
  }
421
- return null;
422
- }
423
-
424
- async function getCodexAccessToken(): Promise<string | null> {
425
- try {
426
- const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
427
- const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown } };
428
- const token = parsed.tokens?.access_token;
429
- return typeof token === "string" && token.trim() ? token : null;
430
- } catch {
431
- return null;
432
- }
433
- }
434
-
435
- export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
436
- const token = await getCodexAccessToken();
437
- if (!token) throw new Error("missing ~/.codex/auth.json access token");
438
-
439
- const resp = await fetch(CODEX_USAGE_URL, {
440
- headers: { Authorization: `Bearer ${token}` },
441
- });
442
- const text = await resp.text();
443
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
444
-
445
- const data = JSON.parse(text) as {
446
- rate_limit?: Record<string, unknown>;
447
- };
448
- const rateLimit = data.rate_limit;
449
- if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
450
-
451
- const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
452
- if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
453
-
454
- return {
455
- fiveHour,
456
- weekly: parseOptionalUsageWindow(rateLimit, [
457
- "secondary_window",
458
- "weekly_window",
459
- "week_window",
460
- "long_window",
461
- ]),
462
- };
463
- }
464
-
465
- async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
437
+ return null;
438
+ }
439
+
440
+ function parseRateLimitResetCredits(data: Record<string, unknown>): number | null {
441
+ const raw = data.rate_limit_reset_credits;
442
+ if (!raw || typeof raw !== "object") return null;
443
+ const availableCount = Number((raw as { available_count?: unknown }).available_count);
444
+ if (!Number.isFinite(availableCount)) return null;
445
+ return Math.max(0, Math.trunc(availableCount));
446
+ }
447
+
448
+ interface CodexAuth {
449
+ accessToken: string;
450
+ accountId?: string;
451
+ }
452
+
453
+ async function getCodexAuth(): Promise<CodexAuth | null> {
454
+ try {
455
+ const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
456
+ const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown; account_id?: unknown } };
457
+ const token = parsed.tokens?.access_token;
458
+ if (typeof token !== "string" || !token.trim()) return null;
459
+ const accountId = parsed.tokens?.account_id;
460
+ return {
461
+ accessToken: token,
462
+ accountId: typeof accountId === "string" && accountId.trim() ? accountId : undefined,
463
+ };
464
+ } catch {
465
+ return null;
466
+ }
467
+ }
468
+
469
+ async function getCodexAccessToken(): Promise<string | null> {
470
+ return (await getCodexAuth())?.accessToken ?? null;
471
+ }
472
+
473
+ function codexAuthHeaders(auth: CodexAuth): Record<string, string> {
474
+ const headers: Record<string, string> = {
475
+ Authorization: `Bearer ${auth.accessToken}`,
476
+ "OpenAI-Beta": "codex-1",
477
+ originator: "Codex Desktop",
478
+ };
479
+ if (auth.accountId) headers["ChatGPT-Account-ID"] = auth.accountId;
480
+ return headers;
481
+ }
482
+
483
+ function parseCodexResetCreditDetails(data: Record<string, unknown>): {
484
+ availableCount: number | null;
485
+ availableCredits: CodexRateLimitResetCredit[];
486
+ } {
487
+ const availableCount = Number(data.available_count);
488
+ const credits = Array.isArray(data.credits) ? data.credits : [];
489
+ return {
490
+ availableCount: Number.isFinite(availableCount) ? Math.max(0, Math.trunc(availableCount)) : null,
491
+ availableCredits: credits.flatMap((raw) => {
492
+ if (!raw || typeof raw !== "object") return [];
493
+ const credit = raw as { status?: unknown; granted_at?: unknown; expires_at?: unknown };
494
+ if (credit.status !== "available") return [];
495
+ if (typeof credit.expires_at !== "string" || !credit.expires_at.trim()) return [];
496
+ return [{
497
+ grantedAt: typeof credit.granted_at === "string" && credit.granted_at.trim() ? credit.granted_at : null,
498
+ expiresAt: credit.expires_at,
499
+ }];
500
+ }),
501
+ };
502
+ }
503
+
504
+ async function fetchCodexRateLimitResetCredits(auth: CodexAuth): Promise<{
505
+ availableCount: number | null;
506
+ availableCredits: CodexRateLimitResetCredit[];
507
+ } | null> {
508
+ try {
509
+ const resp = await fetch(CODEX_RESET_CREDITS_URL, {
510
+ headers: codexAuthHeaders(auth),
511
+ });
512
+ const text = await resp.text();
513
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
514
+ return parseCodexResetCreditDetails(JSON.parse(text) as Record<string, unknown>);
515
+ } catch (err) {
516
+ console.warn(`[Codex] reset credits lookup failed: ${(err as Error).message}`);
517
+ return null;
518
+ }
519
+ }
520
+
521
+ export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
522
+ const auth = await getCodexAuth();
523
+ if (!auth) throw new Error("missing ~/.codex/auth.json access token");
524
+
525
+ const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
526
+
527
+ const resp = await fetch(CODEX_USAGE_URL, {
528
+ headers: { Authorization: `Bearer ${auth.accessToken}` },
529
+ });
530
+ const text = await resp.text();
531
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
532
+
533
+ const data = JSON.parse(text) as {
534
+ rate_limit?: Record<string, unknown>;
535
+ rate_limit_reset_credits?: unknown;
536
+ };
537
+ const rateLimit = data.rate_limit;
538
+ if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
539
+
540
+ const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
541
+ if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
542
+ const resetCredits = await resetCreditsPromise;
543
+
544
+ return {
545
+ fiveHour,
546
+ weekly: parseOptionalUsageWindow(rateLimit, [
547
+ "secondary_window",
548
+ "weekly_window",
549
+ "week_window",
550
+ "long_window",
551
+ ]),
552
+ rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
553
+ rateLimitResetCredits: resetCredits?.availableCredits ?? null,
554
+ };
555
+ }
556
+
557
+ export async function consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult> {
558
+ if (!redeemRequestId.trim()) throw new Error("missing redeem_request_id");
559
+ const token = await getCodexAccessToken();
560
+ if (!token) throw new Error("missing ~/.codex/auth.json access token");
561
+
562
+ const resp = await fetch(CODEX_RESET_CONSUME_URL, {
563
+ method: "POST",
564
+ headers: {
565
+ Authorization: `Bearer ${token}`,
566
+ "Content-Type": "application/json",
567
+ },
568
+ body: JSON.stringify({ redeem_request_id: redeemRequestId }),
569
+ });
570
+ const text = await resp.text();
571
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
572
+
573
+ const data = JSON.parse(text) as { code?: unknown; windows_reset?: unknown };
574
+ const code = data.code;
575
+ if (
576
+ code !== "reset"
577
+ && code !== "nothing_to_reset"
578
+ && code !== "no_credit"
579
+ && code !== "already_redeemed"
580
+ ) {
581
+ throw new Error("missing or unknown reset result code");
582
+ }
583
+ const windowsReset = Number(data.windows_reset);
584
+ return {
585
+ code,
586
+ windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
587
+ };
588
+ }
589
+
590
+ async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
466
591
  try {
467
592
  const summary = await getCodexUsageSummary();
468
593
  if (!summary.weekly) throw new Error("missing weekly usage window");
@@ -31,6 +31,7 @@ export interface FeishuPlatform {
31
31
  disbandChat: typeof realApi.disbandChat;
32
32
  setChatAvatar: typeof realApi.setChatAvatar;
33
33
  getCodexUsageSummary: typeof realApi.getCodexUsageSummary;
34
+ consumeCodexRateLimitResetCredit: typeof realApi.consumeCodexRateLimitResetCredit;
34
35
  getOrDownloadImage: typeof realApi.getOrDownloadImage;
35
36
  verifyAllPermissions: typeof realApi.verifyAllPermissions;
36
37
  reportPermissionResults: typeof realApi.reportPermissionResults;
@@ -117,6 +118,10 @@ export function getCodexUsageSummary(...args: Parameters<typeof realApi.getCodex
117
118
  return _impl.getCodexUsageSummary(...args);
118
119
  }
119
120
 
121
+ export function consumeCodexRateLimitResetCredit(...args: Parameters<typeof realApi.consumeCodexRateLimitResetCredit>): ReturnType<typeof realApi.consumeCodexRateLimitResetCredit> {
122
+ return _impl.consumeCodexRateLimitResetCredit(...args);
123
+ }
124
+
120
125
  export function getOrDownloadImage(...args: Parameters<typeof realApi.getOrDownloadImage>): ReturnType<typeof realApi.getOrDownloadImage> {
121
126
  return _impl.getOrDownloadImage(...args);
122
127
  }
package/src/index.ts CHANGED
@@ -74,6 +74,7 @@ import {
74
74
  verifyAllPermissions,
75
75
  reportPermissionResults,
76
76
  setPlatform,
77
+ consumeCodexRateLimitResetCredit,
77
78
  } from "./feishu-platform.ts";
78
79
  import { SimulatedPlatform, SIM_DEFAULT_CHAT_ID } from "./sim-platform.ts";
79
80
  import { setMessageHandler } from "./sim-store.ts";
@@ -103,6 +104,7 @@ import {
103
104
  import { fixStaleStreamStates } from "./stream-state.ts";
104
105
  import { handleCommand, type PlatformAdapter } from "./orchestrator.ts";
105
106
  import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.ts";
107
+ import { handleCodexResetCardAction } from "./codex-reset-actions.ts";
106
108
 
107
109
  // ---------------------------------------------------------------------------
108
110
  // Feishu 平台适配器
@@ -464,6 +466,17 @@ async function startBotServiceCore(): Promise<void> {
464
466
  return;
465
467
  }
466
468
 
469
+ const handledCodexReset = await handleCodexResetCardAction(data, {
470
+ getTenantAccessToken,
471
+ sendRawCard,
472
+ sendTextReply,
473
+ sendCardReply,
474
+ updateCardMessage,
475
+ recallMessage,
476
+ consumeCodexRateLimitResetCredit,
477
+ });
478
+ if (handledCodexReset) return;
479
+
467
480
  const result = parseCardAction(data);
468
481
  if (!result) return;
469
482
  console.log(`[BTN] chat=${result.chatId} text="${result.text}"`);
@@ -38,13 +38,14 @@ import {
38
38
  import {
39
39
  buildHelpCard,
40
40
  buildModelCard,
41
- buildStatusCard,
42
- buildCdContent,
43
- buildCdCard,
44
- buildSessionsCard,
45
- buildQueuedCard,
46
- buildQueueFullCard,
47
- } from "./cards.ts";
41
+ buildStatusCard,
42
+ buildCdContent,
43
+ buildCdCard,
44
+ buildSessionsCard,
45
+ buildQueuedCard,
46
+ buildQueueFullCard,
47
+ buildCodexUsageCard,
48
+ } from "./cards.ts";
48
49
  import {
49
50
  formatGitResult,
50
51
  gitResultHeaderTemplate,
@@ -113,7 +114,7 @@ function findModelMatch(input: string, models: string[]): string | null {
113
114
  return candidates[0] ?? null;
114
115
  }
115
116
 
116
- function formatCodexUsageSummary(usage: CodexUsageSummary): string {
117
+ function formatCodexUsageSummary(usage: CodexUsageSummary): string {
117
118
  const progressBar = (usedPercent: number) => {
118
119
  const width = 20;
119
120
  const usedBlocks = Math.max(0, Math.min(width, Math.round((usedPercent / 100) * width)));
@@ -152,21 +153,54 @@ function formatCodexUsageSummary(usage: CodexUsageSummary): string {
152
153
  return `${absolute}${formatDuration(balance.resetAfterSeconds)}`;
153
154
  };
154
155
 
155
- const formatWindow = (label: string, balance: CodexUsageSummary["fiveHour"] | null) => {
156
- if (!balance) return `**${label}:** 暂无数据`;
157
- return [
158
- `**${label}:** 已用 ${balance.usedPercent}%,剩余 ${balance.remainingPercent}%,重置: ${formatResetTime(balance)}`,
159
- progressBar(balance.usedPercent),
160
- ].join("\n");
161
- };
162
-
163
- return [
164
- "Codex 用量:",
165
- "",
166
- formatWindow("5h", usage.fiveHour),
167
- formatWindow("周", usage.weekly),
168
- ].join("\n");
169
- }
156
+ const formatWindow = (label: string, balance: CodexUsageSummary["fiveHour"] | null) => {
157
+ if (!balance) return `**${label}:** 暂无数据`;
158
+ return [
159
+ `**${label}:** 已用 ${balance.usedPercent}%,剩余 ${balance.remainingPercent}%,重置: ${formatResetTime(balance)}`,
160
+ progressBar(balance.usedPercent),
161
+ ].join("\n");
162
+ };
163
+
164
+ const formatResetCredits = () => {
165
+ if (usage.rateLimitResetCreditsAvailable === null) return "**主动重置:** 暂无数据";
166
+ const lines = [`**主动重置:** 剩余 ${usage.rateLimitResetCreditsAvailable} 次`];
167
+ const credits = usage.rateLimitResetCredits ?? [];
168
+ if (credits.length > 0) {
169
+ const pad = (value: number) => String(value).padStart(2, "0");
170
+ const formatExpiresAt = (value: string) => {
171
+ const date = new Date(value);
172
+ if (!Number.isFinite(date.getTime())) return value;
173
+ return [
174
+ date.getFullYear(),
175
+ "-",
176
+ pad(date.getMonth() + 1),
177
+ "-",
178
+ pad(date.getDate()),
179
+ " ",
180
+ pad(date.getHours()),
181
+ ":",
182
+ pad(date.getMinutes()),
183
+ ":",
184
+ pad(date.getSeconds()),
185
+ ].join("");
186
+ };
187
+ lines.push("**过期时间:**");
188
+ for (const credit of credits) {
189
+ lines.push(`- ${formatExpiresAt(credit.expiresAt)}`);
190
+ }
191
+ }
192
+ return lines.join("\n");
193
+ };
194
+
195
+ return [
196
+ "Codex 用量:",
197
+ "",
198
+ formatResetCredits(),
199
+ "",
200
+ formatWindow("5h", usage.fiveHour),
201
+ formatWindow("周", usage.weekly),
202
+ ].join("\n");
203
+ }
170
204
 
171
205
  function formatCursorUsageSummary(usage: CursorUsageSummary): string {
172
206
  const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -225,7 +259,7 @@ function formatCursorUsageSummary(usage: CursorUsageSummary): string {
225
259
  }
226
260
 
227
261
  function usageHelpLine(tool: string): string {
228
- if (tool === "codex") return "\n发送 **/usage** 查看 Codex 5h 和周用量。";
262
+ if (tool === "codex") return "\n发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡。";
229
263
  if (tool === "cursor") return "\n发送 **/usage** 查看 Cursor 用量。";
230
264
  return "";
231
265
  }
@@ -250,13 +284,16 @@ async function sendUsageSummary(platform: PlatformAdapter, chatId: string, tool:
250
284
  return;
251
285
  }
252
286
 
253
- const content = formatCodexUsageSummary(await getCodexUsageSummary());
254
- if (platform.kind === "wechat") {
255
- await platform.sendText(chatId, content).catch(() => {});
256
- } else {
257
- await platform.sendCard(chatId, "Codex Usage", content, "blue");
258
- }
259
- }
287
+ const usage = await getCodexUsageSummary();
288
+ const content = formatCodexUsageSummary(usage);
289
+ if (platform.kind === "wechat") {
290
+ await platform.sendText(chatId, content).catch(() => {});
291
+ } else if (platform.kind === "feishu") {
292
+ await platform.sendRawCard(chatId, buildCodexUsageCard(content, usage.rateLimitResetCreditsAvailable));
293
+ } else {
294
+ await platform.sendCard(chatId, "Codex Usage", content, "blue");
295
+ }
296
+ }
260
297
 
261
298
  async function sendUsageError(platform: PlatformAdapter, chatId: string, tool: "codex" | "cursor", err: unknown): Promise<void> {
262
299
  const toolLabel = tool === "cursor" ? "Cursor" : "Codex";
@@ -131,9 +131,15 @@ export const SimulatedPlatform: FeishuPlatform = {
131
131
  return {
132
132
  fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
133
133
  weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
134
+ rateLimitResetCreditsAvailable: null,
135
+ rateLimitResetCredits: null,
134
136
  };
135
137
  },
136
138
 
139
+ async consumeCodexRateLimitResetCredit(_redeemRequestId) {
140
+ return { code: "no_credit" as const, windowsReset: 0 };
141
+ },
142
+
137
143
  // ---- 图片下载 ----
138
144
  async getOrDownloadImage(_token, _messageId, fileKey) {
139
145
  return join(SIM_DIR, "images", fileKey);