chatccc 0.2.179 → 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 +27 -9
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +1 -0
- package/src/__tests__/cards.test.ts +55 -0
- package/src/__tests__/codex-reset-actions.test.ts +113 -0
- package/src/__tests__/feishu-avatar.test.ts +90 -9
- package/src/__tests__/feishu-platform.test.ts +22 -16
- package/src/__tests__/orchestrator.test.ts +91 -37
- package/src/__tests__/shared-prefix.test.ts +36 -0
- package/src/cards.ts +70 -1
- package/src/codex-reset-actions.ts +171 -0
- package/src/feishu-api.ts +131 -6
- package/src/feishu-platform.ts +20 -15
- package/src/index.ts +11 -0
- package/src/orchestrator.ts +73 -32
- package/src/shared-prefix.ts +29 -0
- package/src/sim-platform.ts +20 -14
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
resetState,
|
|
78
78
|
} from "../session.ts";
|
|
79
79
|
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
80
|
+
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
80
81
|
|
|
81
82
|
function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
|
|
82
83
|
return {
|
|
@@ -131,6 +132,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
131
132
|
mockGetCodexUsageSummary.mockResolvedValue({
|
|
132
133
|
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
133
134
|
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
135
|
+
rateLimitResetCreditsAvailable: null,
|
|
136
|
+
rateLimitResetCredits: null,
|
|
134
137
|
});
|
|
135
138
|
mockGetCursorUsageSummary.mockResolvedValue({
|
|
136
139
|
billingCycleStart: "1779357999000",
|
|
@@ -212,6 +215,41 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
212
215
|
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
213
216
|
});
|
|
214
217
|
|
|
218
|
+
it("treats /abd as a shared prompt prefix in an existing session", async () => {
|
|
219
|
+
const platform = mockPlatform();
|
|
220
|
+
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
221
|
+
yield {
|
|
222
|
+
type: "assistant" as const,
|
|
223
|
+
blocks: [{ type: "text" as const, text: "done" }],
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
_setAdapterForToolForTest("claude", {
|
|
227
|
+
displayName: "Claude",
|
|
228
|
+
sessionDescPrefix: "Claude Session:",
|
|
229
|
+
createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
|
|
230
|
+
prompt,
|
|
231
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
232
|
+
sessionId,
|
|
233
|
+
cwd: "F:\\repo",
|
|
234
|
+
}),
|
|
235
|
+
closeSession: async () => {},
|
|
236
|
+
});
|
|
237
|
+
await recordSessionRegistry({
|
|
238
|
+
chatId: "wx-chat",
|
|
239
|
+
sessionId: "sid-wechat",
|
|
240
|
+
tool: "claude",
|
|
241
|
+
chatName: "ready-session",
|
|
242
|
+
running: false,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
246
|
+
|
|
247
|
+
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
248
|
+
const userText = prompt.mock.calls[0][1];
|
|
249
|
+
expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
|
|
250
|
+
expect(userText).not.toContain("/abd");
|
|
251
|
+
});
|
|
252
|
+
|
|
215
253
|
it("does not send the stopped success text until the running prompt really exits", async () => {
|
|
216
254
|
const platform = mockPlatform();
|
|
217
255
|
await recordSessionRegistry({
|
|
@@ -280,6 +318,38 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
280
318
|
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
281
319
|
});
|
|
282
320
|
|
|
321
|
+
it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
|
|
322
|
+
const platform = mockPlatform("feishu");
|
|
323
|
+
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
324
|
+
yield {
|
|
325
|
+
type: "assistant" as const,
|
|
326
|
+
blocks: [{ type: "text" as const, text: "done" }],
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
_setAdapterForToolForTest("claude", {
|
|
330
|
+
displayName: "Claude",
|
|
331
|
+
sessionDescPrefix: "Claude Session:",
|
|
332
|
+
createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
|
|
333
|
+
prompt,
|
|
334
|
+
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
335
|
+
sessionId,
|
|
336
|
+
cwd: "F:\\repo",
|
|
337
|
+
}),
|
|
338
|
+
closeSession: async () => {},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
342
|
+
|
|
343
|
+
expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
|
|
344
|
+
expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
|
|
345
|
+
const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
|
|
346
|
+
expect(updateCall[1]).not.toContain("---");
|
|
347
|
+
expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
|
|
348
|
+
const userText = prompt.mock.calls[0][1];
|
|
349
|
+
expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
|
|
350
|
+
expect(userText).not.toContain("/abd");
|
|
351
|
+
});
|
|
352
|
+
|
|
283
353
|
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
284
354
|
const platform = mockPlatform("feishu");
|
|
285
355
|
await recordSessionRegistry({
|
|
@@ -303,47 +373,31 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
303
373
|
mockGetCodexUsageSummary.mockResolvedValue({
|
|
304
374
|
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
305
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
|
+
],
|
|
306
381
|
});
|
|
307
382
|
|
|
308
383
|
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
309
384
|
|
|
310
385
|
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
311
|
-
expect(platform.sendCard).
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
);
|
|
317
|
-
expect(
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
);
|
|
323
|
-
expect(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
327
|
-
"blue",
|
|
328
|
-
);
|
|
329
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
330
|
-
"feishu-p2p",
|
|
331
|
-
"Codex Usage",
|
|
332
|
-
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
333
|
-
"blue",
|
|
334
|
-
);
|
|
335
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
336
|
-
"feishu-p2p",
|
|
337
|
-
"Codex Usage",
|
|
338
|
-
expect.stringContaining("约 3天18小时17分钟后"),
|
|
339
|
-
"blue",
|
|
340
|
-
);
|
|
341
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
342
|
-
"feishu-p2p",
|
|
343
|
-
"Codex Usage",
|
|
344
|
-
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
345
|
-
"blue",
|
|
346
|
-
);
|
|
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 });
|
|
347
401
|
});
|
|
348
402
|
|
|
349
403
|
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
@@ -384,7 +438,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
384
438
|
expect(codexPlatform.sendCard).toHaveBeenCalledWith(
|
|
385
439
|
"feishu-group",
|
|
386
440
|
"Codex Session Ready",
|
|
387
|
-
expect.stringContaining("发送 **/usage** 查看 Codex 5h
|
|
441
|
+
expect.stringContaining("发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡。"),
|
|
388
442
|
"green",
|
|
389
443
|
);
|
|
390
444
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ABD_APPEND_PROMPT,
|
|
5
|
+
applySharedPrefix,
|
|
6
|
+
} from "../shared-prefix.ts";
|
|
7
|
+
|
|
8
|
+
describe("applySharedPrefix", () => {
|
|
9
|
+
it("leaves normal messages unchanged", () => {
|
|
10
|
+
expect(applySharedPrefix("帮我分析")).toEqual({
|
|
11
|
+
matched: false,
|
|
12
|
+
text: "帮我分析",
|
|
13
|
+
body: "帮我分析",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("removes /abd without requiring a space", () => {
|
|
18
|
+
const result = applySharedPrefix("/abd帮我分析");
|
|
19
|
+
|
|
20
|
+
expect(result.matched).toBe(true);
|
|
21
|
+
expect(result.body).toBe("帮我分析");
|
|
22
|
+
expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("removes whitespace after /abd", () => {
|
|
26
|
+
const result = applySharedPrefix("/abd 帮我分析");
|
|
27
|
+
|
|
28
|
+
expect(result.text).toBe(`帮我分析\n\n---\n${ABD_APPEND_PROMPT}`);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps the appendix when the user body is empty", () => {
|
|
32
|
+
const result = applySharedPrefix("/abd ");
|
|
33
|
+
|
|
34
|
+
expect(result.text).toBe(`---\n${ABD_APPEND_PROMPT}`);
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/cards.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Button helpers
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
|
|
5
|
+
import { ABD_HELP_LINE } from "./shared-prefix.ts";
|
|
6
|
+
|
|
5
7
|
export interface ButtonDef {
|
|
6
8
|
text: string;
|
|
7
9
|
value: string;
|
|
@@ -135,9 +137,10 @@ export function buildHelpCard(
|
|
|
135
137
|
"发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
|
|
136
138
|
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
137
139
|
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
138
|
-
"发送 **/usage** 查看 Codex 5h
|
|
140
|
+
"发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡",
|
|
139
141
|
"发送 **/restart** 重启 ChatCCC 进程",
|
|
140
142
|
"发送 **/update** 更新并重启(仅 npm 全局安装可用)",
|
|
143
|
+
ABD_HELP_LINE,
|
|
141
144
|
].join("\n");
|
|
142
145
|
return JSON.stringify({
|
|
143
146
|
config: { wide_screen_mode: true },
|
|
@@ -427,6 +430,72 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
|
|
|
427
430
|
});
|
|
428
431
|
}
|
|
429
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
|
+
|
|
430
499
|
/** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
|
|
431
500
|
export function buildModelCard(
|
|
432
501
|
currentModel: string,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { buildCodexResetConfirmCard } from "./cards.ts";
|
|
4
|
+
import type { CodexResetConsumeResult } from "./feishu-api.ts";
|
|
5
|
+
|
|
6
|
+
type CodexResetDecision = "yes" | "no";
|
|
7
|
+
|
|
8
|
+
interface PendingCodexResetRequest {
|
|
9
|
+
chatId: string;
|
|
10
|
+
parentMessageId: string;
|
|
11
|
+
status: "pending" | "handled";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface CodexResetActionDeps {
|
|
15
|
+
getTenantAccessToken(): Promise<string>;
|
|
16
|
+
sendRawCard(token: string, chatId: string, cardJson: string): Promise<boolean>;
|
|
17
|
+
sendCardReply(token: string, chatId: string, title: string, content: string, template?: string): Promise<boolean>;
|
|
18
|
+
updateCardMessage(token: string, messageId: string, content: string): Promise<boolean>;
|
|
19
|
+
consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult>;
|
|
20
|
+
createRequestId?: () => string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pendingCodexResetRequests = new Map<string, PendingCodexResetRequest>();
|
|
24
|
+
|
|
25
|
+
function rawEvent(data: unknown): Record<string, unknown> {
|
|
26
|
+
return ((data as Record<string, unknown>)?.event ?? data) as Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function actionValue(raw: Record<string, unknown>): Record<string, unknown> | null {
|
|
30
|
+
const action = raw.action as { value?: unknown } | undefined;
|
|
31
|
+
const value = action?.value;
|
|
32
|
+
if (!value || typeof value !== "object") return null;
|
|
33
|
+
return value as Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function eventMessageId(raw: Record<string, unknown>): string {
|
|
37
|
+
return typeof raw.open_message_id === "string"
|
|
38
|
+
? raw.open_message_id
|
|
39
|
+
: typeof (raw.context as Record<string, unknown> | undefined)?.open_message_id === "string"
|
|
40
|
+
? (raw.context as Record<string, string>).open_message_id
|
|
41
|
+
: "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function eventChatId(raw: Record<string, unknown>): string {
|
|
45
|
+
return typeof raw.open_chat_id === "string"
|
|
46
|
+
? raw.open_chat_id
|
|
47
|
+
: typeof (raw.context as Record<string, unknown> | undefined)?.open_chat_id === "string"
|
|
48
|
+
? (raw.context as Record<string, string>).open_chat_id
|
|
49
|
+
: typeof (raw.message as Record<string, unknown> | undefined)?.chat_id === "string"
|
|
50
|
+
? (raw.message as Record<string, string>).chat_id
|
|
51
|
+
: "";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function collapsedCard(): string {
|
|
55
|
+
return JSON.stringify({
|
|
56
|
+
config: { wide_screen_mode: true },
|
|
57
|
+
elements: [{ tag: "markdown", content: " " }],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function resetResultMessage(result: CodexResetConsumeResult): { content: string; template: string } {
|
|
62
|
+
if (result.code === "reset") {
|
|
63
|
+
return {
|
|
64
|
+
content: `重置成功。已重置 ${result.windowsReset} 个 Codex 用量窗口。`,
|
|
65
|
+
template: "green",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (result.code === "nothing_to_reset") {
|
|
69
|
+
return {
|
|
70
|
+
content: "没有需要重置的 Codex 用量窗口。",
|
|
71
|
+
template: "yellow",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (result.code === "no_credit") {
|
|
75
|
+
return {
|
|
76
|
+
content: "没有可用的 Codex 主动重置次数。",
|
|
77
|
+
template: "red",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
content: "这次 Codex 主动重置请求已经处理过。",
|
|
82
|
+
template: "yellow",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function sendResetRequestConfirmation(
|
|
87
|
+
raw: Record<string, unknown>,
|
|
88
|
+
value: Record<string, unknown>,
|
|
89
|
+
deps: CodexResetActionDeps,
|
|
90
|
+
): Promise<boolean> {
|
|
91
|
+
const parentMessageId = eventMessageId(raw);
|
|
92
|
+
const chatId = eventChatId(raw);
|
|
93
|
+
if (!parentMessageId || !chatId) return true;
|
|
94
|
+
const availableCount = Number(value.availableCount);
|
|
95
|
+
|
|
96
|
+
const requestId = (deps.createRequestId ?? randomUUID)();
|
|
97
|
+
pendingCodexResetRequests.set(requestId, {
|
|
98
|
+
chatId,
|
|
99
|
+
parentMessageId,
|
|
100
|
+
status: "pending",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const token = await deps.getTenantAccessToken();
|
|
104
|
+
await deps.sendRawCard(token, chatId, buildCodexResetConfirmCard({
|
|
105
|
+
availableCount: Number.isFinite(availableCount) ? Math.max(1, Math.trunc(availableCount)) : 1,
|
|
106
|
+
parentMessageId,
|
|
107
|
+
requestId,
|
|
108
|
+
}));
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function handleResetConfirmation(
|
|
113
|
+
raw: Record<string, unknown>,
|
|
114
|
+
value: Record<string, unknown>,
|
|
115
|
+
deps: CodexResetActionDeps,
|
|
116
|
+
): Promise<boolean> {
|
|
117
|
+
const decision = value.decision;
|
|
118
|
+
const requestId = value.requestId;
|
|
119
|
+
const parentMessageId = value.parentMessageId;
|
|
120
|
+
if ((decision !== "yes" && decision !== "no") || typeof requestId !== "string" || typeof parentMessageId !== "string") {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const token = await deps.getTenantAccessToken();
|
|
125
|
+
const confirmMessageId = eventMessageId(raw);
|
|
126
|
+
if (confirmMessageId) {
|
|
127
|
+
await deps.updateCardMessage(token, confirmMessageId, collapsedCard());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const pending = pendingCodexResetRequests.get(requestId);
|
|
131
|
+
const chatId = pending?.chatId ?? eventChatId(raw);
|
|
132
|
+
if (!chatId) return true;
|
|
133
|
+
|
|
134
|
+
if (!pending || pending.status !== "pending" || pending.parentMessageId !== parentMessageId) {
|
|
135
|
+
await deps.sendCardReply(token, chatId, "Codex 主动重置", "这张确认卡片已经失效或已处理。", "yellow");
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
pending.status = "handled";
|
|
139
|
+
|
|
140
|
+
if (decision === "no") {
|
|
141
|
+
await deps.sendCardReply(token, chatId, "Codex 主动重置", "用户取消了重置。", "grey");
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const result = await deps.consumeCodexRateLimitResetCredit(requestId);
|
|
147
|
+
const message = resetResultMessage(result);
|
|
148
|
+
await deps.sendCardReply(token, chatId, "Codex 主动重置", message.content, message.template);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
await deps.sendCardReply(token, chatId, "Codex 主动重置", `重置失败:${(err as Error).message}`, "red");
|
|
151
|
+
}
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function handleCodexResetCardAction(data: unknown, deps: CodexResetActionDeps): Promise<boolean> {
|
|
156
|
+
const raw = rawEvent(data);
|
|
157
|
+
const value = actionValue(raw);
|
|
158
|
+
if (!value) return false;
|
|
159
|
+
const action = value?.action;
|
|
160
|
+
if (action === "codex_reset_request") {
|
|
161
|
+
return sendResetRequestConfirmation(raw, value, deps);
|
|
162
|
+
}
|
|
163
|
+
if (action === "codex_reset_confirm") {
|
|
164
|
+
return handleResetConfirmation(raw, value, deps);
|
|
165
|
+
}
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function _resetCodexResetRequestsForTest(): void {
|
|
170
|
+
pendingCodexResetRequests.clear();
|
|
171
|
+
}
|
package/src/feishu-api.ts
CHANGED
|
@@ -304,6 +304,8 @@ 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
305
|
const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
|
|
306
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"),
|
|
@@ -327,9 +329,23 @@ export interface CodexUsageBalance {
|
|
|
327
329
|
resetAfterSeconds: number | null;
|
|
328
330
|
}
|
|
329
331
|
|
|
332
|
+
export interface CodexRateLimitResetCredit {
|
|
333
|
+
grantedAt: string | null;
|
|
334
|
+
expiresAt: string;
|
|
335
|
+
}
|
|
336
|
+
|
|
330
337
|
export interface CodexUsageSummary {
|
|
331
338
|
fiveHour: CodexUsageBalance;
|
|
332
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;
|
|
333
349
|
}
|
|
334
350
|
|
|
335
351
|
const avatarKeyCache = new Map<string, string>();
|
|
@@ -421,35 +437,109 @@ function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: stri
|
|
|
421
437
|
return null;
|
|
422
438
|
}
|
|
423
439
|
|
|
424
|
-
|
|
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> {
|
|
425
454
|
try {
|
|
426
455
|
const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
|
|
427
|
-
const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown } };
|
|
456
|
+
const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown; account_id?: unknown } };
|
|
428
457
|
const token = parsed.tokens?.access_token;
|
|
429
|
-
|
|
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
|
+
};
|
|
430
464
|
} catch {
|
|
431
465
|
return null;
|
|
432
466
|
}
|
|
433
467
|
}
|
|
434
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
|
+
|
|
435
521
|
export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
|
|
436
|
-
const
|
|
437
|
-
if (!
|
|
522
|
+
const auth = await getCodexAuth();
|
|
523
|
+
if (!auth) throw new Error("missing ~/.codex/auth.json access token");
|
|
524
|
+
|
|
525
|
+
const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
|
|
438
526
|
|
|
439
527
|
const resp = await fetch(CODEX_USAGE_URL, {
|
|
440
|
-
headers: { Authorization: `Bearer ${
|
|
528
|
+
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
441
529
|
});
|
|
442
530
|
const text = await resp.text();
|
|
443
531
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
444
532
|
|
|
445
533
|
const data = JSON.parse(text) as {
|
|
446
534
|
rate_limit?: Record<string, unknown>;
|
|
535
|
+
rate_limit_reset_credits?: unknown;
|
|
447
536
|
};
|
|
448
537
|
const rateLimit = data.rate_limit;
|
|
449
538
|
if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
|
|
450
539
|
|
|
451
540
|
const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
|
|
452
541
|
if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
|
|
542
|
+
const resetCredits = await resetCreditsPromise;
|
|
453
543
|
|
|
454
544
|
return {
|
|
455
545
|
fiveHour,
|
|
@@ -459,6 +549,41 @@ export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
|
|
|
459
549
|
"week_window",
|
|
460
550
|
"long_window",
|
|
461
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,
|
|
462
587
|
};
|
|
463
588
|
}
|
|
464
589
|
|