chatccc 0.2.180 → 0.2.181
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -69
- package/package.json +1 -1
- package/src/__tests__/card-plain-text.test.ts +5 -5
- package/src/__tests__/cards.test.ts +68 -22
- 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 +104 -118
- package/src/__tests__/shared-prefix.test.ts +36 -36
- package/src/cards.ts +78 -12
- 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 +91 -54
- package/src/shared-prefix.ts +29 -29
- package/src/sim-platform.ts +20 -14
|
@@ -65,8 +65,8 @@ vi.mock("../cursor-usage.ts", () => ({
|
|
|
65
65
|
}));
|
|
66
66
|
|
|
67
67
|
import { handleCommand } from "../orchestrator.ts";
|
|
68
|
-
import {
|
|
69
|
-
_clearAdapterCacheForTest,
|
|
68
|
+
import {
|
|
69
|
+
_clearAdapterCacheForTest,
|
|
70
70
|
_resetSessionRegistryFileForTest,
|
|
71
71
|
_resetSessionToolsFileForTest,
|
|
72
72
|
_setAdapterForToolForTest,
|
|
@@ -74,10 +74,10 @@ import {
|
|
|
74
74
|
_setSessionToolsFileForTest,
|
|
75
75
|
loadSessionRegistryForBinding,
|
|
76
76
|
recordSessionRegistry,
|
|
77
|
-
resetState,
|
|
78
|
-
} from "../session.ts";
|
|
79
|
-
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
80
|
-
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
77
|
+
resetState,
|
|
78
|
+
} from "../session.ts";
|
|
79
|
+
import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
|
|
80
|
+
import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
|
|
81
81
|
|
|
82
82
|
function mockPlatform(kind: "wechat" | "feishu" = "wechat"): PlatformAdapter {
|
|
83
83
|
return {
|
|
@@ -132,6 +132,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
132
132
|
mockGetCodexUsageSummary.mockResolvedValue({
|
|
133
133
|
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
134
134
|
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
135
|
+
rateLimitResetCreditsAvailable: null,
|
|
136
|
+
rateLimitResetCredits: null,
|
|
135
137
|
});
|
|
136
138
|
mockGetCursorUsageSummary.mockResolvedValue({
|
|
137
139
|
billingCycleStart: "1779357999000",
|
|
@@ -198,8 +200,8 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
198
200
|
expect(platform.sendCard).not.toHaveBeenCalled();
|
|
199
201
|
});
|
|
200
202
|
|
|
201
|
-
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
202
|
-
const platform = mockPlatform();
|
|
203
|
+
it("sends the WeChat processing ack after the busy check for normal prompts", async () => {
|
|
204
|
+
const platform = mockPlatform();
|
|
203
205
|
await recordSessionRegistry({
|
|
204
206
|
chatId: "wx-chat",
|
|
205
207
|
sessionId: "sid-wechat",
|
|
@@ -209,44 +211,44 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
209
211
|
});
|
|
210
212
|
|
|
211
213
|
await handleCommand(platform, "继续说明", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
212
|
-
|
|
213
|
-
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("treats /abd as a shared prompt prefix in an existing session", async () => {
|
|
217
|
-
const platform = mockPlatform();
|
|
218
|
-
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
219
|
-
yield {
|
|
220
|
-
type: "assistant" as const,
|
|
221
|
-
blocks: [{ type: "text" as const, text: "done" }],
|
|
222
|
-
};
|
|
223
|
-
});
|
|
224
|
-
_setAdapterForToolForTest("claude", {
|
|
225
|
-
displayName: "Claude",
|
|
226
|
-
sessionDescPrefix: "Claude Session:",
|
|
227
|
-
createSession: vi.fn(async () => ({ sessionId: "sid-wechat" })),
|
|
228
|
-
prompt,
|
|
229
|
-
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
230
|
-
sessionId,
|
|
231
|
-
cwd: "F:\\repo",
|
|
232
|
-
}),
|
|
233
|
-
closeSession: async () => {},
|
|
234
|
-
});
|
|
235
|
-
await recordSessionRegistry({
|
|
236
|
-
chatId: "wx-chat",
|
|
237
|
-
sessionId: "sid-wechat",
|
|
238
|
-
tool: "claude",
|
|
239
|
-
chatName: "ready-session",
|
|
240
|
-
running: false,
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
await handleCommand(platform, "/abd帮我分析", "wx-chat", "wx-user", Date.now(), "p2p");
|
|
244
|
-
|
|
245
|
-
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
246
|
-
const userText = prompt.mock.calls[0][1];
|
|
247
|
-
expect(userText).toContain(`[User message]\n帮我分析\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
|
|
248
|
-
expect(userText).not.toContain("/abd");
|
|
249
|
-
});
|
|
214
|
+
|
|
215
|
+
expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
|
|
216
|
+
});
|
|
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
|
+
});
|
|
250
252
|
|
|
251
253
|
it("does not send the stopped success text until the running prompt really exits", async () => {
|
|
252
254
|
const platform = mockPlatform();
|
|
@@ -268,7 +270,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
268
270
|
expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", "会话已停止。");
|
|
269
271
|
});
|
|
270
272
|
|
|
271
|
-
it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
|
|
273
|
+
it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
|
|
272
274
|
const platform = mockPlatform("feishu");
|
|
273
275
|
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
274
276
|
yield {
|
|
@@ -313,40 +315,40 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
313
315
|
|
|
314
316
|
const registry = await loadSessionRegistryForBinding();
|
|
315
317
|
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
316
|
-
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("auto-creates a Feishu group for /abd private messages and sends the transformed prompt", async () => {
|
|
320
|
-
const platform = mockPlatform("feishu");
|
|
321
|
-
const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
|
|
322
|
-
yield {
|
|
323
|
-
type: "assistant" as const,
|
|
324
|
-
blocks: [{ type: "text" as const, text: "done" }],
|
|
325
|
-
};
|
|
326
|
-
});
|
|
327
|
-
_setAdapterForToolForTest("claude", {
|
|
328
|
-
displayName: "Claude",
|
|
329
|
-
sessionDescPrefix: "Claude Session:",
|
|
330
|
-
createSession: vi.fn(async () => ({ sessionId: "sid-feishu-abd" })),
|
|
331
|
-
prompt,
|
|
332
|
-
getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
|
|
333
|
-
sessionId,
|
|
334
|
-
cwd: "F:\\repo",
|
|
335
|
-
}),
|
|
336
|
-
closeSession: async () => {},
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
340
|
-
|
|
341
|
-
expect(platform.createGroup).toHaveBeenCalledWith(expect.stringContaining("帮我看一下日志"), ["ou-user"]);
|
|
342
|
-
expect(platform.createGroup).not.toHaveBeenCalledWith(expect.stringContaining("---"), expect.anything());
|
|
343
|
-
const updateCall = vi.mocked(platform.updateChatInfo).mock.calls[0];
|
|
344
|
-
expect(updateCall[1]).not.toContain("---");
|
|
345
|
-
expect(updateCall[1]).not.toContain(ABD_APPEND_PROMPT);
|
|
346
|
-
const userText = prompt.mock.calls[0][1];
|
|
347
|
-
expect(userText).toContain(`[User message]\n帮我看一下日志\n\n---\n${ABD_APPEND_PROMPT}\n[/User message]`);
|
|
348
|
-
expect(userText).not.toContain("/abd");
|
|
349
|
-
});
|
|
318
|
+
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
319
|
+
});
|
|
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
|
+
});
|
|
350
352
|
|
|
351
353
|
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
352
354
|
const platform = mockPlatform("feishu");
|
|
@@ -371,47 +373,31 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
371
373
|
mockGetCodexUsageSummary.mockResolvedValue({
|
|
372
374
|
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
373
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
|
+
],
|
|
374
381
|
});
|
|
375
382
|
|
|
376
383
|
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
377
384
|
|
|
378
385
|
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
379
|
-
expect(platform.sendCard).
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
);
|
|
385
|
-
expect(
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
);
|
|
391
|
-
expect(
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
395
|
-
"blue",
|
|
396
|
-
);
|
|
397
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
398
|
-
"feishu-p2p",
|
|
399
|
-
"Codex Usage",
|
|
400
|
-
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
401
|
-
"blue",
|
|
402
|
-
);
|
|
403
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
404
|
-
"feishu-p2p",
|
|
405
|
-
"Codex Usage",
|
|
406
|
-
expect.stringContaining("约 3天18小时17分钟后"),
|
|
407
|
-
"blue",
|
|
408
|
-
);
|
|
409
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
410
|
-
"feishu-p2p",
|
|
411
|
-
"Codex Usage",
|
|
412
|
-
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
413
|
-
"blue",
|
|
414
|
-
);
|
|
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 });
|
|
415
401
|
});
|
|
416
402
|
|
|
417
403
|
it("handles /usage as Cursor usage in Cursor chats", async () => {
|
|
@@ -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
|
|
|
@@ -1,36 +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
|
-
});
|
|
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
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Button helpers
|
|
3
|
-
// ---------------------------------------------------------------------------
|
|
4
|
-
|
|
5
|
-
import { ABD_HELP_LINE } from "./shared-prefix.ts";
|
|
6
|
-
|
|
7
|
-
export interface ButtonDef {
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Button helpers
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
import { ABD_HELP_LINE } from "./shared-prefix.ts";
|
|
6
|
+
|
|
7
|
+
export interface ButtonDef {
|
|
8
8
|
text: string;
|
|
9
9
|
value: string;
|
|
10
10
|
type?: "primary" | "default" | "danger";
|
|
@@ -137,11 +137,11 @@ export function buildHelpCard(
|
|
|
137
137
|
"发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
|
|
138
138
|
"发送 **/plan** 以规划模式提问(只读,不执行写操作)",
|
|
139
139
|
"发送 **/ask** 以问答模式提问(只读,不执行写操作)",
|
|
140
|
-
"发送 **/usage** 查看 Codex 5h
|
|
141
|
-
"发送 **/restart** 重启 ChatCCC 进程",
|
|
142
|
-
"发送 **/update** 更新并重启(仅 npm 全局安装可用)",
|
|
143
|
-
ABD_HELP_LINE,
|
|
144
|
-
].join("\n");
|
|
140
|
+
"发送 **/usage** 查看 Codex 5h/周用量,以及查询/使用主动重置卡",
|
|
141
|
+
"发送 **/restart** 重启 ChatCCC 进程",
|
|
142
|
+
"发送 **/update** 更新并重启(仅 npm 全局安装可用)",
|
|
143
|
+
ABD_HELP_LINE,
|
|
144
|
+
].join("\n");
|
|
145
145
|
return JSON.stringify({
|
|
146
146
|
config: { wide_screen_mode: true },
|
|
147
147
|
header: { template: "blue", title: { content: "ChatCCC", tag: "plain_text" } },
|
|
@@ -430,6 +430,72 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
|
|
|
430
430
|
});
|
|
431
431
|
}
|
|
432
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
|
+
|
|
433
499
|
/** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
|
|
434
500
|
export function buildModelCard(
|
|
435
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
|
+
}
|