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