chatccc 0.2.252 → 0.2.254
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 +10 -1
- package/bin/cccagent.mjs +12 -3
- package/bin/chatccc.mjs +15 -6
- package/config.sample.json +2 -1
- package/deepccc-agent/README.md +40 -35
- package/deepccc-agent/os-prompts/darwin.md +8 -8
- package/deepccc-agent/os-prompts/linux.md +8 -8
- package/deepccc-agent/os-prompts/win32.md +11 -11
- package/deepccc-agent/package.json +65 -65
- package/dist/deepccc-agent/src/cli.js +634 -0
- package/dist/deepccc-agent/src/config.js +81 -0
- package/dist/deepccc-agent/src/context.js +348 -0
- package/dist/deepccc-agent/src/file-log.js +34 -0
- package/dist/deepccc-agent/src/file-tools.js +1208 -0
- package/dist/deepccc-agent/src/index.js +601 -0
- package/dist/deepccc-agent/src/permissions.js +170 -0
- package/dist/deepccc-agent/src/privacy.js +124 -0
- package/dist/deepccc-agent/src/proc-tree-kill.js +60 -0
- package/dist/deepccc-agent/src/progress/cards-helpers.js +70 -0
- package/dist/deepccc-agent/src/progress/reducer.js +102 -0
- package/dist/deepccc-agent/src/progress/terminal-renderer.js +264 -0
- package/dist/deepccc-agent/src/progress/view.js +30 -0
- package/dist/deepccc-agent/src/raw-stream-log.js +106 -0
- package/dist/deepccc-agent/src/session-search.js +276 -0
- package/dist/deepccc-agent/src/session-select.js +23 -0
- package/dist/deepccc-agent/src/sigint.js +26 -0
- package/dist/deepccc-agent/src/skills.js +178 -0
- package/dist/deepccc-agent/src/web-tools.js +246 -0
- package/dist/src/adapters/adapter-interface.js +19 -0
- package/dist/src/adapters/ccc-adapter.js +112 -0
- package/dist/src/adapters/claude-adapter.js +497 -0
- package/dist/src/adapters/claude-session-meta-store.js +92 -0
- package/dist/src/adapters/codex-adapter.js +279 -0
- package/dist/src/adapters/codex-session-meta-store.js +94 -0
- package/dist/src/adapters/cursor-adapter.js +491 -0
- package/dist/src/adapters/cursor-session-meta-store.js +116 -0
- package/dist/src/adapters/jsonl-stream.js +104 -0
- package/{src/adapters/proc-tree-kill.ts → dist/src/adapters/proc-tree-kill.js} +94 -97
- package/dist/src/adapters/raw-stream-log.js +106 -0
- package/dist/src/adapters/resource-monitor.js +113 -0
- package/dist/src/agent-activity.js +133 -0
- package/dist/src/agent-delegate-task-rpc.js +129 -0
- package/dist/src/agent-delegate-task.js +48 -0
- package/dist/src/agent-file-rpc.js +152 -0
- package/dist/src/agent-image-rpc.js +148 -0
- package/dist/src/agent-platform-routing.js +13 -0
- package/dist/src/agent-reload-config-rpc.js +23 -0
- package/dist/src/agent-rpc-body.js +87 -0
- package/dist/src/agent-stop-stuck.js +110 -0
- package/dist/src/agent-team/application/board-service.js +142 -0
- package/dist/src/agent-team/application/main-agent-service.js +151 -0
- package/dist/src/agent-team/domain/board.js +69 -0
- package/dist/src/agent-team/http/board-routes.js +213 -0
- package/dist/src/agent-team/infrastructure/filesystem-browser.js +112 -0
- package/dist/src/agent-team/infrastructure/main-agent-session-runtime.js +30 -0
- package/dist/src/agent-team/main-agent-bootstrap.js +15 -0
- package/dist/src/agent-team/repositories/board-repository.js +10 -0
- package/dist/src/agent-team/repositories/feishu-p2p-contact-store.js +64 -0
- package/dist/src/agent-team/repositories/json-board-repository.js +209 -0
- package/dist/src/agent-team/repositories/main-agent-binding-repository.js +84 -0
- package/dist/src/agent-team/sync/board-sync-adapter.js +1 -0
- package/dist/src/agent-team/web/agent-team-page.js +219 -0
- package/dist/src/agent-tool.js +10 -0
- package/dist/src/card-action-routing.js +7 -0
- package/dist/src/card-plain-text.js +101 -0
- package/dist/src/cardkit.js +158 -0
- package/dist/src/cards.js +573 -0
- package/dist/src/chatgpt-subscription-rpc.js +18 -0
- package/dist/src/chatgpt-subscription.js +199 -0
- package/dist/src/chrome-devtools-guard.js +238 -0
- package/dist/src/claude-sdk-installer.js +249 -0
- package/dist/src/codex-reset-actions.js +143 -0
- package/dist/src/config-utils.js +149 -0
- package/dist/src/config.js +808 -0
- package/dist/src/cursor-usage.js +77 -0
- package/dist/src/exit-banner.js +28 -0
- package/dist/src/feishu-api.js +1404 -0
- package/dist/src/feishu-message-ingress.js +137 -0
- package/dist/src/feishu-platform.js +97 -0
- package/dist/src/format-message.js +252 -0
- package/dist/src/git-command.js +155 -0
- package/dist/src/im-skills.js +121 -0
- package/dist/src/index.js +835 -0
- package/dist/src/litellm-proxy.js +300 -0
- package/dist/src/orchestrator.js +2096 -0
- package/dist/src/package-root.js +26 -0
- package/dist/src/platform-adapter.js +7 -0
- package/dist/src/platform-startup.js +6 -0
- package/dist/src/privacy.js +100 -0
- package/dist/src/progress/reducer.js +102 -0
- package/dist/src/progress/terminal-renderer.js +264 -0
- package/dist/src/progress/view.js +30 -0
- package/dist/src/response-stall.js +14 -0
- package/dist/src/runtime-entry.js +13 -0
- package/dist/src/runtime-reload.js +19 -0
- package/dist/src/session-chat-binding.js +183 -0
- package/dist/src/session-name.js +7 -0
- package/dist/src/session.js +2147 -0
- package/dist/src/shared-prefix.js +16 -0
- package/dist/src/shared.js +493 -0
- package/dist/src/sim-agent.js +105 -0
- package/dist/src/sim-platform.js +142 -0
- package/dist/src/sim-store.js +231 -0
- package/dist/src/simplify.js +99 -0
- package/dist/src/startup-lifecycle.js +209 -0
- package/dist/src/stream-state.js +141 -0
- package/dist/src/terminal-error.js +100 -0
- package/dist/src/trace.js +50 -0
- package/dist/src/turn-cards.js +92 -0
- package/dist/src/update-command-guard.js +114 -0
- package/{src/web-ui.ts → dist/src/web-ui.js} +753 -839
- package/dist/src/wechat-platform.js +545 -0
- package/package.json +76 -74
- package/deepccc-agent/LICENSE +0 -201
- package/deepccc-agent/bin/deepccc.mjs +0 -26
- package/deepccc-agent/docs/cache-hit-rate-1.jpg +0 -0
- package/deepccc-agent/docs/cache-hit-rate-2.jpg +0 -0
- package/deepccc-agent/package-lock.json +0 -2027
- package/deepccc-agent/src/__tests__/chat-session.test.ts +0 -877
- package/deepccc-agent/src/__tests__/cli-json.test.ts +0 -49
- package/deepccc-agent/src/__tests__/config.test.ts +0 -34
- package/deepccc-agent/src/__tests__/context.test.ts +0 -341
- package/deepccc-agent/src/__tests__/file-tools.test.ts +0 -240
- package/deepccc-agent/src/__tests__/permissions.test.ts +0 -199
- package/deepccc-agent/src/__tests__/privacy.test.ts +0 -318
- package/deepccc-agent/src/__tests__/progress-reducer.test.ts +0 -121
- package/deepccc-agent/src/__tests__/session-search.test.ts +0 -262
- package/deepccc-agent/src/__tests__/session-select.test.ts +0 -116
- package/deepccc-agent/src/__tests__/sigint.test.ts +0 -56
- package/deepccc-agent/src/__tests__/skills.test.ts +0 -284
- package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +0 -247
- package/deepccc-agent/src/__tests__/web-tools.test.ts +0 -220
- package/deepccc-agent/src/cli.ts +0 -682
- package/deepccc-agent/src/config.ts +0 -101
- package/deepccc-agent/src/context.ts +0 -465
- package/deepccc-agent/src/file-log.ts +0 -38
- package/deepccc-agent/src/file-tools.ts +0 -1493
- package/deepccc-agent/src/index.ts +0 -710
- package/deepccc-agent/src/permissions.ts +0 -226
- package/deepccc-agent/src/privacy.ts +0 -141
- package/deepccc-agent/src/proc-tree-kill.ts +0 -61
- package/deepccc-agent/src/progress/cards-helpers.ts +0 -76
- package/deepccc-agent/src/progress/reducer.ts +0 -113
- package/deepccc-agent/src/progress/terminal-renderer.ts +0 -294
- package/deepccc-agent/src/progress/view.ts +0 -77
- package/deepccc-agent/src/raw-stream-log.ts +0 -124
- package/deepccc-agent/src/session-search.ts +0 -370
- package/deepccc-agent/src/session-select.ts +0 -48
- package/deepccc-agent/src/sigint.ts +0 -50
- package/deepccc-agent/src/skills.ts +0 -205
- package/deepccc-agent/src/web-tools.ts +0 -313
- package/deepccc-agent/tsconfig.build.json +0 -13
- package/deepccc-agent/tsconfig.json +0 -13
- package/deepccc-agent/vitest.config.ts +0 -7
- package/src/__tests__/adapter-interface.test.ts +0 -152
- package/src/__tests__/agent-activity.test.ts +0 -86
- package/src/__tests__/agent-delegate-task-rpc.test.ts +0 -165
- package/src/__tests__/agent-image-rpc.test.ts +0 -34
- package/src/__tests__/agent-platform-routing.test.ts +0 -26
- package/src/__tests__/agent-reload-config-rpc.test.ts +0 -99
- package/src/__tests__/agent-rpc-body.test.ts +0 -42
- package/src/__tests__/builtin-chat-session.test.ts +0 -532
- package/src/__tests__/builtin-cli-json.test.ts +0 -39
- package/src/__tests__/builtin-config.test.ts +0 -26
- package/src/__tests__/builtin-context.test.ts +0 -319
- package/src/__tests__/builtin-file-tools.test.ts +0 -240
- package/src/__tests__/builtin-permissions.test.ts +0 -219
- package/src/__tests__/builtin-session-search.test.ts +0 -262
- package/src/__tests__/builtin-session-select.test.ts +0 -116
- package/src/__tests__/builtin-sigint.test.ts +0 -56
- package/src/__tests__/builtin-skills.test.ts +0 -284
- package/src/__tests__/builtin-web-tools.test.ts +0 -220
- package/src/__tests__/card-action-routing.test.ts +0 -18
- package/src/__tests__/card-plain-text.test.ts +0 -45
- package/src/__tests__/cardkit.test.ts +0 -60
- package/src/__tests__/cards.test.ts +0 -607
- package/src/__tests__/ccc-adapter.test.ts +0 -194
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +0 -89
- package/src/__tests__/chatgpt-subscription.test.ts +0 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +0 -165
- package/src/__tests__/claude-adapter.test.ts +0 -614
- package/src/__tests__/claude-raw-stream-log.test.ts +0 -96
- package/src/__tests__/claude-sdk-installer.test.ts +0 -285
- package/src/__tests__/codex-adapter.test.ts +0 -331
- package/src/__tests__/codex-raw-stream-log.test.ts +0 -170
- package/src/__tests__/codex-reset-actions.test.ts +0 -146
- package/src/__tests__/config-reload.test.ts +0 -284
- package/src/__tests__/config-sample.test.ts +0 -97
- package/src/__tests__/config-utils.test.ts +0 -40
- package/src/__tests__/config.test.ts +0 -395
- package/src/__tests__/crash-logging.test.ts +0 -360
- package/src/__tests__/cursor-adapter.test.ts +0 -890
- package/src/__tests__/cursor-session-meta-store.test.ts +0 -212
- package/src/__tests__/feishu-api.test.ts +0 -60
- package/src/__tests__/feishu-avatar.test.ts +0 -504
- package/src/__tests__/feishu-message-ingress.test.ts +0 -138
- package/src/__tests__/feishu-platform.test.ts +0 -75
- package/src/__tests__/fixtures/codex_simple_text.jsonl +0 -4
- package/src/__tests__/fixtures/codex_with_tool.jsonl +0 -6
- package/src/__tests__/fixtures/cursor_partial_only.jsonl +0 -5
- package/src/__tests__/fixtures/cursor_partial_with_final.jsonl +0 -13
- package/src/__tests__/fixtures/cursor_with_tool_call.jsonl +0 -12
- package/src/__tests__/format-message.test.ts +0 -316
- package/src/__tests__/git-command.test.ts +0 -288
- package/src/__tests__/im-skills.test.ts +0 -125
- package/src/__tests__/jsonl-stream.test.ts +0 -79
- package/src/__tests__/orchestrator.test.ts +0 -1268
- package/src/__tests__/package-files.test.ts +0 -24
- package/src/__tests__/platform-startup.test.ts +0 -19
- package/src/__tests__/privacy.test.ts +0 -198
- package/src/__tests__/proc-tree-kill.test.ts +0 -108
- package/src/__tests__/progress-reducer.test.ts +0 -121
- package/src/__tests__/raw-stream-log.test.ts +0 -106
- package/src/__tests__/response-stall.test.ts +0 -49
- package/src/__tests__/restart.test.ts +0 -232
- package/src/__tests__/session-ccc-config.test.ts +0 -66
- package/src/__tests__/session.test.ts +0 -3004
- package/src/__tests__/shared-prefix.test.ts +0 -36
- package/src/__tests__/sim-agent.test.ts +0 -174
- package/src/__tests__/sim-platform.test.ts +0 -93
- package/src/__tests__/sim-store.test.ts +0 -214
- package/src/__tests__/simplify.test.ts +0 -283
- package/src/__tests__/startup-lifecycle.test.ts +0 -231
- package/src/__tests__/stop-session.test.ts +0 -162
- package/src/__tests__/stream-state.test.ts +0 -164
- package/src/__tests__/terminal-error.test.ts +0 -54
- package/src/__tests__/terminal-renderer.test.ts +0 -247
- package/src/__tests__/update-command-guard.test.ts +0 -144
- package/src/__tests__/web-ui.test.ts +0 -438
- package/src/__tests__/wechat-platform.test.ts +0 -111
- package/src/adapters/adapter-interface.ts +0 -217
- package/src/adapters/ccc-adapter.ts +0 -150
- package/src/adapters/claude-adapter.ts +0 -673
- package/src/adapters/claude-session-meta-store.ts +0 -120
- package/src/adapters/codex-adapter.ts +0 -426
- package/src/adapters/codex-session-meta-store.ts +0 -131
- package/src/adapters/cursor-adapter.ts +0 -681
- package/src/adapters/cursor-session-meta-store.ts +0 -154
- package/src/adapters/jsonl-stream.ts +0 -157
- package/src/adapters/raw-stream-log.ts +0 -124
- package/src/adapters/resource-monitor.ts +0 -141
- package/src/agent-activity.ts +0 -175
- package/src/agent-delegate-task-rpc.ts +0 -153
- package/src/agent-delegate-task.ts +0 -91
- package/src/agent-file-rpc.ts +0 -172
- package/src/agent-image-rpc.ts +0 -168
- package/src/agent-platform-routing.ts +0 -28
- package/src/agent-reload-config-rpc.ts +0 -34
- package/src/agent-rpc-body.ts +0 -92
- package/src/agent-stop-stuck.ts +0 -129
- package/src/card-action-routing.ts +0 -14
- package/src/card-plain-text.ts +0 -108
- package/src/cardkit.ts +0 -179
- package/src/cards.ts +0 -684
- package/src/chatgpt-subscription-rpc.ts +0 -27
- package/src/chatgpt-subscription.ts +0 -299
- package/src/chrome-devtools-guard.ts +0 -318
- package/src/claude-sdk-installer.ts +0 -324
- package/src/codex-reset-actions.ts +0 -184
- package/src/config-utils.ts +0 -211
- package/src/config.ts +0 -1063
- package/src/cursor-usage.ts +0 -128
- package/src/exit-banner.ts +0 -33
- package/src/feishu-api.ts +0 -1616
- package/src/feishu-message-ingress.ts +0 -195
- package/src/feishu-platform.ts +0 -159
- package/src/format-message.ts +0 -293
- package/src/git-command.ts +0 -202
- package/src/im-skills.ts +0 -149
- package/src/index.ts +0 -1089
- package/src/litellm-proxy.ts +0 -374
- package/src/orchestrator.ts +0 -2543
- package/src/platform-adapter.ts +0 -70
- package/src/platform-startup.ts +0 -16
- package/src/privacy.ts +0 -118
- package/src/progress/reducer.ts +0 -113
- package/src/progress/terminal-renderer.ts +0 -294
- package/src/progress/view.ts +0 -77
- package/src/response-stall.ts +0 -28
- package/src/runtime-reload.ts +0 -34
- package/src/session-chat-binding.ts +0 -292
- package/src/session-name.ts +0 -8
- package/src/session.ts +0 -2659
- package/src/shared-prefix.ts +0 -29
- package/src/shared.ts +0 -552
- package/src/sim-agent.ts +0 -167
- package/src/sim-platform.ts +0 -177
- package/src/sim-store.ts +0 -317
- package/src/simplify.ts +0 -120
- package/src/startup-lifecycle.ts +0 -250
- package/src/stream-state.ts +0 -177
- package/src/terminal-error.ts +0 -129
- package/src/trace.ts +0 -51
- package/src/turn-cards.ts +0 -118
- package/src/update-command-guard.ts +0 -165
- package/src/wechat-platform.ts +0 -680
package/src/feishu-api.ts
DELETED
|
@@ -1,1616 +0,0 @@
|
|
|
1
|
-
import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { extname, resolve as resolvePath } from "node:path";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
|
-
import sharp from "sharp";
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
APP_ID,
|
|
9
|
-
APP_SECRET,
|
|
10
|
-
BASE_URL,
|
|
11
|
-
CHAT_LOGS_DIR,
|
|
12
|
-
PROJECT_ROOT,
|
|
13
|
-
USER_DATA_DIR,
|
|
14
|
-
CLAUDE_SESSION_PREFIX,
|
|
15
|
-
CURSOR_SESSION_PREFIX,
|
|
16
|
-
CODEX_SESSION_PREFIX,
|
|
17
|
-
CCC_SESSION_PREFIX,
|
|
18
|
-
ts,
|
|
19
|
-
resolveDefaultAgentTool,
|
|
20
|
-
toolDisplayName,
|
|
21
|
-
config,
|
|
22
|
-
} from "./config.ts";
|
|
23
|
-
import { getCursorUsageSummary, type CursorUsageSummary } from "./cursor-usage.ts";
|
|
24
|
-
import type { ChatAvatarUsageHints } from "./platform-adapter.ts";
|
|
25
|
-
import { applyPrivacy } from "./privacy.ts";
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// 合并转发消息类型
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
export interface FeishuMessageItem {
|
|
32
|
-
message_id?: string;
|
|
33
|
-
msg_type?: string;
|
|
34
|
-
body?: { content?: string };
|
|
35
|
-
sender?: { id?: string; id_type?: string };
|
|
36
|
-
create_time?: string;
|
|
37
|
-
upper_message_id?: string;
|
|
38
|
-
}
|
|
39
|
-
import { buildHelpCard } from "./cards.ts";
|
|
40
|
-
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
// Auth
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
|
|
45
|
-
// 不缓存 token:飞书会在某些情况下(多实例同 APP_ID 反复签发、控制台重置 secret、限流回收等)
|
|
46
|
-
// 让旧 token 在标称有效期内被服务端提前失效。一旦缓存命中失效 token,进程在 TTL 内
|
|
47
|
-
// 所有飞书调用会持续返回 99991663。每次现签可让"被服务端干掉"的 token 自然被新 token 覆盖。
|
|
48
|
-
export async function getTenantAccessToken(): Promise<string> {
|
|
49
|
-
const resp = await fetch(`${BASE_URL}/auth/v3/tenant_access_token/internal`, {
|
|
50
|
-
method: "POST",
|
|
51
|
-
headers: { "Content-Type": "application/json" },
|
|
52
|
-
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
53
|
-
});
|
|
54
|
-
const data = (await resp.json()) as { code: number; msg?: string; tenant_access_token: string };
|
|
55
|
-
if (data.code !== 0) {
|
|
56
|
-
throw new Error(`飞书返回 code=${data.code},msg=${data.msg ?? "(无)"}(请核对 App ID / Secret 与应用发布状态)`);
|
|
57
|
-
}
|
|
58
|
-
return data.tenant_access_token;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// Permission verification (startup check)
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
interface PermissionDef {
|
|
66
|
-
scope: string;
|
|
67
|
-
description: string;
|
|
68
|
-
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
69
|
-
path: string;
|
|
70
|
-
body?: string; // JSON string, supports __TOKEN__ placeholder
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** 项目所需的所有飞书权限及对应的非破坏性测试请求 */
|
|
74
|
-
const REQUIRED_PERMISSIONS: PermissionDef[] = [
|
|
75
|
-
{
|
|
76
|
-
scope: "im:chat",
|
|
77
|
-
description: "读取/创建/更新群聊(创建会话群、改名、读群信息)",
|
|
78
|
-
method: "GET",
|
|
79
|
-
path: "/im/v1/chats?page_size=1",
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
scope: "im:message:send_as_bot",
|
|
83
|
-
description: "以机器人身份发送消息(文本/卡片回复)",
|
|
84
|
-
method: "POST",
|
|
85
|
-
path: "/im/v1/messages?receive_id_type=chat_id",
|
|
86
|
-
body: JSON.stringify({
|
|
87
|
-
receive_id: "oc_000000000000000000000000",
|
|
88
|
-
msg_type: "text",
|
|
89
|
-
content: JSON.stringify({ text: "permcheck" }),
|
|
90
|
-
}),
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
scope: "im:message:reaction",
|
|
94
|
-
description: "添加消息表情回应(Give a like)",
|
|
95
|
-
method: "POST",
|
|
96
|
-
path: "/im/v1/messages/om_000000000000000000000000/reactions",
|
|
97
|
-
body: JSON.stringify({ reaction_type: { emoji_type: "Get" } }),
|
|
98
|
-
},
|
|
99
|
-
{
|
|
100
|
-
scope: "im:message",
|
|
101
|
-
description: "撤回/更新消息(关闭卡片、撤回卡片消息)",
|
|
102
|
-
method: "PATCH",
|
|
103
|
-
path: "/im/v1/messages/om_000000000000000000000000",
|
|
104
|
-
body: JSON.stringify({ content: JSON.stringify({ elements: [{ tag: "markdown", content: " " }] }) }),
|
|
105
|
-
},
|
|
106
|
-
{
|
|
107
|
-
scope: "cardkit:card",
|
|
108
|
-
description: "创建/更新群卡片(流式进度卡片、状态卡片等)",
|
|
109
|
-
method: "POST",
|
|
110
|
-
path: "/cardkit/v1/cards",
|
|
111
|
-
body: JSON.stringify({
|
|
112
|
-
schema: "2.0",
|
|
113
|
-
config: { update_multi: true },
|
|
114
|
-
header: { template: "blue", title: { tag: "plain_text", content: "permcheck" } },
|
|
115
|
-
body: { direction: "vertical", elements: [{ tag: "markdown", content: " " }] },
|
|
116
|
-
}),
|
|
117
|
-
},
|
|
118
|
-
{
|
|
119
|
-
scope: "im:message:read",
|
|
120
|
-
description: "读取合并转发消息中的子消息列表",
|
|
121
|
-
method: "GET",
|
|
122
|
-
path: "/im/v1/messages/om_000000000000000000000000000000",
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
interface PermissionResult {
|
|
127
|
-
scope: string;
|
|
128
|
-
description: string;
|
|
129
|
-
ok: boolean;
|
|
130
|
-
detail: string;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** 对单个权限做非破坏性探针请求。返回 false 表示权限缺失。 */
|
|
134
|
-
async function probePermission(token: string, def: PermissionDef): Promise<PermissionResult> {
|
|
135
|
-
const url = `${BASE_URL}${def.path}`;
|
|
136
|
-
try {
|
|
137
|
-
const resp = await fetch(url, {
|
|
138
|
-
method: def.method,
|
|
139
|
-
headers: {
|
|
140
|
-
Authorization: `Bearer ${token}`,
|
|
141
|
-
"Content-Type": "application/json",
|
|
142
|
-
},
|
|
143
|
-
body: def.body ?? undefined,
|
|
144
|
-
});
|
|
145
|
-
const data = (await resp.json()) as { code: number; msg?: string };
|
|
146
|
-
if (data.code === 0) {
|
|
147
|
-
return { scope: def.scope, description: def.description, ok: true, detail: "通过" };
|
|
148
|
-
}
|
|
149
|
-
const msg = (data.msg ?? "").toLowerCase();
|
|
150
|
-
// 飞书权限缺失时 msg 通常含 "permission" / "scope" / "denied" / "unauthorized"
|
|
151
|
-
// 权限 OK 但资源不存在时 msg 含 "not found" / "chat" / "message" / "不存在"
|
|
152
|
-
const deniedKeywords = ["permission", "scope", "denied", "unauthorized", "无权限", "权限", "access denied"];
|
|
153
|
-
const isDenied = deniedKeywords.some((kw) => msg.includes(kw));
|
|
154
|
-
if (isDenied) {
|
|
155
|
-
return { scope: def.scope, description: def.description, ok: false, detail: `权限缺失 → code=${data.code} msg=${data.msg}` };
|
|
156
|
-
}
|
|
157
|
-
// 资源不存在 = 通过了权限检查,只是目标资源不存在(预期内)
|
|
158
|
-
return { scope: def.scope, description: def.description, ok: true, detail: `通过(资源不存在: code=${data.code} msg=${data.msg})` };
|
|
159
|
-
} catch (err) {
|
|
160
|
-
return { scope: def.scope, description: def.description, ok: false, detail: `网络异常: ${(err as Error).message}` };
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* 验证所有必需权限。返回失败的权限列表。
|
|
166
|
-
* 若全部通过返回空数组。
|
|
167
|
-
*/
|
|
168
|
-
export async function verifyAllPermissions(token: string): Promise<PermissionResult[]> {
|
|
169
|
-
const results: PermissionResult[] = [];
|
|
170
|
-
for (const def of REQUIRED_PERMISSIONS) {
|
|
171
|
-
const r = await probePermission(token, def);
|
|
172
|
-
results.push(r);
|
|
173
|
-
}
|
|
174
|
-
return results;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** 输出权限验证结果到控制台和文件日志,并返回是否有失败 */
|
|
178
|
-
export function reportPermissionResults(
|
|
179
|
-
results: PermissionResult[],
|
|
180
|
-
log: (msg: string) => void
|
|
181
|
-
): boolean {
|
|
182
|
-
const failed = results.filter((r) => !r.ok);
|
|
183
|
-
const passed = results.filter((r) => r.ok);
|
|
184
|
-
|
|
185
|
-
log(`\n${"-".repeat(60)}`);
|
|
186
|
-
log(`[权限验证] 飞书应用权限检查完成: ${passed.length}/${results.length} 通过`);
|
|
187
|
-
for (const r of passed) {
|
|
188
|
-
log(` [通过] ${r.scope} — ${r.description}`);
|
|
189
|
-
}
|
|
190
|
-
for (const r of failed) {
|
|
191
|
-
log(` [失败] ${r.scope} — ${r.description}`);
|
|
192
|
-
log(` 原因: ${r.detail}`);
|
|
193
|
-
}
|
|
194
|
-
if (failed.length > 0) {
|
|
195
|
-
log(`\n[权限验证] 以下权限未在飞书开放平台中配置:`);
|
|
196
|
-
for (const r of failed) {
|
|
197
|
-
log(` - ${r.scope}: ${r.description}`);
|
|
198
|
-
}
|
|
199
|
-
log(`\n请到飞书开放平台 → 应用详情 → 权限管理 中开通上述权限后重试。`);
|
|
200
|
-
log(`${"-".repeat(60)}\n`);
|
|
201
|
-
} else {
|
|
202
|
-
log(`${"-".repeat(60)}\n`);
|
|
203
|
-
}
|
|
204
|
-
return failed.length > 0;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// ---------------------------------------------------------------------------
|
|
208
|
-
// Group chat CRUD
|
|
209
|
-
// ---------------------------------------------------------------------------
|
|
210
|
-
|
|
211
|
-
export async function createGroupChat(
|
|
212
|
-
token: string,
|
|
213
|
-
name: string,
|
|
214
|
-
userIds: string[]
|
|
215
|
-
): Promise<string> {
|
|
216
|
-
const resp = await fetch(`${BASE_URL}/im/v1/chats`, {
|
|
217
|
-
method: "POST",
|
|
218
|
-
headers: {
|
|
219
|
-
Authorization: `Bearer ${token}`,
|
|
220
|
-
"Content-Type": "application/json",
|
|
221
|
-
},
|
|
222
|
-
body: JSON.stringify({ name, description: "Creating...", user_id_list: userIds }),
|
|
223
|
-
});
|
|
224
|
-
const data = (await resp.json()) as {
|
|
225
|
-
code: number; msg?: string; data?: { chat_id?: string };
|
|
226
|
-
};
|
|
227
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
228
|
-
return data.data!.chat_id!;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function updateChatInfo(
|
|
232
|
-
token: string,
|
|
233
|
-
chatId: string,
|
|
234
|
-
name: string,
|
|
235
|
-
description: string
|
|
236
|
-
): Promise<void> {
|
|
237
|
-
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
238
|
-
method: "PUT",
|
|
239
|
-
headers: {
|
|
240
|
-
Authorization: `Bearer ${token}`,
|
|
241
|
-
"Content-Type": "application/json",
|
|
242
|
-
},
|
|
243
|
-
body: JSON.stringify({ name, description }),
|
|
244
|
-
});
|
|
245
|
-
const data = (await resp.json()) as { code: number; msg?: string };
|
|
246
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
export async function getChatInfo(
|
|
250
|
-
token: string,
|
|
251
|
-
chatId: string
|
|
252
|
-
): Promise<{ name: string; description: string }> {
|
|
253
|
-
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
254
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
255
|
-
});
|
|
256
|
-
const data = (await resp.json()) as {
|
|
257
|
-
code: number; msg?: string; data?: { name?: string; description?: string };
|
|
258
|
-
};
|
|
259
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
260
|
-
return {
|
|
261
|
-
name: data.data?.name ?? "",
|
|
262
|
-
description: data.data?.description ?? "",
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
export async function disbandChat(
|
|
267
|
-
token: string,
|
|
268
|
-
chatId: string
|
|
269
|
-
): Promise<void> {
|
|
270
|
-
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
271
|
-
method: "DELETE",
|
|
272
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
273
|
-
});
|
|
274
|
-
const data = (await resp.json()) as { code: number; msg?: string };
|
|
275
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
export function extractSessionInfo(description: string): { sessionId: string; tool: string } | null {
|
|
279
|
-
const PREFIXES: Array<{ prefix: string; tool: string }> = [
|
|
280
|
-
{ prefix: CLAUDE_SESSION_PREFIX, tool: "claude" },
|
|
281
|
-
{ prefix: CURSOR_SESSION_PREFIX, tool: "cursor" },
|
|
282
|
-
{ prefix: CODEX_SESSION_PREFIX, tool: "codex" },
|
|
283
|
-
{ prefix: CCC_SESSION_PREFIX, tool: "ccc" },
|
|
284
|
-
];
|
|
285
|
-
for (const { prefix, tool } of PREFIXES) {
|
|
286
|
-
const idx = description.indexOf(prefix);
|
|
287
|
-
if (idx === -1) continue;
|
|
288
|
-
const after = description.slice(idx + prefix.length).trim();
|
|
289
|
-
const match = after.match(/^([a-zA-Z0-9_.-]+)/);
|
|
290
|
-
if (match) return { sessionId: match[1], tool };
|
|
291
|
-
}
|
|
292
|
-
return null;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
/** @deprecated 使用 extractSessionInfo 代替 */
|
|
296
|
-
export function extractSessionId(description: string): string | null {
|
|
297
|
-
return extractSessionInfo(description)?.sessionId ?? null;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ---------------------------------------------------------------------------
|
|
301
|
-
// Avatar
|
|
302
|
-
// ---------------------------------------------------------------------------
|
|
303
|
-
|
|
304
|
-
const AVATAR_DIR = resolvePath(PROJECT_ROOT, "images", "avatars");
|
|
305
|
-
const AVATAR_BADGE_DIR = resolvePath(AVATAR_DIR, "badges");
|
|
306
|
-
const AVATAR_COMBINATIONS_DIR = resolvePath(AVATAR_DIR, "combinations");
|
|
307
|
-
const AVATAR_KEY_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "avatar-image-keys.json");
|
|
308
|
-
const CODEX_AUTH_FILE = resolvePath(homedir(), ".codex", "auth.json");
|
|
309
|
-
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
310
|
-
const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
|
311
|
-
const CODEX_RESET_CONSUME_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
|
|
312
|
-
const AVATAR_SOURCES: Record<string, string> = {
|
|
313
|
-
new: resolvePath(AVATAR_DIR, "status_new.png"),
|
|
314
|
-
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
315
|
-
idle: resolvePath(AVATAR_DIR, "status_idle.png"),
|
|
316
|
-
};
|
|
317
|
-
const AVATAR_BADGES: Record<string, string> = {
|
|
318
|
-
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
319
|
-
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
320
|
-
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
321
|
-
};
|
|
322
|
-
const AVATAR_SIZE = 256;
|
|
323
|
-
const AVATAR_BADGE_SIZE = 92;
|
|
324
|
-
const AVATAR_BADGE_MARGIN = 10;
|
|
325
|
-
const PLAIN_AVATAR_TOOL = "plain";
|
|
326
|
-
const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-window-aware-v14";
|
|
327
|
-
const CODEX_FAST_FRAME_STYLE_VERSION = "fast-champagne-frame-v2";
|
|
328
|
-
const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
|
|
329
|
-
|
|
330
|
-
export interface CodexUsageBalance {
|
|
331
|
-
usedPercent: number;
|
|
332
|
-
remainingPercent: number;
|
|
333
|
-
resetAtEpochSeconds: number | null;
|
|
334
|
-
resetAfterSeconds: number | null;
|
|
335
|
-
limitWindowSeconds?: number | null;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
export interface CodexRateLimitResetCredit {
|
|
339
|
-
grantedAt: string | null;
|
|
340
|
-
expiresAt: string;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
export interface CodexUsageSummary {
|
|
344
|
-
fiveHour: CodexUsageBalance | null;
|
|
345
|
-
weekly: CodexUsageBalance | null;
|
|
346
|
-
rateLimitResetCreditsAvailable: number | null;
|
|
347
|
-
rateLimitResetCredits: CodexRateLimitResetCredit[] | null;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
export type CodexResetConsumeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
|
|
351
|
-
|
|
352
|
-
export interface CodexResetConsumeResult {
|
|
353
|
-
code: CodexResetConsumeCode;
|
|
354
|
-
windowsReset: number;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const avatarKeyCache = new Map<string, string>();
|
|
358
|
-
let avatarKeyCacheLoaded = false;
|
|
359
|
-
|
|
360
|
-
function normalizeAvatarTool(tool: string): string {
|
|
361
|
-
return AVATAR_BADGES[tool] ? tool : PLAIN_AVATAR_TOOL;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60;
|
|
365
|
-
const SEVEN_DAY_WINDOW_SECONDS = 7 * 24 * 60 * 60;
|
|
366
|
-
|
|
367
|
-
function normalizeAvatarStatus(status: string): string {
|
|
368
|
-
return AVATAR_SOURCES[status] ? status : "idle";
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function avatarCombinationPath(tool: string, status: string): string {
|
|
372
|
-
return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function avatarCacheKey(
|
|
376
|
-
tool: string,
|
|
377
|
-
status: string,
|
|
378
|
-
codexUsage: CodexUsageSummary | null = null,
|
|
379
|
-
cursorBatteryPercent: number | null = null,
|
|
380
|
-
fastMode = false,
|
|
381
|
-
): string {
|
|
382
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
383
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
384
|
-
if (normalizedTool === "codex") {
|
|
385
|
-
const fastKey = fastMode ? `:${CODEX_FAST_FRAME_STYLE_VERSION}` : "";
|
|
386
|
-
if (!codexUsage?.weekly) return `${normalizedTool}:${normalizedStatus}:plain${fastKey}`;
|
|
387
|
-
const ringKey = codexUsage.fiveHour ? `:5h-ring:${codexUsage.fiveHour.remainingPercent}` : "";
|
|
388
|
-
return `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:7d-battery:${codexUsage.weekly.remainingPercent}${ringKey}${fastKey}`;
|
|
389
|
-
}
|
|
390
|
-
if (normalizedTool === "cursor") {
|
|
391
|
-
return cursorBatteryPercent !== null
|
|
392
|
-
? `${normalizedTool}:${normalizedStatus}:${CURSOR_AVATAR_USAGE_STYLE_VERSION}:battery:${cursorBatteryPercent}`
|
|
393
|
-
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
394
|
-
}
|
|
395
|
-
return `${normalizedTool}:${normalizedStatus}`;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
async function loadAvatarKeyCache(): Promise<void> {
|
|
399
|
-
if (avatarKeyCacheLoaded) return;
|
|
400
|
-
avatarKeyCacheLoaded = true;
|
|
401
|
-
try {
|
|
402
|
-
const raw = await readFile(AVATAR_KEY_CACHE_FILE, "utf-8");
|
|
403
|
-
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
404
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
405
|
-
if (typeof value === "string" && value.trim()) avatarKeyCache.set(key, value);
|
|
406
|
-
}
|
|
407
|
-
} catch {
|
|
408
|
-
// Missing or malformed cache should not block avatar updates.
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
async function persistAvatarKeyCache(): Promise<void> {
|
|
413
|
-
await mkdir(dirname(AVATAR_KEY_CACHE_FILE), { recursive: true });
|
|
414
|
-
await writeFile(
|
|
415
|
-
AVATAR_KEY_CACHE_FILE,
|
|
416
|
-
JSON.stringify(Object.fromEntries(avatarKeyCache.entries()), null, 2),
|
|
417
|
-
"utf-8",
|
|
418
|
-
);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function clampPercent(value: number): number {
|
|
422
|
-
if (!Number.isFinite(value)) return 0;
|
|
423
|
-
return Math.max(0, Math.min(100, Math.round(value)));
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string): CodexUsageBalance {
|
|
427
|
-
const value = raw.used_percent;
|
|
428
|
-
const usedPercent = Number(value);
|
|
429
|
-
if (!Number.isFinite(usedPercent)) throw new Error(`missing ${fieldName}.used_percent`);
|
|
430
|
-
const used = clampPercent(usedPercent);
|
|
431
|
-
const resetAt = Number(raw.reset_at);
|
|
432
|
-
const resetAfter = Number(raw.reset_after_seconds);
|
|
433
|
-
const rawLimitWindow = raw.limit_window_seconds;
|
|
434
|
-
const limitWindow = rawLimitWindow === null || rawLimitWindow === undefined || rawLimitWindow === ""
|
|
435
|
-
? Number.NaN
|
|
436
|
-
: Number(rawLimitWindow);
|
|
437
|
-
const balance: CodexUsageBalance = {
|
|
438
|
-
usedPercent: used,
|
|
439
|
-
remainingPercent: clampPercent(100 - used),
|
|
440
|
-
resetAtEpochSeconds: Number.isFinite(resetAt) ? resetAt : null,
|
|
441
|
-
resetAfterSeconds: Number.isFinite(resetAfter) ? resetAfter : null,
|
|
442
|
-
};
|
|
443
|
-
if (Number.isFinite(limitWindow)) balance.limitWindowSeconds = limitWindow;
|
|
444
|
-
return balance;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function isUsageWindowDuration(balance: CodexUsageBalance | null, seconds: number): boolean {
|
|
448
|
-
return balance?.limitWindowSeconds === seconds;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
|
|
452
|
-
for (const key of keys) {
|
|
453
|
-
const raw = rateLimit[key];
|
|
454
|
-
if (!raw || typeof raw !== "object") continue;
|
|
455
|
-
if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
|
|
456
|
-
return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
|
|
457
|
-
}
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function parseRateLimitResetCredits(data: Record<string, unknown>): number | null {
|
|
462
|
-
const raw = data.rate_limit_reset_credits;
|
|
463
|
-
if (!raw || typeof raw !== "object") return null;
|
|
464
|
-
const availableCount = Number((raw as { available_count?: unknown }).available_count);
|
|
465
|
-
if (!Number.isFinite(availableCount)) return null;
|
|
466
|
-
return Math.max(0, Math.trunc(availableCount));
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
interface CodexAuth {
|
|
470
|
-
accessToken: string;
|
|
471
|
-
accountId?: string;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
async function getCodexAuth(): Promise<CodexAuth | null> {
|
|
475
|
-
try {
|
|
476
|
-
const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
|
|
477
|
-
const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown; account_id?: unknown } };
|
|
478
|
-
const token = parsed.tokens?.access_token;
|
|
479
|
-
if (typeof token !== "string" || !token.trim()) return null;
|
|
480
|
-
const accountId = parsed.tokens?.account_id;
|
|
481
|
-
return {
|
|
482
|
-
accessToken: token,
|
|
483
|
-
accountId: typeof accountId === "string" && accountId.trim() ? accountId : undefined,
|
|
484
|
-
};
|
|
485
|
-
} catch {
|
|
486
|
-
return null;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
async function getCodexAccessToken(): Promise<string | null> {
|
|
491
|
-
return (await getCodexAuth())?.accessToken ?? null;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function codexAuthHeaders(auth: CodexAuth): Record<string, string> {
|
|
495
|
-
const headers: Record<string, string> = {
|
|
496
|
-
Authorization: `Bearer ${auth.accessToken}`,
|
|
497
|
-
"OpenAI-Beta": "codex-1",
|
|
498
|
-
originator: "Codex Desktop",
|
|
499
|
-
};
|
|
500
|
-
if (auth.accountId) headers["ChatGPT-Account-ID"] = auth.accountId;
|
|
501
|
-
return headers;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function parseCodexResetCreditDetails(data: Record<string, unknown>): {
|
|
505
|
-
availableCount: number | null;
|
|
506
|
-
availableCredits: CodexRateLimitResetCredit[];
|
|
507
|
-
} {
|
|
508
|
-
const availableCount = Number(data.available_count);
|
|
509
|
-
const credits = Array.isArray(data.credits) ? data.credits : [];
|
|
510
|
-
return {
|
|
511
|
-
availableCount: Number.isFinite(availableCount) ? Math.max(0, Math.trunc(availableCount)) : null,
|
|
512
|
-
availableCredits: credits.flatMap((raw) => {
|
|
513
|
-
if (!raw || typeof raw !== "object") return [];
|
|
514
|
-
const credit = raw as { status?: unknown; granted_at?: unknown; expires_at?: unknown };
|
|
515
|
-
if (credit.status !== "available") return [];
|
|
516
|
-
if (typeof credit.expires_at !== "string" || !credit.expires_at.trim()) return [];
|
|
517
|
-
return [{
|
|
518
|
-
grantedAt: typeof credit.granted_at === "string" && credit.granted_at.trim() ? credit.granted_at : null,
|
|
519
|
-
expiresAt: credit.expires_at,
|
|
520
|
-
}];
|
|
521
|
-
}),
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
async function fetchCodexRateLimitResetCredits(auth: CodexAuth): Promise<{
|
|
526
|
-
availableCount: number | null;
|
|
527
|
-
availableCredits: CodexRateLimitResetCredit[];
|
|
528
|
-
} | null> {
|
|
529
|
-
try {
|
|
530
|
-
const resp = await fetch(CODEX_RESET_CREDITS_URL, {
|
|
531
|
-
headers: codexAuthHeaders(auth),
|
|
532
|
-
});
|
|
533
|
-
const text = await resp.text();
|
|
534
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
535
|
-
return parseCodexResetCreditDetails(JSON.parse(text) as Record<string, unknown>);
|
|
536
|
-
} catch (err) {
|
|
537
|
-
console.warn(`[Codex] reset credits lookup failed: ${(err as Error).message}`);
|
|
538
|
-
return null;
|
|
539
|
-
}
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
|
|
543
|
-
const auth = await getCodexAuth();
|
|
544
|
-
if (!auth) throw new Error("missing ~/.codex/auth.json access token");
|
|
545
|
-
|
|
546
|
-
const resetCreditsPromise = fetchCodexRateLimitResetCredits(auth);
|
|
547
|
-
|
|
548
|
-
const resp = await fetch(CODEX_USAGE_URL, {
|
|
549
|
-
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
550
|
-
});
|
|
551
|
-
const text = await resp.text();
|
|
552
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
553
|
-
|
|
554
|
-
const data = JSON.parse(text) as {
|
|
555
|
-
rate_limit?: Record<string, unknown>;
|
|
556
|
-
rate_limit_reset_credits?: unknown;
|
|
557
|
-
};
|
|
558
|
-
const rateLimit = data.rate_limit;
|
|
559
|
-
if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
|
|
560
|
-
|
|
561
|
-
const primaryWindow = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
|
|
562
|
-
const secondaryWindow = parseOptionalUsageWindow(rateLimit, [
|
|
563
|
-
"secondary_window",
|
|
564
|
-
"weekly_window",
|
|
565
|
-
"week_window",
|
|
566
|
-
"long_window",
|
|
567
|
-
]);
|
|
568
|
-
if (!primaryWindow && !secondaryWindow) throw new Error("missing supported rate_limit usage window");
|
|
569
|
-
|
|
570
|
-
const windows = [primaryWindow, secondaryWindow];
|
|
571
|
-
const fiveHour = windows.find((window) => isUsageWindowDuration(window, FIVE_HOUR_WINDOW_SECONDS))
|
|
572
|
-
?? (primaryWindow?.limitWindowSeconds === undefined ? primaryWindow : null);
|
|
573
|
-
const weekly = windows.find((window) => isUsageWindowDuration(window, SEVEN_DAY_WINDOW_SECONDS))
|
|
574
|
-
?? (secondaryWindow?.limitWindowSeconds === undefined ? secondaryWindow : null);
|
|
575
|
-
const resetCredits = await resetCreditsPromise;
|
|
576
|
-
|
|
577
|
-
return {
|
|
578
|
-
fiveHour,
|
|
579
|
-
weekly,
|
|
580
|
-
rateLimitResetCreditsAvailable: resetCredits?.availableCount ?? parseRateLimitResetCredits(data),
|
|
581
|
-
rateLimitResetCredits: resetCredits?.availableCredits ?? null,
|
|
582
|
-
};
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
export async function consumeCodexRateLimitResetCredit(redeemRequestId: string): Promise<CodexResetConsumeResult> {
|
|
586
|
-
if (!redeemRequestId.trim()) throw new Error("missing redeem_request_id");
|
|
587
|
-
const token = await getCodexAccessToken();
|
|
588
|
-
if (!token) throw new Error("missing ~/.codex/auth.json access token");
|
|
589
|
-
|
|
590
|
-
const resp = await fetch(CODEX_RESET_CONSUME_URL, {
|
|
591
|
-
method: "POST",
|
|
592
|
-
headers: {
|
|
593
|
-
Authorization: `Bearer ${token}`,
|
|
594
|
-
"Content-Type": "application/json",
|
|
595
|
-
},
|
|
596
|
-
body: JSON.stringify({ redeem_request_id: redeemRequestId }),
|
|
597
|
-
});
|
|
598
|
-
const text = await resp.text();
|
|
599
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
600
|
-
|
|
601
|
-
const data = JSON.parse(text) as { code?: unknown; windows_reset?: unknown };
|
|
602
|
-
const code = data.code;
|
|
603
|
-
if (
|
|
604
|
-
code !== "reset"
|
|
605
|
-
&& code !== "nothing_to_reset"
|
|
606
|
-
&& code !== "no_credit"
|
|
607
|
-
&& code !== "already_redeemed"
|
|
608
|
-
) {
|
|
609
|
-
throw new Error("missing or unknown reset result code");
|
|
610
|
-
}
|
|
611
|
-
const windowsReset = Number(data.windows_reset);
|
|
612
|
-
return {
|
|
613
|
-
code,
|
|
614
|
-
windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
|
|
615
|
-
};
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
async function resolveCodexAvatarUsage(usageHint?: CodexUsageSummary | null): Promise<CodexUsageSummary | null> {
|
|
619
|
-
if (usageHint !== undefined) {
|
|
620
|
-
if (!usageHint?.weekly) return null;
|
|
621
|
-
return usageHint;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
try {
|
|
625
|
-
const summary = await getCodexUsageSummary();
|
|
626
|
-
if (!summary.weekly) throw new Error("missing weekly usage window");
|
|
627
|
-
return summary;
|
|
628
|
-
} catch (err) {
|
|
629
|
-
console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
630
|
-
return null;
|
|
631
|
-
}
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function cursorAvatarBatteryPercentFromUsage(summary: CursorUsageSummary): number {
|
|
635
|
-
if (config.cursor.avatarBatteryMode === "onDemandUse") {
|
|
636
|
-
const usedCents = Number(summary.spendLimitUsage?.individualUsed);
|
|
637
|
-
if (!Number.isFinite(usedCents)) throw new Error("missing spendLimitUsage.individualUsed");
|
|
638
|
-
const budget = config.cursor.onDemandMonthlyBudget > 0 ? config.cursor.onDemandMonthlyBudget : 1000;
|
|
639
|
-
return clampPercent(100 - ((usedCents / 100) / budget) * 100);
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
const apiPercentUsed = Number(summary.planUsage?.apiPercentUsed);
|
|
643
|
-
if (!Number.isFinite(apiPercentUsed)) throw new Error("missing planUsage.apiPercentUsed");
|
|
644
|
-
return clampPercent(100 - apiPercentUsed);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
async function resolveCursorAvatarBatteryPercent(usageHint?: CursorUsageSummary | null): Promise<number | null> {
|
|
648
|
-
if (usageHint !== undefined) {
|
|
649
|
-
if (!usageHint) return null;
|
|
650
|
-
try {
|
|
651
|
-
return cursorAvatarBatteryPercentFromUsage(usageHint);
|
|
652
|
-
} catch (err) {
|
|
653
|
-
console.warn(`[${ts()}] [AVATAR] Cursor usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
654
|
-
return null;
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
try {
|
|
659
|
-
return cursorAvatarBatteryPercentFromUsage(await getCursorUsageSummary());
|
|
660
|
-
} catch (err) {
|
|
661
|
-
console.warn(`[${ts()}] [AVATAR] Cursor usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
662
|
-
return null;
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
function codexUsagePalette(remainingPercent: number): { start: string; end: string; glow: string } {
|
|
667
|
-
if (remainingPercent <= 25) return { start: "#ef4444", end: "#fb923c", glow: "#fed7aa" };
|
|
668
|
-
if (remainingPercent <= 60) return { start: "#eab308", end: "#facc15", glow: "#fef3c7" };
|
|
669
|
-
return { start: "#16a34a", end: "#34d399", glow: "#bbf7d0" };
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
673
|
-
const remaining = clampPercent(remainingPercent);
|
|
674
|
-
const label = String(remaining);
|
|
675
|
-
const palette = codexUsagePalette(remaining);
|
|
676
|
-
|
|
677
|
-
const bodyX = 28;
|
|
678
|
-
const bodyY = 38;
|
|
679
|
-
const bodyW = 109;
|
|
680
|
-
const bodyH = 56;
|
|
681
|
-
const capW = 12;
|
|
682
|
-
const capH = 22;
|
|
683
|
-
const capX = bodyX + bodyW;
|
|
684
|
-
const capY = bodyY + Math.round((bodyH - capH) / 2);
|
|
685
|
-
const pad = 6;
|
|
686
|
-
const fillMaxW = bodyW - pad * 2;
|
|
687
|
-
const fillWidth = Math.round((fillMaxW * remaining) / 100);
|
|
688
|
-
const labelFontSize = label.length >= 3 ? 49 : 51;
|
|
689
|
-
|
|
690
|
-
return Buffer.from(`
|
|
691
|
-
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
692
|
-
<defs>
|
|
693
|
-
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
|
694
|
-
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4a2712" flood-opacity="0.30"/>
|
|
695
|
-
</filter>
|
|
696
|
-
<linearGradient id="fill" x1="0" y1="0" x2="1" y2="0">
|
|
697
|
-
<stop offset="0" stop-color="${palette.start}"/>
|
|
698
|
-
<stop offset="1" stop-color="${palette.end}"/>
|
|
699
|
-
</linearGradient>
|
|
700
|
-
<linearGradient id="well" x1="0" y1="0" x2="1" y2="1">
|
|
701
|
-
<stop offset="0" stop-color="#334155"/>
|
|
702
|
-
<stop offset="1" stop-color="#111827"/>
|
|
703
|
-
</linearGradient>
|
|
704
|
-
<clipPath id="batteryInnerClip">
|
|
705
|
-
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="10"/>
|
|
706
|
-
</clipPath>
|
|
707
|
-
</defs>
|
|
708
|
-
<g filter="url(#shadow)">
|
|
709
|
-
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="#0f172a"/>
|
|
710
|
-
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="12" fill="url(#well)"/>
|
|
711
|
-
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillWidth}" height="${bodyH - pad * 2}" fill="url(#fill)" clip-path="url(#batteryInnerClip)"/>
|
|
712
|
-
<rect x="${bodyX + pad + 4}" y="${bodyY + pad + 5}" width="${Math.max(0, fillWidth - 8)}" height="8" rx="4" fill="${palette.glow}" fill-opacity="0.42" clip-path="url(#batteryInnerClip)"/>
|
|
713
|
-
<rect x="${capX}" y="${capY}" width="${capW}" height="${capH}" rx="6" fill="#0f172a"/>
|
|
714
|
-
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="none" stroke="#f8fafc" stroke-opacity="0.82" stroke-width="3.8"/>
|
|
715
|
-
<text x="${bodyX + bodyW / 2}" y="${bodyY + bodyH / 2 + 3}" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" font-family="Arial, Helvetica, sans-serif" font-size="${labelFontSize}" font-weight="600" letter-spacing="0" stroke="#0b1220" stroke-width="4.6" paint-order="stroke" stroke-linejoin="round" fill="#ffffff">${label}</text>
|
|
716
|
-
</g>
|
|
717
|
-
</svg>`);
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
|
|
721
|
-
const remaining = clampPercent(remainingPercent);
|
|
722
|
-
const palette = codexUsagePalette(remaining);
|
|
723
|
-
const cx = 128;
|
|
724
|
-
const cy = 128;
|
|
725
|
-
const r = 118;
|
|
726
|
-
const strokeWidth = 16;
|
|
727
|
-
const used = clampPercent(100 - remaining);
|
|
728
|
-
const polar = (angleDegrees: number) => {
|
|
729
|
-
const angle = (angleDegrees * Math.PI) / 180;
|
|
730
|
-
return {
|
|
731
|
-
x: cx + r * Math.cos(angle),
|
|
732
|
-
y: cy + r * Math.sin(angle),
|
|
733
|
-
};
|
|
734
|
-
};
|
|
735
|
-
const startAngle = -90 + (used / 100) * 360;
|
|
736
|
-
const sweepAngle = (remaining / 100) * 360;
|
|
737
|
-
const start = polar(startAngle);
|
|
738
|
-
const end = polar(startAngle + Math.min(sweepAngle, 359.99));
|
|
739
|
-
const largeArcFlag = sweepAngle > 180 ? 1 : 0;
|
|
740
|
-
const progressPath = remaining >= 100
|
|
741
|
-
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`
|
|
742
|
-
: remaining <= 0
|
|
743
|
-
? ""
|
|
744
|
-
: `<path d="M ${start.x.toFixed(3)} ${start.y.toFixed(3)} A ${r} ${r} 0 ${largeArcFlag} 1 ${end.x.toFixed(3)} ${end.y.toFixed(3)}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`;
|
|
745
|
-
|
|
746
|
-
return Buffer.from(`
|
|
747
|
-
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
748
|
-
<defs>
|
|
749
|
-
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
|
750
|
-
<stop offset="0" stop-color="${palette.start}"/>
|
|
751
|
-
<stop offset="1" stop-color="${palette.end}"/>
|
|
752
|
-
</linearGradient>
|
|
753
|
-
<filter id="ringShadow" x="-22%" y="-22%" width="144%" height="144%">
|
|
754
|
-
<feDropShadow dx="0" dy="0" stdDeviation="5.2" flood-color="#0f172a" flood-opacity="0.78"/>
|
|
755
|
-
<feDropShadow dx="0" dy="3" stdDeviation="2.8" flood-color="#0f172a" flood-opacity="0.45"/>
|
|
756
|
-
</filter>
|
|
757
|
-
</defs>
|
|
758
|
-
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#94a3b8" stroke-width="${strokeWidth}"/>
|
|
759
|
-
${progressPath}
|
|
760
|
-
</svg>`);
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
async function buildAgentBadgeOverlay(
|
|
764
|
-
tool: string,
|
|
765
|
-
margin = AVATAR_BADGE_MARGIN,
|
|
766
|
-
): Promise<sharp.OverlayOptions> {
|
|
767
|
-
const badge = await sharp(AVATAR_BADGES[tool])
|
|
768
|
-
.resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
|
|
769
|
-
fit: "contain",
|
|
770
|
-
kernel: sharp.kernel.lanczos3,
|
|
771
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
772
|
-
})
|
|
773
|
-
.png()
|
|
774
|
-
.toBuffer();
|
|
775
|
-
return {
|
|
776
|
-
input: badge,
|
|
777
|
-
left: AVATAR_SIZE - AVATAR_BADGE_SIZE - margin,
|
|
778
|
-
top: AVATAR_SIZE - AVATAR_BADGE_SIZE - margin,
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
function buildCodexFastFrameOverlay(): sharp.OverlayOptions {
|
|
783
|
-
const size = AVATAR_BADGE_SIZE + 20;
|
|
784
|
-
const innerOffset = 9;
|
|
785
|
-
const innerSize = size - innerOffset * 2;
|
|
786
|
-
const frame = Buffer.from(`
|
|
787
|
-
<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
|
788
|
-
<defs>
|
|
789
|
-
<linearGradient id="champagne" x1="0" y1="0" x2="${size}" y2="${size}" gradientUnits="userSpaceOnUse">
|
|
790
|
-
<stop offset="0" stop-color="#FFF0BE"/>
|
|
791
|
-
<stop offset="0.55" stop-color="#FFD56A"/>
|
|
792
|
-
<stop offset="1" stop-color="#EFA923"/>
|
|
793
|
-
</linearGradient>
|
|
794
|
-
</defs>
|
|
795
|
-
<rect x="0.5" y="0.5" width="${size - 1}" height="${size - 1}" rx="25" fill="url(#champagne)"/>
|
|
796
|
-
<rect x="${innerOffset}" y="${innerOffset}" width="${innerSize}" height="${innerSize}" rx="21" fill="#fffdf4"/>
|
|
797
|
-
</svg>`);
|
|
798
|
-
return {
|
|
799
|
-
input: frame,
|
|
800
|
-
left: AVATAR_SIZE - size - 1,
|
|
801
|
-
top: AVATAR_SIZE - size - 1,
|
|
802
|
-
};
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
async function renderAvatar(
|
|
806
|
-
tool: string,
|
|
807
|
-
status: string,
|
|
808
|
-
codexUsage: CodexUsageSummary | null = null,
|
|
809
|
-
cursorBatteryPercent: number | null = null,
|
|
810
|
-
fastMode = false,
|
|
811
|
-
): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
812
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
813
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
814
|
-
const composites: sharp.OverlayOptions[] = [];
|
|
815
|
-
const hasAgentBadge = normalizedTool !== PLAIN_AVATAR_TOOL;
|
|
816
|
-
const useFastCodexAvatar = normalizedTool === "codex" && fastMode;
|
|
817
|
-
|
|
818
|
-
const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly ?? null : null;
|
|
819
|
-
const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage !== null && codexWeeklyUsage !== null;
|
|
820
|
-
const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
|
|
821
|
-
const useDynamicBadgeAvatar = useDynamicCodexAvatar || useDynamicCursorAvatar || useFastCodexAvatar;
|
|
822
|
-
const basePath = useDynamicBadgeAvatar
|
|
823
|
-
? AVATAR_SOURCES[normalizedStatus]
|
|
824
|
-
: hasAgentBadge
|
|
825
|
-
? avatarCombinationPath(normalizedTool, normalizedStatus)
|
|
826
|
-
: AVATAR_SOURCES[normalizedStatus];
|
|
827
|
-
|
|
828
|
-
if (useDynamicCodexAvatar) {
|
|
829
|
-
if (codexUsage.fiveHour) {
|
|
830
|
-
composites.push({ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 });
|
|
831
|
-
}
|
|
832
|
-
composites.push({ input: buildCodexUsageBatterySvg(codexWeeklyUsage.remainingPercent), left: 0, top: 0 });
|
|
833
|
-
} else if (useDynamicCursorAvatar) {
|
|
834
|
-
composites.push({ input: buildCodexUsageBatterySvg(cursorBatteryPercent), left: 0, top: 0 });
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
if (useDynamicBadgeAvatar) {
|
|
838
|
-
if (useFastCodexAvatar) composites.push(buildCodexFastFrameOverlay());
|
|
839
|
-
composites.push(await buildAgentBadgeOverlay(normalizedTool, useFastCodexAvatar ? 11 : AVATAR_BADGE_MARGIN));
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
let pipeline = sharp(await readFile(basePath))
|
|
843
|
-
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
|
|
844
|
-
if (composites.length > 0) {
|
|
845
|
-
pipeline = pipeline.composite(composites);
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
const jpeg = await pipeline
|
|
849
|
-
.flatten({ background: "#ffffff" })
|
|
850
|
-
.removeAlpha()
|
|
851
|
-
.jpeg({ quality: 95, progressive: false })
|
|
852
|
-
.toBuffer();
|
|
853
|
-
|
|
854
|
-
return {
|
|
855
|
-
buffer: jpeg,
|
|
856
|
-
contentType: "image/jpeg",
|
|
857
|
-
filename: normalizedTool === "codex" && codexUsage?.weekly
|
|
858
|
-
? `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}_7d_${codexUsage.weekly.remainingPercent}${codexUsage.fiveHour ? `_5h_${codexUsage.fiveHour.remainingPercent}` : ""}.jpg`
|
|
859
|
-
: normalizedTool === "cursor" && cursorBatteryPercent !== null
|
|
860
|
-
? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
|
|
861
|
-
: `avatar_${normalizedTool}_${normalizedStatus}${useFastCodexAvatar ? "_fast" : ""}.jpg`,
|
|
862
|
-
};
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
async function uploadImage(
|
|
866
|
-
token: string,
|
|
867
|
-
tool: string,
|
|
868
|
-
status: string,
|
|
869
|
-
codexUsage: CodexUsageSummary | null = null,
|
|
870
|
-
cursorBatteryPercent: number | null = null,
|
|
871
|
-
fastMode = false,
|
|
872
|
-
): Promise<string> {
|
|
873
|
-
const image = await renderAvatar(tool, status, codexUsage, cursorBatteryPercent, fastMode);
|
|
874
|
-
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
875
|
-
const form = new FormData();
|
|
876
|
-
form.append("image_type", "avatar");
|
|
877
|
-
form.append("image", blob, image.filename);
|
|
878
|
-
|
|
879
|
-
// Group avatars need an im/v1 image_key uploaded with image_type=avatar.
|
|
880
|
-
const resp = await fetch(`${BASE_URL}/im/v1/images`, {
|
|
881
|
-
method: "POST",
|
|
882
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
883
|
-
body: form,
|
|
884
|
-
});
|
|
885
|
-
const text = await resp.text();
|
|
886
|
-
let data: { code: number; msg?: string; data?: { image_key?: string } };
|
|
887
|
-
try { data = JSON.parse(text); } catch {
|
|
888
|
-
throw new Error(`uploadImage non-JSON response: ${text.slice(0, 200)}`);
|
|
889
|
-
}
|
|
890
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
891
|
-
return data.data!.image_key!;
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
async function getOrUploadAvatarKey(
|
|
895
|
-
token: string,
|
|
896
|
-
tool: string,
|
|
897
|
-
status: string,
|
|
898
|
-
usageHints: ChatAvatarUsageHints = {},
|
|
899
|
-
): Promise<string> {
|
|
900
|
-
await loadAvatarKeyCache();
|
|
901
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
902
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
903
|
-
const codexUsage = normalizedTool === "codex" ? await resolveCodexAvatarUsage(usageHints.codexUsage) : null;
|
|
904
|
-
const cursorBatteryPercent = normalizedTool === "cursor"
|
|
905
|
-
? await resolveCursorAvatarBatteryPercent(usageHints.cursorUsage)
|
|
906
|
-
: null;
|
|
907
|
-
const fastMode = normalizedTool === "codex" && usageHints.fastMode === true;
|
|
908
|
-
const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
|
|
909
|
-
const cached = avatarKeyCache.get(keyName);
|
|
910
|
-
if (cached) return cached;
|
|
911
|
-
const key = await uploadImage(token, normalizedTool, normalizedStatus, codexUsage, cursorBatteryPercent, fastMode);
|
|
912
|
-
avatarKeyCache.set(keyName, key);
|
|
913
|
-
await persistAvatarKeyCache().catch((err) => {
|
|
914
|
-
console.error(`[${ts()}] [AVATAR] persist cache FAIL: ${(err as Error).message}`);
|
|
915
|
-
});
|
|
916
|
-
console.log(`[${ts()}] [AVATAR] Uploaded "${keyName}" → image_key=${key}`);
|
|
917
|
-
return key;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
export async function setChatAvatar(
|
|
921
|
-
token: string,
|
|
922
|
-
chatId: string,
|
|
923
|
-
tool: string,
|
|
924
|
-
status: string,
|
|
925
|
-
usageHints: ChatAvatarUsageHints = {},
|
|
926
|
-
): Promise<void> {
|
|
927
|
-
try {
|
|
928
|
-
const avatarKey = await getOrUploadAvatarKey(token, tool, status, usageHints);
|
|
929
|
-
const resp = await fetch(`${BASE_URL}/im/v1/chats/${chatId}`, {
|
|
930
|
-
method: "PUT",
|
|
931
|
-
headers: {
|
|
932
|
-
Authorization: `Bearer ${token}`,
|
|
933
|
-
"Content-Type": "application/json",
|
|
934
|
-
},
|
|
935
|
-
body: JSON.stringify({ avatar: avatarKey }),
|
|
936
|
-
});
|
|
937
|
-
const text = await resp.text();
|
|
938
|
-
let data: { code: number; msg?: string };
|
|
939
|
-
try { data = JSON.parse(text); } catch {
|
|
940
|
-
if (resp.ok) return;
|
|
941
|
-
throw new Error(`setChatAvatar non-JSON response (status=${resp.status}): ${text.slice(0, 200)}`);
|
|
942
|
-
}
|
|
943
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
944
|
-
} catch (err) {
|
|
945
|
-
console.error(`[${ts()}] [AVATAR] setChatAvatar FAIL: chatId=${chatId} tool=${tool} status=${status} ${(err as Error).message}`);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
// ---------------------------------------------------------------------------
|
|
950
|
-
// Image download & cache
|
|
951
|
-
// ---------------------------------------------------------------------------
|
|
952
|
-
|
|
953
|
-
const IMAGE_DOWNLOAD_DIR = resolvePath(USER_DATA_DIR, "images", "downloads");
|
|
954
|
-
const IMAGE_CACHE_FILE = resolvePath(USER_DATA_DIR, "state", "image-cache.json");
|
|
955
|
-
|
|
956
|
-
const imageCache = new Map<string, string>();
|
|
957
|
-
let imageCacheLoaded = false;
|
|
958
|
-
|
|
959
|
-
async function loadImageCache(): Promise<void> {
|
|
960
|
-
if (imageCacheLoaded) return;
|
|
961
|
-
imageCacheLoaded = true;
|
|
962
|
-
try {
|
|
963
|
-
const raw = await readFile(IMAGE_CACHE_FILE, "utf-8");
|
|
964
|
-
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
965
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
966
|
-
if (typeof value === "string" && value.trim()) imageCache.set(key, value);
|
|
967
|
-
}
|
|
968
|
-
} catch { /* missing or malformed cache is not fatal */ }
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
async function persistImageCache(): Promise<void> {
|
|
972
|
-
await mkdir(dirname(IMAGE_CACHE_FILE), { recursive: true });
|
|
973
|
-
await writeFile(
|
|
974
|
-
IMAGE_CACHE_FILE,
|
|
975
|
-
JSON.stringify(Object.fromEntries(imageCache.entries()), null, 2),
|
|
976
|
-
"utf-8",
|
|
977
|
-
);
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
function extFromContentType(contentType: string | null): string {
|
|
981
|
-
if (!contentType) return ".png";
|
|
982
|
-
const mime = contentType.split(";")[0].trim().toLowerCase();
|
|
983
|
-
const map: Record<string, string> = {
|
|
984
|
-
"image/png": ".png",
|
|
985
|
-
"image/jpeg": ".jpg",
|
|
986
|
-
"image/webp": ".webp",
|
|
987
|
-
"image/gif": ".gif",
|
|
988
|
-
"image/bmp": ".bmp",
|
|
989
|
-
"image/svg+xml": ".svg",
|
|
990
|
-
};
|
|
991
|
-
return map[mime] ?? ".png";
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
async function downloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
|
|
995
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}/resources/${fileKey}?type=image`, {
|
|
996
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
997
|
-
});
|
|
998
|
-
if (!resp.ok) {
|
|
999
|
-
const text = await resp.text().catch(() => "");
|
|
1000
|
-
throw new Error(`downloadImage HTTP ${resp.status}: ${text.slice(0, 200)}`);
|
|
1001
|
-
}
|
|
1002
|
-
const ext = extFromContentType(resp.headers.get("content-type"));
|
|
1003
|
-
const buffer = Buffer.from(await resp.arrayBuffer());
|
|
1004
|
-
await mkdir(IMAGE_DOWNLOAD_DIR, { recursive: true });
|
|
1005
|
-
const localPath = resolvePath(IMAGE_DOWNLOAD_DIR, `${fileKey}${ext}`);
|
|
1006
|
-
await writeFile(localPath, buffer);
|
|
1007
|
-
return localPath;
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
export async function getOrDownloadImage(token: string, messageId: string, fileKey: string): Promise<string> {
|
|
1011
|
-
await loadImageCache();
|
|
1012
|
-
const cached = imageCache.get(fileKey);
|
|
1013
|
-
if (cached) return cached;
|
|
1014
|
-
const localPath = await downloadImage(token, messageId, fileKey);
|
|
1015
|
-
imageCache.set(fileKey, localPath);
|
|
1016
|
-
await persistImageCache().catch((err) => {
|
|
1017
|
-
console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
|
|
1018
|
-
});
|
|
1019
|
-
console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
|
|
1020
|
-
return localPath;
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
// ---------------------------------------------------------------------------
|
|
1024
|
-
// Messaging
|
|
1025
|
-
// ---------------------------------------------------------------------------
|
|
1026
|
-
|
|
1027
|
-
const DELAY_NOTICE_THRESHOLD_MS = 15 * 60 * 1000; // 15 分钟
|
|
1028
|
-
|
|
1029
|
-
/** 消息延迟超过阈值时生成提醒文本,否则返回 null */
|
|
1030
|
-
export function formatDelayNotice(createTimeMs: number, messageText?: string, nowMs?: number): string | null {
|
|
1031
|
-
const now = nowMs ?? Date.now();
|
|
1032
|
-
const delayMs = now - createTimeMs;
|
|
1033
|
-
if (delayMs < DELAY_NOTICE_THRESHOLD_MS) return null;
|
|
1034
|
-
|
|
1035
|
-
const sendDate = new Date(createTimeMs);
|
|
1036
|
-
const month = sendDate.getMonth() + 1;
|
|
1037
|
-
const day = sendDate.getDate();
|
|
1038
|
-
const hour = String(sendDate.getHours()).padStart(2, "0");
|
|
1039
|
-
const min = String(sendDate.getMinutes()).padStart(2, "0");
|
|
1040
|
-
const sendTimeStr = `${month}月${day}日 ${hour}:${min}`;
|
|
1041
|
-
|
|
1042
|
-
const totalMinutes = Math.floor(delayMs / 60000);
|
|
1043
|
-
let delayStr: string;
|
|
1044
|
-
if (totalMinutes < 60) {
|
|
1045
|
-
delayStr = `${totalMinutes} 分钟`;
|
|
1046
|
-
} else {
|
|
1047
|
-
const hours = Math.floor(totalMinutes / 60);
|
|
1048
|
-
const mins = totalMinutes % 60;
|
|
1049
|
-
if (hours < 24) {
|
|
1050
|
-
delayStr = mins > 0 ? `${hours} 小时 ${mins} 分钟` : `${hours} 小时`;
|
|
1051
|
-
} else {
|
|
1052
|
-
const days = Math.floor(hours / 24);
|
|
1053
|
-
const remainHours = hours % 24;
|
|
1054
|
-
delayStr = remainHours > 0 ? `${days} 天 ${remainHours} 小时` : `${days} 天`;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
const contentLine = messageText ? `\n> 原始内容:${messageText.slice(0, 200)}` : "";
|
|
1059
|
-
return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,现延迟约 ${delayStr}后送达${contentLine}`;
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
/**
|
|
1063
|
-
* 检测文本中的 markdown 表格,用代码块包裹。
|
|
1064
|
-
*
|
|
1065
|
-
* 飞书 markdown 渲染对表格支持不稳定(列对齐易错乱),用代码块包裹可保证
|
|
1066
|
-
* 等宽字体显示、列对齐正确。只处理不在已有代码块内的表格。
|
|
1067
|
-
*
|
|
1068
|
-
* 检测规则:至少两行连续的 "|col|col|" 模式,其中必须包含一行分隔行
|
|
1069
|
-
* (如 |---|----|),避免将单行含 | 的普通文本误判为表格。
|
|
1070
|
-
*/
|
|
1071
|
-
function wrapMarkdownTables(text: string): string {
|
|
1072
|
-
const lines = text.split("\n");
|
|
1073
|
-
const result: string[] = [];
|
|
1074
|
-
let inCodeBlock = false;
|
|
1075
|
-
let tableRows: string[] = [];
|
|
1076
|
-
let foundSeparator = false;
|
|
1077
|
-
|
|
1078
|
-
const isTableRow = (line: string): boolean =>
|
|
1079
|
-
/^\s*\|.+\|/.test(line) && !isSeparatorRow(line);
|
|
1080
|
-
|
|
1081
|
-
const isSeparatorRow = (line: string): boolean =>
|
|
1082
|
-
/^\s*\|[\s\-:]+\|/.test(line) && /-/.test(line);
|
|
1083
|
-
|
|
1084
|
-
const flushTable = (): void => {
|
|
1085
|
-
if (foundSeparator && tableRows.length >= 2) {
|
|
1086
|
-
result.push("```");
|
|
1087
|
-
result.push(...tableRows);
|
|
1088
|
-
result.push("```");
|
|
1089
|
-
} else {
|
|
1090
|
-
result.push(...tableRows);
|
|
1091
|
-
}
|
|
1092
|
-
tableRows = [];
|
|
1093
|
-
foundSeparator = false;
|
|
1094
|
-
};
|
|
1095
|
-
|
|
1096
|
-
for (const line of lines) {
|
|
1097
|
-
const trimmed = line.trim();
|
|
1098
|
-
|
|
1099
|
-
if (trimmed.startsWith("```")) {
|
|
1100
|
-
flushTable();
|
|
1101
|
-
inCodeBlock = !inCodeBlock;
|
|
1102
|
-
result.push(line);
|
|
1103
|
-
continue;
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
if (inCodeBlock) {
|
|
1107
|
-
result.push(line);
|
|
1108
|
-
continue;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
if (!foundSeparator) {
|
|
1112
|
-
if (isTableRow(line)) {
|
|
1113
|
-
tableRows.push(line);
|
|
1114
|
-
} else if (tableRows.length > 0 && isSeparatorRow(line)) {
|
|
1115
|
-
tableRows.push(line);
|
|
1116
|
-
foundSeparator = true;
|
|
1117
|
-
} else {
|
|
1118
|
-
if (tableRows.length > 0) {
|
|
1119
|
-
result.push(...tableRows);
|
|
1120
|
-
tableRows = [];
|
|
1121
|
-
}
|
|
1122
|
-
result.push(line);
|
|
1123
|
-
}
|
|
1124
|
-
} else {
|
|
1125
|
-
if (isTableRow(line)) {
|
|
1126
|
-
tableRows.push(line);
|
|
1127
|
-
} else {
|
|
1128
|
-
flushTable();
|
|
1129
|
-
result.push(line);
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
flushTable();
|
|
1135
|
-
return result.join("\n");
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
export async function sendTextReply(
|
|
1139
|
-
token: string,
|
|
1140
|
-
chatId: string,
|
|
1141
|
-
text: string
|
|
1142
|
-
): Promise<boolean> {
|
|
1143
|
-
const safeText = applyPrivacy(text);
|
|
1144
|
-
const wrappedText = wrapMarkdownTables(safeText);
|
|
1145
|
-
const card = JSON.stringify({
|
|
1146
|
-
config: { wide_screen_mode: true },
|
|
1147
|
-
elements: [{ tag: "markdown", content: wrappedText }],
|
|
1148
|
-
});
|
|
1149
|
-
const timeoutMs = 15_000;
|
|
1150
|
-
const controller = new AbortController();
|
|
1151
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
1152
|
-
try {
|
|
1153
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1154
|
-
method: "POST",
|
|
1155
|
-
headers: {
|
|
1156
|
-
Authorization: `Bearer ${token}`,
|
|
1157
|
-
"Content-Type": "application/json",
|
|
1158
|
-
},
|
|
1159
|
-
body: JSON.stringify({
|
|
1160
|
-
receive_id: chatId,
|
|
1161
|
-
msg_type: "interactive",
|
|
1162
|
-
content: card,
|
|
1163
|
-
}),
|
|
1164
|
-
signal: controller.signal,
|
|
1165
|
-
});
|
|
1166
|
-
const respText = await resp.text();
|
|
1167
|
-
let data: { code: number; msg?: string; data?: { message_id?: string } };
|
|
1168
|
-
try {
|
|
1169
|
-
data = JSON.parse(respText);
|
|
1170
|
-
} catch {
|
|
1171
|
-
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} invalid JSON status=${resp.status}`);
|
|
1172
|
-
return false;
|
|
1173
|
-
}
|
|
1174
|
-
if (data.code !== 0) {
|
|
1175
|
-
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1176
|
-
return false;
|
|
1177
|
-
}
|
|
1178
|
-
console.log(`[${ts()}] [SEND] text OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1179
|
-
return true;
|
|
1180
|
-
} catch (err) {
|
|
1181
|
-
if ((err as Error).name === "AbortError") {
|
|
1182
|
-
console.error(`[${ts()}] [SEND] text TIMEOUT after ${timeoutMs}ms: chatId=${chatId}`);
|
|
1183
|
-
return false;
|
|
1184
|
-
}
|
|
1185
|
-
console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
1186
|
-
return false;
|
|
1187
|
-
} finally {
|
|
1188
|
-
clearTimeout(timer);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
function messageImageContentType(imagePath: string): string {
|
|
1193
|
-
const ext = extname(imagePath).toLowerCase();
|
|
1194
|
-
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
|
1195
|
-
if (ext === ".webp") return "image/webp";
|
|
1196
|
-
if (ext === ".gif") return "image/gif";
|
|
1197
|
-
if (ext === ".bmp") return "image/bmp";
|
|
1198
|
-
return "image/png";
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
async function uploadMessageImage(token: string, imagePath: string): Promise<string> {
|
|
1202
|
-
const buffer = await readFile(imagePath);
|
|
1203
|
-
const blob = new Blob([new Uint8Array(buffer)], {
|
|
1204
|
-
type: messageImageContentType(imagePath),
|
|
1205
|
-
});
|
|
1206
|
-
const form = new FormData();
|
|
1207
|
-
form.append("image_type", "message");
|
|
1208
|
-
form.append("image", blob, imagePath.split(/[\\/]/).pop() || "image.png");
|
|
1209
|
-
|
|
1210
|
-
const resp = await fetch(`${BASE_URL}/im/v1/images`, {
|
|
1211
|
-
method: "POST",
|
|
1212
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
1213
|
-
body: form,
|
|
1214
|
-
});
|
|
1215
|
-
const text = await resp.text();
|
|
1216
|
-
let data: { code: number; msg?: string; data?: { image_key?: string } };
|
|
1217
|
-
try { data = JSON.parse(text); } catch {
|
|
1218
|
-
throw new Error(`uploadMessageImage non-JSON response: ${text.slice(0, 200)}`);
|
|
1219
|
-
}
|
|
1220
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
1221
|
-
const imageKey = data.data?.image_key;
|
|
1222
|
-
if (!imageKey) throw new Error("uploadMessageImage response missing image_key");
|
|
1223
|
-
return imageKey;
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
export async function sendImageReply(
|
|
1227
|
-
token: string,
|
|
1228
|
-
chatId: string,
|
|
1229
|
-
imagePath: string,
|
|
1230
|
-
): Promise<boolean> {
|
|
1231
|
-
try {
|
|
1232
|
-
const imageKey = await uploadMessageImage(token, imagePath);
|
|
1233
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1234
|
-
method: "POST",
|
|
1235
|
-
headers: {
|
|
1236
|
-
Authorization: `Bearer ${token}`,
|
|
1237
|
-
"Content-Type": "application/json",
|
|
1238
|
-
},
|
|
1239
|
-
body: JSON.stringify({
|
|
1240
|
-
receive_id: chatId,
|
|
1241
|
-
msg_type: "image",
|
|
1242
|
-
content: JSON.stringify({ image_key: imageKey }),
|
|
1243
|
-
}),
|
|
1244
|
-
});
|
|
1245
|
-
const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
|
|
1246
|
-
if (data.code !== 0) {
|
|
1247
|
-
console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1248
|
-
throw new Error(`[${data.code}] ${data.msg ?? "send image failed"}`);
|
|
1249
|
-
}
|
|
1250
|
-
console.log(`[${ts()}] [SEND] image OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1251
|
-
return true;
|
|
1252
|
-
} catch (err) {
|
|
1253
|
-
if (!(err instanceof Error && err.message.startsWith("["))) {
|
|
1254
|
-
console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} ${(err as Error).message}`);
|
|
1255
|
-
}
|
|
1256
|
-
throw err;
|
|
1257
|
-
}
|
|
1258
|
-
}
|
|
1259
|
-
|
|
1260
|
-
function feishuFileType(ext: string): string {
|
|
1261
|
-
const map: Record<string, string> = {
|
|
1262
|
-
".mp4": "mp4",
|
|
1263
|
-
".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
|
1264
|
-
".xls": "xls", ".xlsx": "xls", ".csv": "xls",
|
|
1265
|
-
".ppt": "ppt", ".pptx": "ppt",
|
|
1266
|
-
};
|
|
1267
|
-
return map[ext] ?? "stream";
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
const VIDEO_EXTENSIONS = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv"];
|
|
1271
|
-
|
|
1272
|
-
function parseMediaDurationMs(buffer: Buffer): number {
|
|
1273
|
-
try {
|
|
1274
|
-
let offset = 0;
|
|
1275
|
-
while (offset + 8 <= buffer.length) {
|
|
1276
|
-
const size = buffer.readUInt32BE(offset);
|
|
1277
|
-
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
|
1278
|
-
if (size < 8 || offset + size > buffer.length) break;
|
|
1279
|
-
if (type === "moov") {
|
|
1280
|
-
let mo = offset + 8;
|
|
1281
|
-
const moEnd = offset + size;
|
|
1282
|
-
while (mo + 8 <= moEnd) {
|
|
1283
|
-
const s = buffer.readUInt32BE(mo);
|
|
1284
|
-
const t = buffer.toString("ascii", mo + 4, mo + 8);
|
|
1285
|
-
if (s < 8 || mo + s > moEnd) break;
|
|
1286
|
-
if (t === "mvhd") {
|
|
1287
|
-
const ds = mo + 8;
|
|
1288
|
-
const ver = buffer[ds];
|
|
1289
|
-
let timescale: number, duration: number;
|
|
1290
|
-
if (ver === 1) {
|
|
1291
|
-
timescale = buffer.readUInt32BE(ds + 20);
|
|
1292
|
-
duration = Number(buffer.readBigUInt64BE(ds + 24));
|
|
1293
|
-
} else {
|
|
1294
|
-
timescale = buffer.readUInt32BE(ds + 12);
|
|
1295
|
-
duration = buffer.readUInt32BE(ds + 16);
|
|
1296
|
-
}
|
|
1297
|
-
if (timescale <= 0) return 0;
|
|
1298
|
-
return Math.round((duration / timescale) * 1000);
|
|
1299
|
-
}
|
|
1300
|
-
mo += s;
|
|
1301
|
-
}
|
|
1302
|
-
}
|
|
1303
|
-
offset += size;
|
|
1304
|
-
}
|
|
1305
|
-
} catch {}
|
|
1306
|
-
return 0;
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
async function uploadMessageFile(token: string, filePath: string): Promise<string> {
|
|
1310
|
-
const ext = extname(filePath).toLowerCase();
|
|
1311
|
-
const isVideo = VIDEO_EXTENSIONS.includes(ext);
|
|
1312
|
-
const buffer = await readFile(filePath);
|
|
1313
|
-
const fileName = filePath.split(/[\\/]/).pop() || "file";
|
|
1314
|
-
const fileType = isVideo ? "stream" : feishuFileType(ext);
|
|
1315
|
-
const blob = new Blob([new Uint8Array(buffer)], { type: isVideo ? "video/mp4" : "application/octet-stream" });
|
|
1316
|
-
const form = new FormData();
|
|
1317
|
-
form.append("file_type", fileType);
|
|
1318
|
-
form.append("file_name", fileName);
|
|
1319
|
-
// 音频文件传 duration(opus 需要)
|
|
1320
|
-
if (fileType === "opus") {
|
|
1321
|
-
const durationMs = parseMediaDurationMs(buffer);
|
|
1322
|
-
if (durationMs > 0) form.append("duration", String(durationMs));
|
|
1323
|
-
}
|
|
1324
|
-
form.append("file", blob, fileName);
|
|
1325
|
-
|
|
1326
|
-
const resp = await fetch(`${BASE_URL}/im/v1/files`, {
|
|
1327
|
-
method: "POST",
|
|
1328
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
1329
|
-
body: form,
|
|
1330
|
-
});
|
|
1331
|
-
const text = await resp.text();
|
|
1332
|
-
let data: { code: number; msg?: string; data?: { file_key?: string } };
|
|
1333
|
-
try { data = JSON.parse(text); } catch {
|
|
1334
|
-
throw new Error(`uploadMessageFile non-JSON response: ${text.slice(0, 200)}`);
|
|
1335
|
-
}
|
|
1336
|
-
if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
|
|
1337
|
-
const fileKey = data.data?.file_key;
|
|
1338
|
-
if (!fileKey) throw new Error("uploadMessageFile response missing file_key");
|
|
1339
|
-
return fileKey;
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
export async function sendFileReply(
|
|
1343
|
-
token: string,
|
|
1344
|
-
chatId: string,
|
|
1345
|
-
filePath: string,
|
|
1346
|
-
): Promise<boolean> {
|
|
1347
|
-
try {
|
|
1348
|
-
const fileKey = await uploadMessageFile(token, filePath);
|
|
1349
|
-
const fileName = filePath.split(/[\\/]/).pop() || "file";
|
|
1350
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1351
|
-
method: "POST",
|
|
1352
|
-
headers: {
|
|
1353
|
-
Authorization: `Bearer ${token}`,
|
|
1354
|
-
"Content-Type": "application/json",
|
|
1355
|
-
},
|
|
1356
|
-
body: JSON.stringify({
|
|
1357
|
-
receive_id: chatId,
|
|
1358
|
-
msg_type: "file",
|
|
1359
|
-
content: JSON.stringify({ file_key: fileKey }),
|
|
1360
|
-
}),
|
|
1361
|
-
});
|
|
1362
|
-
const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
|
|
1363
|
-
if (data.code !== 0) {
|
|
1364
|
-
console.error(`[${ts()}] [SEND] file FAIL: chatId=${chatId} path=${filePath} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1365
|
-
throw new Error(`[${data.code}] ${data.msg ?? "send file failed"}`);
|
|
1366
|
-
}
|
|
1367
|
-
console.log(`[${ts()}] [SEND] file OK: chatId=${chatId} name=${fileName} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1368
|
-
return true;
|
|
1369
|
-
} catch (err) {
|
|
1370
|
-
if (!(err instanceof Error && err.message.startsWith("["))) {
|
|
1371
|
-
console.error(`[${ts()}] [SEND] file FAIL: chatId=${chatId} path=${filePath} ${(err as Error).message}`);
|
|
1372
|
-
}
|
|
1373
|
-
throw err;
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
export async function addReaction(
|
|
1378
|
-
token: string,
|
|
1379
|
-
messageId: string,
|
|
1380
|
-
emojiType = "Get"
|
|
1381
|
-
): Promise<void> {
|
|
1382
|
-
await fetch(`${BASE_URL}/im/v1/messages/${messageId}/reactions`, {
|
|
1383
|
-
method: "POST",
|
|
1384
|
-
headers: {
|
|
1385
|
-
Authorization: `Bearer ${token}`,
|
|
1386
|
-
"Content-Type": "application/json",
|
|
1387
|
-
},
|
|
1388
|
-
body: JSON.stringify({ reaction_type: { emoji_type: emojiType } }),
|
|
1389
|
-
});
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
export async function sendCardReply(
|
|
1393
|
-
token: string,
|
|
1394
|
-
chatId: string,
|
|
1395
|
-
title: string,
|
|
1396
|
-
content: string,
|
|
1397
|
-
template = "green"
|
|
1398
|
-
): Promise<boolean> {
|
|
1399
|
-
const safeTitle = applyPrivacy(title);
|
|
1400
|
-
const safeContent = applyPrivacy(content);
|
|
1401
|
-
const card = JSON.stringify({
|
|
1402
|
-
config: { wide_screen_mode: true },
|
|
1403
|
-
header: { template, title: { content: safeTitle, tag: "plain_text" } },
|
|
1404
|
-
elements: [{ tag: "div", text: { tag: "lark_md", content: safeContent } }],
|
|
1405
|
-
});
|
|
1406
|
-
|
|
1407
|
-
try {
|
|
1408
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1409
|
-
method: "POST",
|
|
1410
|
-
headers: {
|
|
1411
|
-
Authorization: `Bearer ${token}`,
|
|
1412
|
-
"Content-Type": "application/json",
|
|
1413
|
-
},
|
|
1414
|
-
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
|
|
1415
|
-
});
|
|
1416
|
-
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
1417
|
-
if (data.code !== 0) {
|
|
1418
|
-
console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1419
|
-
return false;
|
|
1420
|
-
}
|
|
1421
|
-
console.log(`[${ts()}] [SEND] card OK: chatId=${chatId} title="${title}" msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1422
|
-
return true;
|
|
1423
|
-
} catch (err) {
|
|
1424
|
-
console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" ${(err as Error).message}`);
|
|
1425
|
-
return false;
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
export async function sendRawCard(
|
|
1430
|
-
token: string,
|
|
1431
|
-
chatId: string,
|
|
1432
|
-
cardJson: string
|
|
1433
|
-
): Promise<boolean> {
|
|
1434
|
-
const safeJson = applyPrivacy(cardJson);
|
|
1435
|
-
try {
|
|
1436
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1437
|
-
method: "POST",
|
|
1438
|
-
headers: {
|
|
1439
|
-
Authorization: `Bearer ${token}`,
|
|
1440
|
-
"Content-Type": "application/json",
|
|
1441
|
-
},
|
|
1442
|
-
body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: safeJson }),
|
|
1443
|
-
});
|
|
1444
|
-
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
1445
|
-
if (data.code !== 0) {
|
|
1446
|
-
console.error(`[${ts()}] [SEND] raw_card FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1447
|
-
return false;
|
|
1448
|
-
}
|
|
1449
|
-
console.log(`[${ts()}] [SEND] raw_card OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1450
|
-
return true;
|
|
1451
|
-
} catch (err) {
|
|
1452
|
-
console.error(`[${ts()}] [SEND] raw_card FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
1453
|
-
return false;
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
|
|
1458
|
-
export async function sendRestartCard(token: string): Promise<void> {
|
|
1459
|
-
try {
|
|
1460
|
-
const files = await readdir(CHAT_LOGS_DIR).catch(() => [] as string[]);
|
|
1461
|
-
if (files.length === 0) {
|
|
1462
|
-
console.log(`[${ts()}] [RESTART] No chat logs found, skipping notification`);
|
|
1463
|
-
return;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
let latestChatId: string | null = null;
|
|
1467
|
-
let latestTime = 0;
|
|
1468
|
-
for (const f of files) {
|
|
1469
|
-
if (!f.endsWith(".jsonl")) continue;
|
|
1470
|
-
const filePath = join(CHAT_LOGS_DIR, f);
|
|
1471
|
-
const st = await stat(filePath).catch(() => null);
|
|
1472
|
-
if (!st) continue;
|
|
1473
|
-
if (st.mtimeMs > latestTime) {
|
|
1474
|
-
latestTime = st.mtimeMs;
|
|
1475
|
-
latestChatId = f.replace(".jsonl", "");
|
|
1476
|
-
}
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
if (!latestChatId) {
|
|
1480
|
-
console.log(`[${ts()}] [RESTART] Could not determine latest chat with messages`);
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
// 微信 chat ID 无法通过飞书 API 发送,跳过
|
|
1485
|
-
if (latestChatId.includes("@im.wechat")) {
|
|
1486
|
-
console.log(`[${ts()}] [RESTART] Latest chat is WeChat (${latestChatId}), skipping Feishu notification`);
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
console.log(`[${ts()}] [RESTART] Latest active chat: ${latestChatId} (mtime=${new Date(latestTime).toISOString()})`);
|
|
1491
|
-
|
|
1492
|
-
const restartCard = buildHelpCard("", {
|
|
1493
|
-
greeting: "Bot 已启动完成,可以继续使用。",
|
|
1494
|
-
defaultToolLabel: toolDisplayName(resolveDefaultAgentTool()),
|
|
1495
|
-
});
|
|
1496
|
-
await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1497
|
-
method: "POST",
|
|
1498
|
-
headers: {
|
|
1499
|
-
Authorization: `Bearer ${token}`,
|
|
1500
|
-
"Content-Type": "application/json",
|
|
1501
|
-
},
|
|
1502
|
-
body: JSON.stringify({ receive_id: latestChatId, msg_type: "interactive", content: restartCard }),
|
|
1503
|
-
});
|
|
1504
|
-
console.log(`[${ts()}] [RESTART] Notification sent to chat ${latestChatId}`);
|
|
1505
|
-
} catch (err) {
|
|
1506
|
-
console.error(`[${ts()}] [RESTART] Failed to send notification: ${(err as Error).message}`);
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
// 撤回消息,成功返回 true
|
|
1511
|
-
export async function recallMessage(token: string, messageId: string): Promise<boolean> {
|
|
1512
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
|
|
1513
|
-
method: "DELETE",
|
|
1514
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
1515
|
-
});
|
|
1516
|
-
const data = (await resp.json()) as { code: number };
|
|
1517
|
-
return data.code === 0;
|
|
1518
|
-
}
|
|
1519
|
-
|
|
1520
|
-
// 更新卡片消息内容(PATCH 方式仅适用于 interactive 消息)
|
|
1521
|
-
// 返回 true 表示更新成功,false 表示 API 返回了错误
|
|
1522
|
-
export async function updateCardMessage(token: string, messageId: string, content: string): Promise<boolean> {
|
|
1523
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
|
|
1524
|
-
method: "PATCH",
|
|
1525
|
-
headers: {
|
|
1526
|
-
Authorization: `Bearer ${token}`,
|
|
1527
|
-
"Content-Type": "application/json",
|
|
1528
|
-
},
|
|
1529
|
-
body: JSON.stringify({ content }),
|
|
1530
|
-
});
|
|
1531
|
-
const data = await resp.json().catch(() => ({})) as { code: number; msg?: string };
|
|
1532
|
-
if (data.code !== 0) {
|
|
1533
|
-
console.error(`[${ts()}] [PATCH] updateCardMessage FAIL: messageId=${messageId} code=${data.code} msg="${data.msg}"`);
|
|
1534
|
-
return false;
|
|
1535
|
-
}
|
|
1536
|
-
return true;
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
/**
|
|
1540
|
-
* 发送飞书 post 类型消息(非卡片富文本消息)。
|
|
1541
|
-
*
|
|
1542
|
-
* post 消息支持 table、text、a、at、img 等富文本元素,
|
|
1543
|
-
* 与 interactive 卡片消息不同,post 直接在消息流中渲染。
|
|
1544
|
-
*
|
|
1545
|
-
* @param postContent 飞书 post content 格式的二维数组,每个元素是一个 paragraph
|
|
1546
|
-
*/
|
|
1547
|
-
export async function sendPostMessage(
|
|
1548
|
-
token: string,
|
|
1549
|
-
chatId: string,
|
|
1550
|
-
title: string,
|
|
1551
|
-
postContent: unknown[][],
|
|
1552
|
-
): Promise<boolean> {
|
|
1553
|
-
const content = JSON.stringify({
|
|
1554
|
-
post: {
|
|
1555
|
-
zh_cn: {
|
|
1556
|
-
title,
|
|
1557
|
-
content: postContent,
|
|
1558
|
-
},
|
|
1559
|
-
},
|
|
1560
|
-
});
|
|
1561
|
-
try {
|
|
1562
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
|
|
1563
|
-
method: "POST",
|
|
1564
|
-
headers: {
|
|
1565
|
-
Authorization: `Bearer ${token}`,
|
|
1566
|
-
"Content-Type": "application/json",
|
|
1567
|
-
},
|
|
1568
|
-
body: JSON.stringify({
|
|
1569
|
-
receive_id: chatId,
|
|
1570
|
-
msg_type: "post",
|
|
1571
|
-
content,
|
|
1572
|
-
}),
|
|
1573
|
-
});
|
|
1574
|
-
const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
|
|
1575
|
-
if (data.code !== 0) {
|
|
1576
|
-
console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
|
|
1577
|
-
return false;
|
|
1578
|
-
}
|
|
1579
|
-
console.log(`[${ts()}] [SEND] post OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
|
|
1580
|
-
return true;
|
|
1581
|
-
} catch (err) {
|
|
1582
|
-
console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} ${(err as Error).message}`);
|
|
1583
|
-
return false;
|
|
1584
|
-
}
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
// ---------------------------------------------------------------------------
|
|
1588
|
-
// 合并转发消息
|
|
1589
|
-
// ---------------------------------------------------------------------------
|
|
1590
|
-
|
|
1591
|
-
/**
|
|
1592
|
-
* 获取合并转发消息中的子消息列表。
|
|
1593
|
-
*
|
|
1594
|
-
* 对合并转发消息调用 GET /im/v1/messages/{messageId} 时,
|
|
1595
|
-
* 返回 data.items[] 扁平的子消息列表,通过 upper_message_id 构建层级。
|
|
1596
|
-
* 第一个 item(无 upper_message_id)是合并转发消息自身。
|
|
1597
|
-
*
|
|
1598
|
-
* 权限要求: im:message:read
|
|
1599
|
-
*/
|
|
1600
|
-
export async function getMergeForwardMessages(
|
|
1601
|
-
token: string,
|
|
1602
|
-
messageId: string,
|
|
1603
|
-
): Promise<FeishuMessageItem[]> {
|
|
1604
|
-
const resp = await fetch(`${BASE_URL}/im/v1/messages/${messageId}`, {
|
|
1605
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
1606
|
-
});
|
|
1607
|
-
const json = (await resp.json()) as {
|
|
1608
|
-
code: number;
|
|
1609
|
-
msg?: string;
|
|
1610
|
-
data?: { items?: FeishuMessageItem[] };
|
|
1611
|
-
};
|
|
1612
|
-
if (json.code !== 0) {
|
|
1613
|
-
throw new Error(`getMergeForwardMessages [${json.code}] ${json.msg ?? ""}`);
|
|
1614
|
-
}
|
|
1615
|
-
return json.data?.items ?? [];
|
|
1616
|
-
}
|