chatccc 0.2.278 → 0.2.280

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.
@@ -5,7 +5,7 @@ description: Create, clone, or configure a Feishu enterprise self-built bot appl
5
5
 
6
6
  # Create a ChatCCC Feishu App
7
7
 
8
- Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the minimum requirement and the existing app named exactly lowercase `chatccc` as the configuration template.
8
+ Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the authoritative source for permissions and required capabilities. When imitation is requested, use the existing app named exactly lowercase `chatccc` only as a template for presentation, subscriptions, and release settings.
9
9
 
10
10
  ## 1. Establish the target and authorization
11
11
 
@@ -21,13 +21,12 @@ Use the logged-in Feishu developer console to create and completely configure a
21
21
  3. Record its non-secret configuration:
22
22
  - app description and icon;
23
23
  - application capabilities, especially `机器人`;
24
- - every granted permission and its identity type;
25
24
  - event and callback subscription modes;
26
25
  - every event and callback identifier;
27
26
  - version availability, external sharing choices, and release state.
28
27
  4. Never reveal, log, export to chat, or persist the template App Secret.
29
28
 
30
- Prefer current console data over a stale hard-coded list. At the time this workflow was verified, lowercase `chatccc` had 62 application-identity permissions, one user-identity permission, 12 application events, and one callback. Use those counts as a sanity check, not as a replacement for inspecting the template.
29
+ Do not use the template's permission set or permission counts as the source of truth. Permissions must always follow the current README rule below. Prefer current console data over a stale hard-coded list for the remaining template settings.
31
30
 
32
31
  ## 3. Create the target app
33
32
 
@@ -36,16 +35,17 @@ Prefer current console data over a stale hard-coded list. At the time this workf
36
35
  3. Click the final `创建` action only after confirmation.
37
36
  4. Click once and wait for SPA navigation. Verify the exact app name, App ID, URL, and status before retrying; the console can temporarily show stale text after a successful mutation.
38
37
 
39
- ## 4. Clone the complete ChatCCC configuration
38
+ ## 4. Configure the complete README-defined ChatCCC setup
40
39
 
41
40
  Perform the configuration in this order:
42
41
 
43
42
  1. Add the `机器人` application capability and verify that the page offers `删除能力`, which means the capability is enabled.
44
- 2. Clone permissions with `权限管理` > `批量处理` > `批量导入/导出权限`:
45
- - export from lowercase `chatccc`;
46
- - import into the target app;
47
- - confirm all additions and verify both application-identity and user-identity totals;
48
- - do not save the export JSON in the repository or chat.
43
+ 2. Configure permissions from the README, not from the lowercase template:
44
+ - under `权限管理`, search for the `im:` prefix and enable every permission whose name starts with `im:`;
45
+ - search for the `cardkit:` prefix and enable every permission whose name starts with `cardkit:`;
46
+ - separately search for the exact scope `im:message.p2p_msg:readonly` and verify that it is enabled. This private-message read permission is easy to miss and must not be inferred from other message permissions;
47
+ - confirm all permission changes and complete any required administrator approval;
48
+ - do not use template permission export/import as an authoritative shortcut, because the template may be incomplete or stale.
49
49
  3. Under `事件与回调` > `事件配置`, set the subscription mode to `使用长连接接收事件`, then add every event present in the template. The verified baseline is:
50
50
  - `im.chat.disbanded_v1`
51
51
  - `im.chat.member.bot.added_v1`
@@ -62,7 +62,7 @@ Perform the configuration in this order:
62
62
  4. Under `回调配置`, independently set `使用长连接接收回调` and add `card.action.trigger`. Configuring the event mode does not configure the callback mode.
63
63
  5. Verify that every listed event shows a sufficient permission as `已开通`.
64
64
 
65
- The README minimum is the bot capability, relevant `im:` and `cardkit:` permissions, `im.message.receive_v1`, and `card.action.trigger`; a clone request requires the complete current template configuration, not only that minimum.
65
+ The README requires the bot capability, every permission whose name starts with `im:` or `cardkit:`, explicit verification of `im:message.p2p_msg:readonly`, `im.message.receive_v1`, and `card.action.trigger`. A clone request may copy the current template's presentation, subscriptions, availability, and release settings, but it must not weaken the README permission rule.
66
66
 
67
67
  ## 5. Create and publish a version
68
68
 
@@ -78,8 +78,8 @@ The README minimum is the bot capability, relevant `im:` and `cardkit:` permissi
78
78
 
79
79
  ## 6. Verify and hand off safely
80
80
 
81
- - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, permission identity counts match, both subscription modes are long connection, event/callback identifiers match, and the version is published.
81
+ - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, all current `im:` and `cardkit:` permissions are enabled, `im:message.p2p_msg:readonly` is explicitly present, both subscription modes are long connection, event/callback identifiers match, and the version is published.
82
82
  - Treat network responses and a refreshed page as stronger evidence than an immediately stale SPA view. Never repeat a create, add, or publish action only because the first page read is stale.
83
- - Report the app name, App ID, capability, permission counts, event/callback counts, version, release state, and any remaining action.
83
+ - Report the app name, App ID, capability, whether the README permission checks passed, event/callback counts, version, release state, and any remaining action.
84
84
  - Never print the App Secret. If the user explicitly asks to configure ChatCCC, transfer the App ID and App Secret directly into the local private configuration without echoing the secret in commands, logs, screenshots, files tracked by Git, or chat.
85
85
  - Do not modify local ChatCCC configuration or transmit credentials elsewhere unless explicitly authorized.
@@ -5,7 +5,7 @@ description: Create, clone, or configure a Feishu enterprise self-built bot appl
5
5
 
6
6
  # Create a ChatCCC Feishu App
7
7
 
8
- Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the minimum requirement and the existing app named exactly lowercase `chatccc` as the configuration template.
8
+ Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the authoritative source for permissions and required capabilities. When imitation is requested, use the existing app named exactly lowercase `chatccc` only as a template for presentation, subscriptions, and release settings.
9
9
 
10
10
  ## 1. Establish the target and authorization
11
11
 
@@ -21,13 +21,12 @@ Use the logged-in Feishu developer console to create and completely configure a
21
21
  3. Record its non-secret configuration:
22
22
  - app description and icon;
23
23
  - application capabilities, especially `机器人`;
24
- - every granted permission and its identity type;
25
24
  - event and callback subscription modes;
26
25
  - every event and callback identifier;
27
26
  - version availability, external sharing choices, and release state.
28
27
  4. Never reveal, log, export to chat, or persist the template App Secret.
29
28
 
30
- Prefer current console data over a stale hard-coded list. At the time this workflow was verified, lowercase `chatccc` had 62 application-identity permissions, one user-identity permission, 12 application events, and one callback. Use those counts as a sanity check, not as a replacement for inspecting the template.
29
+ Do not use the template's permission set or permission counts as the source of truth. Permissions must always follow the current README rule below. Prefer current console data over a stale hard-coded list for the remaining template settings.
31
30
 
32
31
  ## 3. Create the target app
33
32
 
@@ -36,16 +35,17 @@ Prefer current console data over a stale hard-coded list. At the time this workf
36
35
  3. Click the final `创建` action only after confirmation.
37
36
  4. Click once and wait for SPA navigation. Verify the exact app name, App ID, URL, and status before retrying; the console can temporarily show stale text after a successful mutation.
38
37
 
39
- ## 4. Clone the complete ChatCCC configuration
38
+ ## 4. Configure the complete README-defined ChatCCC setup
40
39
 
41
40
  Perform the configuration in this order:
42
41
 
43
42
  1. Add the `机器人` application capability and verify that the page offers `删除能力`, which means the capability is enabled.
44
- 2. Clone permissions with `权限管理` > `批量处理` > `批量导入/导出权限`:
45
- - export from lowercase `chatccc`;
46
- - import into the target app;
47
- - confirm all additions and verify both application-identity and user-identity totals;
48
- - do not save the export JSON in the repository or chat.
43
+ 2. Configure permissions from the README, not from the lowercase template:
44
+ - under `权限管理`, search for the `im:` prefix and enable every permission whose name starts with `im:`;
45
+ - search for the `cardkit:` prefix and enable every permission whose name starts with `cardkit:`;
46
+ - separately search for the exact scope `im:message.p2p_msg:readonly` and verify that it is enabled. This private-message read permission is easy to miss and must not be inferred from other message permissions;
47
+ - confirm all permission changes and complete any required administrator approval;
48
+ - do not use template permission export/import as an authoritative shortcut, because the template may be incomplete or stale.
49
49
  3. Under `事件与回调` > `事件配置`, set the subscription mode to `使用长连接接收事件`, then add every event present in the template. The verified baseline is:
50
50
  - `im.chat.disbanded_v1`
51
51
  - `im.chat.member.bot.added_v1`
@@ -62,7 +62,7 @@ Perform the configuration in this order:
62
62
  4. Under `回调配置`, independently set `使用长连接接收回调` and add `card.action.trigger`. Configuring the event mode does not configure the callback mode.
63
63
  5. Verify that every listed event shows a sufficient permission as `已开通`.
64
64
 
65
- The README minimum is the bot capability, relevant `im:` and `cardkit:` permissions, `im.message.receive_v1`, and `card.action.trigger`; a clone request requires the complete current template configuration, not only that minimum.
65
+ The README requires the bot capability, every permission whose name starts with `im:` or `cardkit:`, explicit verification of `im:message.p2p_msg:readonly`, `im.message.receive_v1`, and `card.action.trigger`. A clone request may copy the current template's presentation, subscriptions, availability, and release settings, but it must not weaken the README permission rule.
66
66
 
67
67
  ## 5. Create and publish a version
68
68
 
@@ -78,8 +78,8 @@ The README minimum is the bot capability, relevant `im:` and `cardkit:` permissi
78
78
 
79
79
  ## 6. Verify and hand off safely
80
80
 
81
- - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, permission identity counts match, both subscription modes are long connection, event/callback identifiers match, and the version is published.
81
+ - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, all current `im:` and `cardkit:` permissions are enabled, `im:message.p2p_msg:readonly` is explicitly present, both subscription modes are long connection, event/callback identifiers match, and the version is published.
82
82
  - Treat network responses and a refreshed page as stronger evidence than an immediately stale SPA view. Never repeat a create, add, or publish action only because the first page read is stale.
83
- - Report the app name, App ID, capability, permission counts, event/callback counts, version, release state, and any remaining action.
83
+ - Report the app name, App ID, capability, whether the README permission checks passed, event/callback counts, version, release state, and any remaining action.
84
84
  - Never print the App Secret. If the user explicitly asks to configure ChatCCC, transfer the App ID and App Secret directly into the local private configuration without echoing the secret in commands, logs, screenshots, files tracked by Git, or chat.
85
85
  - Do not modify local ChatCCC configuration or transmit credentials elsewhere unless explicitly authorized.
@@ -5,7 +5,7 @@ description: Create, clone, or configure a Feishu enterprise self-built bot appl
5
5
 
6
6
  # Create a ChatCCC Feishu App
7
7
 
8
- Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the minimum requirement and the existing app named exactly lowercase `chatccc` as the configuration template.
8
+ Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the authoritative source for permissions and required capabilities. When imitation is requested, use the existing app named exactly lowercase `chatccc` only as a template for presentation, subscriptions, and release settings.
9
9
 
10
10
  ## 1. Establish the target and authorization
11
11
 
@@ -21,13 +21,12 @@ Use the logged-in Feishu developer console to create and completely configure a
21
21
  3. Record its non-secret configuration:
22
22
  - app description and icon;
23
23
  - application capabilities, especially `机器人`;
24
- - every granted permission and its identity type;
25
24
  - event and callback subscription modes;
26
25
  - every event and callback identifier;
27
26
  - version availability, external sharing choices, and release state.
28
27
  4. Never reveal, log, export to chat, or persist the template App Secret.
29
28
 
30
- Prefer current console data over a stale hard-coded list. At the time this workflow was verified, lowercase `chatccc` had 62 application-identity permissions, one user-identity permission, 12 application events, and one callback. Use those counts as a sanity check, not as a replacement for inspecting the template.
29
+ Do not use the template's permission set or permission counts as the source of truth. Permissions must always follow the current README rule below. Prefer current console data over a stale hard-coded list for the remaining template settings.
31
30
 
32
31
  ## 3. Create the target app
33
32
 
@@ -36,16 +35,17 @@ Prefer current console data over a stale hard-coded list. At the time this workf
36
35
  3. Click the final `创建` action only after confirmation.
37
36
  4. Click once and wait for SPA navigation. Verify the exact app name, App ID, URL, and status before retrying; the console can temporarily show stale text after a successful mutation.
38
37
 
39
- ## 4. Clone the complete ChatCCC configuration
38
+ ## 4. Configure the complete README-defined ChatCCC setup
40
39
 
41
40
  Perform the configuration in this order:
42
41
 
43
42
  1. Add the `机器人` application capability and verify that the page offers `删除能力`, which means the capability is enabled.
44
- 2. Clone permissions with `权限管理` > `批量处理` > `批量导入/导出权限`:
45
- - export from lowercase `chatccc`;
46
- - import into the target app;
47
- - confirm all additions and verify both application-identity and user-identity totals;
48
- - do not save the export JSON in the repository or chat.
43
+ 2. Configure permissions from the README, not from the lowercase template:
44
+ - under `权限管理`, search for the `im:` prefix and enable every permission whose name starts with `im:`;
45
+ - search for the `cardkit:` prefix and enable every permission whose name starts with `cardkit:`;
46
+ - separately search for the exact scope `im:message.p2p_msg:readonly` and verify that it is enabled. This private-message read permission is easy to miss and must not be inferred from other message permissions;
47
+ - confirm all permission changes and complete any required administrator approval;
48
+ - do not use template permission export/import as an authoritative shortcut, because the template may be incomplete or stale.
49
49
  3. Under `事件与回调` > `事件配置`, set the subscription mode to `使用长连接接收事件`, then add every event present in the template. The verified baseline is:
50
50
  - `im.chat.disbanded_v1`
51
51
  - `im.chat.member.bot.added_v1`
@@ -62,7 +62,7 @@ Perform the configuration in this order:
62
62
  4. Under `回调配置`, independently set `使用长连接接收回调` and add `card.action.trigger`. Configuring the event mode does not configure the callback mode.
63
63
  5. Verify that every listed event shows a sufficient permission as `已开通`.
64
64
 
65
- The README minimum is the bot capability, relevant `im:` and `cardkit:` permissions, `im.message.receive_v1`, and `card.action.trigger`; a clone request requires the complete current template configuration, not only that minimum.
65
+ The README requires the bot capability, every permission whose name starts with `im:` or `cardkit:`, explicit verification of `im:message.p2p_msg:readonly`, `im.message.receive_v1`, and `card.action.trigger`. A clone request may copy the current template's presentation, subscriptions, availability, and release settings, but it must not weaken the README permission rule.
66
66
 
67
67
  ## 5. Create and publish a version
68
68
 
@@ -78,8 +78,8 @@ The README minimum is the bot capability, relevant `im:` and `cardkit:` permissi
78
78
 
79
79
  ## 6. Verify and hand off safely
80
80
 
81
- - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, permission identity counts match, both subscription modes are long connection, event/callback identifiers match, and the version is published.
81
+ - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, all current `im:` and `cardkit:` permissions are enabled, `im:message.p2p_msg:readonly` is explicitly present, both subscription modes are long connection, event/callback identifiers match, and the version is published.
82
82
  - Treat network responses and a refreshed page as stronger evidence than an immediately stale SPA view. Never repeat a create, add, or publish action only because the first page read is stale.
83
- - Report the app name, App ID, capability, permission counts, event/callback counts, version, release state, and any remaining action.
83
+ - Report the app name, App ID, capability, whether the README permission checks passed, event/callback counts, version, release state, and any remaining action.
84
84
  - Never print the App Secret. If the user explicitly asks to configure ChatCCC, transfer the App ID and App Secret directly into the local private configuration without echoing the secret in commands, logs, screenshots, files tracked by Git, or chat.
85
85
  - Do not modify local ChatCCC configuration or transmit credentials elsewhere unless explicitly authorized.
package/README.md CHANGED
@@ -182,12 +182,15 @@ npm run dev
182
182
 
183
183
  1. 打开 [飞书开放平台](https://open.feishu.cn),创建一个**企业自建应用**。
184
184
  2. 在「应用功能」里开启**机器人**能力。
185
- 3. 在「权限管理」里开通 `im:` `cardkit:` 前缀下的相关权限:
185
+ 3. 在「权限管理」里开通**所有权限名称以 `im:` `cardkit:` 开头的权限**。不要只依赖事件页面自动推荐的权限;开通后应分别搜索这两个前缀并逐项确认:
186
186
 
187
187
  | 前缀 | 用途 |
188
188
  | --- | --- |
189
- | `im:` | 收发消息、创建和管理群聊、机器人发言 |
190
- | `cardkit:` | 卡片展示、流式更新、按钮和交互回调 |
189
+ | `im:` | 全部开通,用于收发消息、创建和管理群聊、机器人发言等 |
190
+ | `cardkit:` | 全部开通,用于卡片展示、流式更新、按钮和交互回调等 |
191
+
192
+ > [!IMPORTANT]
193
+ > 请额外单独搜索并确认已开通 `im:message.p2p_msg:readonly`(获取用户发给机器人的单聊消息)。这个权限在飞书开发者后台配置时容易遗漏;缺少它可能导致 ChatCCC 无法正常识别或处理私聊消息。
191
194
 
192
195
  <p align="center">
193
196
  <img src="images/img_readme_permission.png" alt="飞书应用权限配置" width="280" />
@@ -427,7 +430,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
427
430
  | `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
428
431
  | `/sessions` | 查看所有会话状态 |
429
432
  | `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
430
- | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,Cursor 显示当前周期用量,CCC Agent 和 DSH 仅在官方 DeepSeek 端点时显示账户余额(其他兼容端点自动跳过) |
433
+ | `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,主动重置次数查询失败时会明确展示上次成功快照及其查询时间(缓存结果不提供重置按钮);Cursor 显示当前周期用量,CCC Agent 和 DSH 仅在官方 DeepSeek 端点时显示账户余额(其他兼容端点自动跳过) |
431
434
  | `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
432
435
  | `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
433
436
  | `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
@@ -0,0 +1,105 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { USER_DATA_DIR } from "./config.js";
5
+ const CACHE_FILE = join(USER_DATA_DIR, "state", "codex-reset-credits.json");
6
+ let mutationQueue = Promise.resolve();
7
+ export function codexResetCreditsAccountKey(accountId, accessToken) {
8
+ if (accountId?.trim())
9
+ return `account:${accountId.trim()}`;
10
+ const digest = createHash("sha256").update(accessToken).digest("hex").slice(0, 24);
11
+ return `token-sha256:${digest}`;
12
+ }
13
+ function emptyCache() {
14
+ return { version: 1, accounts: {} };
15
+ }
16
+ function normalizeSnapshot(raw) {
17
+ if (!raw || typeof raw !== "object")
18
+ return null;
19
+ const value = raw;
20
+ const availableCount = Number(value.availableCount);
21
+ if (!Number.isFinite(availableCount) || typeof value.queriedAt !== "string" || !value.queriedAt.trim())
22
+ return null;
23
+ const credits = Array.isArray(value.credits)
24
+ ? value.credits.flatMap((credit) => {
25
+ if (!credit || typeof credit !== "object")
26
+ return [];
27
+ const item = credit;
28
+ if (typeof item.expiresAt !== "string" || !item.expiresAt.trim())
29
+ return [];
30
+ return [{
31
+ grantedAt: typeof item.grantedAt === "string" && item.grantedAt.trim() ? item.grantedAt : null,
32
+ expiresAt: item.expiresAt,
33
+ }];
34
+ })
35
+ : [];
36
+ return {
37
+ availableCount: Math.max(0, Math.trunc(availableCount)),
38
+ credits,
39
+ queriedAt: value.queriedAt,
40
+ locallyAdjusted: value.locallyAdjusted === true,
41
+ };
42
+ }
43
+ async function readCache() {
44
+ try {
45
+ const parsed = JSON.parse(await readFile(CACHE_FILE, "utf-8"));
46
+ const accounts = {};
47
+ if (parsed.accounts && typeof parsed.accounts === "object") {
48
+ for (const [key, raw] of Object.entries(parsed.accounts)) {
49
+ const snapshot = normalizeSnapshot(raw);
50
+ if (snapshot)
51
+ accounts[key] = snapshot;
52
+ }
53
+ }
54
+ return { version: 1, accounts };
55
+ }
56
+ catch {
57
+ return emptyCache();
58
+ }
59
+ }
60
+ async function writeCache(cache) {
61
+ await mkdir(dirname(CACHE_FILE), { recursive: true });
62
+ const tempFile = `${CACHE_FILE}.${process.pid}.${randomUUID()}.tmp`;
63
+ await writeFile(tempFile, JSON.stringify(cache, null, 2), "utf-8");
64
+ try {
65
+ await rename(tempFile, CACHE_FILE);
66
+ }
67
+ catch {
68
+ await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
69
+ await rm(tempFile, { force: true }).catch(() => { });
70
+ }
71
+ }
72
+ async function mutateCache(mutate) {
73
+ const operation = mutationQueue.then(async () => {
74
+ const cache = await readCache();
75
+ mutate(cache);
76
+ await writeCache(cache);
77
+ });
78
+ mutationQueue = operation.catch(() => { });
79
+ return operation;
80
+ }
81
+ export async function readCodexResetCreditsSnapshot(accountKey) {
82
+ return (await readCache()).accounts[accountKey] ?? null;
83
+ }
84
+ export async function saveCodexResetCreditsSnapshot(accountKey, snapshot) {
85
+ await mutateCache((cache) => {
86
+ cache.accounts[accountKey] = {
87
+ ...snapshot,
88
+ availableCount: Math.max(0, Math.trunc(snapshot.availableCount)),
89
+ locallyAdjusted: false,
90
+ };
91
+ });
92
+ }
93
+ export async function decrementCachedCodexResetCredits(accountKey) {
94
+ await mutateCache((cache) => {
95
+ const current = cache.accounts[accountKey];
96
+ if (!current)
97
+ return;
98
+ cache.accounts[accountKey] = {
99
+ ...current,
100
+ availableCount: Math.max(0, current.availableCount - 1),
101
+ credits: current.credits.slice(1),
102
+ locallyAdjusted: true,
103
+ };
104
+ });
105
+ }
@@ -6,6 +6,7 @@ import sharp from "sharp";
6
6
  import { APP_ID, APP_SECRET, BASE_URL, CHAT_LOGS_DIR, PROJECT_ROOT, USER_DATA_DIR, CLAUDE_SESSION_PREFIX, CURSOR_SESSION_PREFIX, CODEX_SESSION_PREFIX, CCC_SESSION_PREFIX, DSH_SESSION_PREFIX, ts, resolveDefaultAgentTool, toolDisplayName, config, } from "./config.js";
7
7
  import { getCursorUsageSummary } from "./cursor-usage.js";
8
8
  import { applyPrivacy } from "./privacy.js";
9
+ import { codexResetCreditsAccountKey, decrementCachedCodexResetCredits, readCodexResetCreditsSnapshot, saveCodexResetCreditsSnapshot, } from "./codex-reset-credits-cache.js";
9
10
  import { buildHelpCard } from "./cards.js";
10
11
  // ---------------------------------------------------------------------------
11
12
  // Auth
@@ -366,9 +367,6 @@ async function getCodexAuth() {
366
367
  return null;
367
368
  }
368
369
  }
369
- async function getCodexAccessToken() {
370
- return (await getCodexAuth())?.accessToken ?? null;
371
- }
372
370
  function codexAuthHeaders(auth) {
373
371
  const headers = {
374
372
  Authorization: `Bearer ${auth.accessToken}`,
@@ -399,26 +397,42 @@ function parseCodexResetCreditDetails(data) {
399
397
  }),
400
398
  };
401
399
  }
400
+ const codexResetCreditsInFlight = new Map();
402
401
  async function fetchCodexRateLimitResetCredits(auth) {
403
- try {
404
- const resp = await fetch(CODEX_RESET_CREDITS_URL, {
405
- headers: codexAuthHeaders(auth),
406
- });
407
- const text = await resp.text();
408
- if (!resp.ok)
409
- throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
410
- return parseCodexResetCreditDetails(JSON.parse(text));
411
- }
412
- catch (err) {
413
- console.warn(`[Codex] reset credits lookup failed: ${err.message}`);
414
- return null;
415
- }
402
+ const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
403
+ const existing = codexResetCreditsInFlight.get(accountKey);
404
+ if (existing)
405
+ return existing;
406
+ const lookup = (async () => {
407
+ try {
408
+ const resp = await fetch(CODEX_RESET_CREDITS_URL, {
409
+ headers: codexAuthHeaders(auth),
410
+ });
411
+ const text = await resp.text();
412
+ if (!resp.ok)
413
+ throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
414
+ return {
415
+ details: parseCodexResetCreditDetails(JSON.parse(text)),
416
+ error: null,
417
+ };
418
+ }
419
+ catch (err) {
420
+ const error = err.message;
421
+ console.warn(`[Codex] reset credits lookup failed: ${error}`);
422
+ return { details: null, error };
423
+ }
424
+ })().finally(() => {
425
+ codexResetCreditsInFlight.delete(accountKey);
426
+ });
427
+ codexResetCreditsInFlight.set(accountKey, lookup);
428
+ return lookup;
416
429
  }
417
- export async function getCodexUsageSummary() {
430
+ export async function getCodexUsageSummary(options = {}) {
418
431
  const auth = await getCodexAuth();
419
432
  if (!auth)
420
433
  throw new Error("missing ~/.codex/auth.json access token");
421
- const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
434
+ const includeResetCredits = options.includeResetCredits !== false;
435
+ const resetCreditsPromise = includeResetCredits ? fetchCodexRateLimitResetCredits(auth) : null;
422
436
  const resp = await fetch(CODEX_USAGE_URL, {
423
437
  headers: { Authorization: `Bearer ${auth.accessToken}` },
424
438
  });
@@ -443,24 +457,77 @@ export async function getCodexUsageSummary() {
443
457
  ?? (primaryWindow?.limitWindowSeconds === undefined ? primaryWindow : null);
444
458
  const weekly = windows.find((window) => isUsageWindowDuration(window, SEVEN_DAY_WINDOW_SECONDS))
445
459
  ?? (secondaryWindow?.limitWindowSeconds === undefined ? secondaryWindow : null);
446
- const resetCredits = await resetCreditsPromise;
460
+ if (!includeResetCredits) {
461
+ return {
462
+ fiveHour,
463
+ weekly,
464
+ rateLimitResetCreditsAvailable: null,
465
+ rateLimitResetCredits: null,
466
+ rateLimitResetCreditsSource: "not_requested",
467
+ rateLimitResetCreditsQueriedAt: null,
468
+ rateLimitResetCreditsLocallyAdjusted: false,
469
+ rateLimitResetCreditsError: null,
470
+ };
471
+ }
472
+ const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
473
+ const resetLookup = await resetCreditsPromise;
474
+ const embeddedAvailableCount = parseRateLimitResetCredits(data);
475
+ const liveAvailableCount = resetLookup.details?.availableCount ?? embeddedAvailableCount;
476
+ if (liveAvailableCount !== null) {
477
+ const queriedAt = new Date().toISOString();
478
+ const liveCredits = resetLookup.details?.availableCredits ?? [];
479
+ await saveCodexResetCreditsSnapshot(accountKey, {
480
+ availableCount: liveAvailableCount,
481
+ credits: liveCredits,
482
+ queriedAt,
483
+ }).catch((err) => {
484
+ console.warn(`[Codex] reset credits snapshot write failed: ${err.message}`);
485
+ });
486
+ return {
487
+ fiveHour,
488
+ weekly,
489
+ rateLimitResetCreditsAvailable: liveAvailableCount,
490
+ rateLimitResetCredits: liveCredits,
491
+ rateLimitResetCreditsSource: "live",
492
+ rateLimitResetCreditsQueriedAt: queriedAt,
493
+ rateLimitResetCreditsLocallyAdjusted: false,
494
+ rateLimitResetCreditsError: null,
495
+ };
496
+ }
497
+ const cached = await readCodexResetCreditsSnapshot(accountKey);
498
+ if (cached) {
499
+ return {
500
+ fiveHour,
501
+ weekly,
502
+ rateLimitResetCreditsAvailable: cached.availableCount,
503
+ rateLimitResetCredits: cached.credits,
504
+ rateLimitResetCreditsSource: "cache",
505
+ rateLimitResetCreditsQueriedAt: cached.queriedAt,
506
+ rateLimitResetCreditsLocallyAdjusted: cached.locallyAdjusted,
507
+ rateLimitResetCreditsError: resetLookup.error ?? "OpenAI returned no reset-credit data",
508
+ };
509
+ }
447
510
  return {
448
511
  fiveHour,
449
512
  weekly,
450
- rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
451
- rateLimitResetCredits: resetCredits?.availableCredits ?? null,
513
+ rateLimitResetCreditsAvailable: null,
514
+ rateLimitResetCredits: null,
515
+ rateLimitResetCreditsSource: "unavailable",
516
+ rateLimitResetCreditsQueriedAt: null,
517
+ rateLimitResetCreditsLocallyAdjusted: false,
518
+ rateLimitResetCreditsError: resetLookup.error ?? "OpenAI returned no reset-credit data",
452
519
  };
453
520
  }
454
521
  export async function consumeCodexRateLimitResetCredit(redeemRequestId) {
455
522
  if (!redeemRequestId.trim())
456
523
  throw new Error("missing redeem_request_id");
457
- const token = await getCodexAccessToken();
458
- if (!token)
524
+ const auth = await getCodexAuth();
525
+ if (!auth)
459
526
  throw new Error("missing ~/.codex/auth.json access token");
460
527
  const resp = await fetch(CODEX_RESET_CONSUME_URL, {
461
528
  method: "POST",
462
529
  headers: {
463
- Authorization: `Bearer ${token}`,
530
+ Authorization: `Bearer ${auth.accessToken}`,
464
531
  "Content-Type": "application/json",
465
532
  },
466
533
  body: JSON.stringify({ redeem_request_id: redeemRequestId }),
@@ -477,10 +544,17 @@ export async function consumeCodexRateLimitResetCredit(redeemRequestId) {
477
544
  throw new Error("missing or unknown reset result code");
478
545
  }
479
546
  const windowsReset = Number(data.windows_reset);
480
- return {
547
+ const result = {
481
548
  code,
482
549
  windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
483
550
  };
551
+ if (result.code === "reset") {
552
+ const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
553
+ await decrementCachedCodexResetCredits(accountKey).catch((err) => {
554
+ console.warn(`[Codex] reset credits snapshot update failed: ${err.message}`);
555
+ });
556
+ }
557
+ return result;
484
558
  }
485
559
  async function resolveCodexAvatarUsage(usageHint) {
486
560
  if (usageHint !== undefined) {
@@ -489,7 +563,7 @@ async function resolveCodexAvatarUsage(usageHint) {
489
563
  return usageHint;
490
564
  }
491
565
  try {
492
- const summary = await getCodexUsageSummary();
566
+ const summary = await getCodexUsageSummary({ includeResetCredits: false });
493
567
  if (!summary.weekly)
494
568
  throw new Error("missing weekly usage window");
495
569
  return summary;
@@ -100,33 +100,69 @@ function formatCodexUsageSummary(usage, chatGptSubscription = null) {
100
100
  ].join("\n");
101
101
  };
102
102
  const formatResetCredits = () => {
103
+ const formatDateTime = (value) => {
104
+ const date = new Date(value);
105
+ if (!Number.isFinite(date.getTime()))
106
+ return value;
107
+ const pad = (part) => String(part).padStart(2, "0");
108
+ return [
109
+ date.getFullYear(),
110
+ "-",
111
+ pad(date.getMonth() + 1),
112
+ "-",
113
+ pad(date.getDate()),
114
+ " ",
115
+ pad(date.getHours()),
116
+ ":",
117
+ pad(date.getMinutes()),
118
+ ":",
119
+ pad(date.getSeconds()),
120
+ ].join("");
121
+ };
122
+ const failureReason = () => {
123
+ const raw = usage.rateLimitResetCreditsError?.replace(/\s+/g, " ").trim();
124
+ if (!raw)
125
+ return "OpenAI 未返回主动重置数据";
126
+ if (/HTTP 429/i.test(raw))
127
+ return "OpenAI 返回 HTTP 429(请求受限)";
128
+ if (/HTTP 404/i.test(raw))
129
+ return "OpenAI 返回 HTTP 404(接口暂不可用)";
130
+ return raw.length > 180 ? `${raw.slice(0, 180)}...` : raw;
131
+ };
132
+ const source = usage.rateLimitResetCreditsSource ?? "live";
133
+ if (source === "unavailable") {
134
+ return [
135
+ `**主动重置:** 本次查询失败(${failureReason()})`,
136
+ "- 没有可用的历史缓存结果",
137
+ ].join("\n");
138
+ }
139
+ if (source === "cache") {
140
+ const lines = [
141
+ `**主动重置:** 本次查询失败(${failureReason()})`,
142
+ `**上次缓存结果:** 剩余 ${usage.rateLimitResetCreditsAvailable ?? 0} 次`,
143
+ ];
144
+ if (usage.rateLimitResetCreditsQueriedAt) {
145
+ lines.push(`- 查询时间: ${formatDateTime(usage.rateLimitResetCreditsQueriedAt)}`);
146
+ }
147
+ if (usage.rateLimitResetCreditsLocallyAdjusted) {
148
+ lines.push("- 状态: 本地推算,待下次查询确认");
149
+ }
150
+ const credits = usage.rateLimitResetCredits ?? [];
151
+ if (credits.length > 0) {
152
+ lines.push("**缓存中的过期时间:**");
153
+ for (const credit of credits)
154
+ lines.push(`- ${formatDateTime(credit.expiresAt)}`);
155
+ }
156
+ return lines.join("\n");
157
+ }
103
158
  if (usage.rateLimitResetCreditsAvailable === null)
104
159
  return "**主动重置:** 暂无数据";
105
160
  const lines = [`**主动重置:** 剩余 ${usage.rateLimitResetCreditsAvailable} 次`];
106
161
  const credits = usage.rateLimitResetCredits ?? [];
107
162
  if (credits.length > 0) {
108
- const pad = (value) => String(value).padStart(2, "0");
109
- const formatExpiresAt = (value) => {
110
- const date = new Date(value);
111
- if (!Number.isFinite(date.getTime()))
112
- return value;
113
- return [
114
- date.getFullYear(),
115
- "-",
116
- pad(date.getMonth() + 1),
117
- "-",
118
- pad(date.getDate()),
119
- " ",
120
- pad(date.getHours()),
121
- ":",
122
- pad(date.getMinutes()),
123
- ":",
124
- pad(date.getSeconds()),
125
- ].join("");
126
- };
127
163
  lines.push("**过期时间:**");
128
164
  for (const credit of credits) {
129
- lines.push(`- ${formatExpiresAt(credit.expiresAt)}`);
165
+ lines.push(`- ${formatDateTime(credit.expiresAt)}`);
130
166
  }
131
167
  }
132
168
  return lines.join("\n");
@@ -397,7 +433,11 @@ async function sendUsageSummary(platform, chatId, tool, avatarStatus = "idle", s
397
433
  await platform.sendText(chatId, content).catch(() => { });
398
434
  }
399
435
  else if (platform.kind === "feishu") {
400
- await platform.sendRawCard(chatId, buildCodexUsageCard(content, usage.rateLimitResetCreditsAvailable));
436
+ const liveResetCredits = usage.rateLimitResetCreditsSource === undefined
437
+ || usage.rateLimitResetCreditsSource === "live"
438
+ ? usage.rateLimitResetCreditsAvailable
439
+ : null;
440
+ await platform.sendRawCard(chatId, buildCodexUsageCard(content, liveResetCredits));
401
441
  }
402
442
  else {
403
443
  await platform.sendCard(chatId, "Codex Usage", content, "blue");
@@ -105,6 +105,10 @@ export const SimulatedPlatform = {
105
105
  weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
106
106
  rateLimitResetCreditsAvailable: null,
107
107
  rateLimitResetCredits: null,
108
+ rateLimitResetCreditsSource: "unavailable",
109
+ rateLimitResetCreditsQueriedAt: null,
110
+ rateLimitResetCreditsLocallyAdjusted: false,
111
+ rateLimitResetCreditsError: "Simulated platform has no reset-credit data",
108
112
  };
109
113
  },
110
114
  async consumeCodexRateLimitResetCredit(_redeemRequestId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.278",
3
+ "version": "0.2.280",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",