chatccc 0.2.175 → 0.2.177
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/privacy.test.ts +198 -143
- package/src/feishu-api.ts +236 -236
- package/src/privacy.ts +118 -68
package/package.json
CHANGED
|
@@ -1,143 +1,198 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
|
2
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
vi.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
let
|
|
18
|
-
let
|
|
19
|
-
let
|
|
20
|
-
|
|
21
|
-
beforeEach(async () => {
|
|
22
|
-
vi.resetModules();
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
afterEach(async () => {
|
|
34
|
-
try {
|
|
35
|
-
await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
|
|
36
|
-
} catch {}
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
afterAll(async () => {
|
|
40
|
-
try {
|
|
41
|
-
await rm(TEST_DATA_DIR, { recursive: true, force: true });
|
|
42
|
-
} catch {}
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("applyPrivacy", () => {
|
|
46
|
-
it("
|
|
47
|
-
reloadPrivacyRules();
|
|
48
|
-
expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("privacy
|
|
52
|
-
await writeFile(
|
|
53
|
-
join(TEST_DATA_DIR, "privacy.json"),
|
|
54
|
-
JSON.stringify({ weizhangjian: "wzj", secret: "***" }),
|
|
55
|
-
"utf-8",
|
|
56
|
-
);
|
|
57
|
-
reloadPrivacyRules();
|
|
58
|
-
|
|
59
|
-
expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
|
|
60
|
-
expect(applyPrivacy("my secret is safe")).toBe("my *** is safe");
|
|
61
|
-
expect(applyPrivacy("weizhangjian and secret")).toBe("wzj and ***");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("
|
|
65
|
-
await writeFile(
|
|
66
|
-
join(TEST_DATA_DIR, "privacy.json"),
|
|
67
|
-
JSON.stringify({
|
|
68
|
-
"utf-8",
|
|
69
|
-
);
|
|
70
|
-
reloadPrivacyRules();
|
|
71
|
-
|
|
72
|
-
expect(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
expect(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from "vitest";
|
|
2
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-privacy-test-"));
|
|
7
|
+
vi.mock("../config.ts", async () => {
|
|
8
|
+
const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
USER_DATA_DIR: TEST_DATA_DIR,
|
|
12
|
+
ts: () => "test-ts",
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
let applyPrivacy: (text: string) => string;
|
|
17
|
+
let reloadPrivacyRules: () => void;
|
|
18
|
+
let getPrivacyRules: () => Record<string, string>;
|
|
19
|
+
let getPrivacyConfig: () => { enabled: boolean; rules: Record<string, string> };
|
|
20
|
+
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
vi.resetModules();
|
|
23
|
+
try {
|
|
24
|
+
await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
|
|
25
|
+
} catch {}
|
|
26
|
+
const mod = await import("../privacy.ts");
|
|
27
|
+
applyPrivacy = mod.applyPrivacy;
|
|
28
|
+
reloadPrivacyRules = mod.reloadPrivacyRules;
|
|
29
|
+
getPrivacyRules = mod.getPrivacyRules;
|
|
30
|
+
getPrivacyConfig = mod.getPrivacyConfig;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(async () => {
|
|
34
|
+
try {
|
|
35
|
+
await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
|
|
36
|
+
} catch {}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterAll(async () => {
|
|
40
|
+
try {
|
|
41
|
+
await rm(TEST_DATA_DIR, { recursive: true, force: true });
|
|
42
|
+
} catch {}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("applyPrivacy", () => {
|
|
46
|
+
it("returns original text when privacy.json is missing", () => {
|
|
47
|
+
reloadPrivacyRules();
|
|
48
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("supports legacy flat privacy rules", async () => {
|
|
52
|
+
await writeFile(
|
|
53
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
54
|
+
JSON.stringify({ weizhangjian: "wzj", secret: "***" }),
|
|
55
|
+
"utf-8",
|
|
56
|
+
);
|
|
57
|
+
reloadPrivacyRules();
|
|
58
|
+
|
|
59
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
|
|
60
|
+
expect(applyPrivacy("my secret is safe")).toBe("my *** is safe");
|
|
61
|
+
expect(applyPrivacy("weizhangjian and secret")).toBe("wzj and ***");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("supports privacy.json schema with enabled=false", async () => {
|
|
65
|
+
await writeFile(
|
|
66
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
67
|
+
JSON.stringify({ enabled: false, rules: { weizhangjian: "wzj" } }),
|
|
68
|
+
"utf-8",
|
|
69
|
+
);
|
|
70
|
+
reloadPrivacyRules();
|
|
71
|
+
|
|
72
|
+
expect(getPrivacyConfig()).toEqual({ enabled: false, rules: { weizhangjian: "wzj" } });
|
|
73
|
+
expect(getPrivacyRules()).toEqual({ weizhangjian: "wzj" });
|
|
74
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("supports privacy.json schema with enabled=true", async () => {
|
|
78
|
+
await writeFile(
|
|
79
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
80
|
+
JSON.stringify({ enabled: true, rules: { weizhangjian: "wzj" } }),
|
|
81
|
+
"utf-8",
|
|
82
|
+
);
|
|
83
|
+
reloadPrivacyRules();
|
|
84
|
+
|
|
85
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("accepts UTF-8 BOM in privacy.json", async () => {
|
|
89
|
+
await writeFile(
|
|
90
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
91
|
+
`\uFEFF${JSON.stringify({ enabled: false, rules: { weizhangjian: "wzj" } })}`,
|
|
92
|
+
"utf-8",
|
|
93
|
+
);
|
|
94
|
+
reloadPrivacyRules();
|
|
95
|
+
|
|
96
|
+
expect(getPrivacyConfig()).toEqual({ enabled: false, rules: { weizhangjian: "wzj" } });
|
|
97
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("auto reloads privacy.json changes without explicit reload", async () => {
|
|
101
|
+
await writeFile(
|
|
102
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
103
|
+
JSON.stringify({ weizhangjian: "wzj" }),
|
|
104
|
+
"utf-8",
|
|
105
|
+
);
|
|
106
|
+
reloadPrivacyRules();
|
|
107
|
+
|
|
108
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello wzj");
|
|
109
|
+
|
|
110
|
+
await writeFile(
|
|
111
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
112
|
+
JSON.stringify({ enabled: false, rules: { weizhangjian: "wzj-disabled" } }),
|
|
113
|
+
"utf-8",
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
|
|
117
|
+
expect(getPrivacyConfig()).toEqual({ enabled: false, rules: { weizhangjian: "wzj-disabled" } });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("replaces multiple rules and repeated occurrences", async () => {
|
|
121
|
+
await writeFile(
|
|
122
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
123
|
+
JSON.stringify({ a: "A", b: "B" }),
|
|
124
|
+
"utf-8",
|
|
125
|
+
);
|
|
126
|
+
reloadPrivacyRules();
|
|
127
|
+
|
|
128
|
+
expect(applyPrivacy("a b a b")).toBe("A B A B");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns empty text directly", async () => {
|
|
132
|
+
await writeFile(
|
|
133
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
134
|
+
JSON.stringify({ x: "y" }),
|
|
135
|
+
"utf-8",
|
|
136
|
+
);
|
|
137
|
+
reloadPrivacyRules();
|
|
138
|
+
|
|
139
|
+
expect(applyPrivacy("")).toBe("");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("treats special characters in rule keys literally", async () => {
|
|
143
|
+
await writeFile(
|
|
144
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
145
|
+
JSON.stringify({ "a.b": "X", "(test)": "Y", "*star": "Z" }),
|
|
146
|
+
"utf-8",
|
|
147
|
+
);
|
|
148
|
+
reloadPrivacyRules();
|
|
149
|
+
|
|
150
|
+
expect(applyPrivacy("hello a.b world")).toBe("hello X world");
|
|
151
|
+
expect(applyPrivacy("text (test) here")).toBe("text Y here");
|
|
152
|
+
expect(applyPrivacy("a *star shines")).toBe("a Z shines");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("reloadPrivacyRules forces a reload", async () => {
|
|
156
|
+
await writeFile(
|
|
157
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
158
|
+
JSON.stringify({ old: "OLD" }),
|
|
159
|
+
"utf-8",
|
|
160
|
+
);
|
|
161
|
+
reloadPrivacyRules();
|
|
162
|
+
|
|
163
|
+
expect(applyPrivacy("old")).toBe("OLD");
|
|
164
|
+
expect(getPrivacyRules()).toEqual({ old: "OLD" });
|
|
165
|
+
|
|
166
|
+
await writeFile(
|
|
167
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
168
|
+
JSON.stringify({ new: "NEW" }),
|
|
169
|
+
"utf-8",
|
|
170
|
+
);
|
|
171
|
+
reloadPrivacyRules();
|
|
172
|
+
|
|
173
|
+
expect(applyPrivacy("new")).toBe("NEW");
|
|
174
|
+
expect(getPrivacyRules()).toEqual({ new: "NEW" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("returns original text for malformed JSON", async () => {
|
|
178
|
+
await writeFile(
|
|
179
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
180
|
+
"not json",
|
|
181
|
+
"utf-8",
|
|
182
|
+
);
|
|
183
|
+
reloadPrivacyRules();
|
|
184
|
+
|
|
185
|
+
expect(applyPrivacy("hello")).toBe("hello");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("returns original text for array JSON", async () => {
|
|
189
|
+
await writeFile(
|
|
190
|
+
join(TEST_DATA_DIR, "privacy.json"),
|
|
191
|
+
JSON.stringify(["a", "b"]),
|
|
192
|
+
"utf-8",
|
|
193
|
+
);
|
|
194
|
+
reloadPrivacyRules();
|
|
195
|
+
|
|
196
|
+
expect(applyPrivacy("hello")).toBe("hello");
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/feishu-api.ts
CHANGED
|
@@ -307,30 +307,30 @@ const AVATAR_SOURCES: Record<string, string> = {
|
|
|
307
307
|
busy: resolvePath(AVATAR_DIR, "status_busy.png"),
|
|
308
308
|
idle: resolvePath(AVATAR_DIR, "status_idle.png"),
|
|
309
309
|
};
|
|
310
|
-
const AVATAR_BADGES: Record<string, string> = {
|
|
311
|
-
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
312
|
-
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
313
|
-
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
314
|
-
};
|
|
315
|
-
const AVATAR_SIZE = 256;
|
|
316
|
-
const AVATAR_BADGE_SIZE = 92;
|
|
317
|
-
const AVATAR_BADGE_MARGIN = 10;
|
|
318
|
-
const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-
|
|
319
|
-
|
|
320
|
-
export interface CodexUsageBalance {
|
|
321
|
-
usedPercent: number;
|
|
322
|
-
remainingPercent: number;
|
|
323
|
-
resetAtEpochSeconds: number | null;
|
|
324
|
-
resetAfterSeconds: number | null;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
export interface CodexUsageSummary {
|
|
328
|
-
fiveHour: CodexUsageBalance;
|
|
329
|
-
weekly: CodexUsageBalance | null;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const avatarKeyCache = new Map<string, string>();
|
|
333
|
-
let avatarKeyCacheLoaded = false;
|
|
310
|
+
const AVATAR_BADGES: Record<string, string> = {
|
|
311
|
+
claude: resolvePath(AVATAR_BADGE_DIR, "badge_claude.png"),
|
|
312
|
+
cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
|
|
313
|
+
codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
|
|
314
|
+
};
|
|
315
|
+
const AVATAR_SIZE = 256;
|
|
316
|
+
const AVATAR_BADGE_SIZE = 92;
|
|
317
|
+
const AVATAR_BADGE_MARGIN = 10;
|
|
318
|
+
const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v8";
|
|
319
|
+
|
|
320
|
+
export interface CodexUsageBalance {
|
|
321
|
+
usedPercent: number;
|
|
322
|
+
remainingPercent: number;
|
|
323
|
+
resetAtEpochSeconds: number | null;
|
|
324
|
+
resetAfterSeconds: number | null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface CodexUsageSummary {
|
|
328
|
+
fiveHour: CodexUsageBalance;
|
|
329
|
+
weekly: CodexUsageBalance | null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const avatarKeyCache = new Map<string, string>();
|
|
333
|
+
let avatarKeyCacheLoaded = false;
|
|
334
334
|
|
|
335
335
|
function normalizeAvatarTool(tool: string): string {
|
|
336
336
|
return AVATAR_BADGES[tool] ? tool : "claude";
|
|
@@ -344,16 +344,16 @@ function avatarCombinationPath(tool: string, status: string): string {
|
|
|
344
344
|
return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
-
function avatarCacheKey(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): string {
|
|
348
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
349
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
350
|
-
if (normalizedTool === "codex") {
|
|
351
|
-
return codexUsage
|
|
352
|
-
? `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:week-battery:${codexUsage.weekly?.remainingPercent}:5h-ring:${codexUsage.fiveHour.remainingPercent}`
|
|
353
|
-
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
354
|
-
}
|
|
355
|
-
return `${normalizedTool}:${normalizedStatus}`;
|
|
356
|
-
}
|
|
347
|
+
function avatarCacheKey(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): string {
|
|
348
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
349
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
350
|
+
if (normalizedTool === "codex") {
|
|
351
|
+
return codexUsage
|
|
352
|
+
? `${normalizedTool}:${normalizedStatus}:${CODEX_AVATAR_USAGE_STYLE_VERSION}:week-battery:${codexUsage.weekly?.remainingPercent}:5h-ring:${codexUsage.fiveHour.remainingPercent}`
|
|
353
|
+
: `${normalizedTool}:${normalizedStatus}:plain`;
|
|
354
|
+
}
|
|
355
|
+
return `${normalizedTool}:${normalizedStatus}`;
|
|
356
|
+
}
|
|
357
357
|
|
|
358
358
|
async function loadAvatarKeyCache(): Promise<void> {
|
|
359
359
|
if (avatarKeyCacheLoaded) return;
|
|
@@ -378,37 +378,37 @@ async function persistAvatarKeyCache(): Promise<void> {
|
|
|
378
378
|
);
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
function clampPercent(value: number): number {
|
|
382
|
-
if (!Number.isFinite(value)) return 0;
|
|
383
|
-
return Math.max(0, Math.min(100, Math.round(value)));
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string): CodexUsageBalance {
|
|
387
|
-
const value = raw.used_percent;
|
|
388
|
-
const usedPercent = Number(value);
|
|
389
|
-
if (!Number.isFinite(usedPercent)) throw new Error(`missing ${fieldName}.used_percent`);
|
|
390
|
-
const used = clampPercent(usedPercent);
|
|
391
|
-
const resetAt = Number(raw.reset_at);
|
|
392
|
-
const resetAfter = Number(raw.reset_after_seconds);
|
|
393
|
-
return {
|
|
394
|
-
usedPercent: used,
|
|
395
|
-
remainingPercent: clampPercent(100 - used),
|
|
396
|
-
resetAtEpochSeconds: Number.isFinite(resetAt) ? resetAt : null,
|
|
397
|
-
resetAfterSeconds: Number.isFinite(resetAfter) ? resetAfter : null,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
|
|
402
|
-
for (const key of keys) {
|
|
403
|
-
const raw = rateLimit[key];
|
|
404
|
-
if (!raw || typeof raw !== "object") continue;
|
|
405
|
-
if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
|
|
406
|
-
return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
|
|
407
|
-
}
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
async function getCodexAccessToken(): Promise<string | null> {
|
|
381
|
+
function clampPercent(value: number): number {
|
|
382
|
+
if (!Number.isFinite(value)) return 0;
|
|
383
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function usageBalanceFromWindow(raw: Record<string, unknown>, fieldName: string): CodexUsageBalance {
|
|
387
|
+
const value = raw.used_percent;
|
|
388
|
+
const usedPercent = Number(value);
|
|
389
|
+
if (!Number.isFinite(usedPercent)) throw new Error(`missing ${fieldName}.used_percent`);
|
|
390
|
+
const used = clampPercent(usedPercent);
|
|
391
|
+
const resetAt = Number(raw.reset_at);
|
|
392
|
+
const resetAfter = Number(raw.reset_after_seconds);
|
|
393
|
+
return {
|
|
394
|
+
usedPercent: used,
|
|
395
|
+
remainingPercent: clampPercent(100 - used),
|
|
396
|
+
resetAtEpochSeconds: Number.isFinite(resetAt) ? resetAt : null,
|
|
397
|
+
resetAfterSeconds: Number.isFinite(resetAfter) ? resetAfter : null,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseOptionalUsageWindow(rateLimit: Record<string, unknown>, keys: string[]): CodexUsageBalance | null {
|
|
402
|
+
for (const key of keys) {
|
|
403
|
+
const raw = rateLimit[key];
|
|
404
|
+
if (!raw || typeof raw !== "object") continue;
|
|
405
|
+
if ((raw as { used_percent?: unknown }).used_percent === undefined) continue;
|
|
406
|
+
return usageBalanceFromWindow(raw as Record<string, unknown>, `rate_limit.${key}`);
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function getCodexAccessToken(): Promise<string | null> {
|
|
412
412
|
try {
|
|
413
413
|
const raw = await readFile(CODEX_AUTH_FILE, "utf-8");
|
|
414
414
|
const parsed = JSON.parse(raw) as { tokens?: { access_token?: unknown } };
|
|
@@ -416,49 +416,49 @@ async function getCodexAccessToken(): Promise<string | null> {
|
|
|
416
416
|
return typeof token === "string" && token.trim() ? token : null;
|
|
417
417
|
} catch {
|
|
418
418
|
return null;
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
|
|
423
|
-
const token = await getCodexAccessToken();
|
|
424
|
-
if (!token) throw new Error("missing ~/.codex/auth.json access token");
|
|
425
|
-
|
|
426
|
-
const resp = await fetch(CODEX_USAGE_URL, {
|
|
427
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
428
|
-
});
|
|
429
|
-
const text = await resp.text();
|
|
430
|
-
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
431
|
-
|
|
432
|
-
const data = JSON.parse(text) as {
|
|
433
|
-
rate_limit?: Record<string, unknown>;
|
|
434
|
-
};
|
|
435
|
-
const rateLimit = data.rate_limit;
|
|
436
|
-
if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
|
|
437
|
-
|
|
438
|
-
const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
|
|
439
|
-
if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
|
|
440
|
-
|
|
441
|
-
return {
|
|
442
|
-
fiveHour,
|
|
443
|
-
weekly: parseOptionalUsageWindow(rateLimit, [
|
|
444
|
-
"secondary_window",
|
|
445
|
-
"weekly_window",
|
|
446
|
-
"week_window",
|
|
447
|
-
"long_window",
|
|
448
|
-
]),
|
|
449
|
-
};
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
|
|
453
|
-
try {
|
|
454
|
-
const summary = await getCodexUsageSummary();
|
|
455
|
-
if (!summary.weekly) throw new Error("missing weekly usage window");
|
|
456
|
-
return summary;
|
|
457
|
-
} catch (err) {
|
|
458
|
-
console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
459
|
-
return null;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export async function getCodexUsageSummary(): Promise<CodexUsageSummary> {
|
|
423
|
+
const token = await getCodexAccessToken();
|
|
424
|
+
if (!token) throw new Error("missing ~/.codex/auth.json access token");
|
|
425
|
+
|
|
426
|
+
const resp = await fetch(CODEX_USAGE_URL, {
|
|
427
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
428
|
+
});
|
|
429
|
+
const text = await resp.text();
|
|
430
|
+
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
431
|
+
|
|
432
|
+
const data = JSON.parse(text) as {
|
|
433
|
+
rate_limit?: Record<string, unknown>;
|
|
434
|
+
};
|
|
435
|
+
const rateLimit = data.rate_limit;
|
|
436
|
+
if (!rateLimit || typeof rateLimit !== "object") throw new Error("missing rate_limit");
|
|
437
|
+
|
|
438
|
+
const fiveHour = parseOptionalUsageWindow(rateLimit, ["primary_window"]);
|
|
439
|
+
if (!fiveHour) throw new Error("missing rate_limit.primary_window.used_percent");
|
|
440
|
+
|
|
441
|
+
return {
|
|
442
|
+
fiveHour,
|
|
443
|
+
weekly: parseOptionalUsageWindow(rateLimit, [
|
|
444
|
+
"secondary_window",
|
|
445
|
+
"weekly_window",
|
|
446
|
+
"week_window",
|
|
447
|
+
"long_window",
|
|
448
|
+
]),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function fetchCodexAvatarUsage(): Promise<CodexUsageSummary | null> {
|
|
453
|
+
try {
|
|
454
|
+
const summary = await getCodexUsageSummary();
|
|
455
|
+
if (!summary.weekly) throw new Error("missing weekly usage window");
|
|
456
|
+
return summary;
|
|
457
|
+
} catch (err) {
|
|
458
|
+
console.warn(`[${ts()}] [AVATAR] Codex usage unavailable, using plain avatar: ${(err as Error).message}`);
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
462
|
|
|
463
463
|
function codexUsagePalette(remainingPercent: number): { start: string; end: string; glow: string } {
|
|
464
464
|
if (remainingPercent <= 25) return { start: "#ef4444", end: "#fb923c", glow: "#fed7aa" };
|
|
@@ -466,26 +466,26 @@ function codexUsagePalette(remainingPercent: number): { start: string; end: stri
|
|
|
466
466
|
return { start: "#16a34a", end: "#34d399", glow: "#bbf7d0" };
|
|
467
467
|
}
|
|
468
468
|
|
|
469
|
-
function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
470
|
-
const remaining = clampPercent(remainingPercent);
|
|
471
|
-
const label = String(remaining);
|
|
472
|
-
const palette = codexUsagePalette(remaining);
|
|
473
|
-
|
|
474
|
-
const bodyX = 28;
|
|
475
|
-
const bodyY = 38;
|
|
476
|
-
const bodyW = 109;
|
|
477
|
-
const bodyH = 56;
|
|
478
|
-
const capW = 12;
|
|
479
|
-
const capH = 22;
|
|
480
|
-
const capX = bodyX + bodyW;
|
|
481
|
-
const capY = bodyY + Math.round((bodyH - capH) / 2);
|
|
482
|
-
const pad = 6;
|
|
483
|
-
const fillMaxW = bodyW - pad * 2;
|
|
484
|
-
const fillWidth = Math.round((fillMaxW * remaining) / 100);
|
|
485
|
-
const labelFontSize = label.length >= 3 ? 49 : 51;
|
|
486
|
-
|
|
487
|
-
return Buffer.from(`
|
|
488
|
-
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
469
|
+
function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
470
|
+
const remaining = clampPercent(remainingPercent);
|
|
471
|
+
const label = String(remaining);
|
|
472
|
+
const palette = codexUsagePalette(remaining);
|
|
473
|
+
|
|
474
|
+
const bodyX = 28;
|
|
475
|
+
const bodyY = 38;
|
|
476
|
+
const bodyW = 109;
|
|
477
|
+
const bodyH = 56;
|
|
478
|
+
const capW = 12;
|
|
479
|
+
const capH = 22;
|
|
480
|
+
const capX = bodyX + bodyW;
|
|
481
|
+
const capY = bodyY + Math.round((bodyH - capH) / 2);
|
|
482
|
+
const pad = 6;
|
|
483
|
+
const fillMaxW = bodyW - pad * 2;
|
|
484
|
+
const fillWidth = Math.round((fillMaxW * remaining) / 100);
|
|
485
|
+
const labelFontSize = label.length >= 3 ? 49 : 51;
|
|
486
|
+
|
|
487
|
+
return Buffer.from(`
|
|
488
|
+
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
489
489
|
<defs>
|
|
490
490
|
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
|
491
491
|
<feDropShadow dx="0" dy="4" stdDeviation="4" flood-color="#4a2712" flood-opacity="0.30"/>
|
|
@@ -501,100 +501,100 @@ function buildCodexUsageBatterySvg(remainingPercent: number): Buffer {
|
|
|
501
501
|
<clipPath id="batteryInnerClip">
|
|
502
502
|
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="10"/>
|
|
503
503
|
</clipPath>
|
|
504
|
-
</defs>
|
|
505
|
-
<g filter="url(#shadow)">
|
|
506
|
-
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="#0f172a"/>
|
|
507
|
-
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="12" fill="url(#well)"/>
|
|
508
|
-
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillWidth}" height="${bodyH - pad * 2}" fill="url(#fill)" clip-path="url(#batteryInnerClip)"/>
|
|
509
|
-
<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)"/>
|
|
510
|
-
<rect x="${capX}" y="${capY}" width="${capW}" height="${capH}" rx="6" fill="#0f172a"/>
|
|
511
|
-
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="none" stroke="#f8fafc" stroke-opacity="0.82" stroke-width="3.8"/>
|
|
512
|
-
<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="
|
|
513
|
-
</g>
|
|
514
|
-
</svg>`);
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
|
|
518
|
-
const remaining = clampPercent(remainingPercent);
|
|
519
|
-
const palette = codexUsagePalette(remaining);
|
|
520
|
-
const cx = 128;
|
|
521
|
-
const cy = 128;
|
|
522
|
-
const r = 118;
|
|
523
|
-
const strokeWidth = 13;
|
|
524
|
-
const used = clampPercent(100 - remaining);
|
|
525
|
-
const polar = (angleDegrees: number) => {
|
|
526
|
-
const angle = (angleDegrees * Math.PI) / 180;
|
|
527
|
-
return {
|
|
528
|
-
x: cx + r * Math.cos(angle),
|
|
529
|
-
y: cy + r * Math.sin(angle),
|
|
530
|
-
};
|
|
531
|
-
};
|
|
532
|
-
const startAngle = -90 + (used / 100) * 360;
|
|
533
|
-
const sweepAngle = (remaining / 100) * 360;
|
|
534
|
-
const start = polar(startAngle);
|
|
535
|
-
const end = polar(startAngle + Math.min(sweepAngle, 359.99));
|
|
536
|
-
const largeArcFlag = sweepAngle > 180 ? 1 : 0;
|
|
537
|
-
const progressPath = remaining >= 100
|
|
538
|
-
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`
|
|
539
|
-
: remaining <= 0
|
|
540
|
-
? ""
|
|
541
|
-
: `<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)"/>`;
|
|
542
|
-
|
|
543
|
-
return Buffer.from(`
|
|
544
|
-
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
545
|
-
<defs>
|
|
546
|
-
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
|
547
|
-
<stop offset="0" stop-color="${palette.start}"/>
|
|
548
|
-
<stop offset="1" stop-color="${palette.end}"/>
|
|
549
|
-
</linearGradient>
|
|
550
|
-
<filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
|
|
551
|
-
<feDropShadow dx="0" dy="2" stdDeviation="2.4" flood-color="#0f172a" flood-opacity="0.25"/>
|
|
552
|
-
</filter>
|
|
553
|
-
</defs>
|
|
554
|
-
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#cbd5e1" stroke-width="${strokeWidth}"/>
|
|
555
|
-
${progressPath}
|
|
556
|
-
</svg>`);
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
|
|
560
|
-
const badge = await sharp(AVATAR_BADGES[tool])
|
|
561
|
-
.resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
|
|
562
|
-
fit: "contain",
|
|
563
|
-
kernel: sharp.kernel.lanczos3,
|
|
564
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
565
|
-
})
|
|
566
|
-
.png()
|
|
567
|
-
.toBuffer();
|
|
568
|
-
return {
|
|
569
|
-
input: badge,
|
|
570
|
-
left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
571
|
-
top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
async function renderAvatar(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
576
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
577
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
578
|
-
const composites: sharp.OverlayOptions[] = [];
|
|
579
|
-
|
|
580
|
-
const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage?.weekly;
|
|
581
|
-
const basePath = useDynamicCodexAvatar
|
|
582
|
-
? AVATAR_SOURCES[normalizedStatus]
|
|
583
|
-
: avatarCombinationPath(normalizedTool, normalizedStatus);
|
|
584
|
-
|
|
585
|
-
if (useDynamicCodexAvatar) {
|
|
586
|
-
composites.push(
|
|
587
|
-
{ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 },
|
|
588
|
-
{ input: buildCodexUsageBatterySvg(codexUsage.weekly.remainingPercent), left: 0, top: 0 },
|
|
589
|
-
await buildAgentBadgeOverlay(normalizedTool),
|
|
590
|
-
);
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
let pipeline = sharp(await readFile(basePath))
|
|
594
|
-
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
|
|
595
|
-
if (composites.length > 0) {
|
|
596
|
-
pipeline = pipeline.composite(composites);
|
|
597
|
-
}
|
|
504
|
+
</defs>
|
|
505
|
+
<g filter="url(#shadow)">
|
|
506
|
+
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="#0f172a"/>
|
|
507
|
+
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillMaxW}" height="${bodyH - pad * 2}" rx="12" fill="url(#well)"/>
|
|
508
|
+
<rect x="${bodyX + pad}" y="${bodyY + pad}" width="${fillWidth}" height="${bodyH - pad * 2}" fill="url(#fill)" clip-path="url(#batteryInnerClip)"/>
|
|
509
|
+
<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)"/>
|
|
510
|
+
<rect x="${capX}" y="${capY}" width="${capW}" height="${capH}" rx="6" fill="#0f172a"/>
|
|
511
|
+
<rect x="${bodyX}" y="${bodyY}" width="${bodyW}" height="${bodyH}" rx="18" fill="none" stroke="#f8fafc" stroke-opacity="0.82" stroke-width="3.8"/>
|
|
512
|
+
<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>
|
|
513
|
+
</g>
|
|
514
|
+
</svg>`);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function buildCodexUsageRingSvg(remainingPercent: number): Buffer {
|
|
518
|
+
const remaining = clampPercent(remainingPercent);
|
|
519
|
+
const palette = codexUsagePalette(remaining);
|
|
520
|
+
const cx = 128;
|
|
521
|
+
const cy = 128;
|
|
522
|
+
const r = 118;
|
|
523
|
+
const strokeWidth = 13;
|
|
524
|
+
const used = clampPercent(100 - remaining);
|
|
525
|
+
const polar = (angleDegrees: number) => {
|
|
526
|
+
const angle = (angleDegrees * Math.PI) / 180;
|
|
527
|
+
return {
|
|
528
|
+
x: cx + r * Math.cos(angle),
|
|
529
|
+
y: cy + r * Math.sin(angle),
|
|
530
|
+
};
|
|
531
|
+
};
|
|
532
|
+
const startAngle = -90 + (used / 100) * 360;
|
|
533
|
+
const sweepAngle = (remaining / 100) * 360;
|
|
534
|
+
const start = polar(startAngle);
|
|
535
|
+
const end = polar(startAngle + Math.min(sweepAngle, 359.99));
|
|
536
|
+
const largeArcFlag = sweepAngle > 180 ? 1 : 0;
|
|
537
|
+
const progressPath = remaining >= 100
|
|
538
|
+
? `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="url(#ring)" stroke-width="${strokeWidth}" stroke-linecap="round" filter="url(#ringShadow)"/>`
|
|
539
|
+
: remaining <= 0
|
|
540
|
+
? ""
|
|
541
|
+
: `<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)"/>`;
|
|
542
|
+
|
|
543
|
+
return Buffer.from(`
|
|
544
|
+
<svg width="256" height="256" xmlns="http://www.w3.org/2000/svg">
|
|
545
|
+
<defs>
|
|
546
|
+
<linearGradient id="ring" x1="0" y1="0" x2="1" y2="1">
|
|
547
|
+
<stop offset="0" stop-color="${palette.start}"/>
|
|
548
|
+
<stop offset="1" stop-color="${palette.end}"/>
|
|
549
|
+
</linearGradient>
|
|
550
|
+
<filter id="ringShadow" x="-10%" y="-10%" width="120%" height="120%">
|
|
551
|
+
<feDropShadow dx="0" dy="2" stdDeviation="2.4" flood-color="#0f172a" flood-opacity="0.25"/>
|
|
552
|
+
</filter>
|
|
553
|
+
</defs>
|
|
554
|
+
<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#cbd5e1" stroke-width="${strokeWidth}"/>
|
|
555
|
+
${progressPath}
|
|
556
|
+
</svg>`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function buildAgentBadgeOverlay(tool: string): Promise<sharp.OverlayOptions> {
|
|
560
|
+
const badge = await sharp(AVATAR_BADGES[tool])
|
|
561
|
+
.resize(AVATAR_BADGE_SIZE, AVATAR_BADGE_SIZE, {
|
|
562
|
+
fit: "contain",
|
|
563
|
+
kernel: sharp.kernel.lanczos3,
|
|
564
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
565
|
+
})
|
|
566
|
+
.png()
|
|
567
|
+
.toBuffer();
|
|
568
|
+
return {
|
|
569
|
+
input: badge,
|
|
570
|
+
left: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
571
|
+
top: AVATAR_SIZE - AVATAR_BADGE_SIZE - AVATAR_BADGE_MARGIN,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async function renderAvatar(tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
|
|
576
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
577
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
578
|
+
const composites: sharp.OverlayOptions[] = [];
|
|
579
|
+
|
|
580
|
+
const useDynamicCodexAvatar = normalizedTool === "codex" && codexUsage?.weekly;
|
|
581
|
+
const basePath = useDynamicCodexAvatar
|
|
582
|
+
? AVATAR_SOURCES[normalizedStatus]
|
|
583
|
+
: avatarCombinationPath(normalizedTool, normalizedStatus);
|
|
584
|
+
|
|
585
|
+
if (useDynamicCodexAvatar) {
|
|
586
|
+
composites.push(
|
|
587
|
+
{ input: buildCodexUsageRingSvg(codexUsage.fiveHour.remainingPercent), left: 0, top: 0 },
|
|
588
|
+
{ input: buildCodexUsageBatterySvg(codexUsage.weekly.remainingPercent), left: 0, top: 0 },
|
|
589
|
+
await buildAgentBadgeOverlay(normalizedTool),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let pipeline = sharp(await readFile(basePath))
|
|
594
|
+
.resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "center" });
|
|
595
|
+
if (composites.length > 0) {
|
|
596
|
+
pipeline = pipeline.composite(composites);
|
|
597
|
+
}
|
|
598
598
|
|
|
599
599
|
const jpeg = await pipeline
|
|
600
600
|
.flatten({ background: "#ffffff" })
|
|
@@ -602,16 +602,16 @@ async function renderAvatar(tool: string, status: string, codexUsage: CodexUsage
|
|
|
602
602
|
.jpeg({ quality: 95, progressive: false })
|
|
603
603
|
.toBuffer();
|
|
604
604
|
|
|
605
|
-
return {
|
|
606
|
-
buffer: jpeg,
|
|
607
|
-
contentType: "image/jpeg",
|
|
608
|
-
filename: codexUsage?.weekly
|
|
609
|
-
? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
|
|
610
|
-
: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
611
|
-
};
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
async function uploadImage(token: string, tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<string> {
|
|
605
|
+
return {
|
|
606
|
+
buffer: jpeg,
|
|
607
|
+
contentType: "image/jpeg",
|
|
608
|
+
filename: codexUsage?.weekly
|
|
609
|
+
? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
|
|
610
|
+
: `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function uploadImage(token: string, tool: string, status: string, codexUsage: CodexUsageSummary | null = null): Promise<string> {
|
|
615
615
|
const image = await renderAvatar(tool, status, codexUsage);
|
|
616
616
|
const blob = new Blob([new Uint8Array(image.buffer)], { type: image.contentType });
|
|
617
617
|
const form = new FormData();
|
|
@@ -634,10 +634,10 @@ async function uploadImage(token: string, tool: string, status: string, codexUsa
|
|
|
634
634
|
}
|
|
635
635
|
|
|
636
636
|
async function getOrUploadAvatarKey(token: string, tool: string, status: string): Promise<string> {
|
|
637
|
-
await loadAvatarKeyCache();
|
|
638
|
-
const normalizedTool = normalizeAvatarTool(tool);
|
|
639
|
-
const normalizedStatus = normalizeAvatarStatus(status);
|
|
640
|
-
const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
|
|
637
|
+
await loadAvatarKeyCache();
|
|
638
|
+
const normalizedTool = normalizeAvatarTool(tool);
|
|
639
|
+
const normalizedStatus = normalizeAvatarStatus(status);
|
|
640
|
+
const codexUsage = normalizedTool === "codex" ? await fetchCodexAvatarUsage() : null;
|
|
641
641
|
const keyName = avatarCacheKey(normalizedTool, normalizedStatus, codexUsage);
|
|
642
642
|
const cached = avatarKeyCache.get(keyName);
|
|
643
643
|
if (cached) return cached;
|
package/src/privacy.ts
CHANGED
|
@@ -1,68 +1,118 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { USER_DATA_DIR } from "./config.ts";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
interface
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
let
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { USER_DATA_DIR } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
interface PrivacyRules {
|
|
6
|
+
[key: string]: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface PrivacyConfig {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
rules: PrivacyRules;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let config: PrivacyConfig | null = null;
|
|
15
|
+
let loadedStamp: string | null | undefined;
|
|
16
|
+
|
|
17
|
+
function privacyFilePath(): string {
|
|
18
|
+
return join(USER_DATA_DIR, "privacy.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function privacyFileStamp(): string | null {
|
|
22
|
+
const filePath = privacyFilePath();
|
|
23
|
+
if (!existsSync(filePath)) return null;
|
|
24
|
+
try {
|
|
25
|
+
const s = statSync(filePath);
|
|
26
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
|
|
33
|
+
const result: PrivacyRules = {};
|
|
34
|
+
for (const [from, to] of Object.entries(raw)) {
|
|
35
|
+
if (!from) {
|
|
36
|
+
console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (typeof to !== "string") {
|
|
40
|
+
console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
result[from] = to;
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
|
|
49
|
+
const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
|
|
50
|
+
Object.prototype.hasOwnProperty.call(parsed, "rules");
|
|
51
|
+
|
|
52
|
+
if (!hasNewSchema) {
|
|
53
|
+
return { enabled: true, rules: sanitizeRules(parsed) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const enabledRaw = parsed.enabled;
|
|
57
|
+
const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
|
|
58
|
+
if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
|
|
59
|
+
console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const rulesRaw = parsed.rules;
|
|
63
|
+
if (rulesRaw === undefined) return { enabled, rules: {} };
|
|
64
|
+
if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
|
|
65
|
+
console.error("[PRIVACY] privacy.json rules must be an object");
|
|
66
|
+
return { enabled, rules: {} };
|
|
67
|
+
}
|
|
68
|
+
return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadConfig(): PrivacyConfig {
|
|
72
|
+
const filePath = privacyFilePath();
|
|
73
|
+
if (!existsSync(filePath)) {
|
|
74
|
+
return { enabled: true, rules: {} };
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
|
|
78
|
+
const parsed = JSON.parse(raw);
|
|
79
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
80
|
+
console.error("[PRIVACY] privacy.json must be an object");
|
|
81
|
+
return { enabled: true, rules: {} };
|
|
82
|
+
}
|
|
83
|
+
return normalizeConfig(parsed as Record<string, unknown>);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
|
|
86
|
+
return { enabled: true, rules: {} };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function getPrivacyConfig(): PrivacyConfig {
|
|
91
|
+
const stamp = privacyFileStamp();
|
|
92
|
+
if (!config || stamp !== loadedStamp) {
|
|
93
|
+
config = loadConfig();
|
|
94
|
+
loadedStamp = stamp;
|
|
95
|
+
}
|
|
96
|
+
return config;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getPrivacyRules(): PrivacyRules {
|
|
100
|
+
return getPrivacyConfig().rules;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function reloadPrivacyRules(): void {
|
|
104
|
+
config = null;
|
|
105
|
+
loadedStamp = undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function applyPrivacy(text: string): string {
|
|
109
|
+
const { enabled, rules } = getPrivacyConfig();
|
|
110
|
+
if (!enabled || Object.keys(rules).length === 0 || !text) return text;
|
|
111
|
+
|
|
112
|
+
let result = text;
|
|
113
|
+
for (const [from, to] of Object.entries(rules)) {
|
|
114
|
+
// Use split+join instead of regex replacement so rule keys stay literal.
|
|
115
|
+
result = result.split(from).join(to);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|