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.
package/README.md CHANGED
@@ -144,12 +144,12 @@ npm install -g chatccc
144
144
  chatccc
145
145
  ```
146
146
 
147
- 要求 Node.js >= 20。安装完成后,在任意目录执行 `chatccc` 即可启动。配置、日志和状态文件会保存在用户目录的 `.chatccc` 下:
148
-
149
- - Windows:`C:\Users\<用户名>\.chatccc\`
150
- - macOS / Linux:`~/.chatccc/`
151
-
152
- 旧版本留在仓库或包目录下的 `config.json`、`logs/`、`state/` 会在首次启动时自动迁移到用户目录。
147
+ 要求 Node.js >= 20。安装完成后,在任意目录执行 `chatccc` 即可启动。配置、日志和状态文件会保存在用户目录的 `.chatccc` 下:
148
+
149
+ - Windows:`C:\Users\<用户名>\.chatccc\`
150
+ - macOS / Linux:`~/.chatccc/`
151
+
152
+ 旧版本留在仓库或包目录下的 `config.json`、`logs/`、`state/` 会在首次启动时自动迁移到用户目录。
153
153
 
154
154
  首次启动时,如果还没有有效配置,ChatCCC 会自动打开本地 Web 配置向导(默认 `http://127.0.0.1:18080`)。
155
155
 
@@ -202,7 +202,7 @@ chatccc
202
202
  # 在微信里找到机器人,发送 /new 开始对话
203
203
  ```
204
204
 
205
- 微信登录信息会保存到 `~/.chatccc/state/ilink-auth.json`。token 过期后重新扫码即可。
205
+ 微信登录信息会保存到 `~/.chatccc/state/ilink-auth.json`。token 过期后重新扫码即可。
206
206
 
207
207
  ### 3. AI 工具配置
208
208
 
@@ -239,7 +239,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
239
239
 
240
240
  ### 4. `config.json`
241
241
 
242
- `~/.chatccc/config.json` 不存在时,ChatCCC 会从 `config.sample.json` 复制一份。常用结构如下:
242
+ `~/.chatccc/config.json` 不存在时,ChatCCC 会从 `config.sample.json` 复制一份。常用结构如下:
243
243
 
244
244
  ```json
245
245
  {
@@ -247,32 +247,32 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
247
247
  "appId": "cli_xxxxxxxxxxxx",
248
248
  "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxx"
249
249
  },
250
- "platforms": {
251
- "feishu": { "enabled": true, "platformType": "feishu" },
252
- "ilink": { "enabled": true, "reuseTokenOnStart": true }
253
- },
254
- "port": 18080,
255
- "gitTimeoutSeconds": 180,
256
- "allowInterrupt": false,
257
- "claude": {
258
- "enabled": false,
259
- "defaultAgent": true,
260
- "model": "claude-sonnet-4-6",
261
- "subagentModel": "",
262
- "effort": "",
263
- "apiKey": "",
264
- "baseUrl": "",
265
- "maxTurn": 0
266
- },
267
- "cursor": {
268
- "enabled": false,
269
- "defaultAgent": false,
270
- "path": "",
271
- "model": "",
272
- "avatarBatteryMode": "apiPercent",
273
- "onDemandMonthlyBudget": 1000
274
- },
275
- "codex": {
250
+ "platforms": {
251
+ "feishu": { "enabled": true, "platformType": "feishu" },
252
+ "ilink": { "enabled": true, "reuseTokenOnStart": true }
253
+ },
254
+ "port": 18080,
255
+ "gitTimeoutSeconds": 180,
256
+ "allowInterrupt": false,
257
+ "claude": {
258
+ "enabled": false,
259
+ "defaultAgent": true,
260
+ "model": "claude-sonnet-4-6",
261
+ "subagentModel": "",
262
+ "effort": "",
263
+ "apiKey": "",
264
+ "baseUrl": "",
265
+ "maxTurn": 0
266
+ },
267
+ "cursor": {
268
+ "enabled": false,
269
+ "defaultAgent": false,
270
+ "path": "",
271
+ "model": "",
272
+ "avatarBatteryMode": "apiPercent",
273
+ "onDemandMonthlyBudget": 1000
274
+ },
275
+ "codex": {
276
276
  "enabled": false,
277
277
  "defaultAgent": false,
278
278
  "path": "",
@@ -285,21 +285,21 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
285
285
  | 字段 | 说明 |
286
286
  | --- | --- |
287
287
  | `feishu.appId` / `feishu.appSecret` | 飞书应用凭证 |
288
- | `platforms.feishu.enabled` | 是否启用飞书 |
289
- | `platforms.feishu.platformType` | 飞书平台类型,默认 `feishu` |
290
- | `platforms.ilink.enabled` | 是否启用微信 iLink |
291
- | `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
292
- | `port` | 本地 Web 配置面板和中继服务端口 |
293
- | `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
294
- | `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
295
- | `*.enabled` | 是否启用对应 AI Agent |
296
- | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
297
- | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
298
- | `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
299
- | `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
300
- | `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
301
- | `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
302
- | `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
288
+ | `platforms.feishu.enabled` | 是否启用飞书 |
289
+ | `platforms.feishu.platformType` | 飞书平台类型,默认 `feishu` |
290
+ | `platforms.ilink.enabled` | 是否启用微信 iLink |
291
+ | `platforms.ilink.reuseTokenOnStart` | 启动时是否复用已有微信登录 token |
292
+ | `port` | 本地 Web 配置面板和中继服务端口 |
293
+ | `gitTimeoutSeconds` | `/git` 命令超时时间,默认 180 秒 |
294
+ | `allowInterrupt` | 是否允许新消息中断正在运行的任务;默认 false |
295
+ | `*.enabled` | 是否启用对应 AI Agent |
296
+ | `*.defaultAgent` | `/new` 未指定 Agent 时使用哪个工具 |
297
+ | `cursor.path` / `codex.path` | CLI 可执行文件路径;留空时自动探测或使用 PATH |
298
+ | `cursor.avatarBatteryMode` | Cursor 头像电量显示来源:`apiPercent` 或 `onDemandUse` |
299
+ | `cursor.onDemandMonthlyBudget` | `avatarBatteryMode=onDemandUse` 时用于计算电量的月预算 |
300
+ | `claude.model` / `claude.subagentModel` / `claude.effort` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
301
+ | `claude.apiKey` / `claude.baseUrl` | 选填;设置后传给 Claude Agent SDK,留空以 `~/.claude/settings.json` 为准 |
302
+ | `claude.maxTurn` | 选填;Claude 最大对话轮数,默认 0(无限制),可在 Web UI 编辑 |
303
303
 
304
304
  > **权限控制**:普通消息以 `bypassPermissions` 模式运行,跳过 Agent 操作确认。使用 `/plan` 或 `/ask` 前缀时,ChatCCC 自动切换为只读模式:Claude SDK 仅放行 Read + stop-stuck-loop 网络请求,Codex 使用 `--sandbox read-only`,Cursor 使用 `--mode plan/ask`。请只在可信环境中使用。
305
305
 
@@ -315,26 +315,26 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
315
315
  | --- | --- |
316
316
  | `/new` | 使用默认 Agent 创建新会话 |
317
317
  | `/new claude` | 创建 Claude Code 会话 |
318
- | `/new cursor` | 创建 Cursor 会话 |
319
- | `/new codex` | 创建 Codex 会话 |
320
- | `/newh` | 重置当前会话,保留工作目录 |
321
- | `/model` | 查看或切换当前会话的模型 |
322
- | `/stop` | 停止当前回复 |
323
- | `/cancel` | 取消当前会话里排队等待处理的消息 |
324
- | `/state` | 查看当前会话状态 |
325
- | `/cd` | 查看或设置当前会话工作目录 |
326
- | `/sessions` | 查看所有会话状态 |
327
- | `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话 |
328
- | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/周用量,Cursor 显示当前周期用量 |
329
- | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
330
- | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
331
- | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
332
- | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
333
- | `/restart` | 重启机器人进程 |
334
- | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
335
- | `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
336
-
337
- > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
318
+ | `/new cursor` | 创建 Cursor 会话 |
319
+ | `/new codex` | 创建 Codex 会话 |
320
+ | `/newh` | 重置当前会话,保留工作目录 |
321
+ | `/model` | 查看或切换当前会话的模型 |
322
+ | `/stop` | 停止当前回复 |
323
+ | `/cancel` | 取消当前会话里排队等待处理的消息 |
324
+ | `/state` | 查看当前会话状态 |
325
+ | `/cd` | 查看或设置当前会话工作目录 |
326
+ | `/sessions` | 查看所有会话状态 |
327
+ | `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话 |
328
+ | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/周用量,Cursor 显示当前周期用量 |
329
+ | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
330
+ | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
331
+ | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
332
+ | `/ask <内容>` | 只读问答模式:与 /plan 相同,仅允许读文件和 stop-stuck-loop 请求 |
333
+ | `/restart` | 重启机器人进程 |
334
+ | `/update` | 更新 npm 全局包并重启(仅限 `npm install -g chatccc` 安装的全局进程) |
335
+ | `/deleteg` | 解散当前飞书会话群;Agent 会话记录保留 |
336
+
337
+ > **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`,Cursor 使用 `cursor.model`,Codex 使用 `codex.model`。
338
338
 
339
339
  ---
340
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.180",
3
+ "version": "0.2.181",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -16,11 +16,11 @@ describe("cardJsonToPlainText", () => {
16
16
  expect(text).toContain("/new");
17
17
  expect(text).toContain("/new cursor");
18
18
  expect(text).toContain("/new codex");
19
- expect(text).toContain("/restart");
20
- expect(text).toContain("/update");
21
- expect(text).toContain("/cd");
22
- expect(text).toContain("/abd");
23
- });
19
+ expect(text).toContain("/restart");
20
+ expect(text).toContain("/update");
21
+ expect(text).toContain("/cd");
22
+ expect(text).toContain("/abd");
23
+ });
24
24
 
25
25
  it("converts status cards from v1 card format", () => {
26
26
  const text = cardJsonToPlainText(buildStatusCard("status body", "green"));
@@ -1,16 +1,18 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import {
3
- buildProgressCard,
4
- buildHelpCard,
2
+ import {
3
+ buildProgressCard,
4
+ buildHelpCard,
5
5
  buildCdContent,
6
6
  buildCdCard,
7
7
  buildSessionsCard,
8
8
  buildStatusCard,
9
+ buildCodexUsageCard,
10
+ buildCodexResetConfirmCard,
9
11
  buildButtons,
10
12
  truncateContent,
11
- getToolEmoji,
12
- } from "../cards.ts";
13
- import { ABD_HELP_LINE } from "../shared-prefix.ts";
13
+ getToolEmoji,
14
+ } from "../cards.ts";
15
+ import { ABD_HELP_LINE } from "../shared-prefix.ts";
14
16
 
15
17
  // ---------------------------------------------------------------------------
16
18
  // truncateContent
@@ -152,22 +154,23 @@ describe("buildHelpCard", () => {
152
154
  expect(parsed.elements[0].text.content).toContain("你好");
153
155
  });
154
156
 
155
- it("includes action buttons", () => {
156
- const card = buildHelpCard("test");
157
- const parsed = JSON.parse(card);
158
- const action = parsed.elements[2];
159
- expect(action.tag).toBe("action");
160
- expect(action.actions).toHaveLength(7);
161
- });
162
-
163
- it("adds ABD prefix help as the final help line", () => {
164
- const card = buildHelpCard("test");
165
- const parsed = JSON.parse(card);
166
- const lines = parsed.elements[1].text.content.split("\n");
167
-
168
- expect(lines.at(-1)).toBe(ABD_HELP_LINE);
169
- });
170
- });
157
+ it("includes action buttons", () => {
158
+ const card = buildHelpCard("test");
159
+ const parsed = JSON.parse(card);
160
+ const action = parsed.elements[2];
161
+ expect(action.tag).toBe("action");
162
+ expect(action.actions).toHaveLength(7);
163
+ });
164
+
165
+ it("adds ABD prefix help as the final help line", () => {
166
+ const card = buildHelpCard("test");
167
+ const parsed = JSON.parse(card);
168
+ const lines = parsed.elements[1].text.content.split("\n");
169
+
170
+ expect(lines).toContain("发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡");
171
+ expect(lines.at(-1)).toBe(ABD_HELP_LINE);
172
+ });
173
+ });
171
174
 
172
175
  // ---------------------------------------------------------------------------
173
176
  // buildCdContent
@@ -444,6 +447,49 @@ describe("buildStatusCard", () => {
444
447
  });
445
448
  });
446
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
+
447
493
  // ---------------------------------------------------------------------------
448
494
  // buildButtons
449
495
  // ---------------------------------------------------------------------------
@@ -0,0 +1,113 @@
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
+ sendCardReply: ReturnType<typeof vi.fn>;
13
+ updateCardMessage: ReturnType<typeof vi.fn>;
14
+ consumeCodexRateLimitResetCredit: ReturnType<typeof vi.fn>;
15
+ createRequestId: ReturnType<typeof vi.fn>;
16
+ } {
17
+ return {
18
+ getTenantAccessToken: vi.fn(async () => "tenant-token"),
19
+ sendRawCard: vi.fn(async () => true),
20
+ sendCardReply: vi.fn(async () => true),
21
+ updateCardMessage: vi.fn(async () => true),
22
+ consumeCodexRateLimitResetCredit: vi.fn(async () => ({ code: "reset" as const, windowsReset: 2 })),
23
+ createRequestId: vi.fn(() => "request-1"),
24
+ };
25
+ }
26
+
27
+ function event(value: Record<string, unknown>, messageId: string) {
28
+ return {
29
+ event: {
30
+ open_message_id: messageId,
31
+ open_chat_id: "chat-1",
32
+ operator: { open_id: "ou-user" },
33
+ action: { value },
34
+ },
35
+ };
36
+ }
37
+
38
+ describe("Codex reset card actions", () => {
39
+ beforeEach(() => {
40
+ _resetCodexResetRequestsForTest();
41
+ });
42
+
43
+ it("sends a confirmation card tied to the clicked usage card", async () => {
44
+ const d = deps();
45
+
46
+ await expect(handleCodexResetCardAction(
47
+ event({ action: "codex_reset_request" }, "usage-message"),
48
+ d,
49
+ )).resolves.toBe(true);
50
+
51
+ expect(d.sendRawCard).toHaveBeenCalledTimes(1);
52
+ expect(d.sendRawCard.mock.calls[0][1]).toBe("chat-1");
53
+ const confirmCard = JSON.parse(d.sendRawCard.mock.calls[0][2]);
54
+ const action = confirmCard.elements.find((element: any) => element.tag === "action");
55
+ expect(action.actions[0].value).toEqual({
56
+ action: "codex_reset_confirm",
57
+ decision: "yes",
58
+ parentMessageId: "usage-message",
59
+ requestId: "request-1",
60
+ });
61
+ });
62
+
63
+ it("consumes a reset credit, sends the result, and collapses the confirmation card when user confirms", async () => {
64
+ const d = deps();
65
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
66
+
67
+ await expect(handleCodexResetCardAction(
68
+ event({
69
+ action: "codex_reset_confirm",
70
+ decision: "yes",
71
+ parentMessageId: "usage-message",
72
+ requestId: "request-1",
73
+ }, "confirm-message"),
74
+ d,
75
+ )).resolves.toBe(true);
76
+
77
+ expect(d.consumeCodexRateLimitResetCredit).toHaveBeenCalledWith("request-1");
78
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
79
+ expect(d.updateCardMessage.mock.calls[0][1]).toBe("confirm-message");
80
+ expect(d.sendCardReply).toHaveBeenCalledWith(
81
+ "tenant-token",
82
+ "chat-1",
83
+ "Codex 主动重置",
84
+ expect.stringContaining("重置成功"),
85
+ "green",
86
+ );
87
+ });
88
+
89
+ it("does not consume a reset credit and sends a cancellation message when user declines", async () => {
90
+ const d = deps();
91
+ await handleCodexResetCardAction(event({ action: "codex_reset_request" }, "usage-message"), d);
92
+
93
+ await expect(handleCodexResetCardAction(
94
+ event({
95
+ action: "codex_reset_confirm",
96
+ decision: "no",
97
+ parentMessageId: "usage-message",
98
+ requestId: "request-1",
99
+ }, "confirm-message"),
100
+ d,
101
+ )).resolves.toBe(true);
102
+
103
+ expect(d.consumeCodexRateLimitResetCredit).not.toHaveBeenCalled();
104
+ expect(d.updateCardMessage).toHaveBeenCalledTimes(1);
105
+ expect(d.sendCardReply).toHaveBeenCalledWith(
106
+ "tenant-token",
107
+ "chat-1",
108
+ "Codex 主动重置",
109
+ "用户取消了重置。",
110
+ "grey",
111
+ );
112
+ });
113
+ });
@@ -41,7 +41,7 @@ async function writeCodexAuth(homeDir: string): Promise<void> {
41
41
  await mkdir(codexDir, { recursive: true });
42
42
  await writeFile(
43
43
  join(codexDir, "auth.json"),
44
- JSON.stringify({ tokens: { access_token: "codex-access-token" } }),
44
+ JSON.stringify({ tokens: { access_token: "codex-access-token", account_id: "codex-account-id" } }),
45
45
  "utf-8",
46
46
  );
47
47
  }
@@ -52,6 +52,9 @@ function mockAvatarFetch(uploadedNames: string[], usageResponse: Response): void
52
52
  if (urlText === "https://chatgpt.com/backend-api/wham/usage") {
53
53
  return usageResponse.clone();
54
54
  }
55
+ if (urlText === "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits") {
56
+ return new Response(JSON.stringify({ credits: [] }), { status: 200 });
57
+ }
55
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;
@@ -100,24 +103,102 @@ describe("Codex avatar usage battery", () => {
100
103
  }
101
104
  });
102
105
 
103
- it("returns both 5h and weekly Codex usage windows", async () => {
106
+ it("returns both usage windows and available reset credit expiries", async () => {
104
107
  const homeDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-home-"));
105
108
  const userDataDir = await mkdtemp(join(tmpdir(), "chatccc-avatar-data-"));
106
- const uploadedNames: string[] = [];
107
109
  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 }));
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);
114
146
 
115
147
  try {
116
148
  const { getCodexUsageSummary } = await loadFeishuApiWithHome(homeDir, userDataDir);
117
149
  await expect(getCodexUsageSummary()).resolves.toEqual({
118
150
  fiveHour: { usedPercent: 37, remainingPercent: 63, resetAfterSeconds: 10349, resetAtEpochSeconds: 1781528212 },
119
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 });
172
+ }
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,
120
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
+ );
121
202
  } finally {
122
203
  await rm(homeDir, { recursive: true, force: true });
123
204
  await rm(userDataDir, { recursive: true, force: true });
@@ -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();
@@ -26,13 +26,16 @@ describe("feishu-platform", () => {
26
26
  createGroupChat: async () => "mock_chat",
27
27
  updateChatInfo: async () => {},
28
28
  getChatInfo: async () => ({ name: "x", description: "y" }),
29
- disbandChat: async () => {},
30
- setChatAvatar: async () => {},
31
- getCodexUsageSummary: async () => ({
32
- fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
33
- weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
34
- }),
35
- getOrDownloadImage: async () => "/tmp/img.png",
29
+ disbandChat: async () => {},
30
+ setChatAvatar: async () => {},
31
+ getCodexUsageSummary: async () => ({
32
+ fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
33
+ weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
34
+ rateLimitResetCreditsAvailable: 1,
35
+ rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
36
+ }),
37
+ consumeCodexRateLimitResetCredit: async () => ({ code: "reset", windowsReset: 2 }),
38
+ getOrDownloadImage: async () => "/tmp/img.png",
36
39
  verifyAllPermissions: async () => [],
37
40
  reportPermissionResults: realPlatform.reportPermissionResults,
38
41
  extractSessionInfo: realPlatform.extractSessionInfo,
@@ -50,13 +53,16 @@ describe("feishu-platform", () => {
50
53
  expect(await getChatInfo("t", "mock_chat")).toEqual({ name: "x", description: "y" });
51
54
  const perms = await verifyAllPermissions("t");
52
55
  expect(perms).toEqual([]);
53
- const mergeMsgs = await getMergeForwardMessages("t", "om_test");
54
- expect(mergeMsgs).toEqual([]);
55
- expect(await getCodexUsageSummary()).toEqual({
56
- fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
57
- weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
58
- });
59
- } finally {
56
+ const mergeMsgs = await getMergeForwardMessages("t", "om_test");
57
+ expect(mergeMsgs).toEqual([]);
58
+ expect(await getCodexUsageSummary()).toEqual({
59
+ fiveHour: { usedPercent: 10, remainingPercent: 90, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
60
+ weekly: { usedPercent: 20, remainingPercent: 80, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
61
+ rateLimitResetCreditsAvailable: 1,
62
+ rateLimitResetCredits: [{ grantedAt: null, expiresAt: "2026-07-12T04:01:47.770016Z" }],
63
+ });
64
+ expect(await consumeCodexRateLimitResetCredit("request-1")).toEqual({ code: "reset", windowsReset: 2 });
65
+ } finally {
60
66
  // 恢复到真实实现,避免影响后续测试
61
67
  setPlatform(realPlatform);
62
68
  }
@@ -66,4 +72,4 @@ describe("feishu-platform", () => {
66
72
  setPlatform(realPlatform);
67
73
  expect(getPlatform()).toBe(realPlatform);
68
74
  });
69
- });
75
+ });