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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.175",
3
+ "version": "0.2.177",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -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
- // import privacy 之前 mock config,让 USER_DATA_DIR 指向临时目录
7
- const TEST_DATA_DIR = await mkdtemp(join(tmpdir(), "chatccc-privacy-test-"));
8
- vi.mock("../config.ts", async () => {
9
- const actual = await vi.importActual<typeof import("../config.ts")>("../config.ts");
10
- return {
11
- ...actual,
12
- USER_DATA_DIR: TEST_DATA_DIR,
13
- ts: () => "test-ts",
14
- };
15
- });
16
-
17
- let applyPrivacy: (text: string) => string;
18
- let reloadPrivacyRules: () => void;
19
- let getPrivacyRules: () => Record<string, string>;
20
-
21
- beforeEach(async () => {
22
- vi.resetModules();
23
- // 清理临时目录中的 privacy.json
24
- try {
25
- await rm(join(TEST_DATA_DIR, "privacy.json"), { force: true });
26
- } catch {}
27
- const mod = await import("../privacy.ts");
28
- applyPrivacy = mod.applyPrivacy;
29
- reloadPrivacyRules = mod.reloadPrivacyRules;
30
- getPrivacyRules = mod.getPrivacyRules;
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(" privacy.json 时返回原文", () => {
47
- reloadPrivacyRules();
48
- expect(applyPrivacy("hello weizhangjian")).toBe("hello weizhangjian");
49
- });
50
-
51
- it("privacy.json 存在时按规则替换", 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("多规则替换多次出现", async () => {
65
- await writeFile(
66
- join(TEST_DATA_DIR, "privacy.json"),
67
- JSON.stringify({ a: "A", b: "B" }),
68
- "utf-8",
69
- );
70
- reloadPrivacyRules();
71
-
72
- expect(applyPrivacy("a b a b")).toBe("A B A B");
73
- });
74
-
75
- it("空文本直接返回", async () => {
76
- await writeFile(
77
- join(TEST_DATA_DIR, "privacy.json"),
78
- JSON.stringify({ x: "y" }),
79
- "utf-8",
80
- );
81
- reloadPrivacyRules();
82
-
83
- expect(applyPrivacy("")).toBe("");
84
- });
85
-
86
- it("规则中的特殊字符不会被当作正则", async () => {
87
- await writeFile(
88
- join(TEST_DATA_DIR, "privacy.json"),
89
- JSON.stringify({ "a.b": "X", "(test)": "Y", "*star": "Z" }),
90
- "utf-8",
91
- );
92
- reloadPrivacyRules();
93
-
94
- expect(applyPrivacy("hello a.b world")).toBe("hello X world");
95
- expect(applyPrivacy("text (test) here")).toBe("text Y here");
96
- expect(applyPrivacy("a *star shines")).toBe("a Z shines");
97
- });
98
-
99
- it("reloadPrivacyRules 强制重新加载", async () => {
100
- await writeFile(
101
- join(TEST_DATA_DIR, "privacy.json"),
102
- JSON.stringify({ old: "OLD" }),
103
- "utf-8",
104
- );
105
- reloadPrivacyRules();
106
-
107
- expect(applyPrivacy("old")).toBe("OLD");
108
- expect(getPrivacyRules()).toEqual({ old: "OLD" });
109
-
110
- // 变更磁盘内容后 reload
111
- await writeFile(
112
- join(TEST_DATA_DIR, "privacy.json"),
113
- JSON.stringify({ new: "NEW" }),
114
- "utf-8",
115
- );
116
- reloadPrivacyRules();
117
-
118
- expect(applyPrivacy("new")).toBe("NEW");
119
- expect(getPrivacyRules()).toEqual({ new: "NEW" });
120
- });
121
-
122
- it("格式错误的 JSON 不抛异常,返回原文", async () => {
123
- await writeFile(
124
- join(TEST_DATA_DIR, "privacy.json"),
125
- "not json",
126
- "utf-8",
127
- );
128
- reloadPrivacyRules();
129
-
130
- expect(applyPrivacy("hello")).toBe("hello");
131
- });
132
-
133
- it("数组格式的 JSON 不抛异常,返回原文", async () => {
134
- await writeFile(
135
- join(TEST_DATA_DIR, "privacy.json"),
136
- JSON.stringify(["a", "b"]),
137
- "utf-8",
138
- );
139
- reloadPrivacyRules();
140
-
141
- expect(applyPrivacy("hello")).toBe("hello");
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-v5";
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="700" letter-spacing="0" stroke="#0b1220" stroke-width="2.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
- }
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 PrivacyRules {
10
- [key: string]: string;
11
- }
12
-
13
- let rules: PrivacyRules | null = null;
14
- let loaded = false;
15
-
16
- function loadRules(): PrivacyRules {
17
- const filePath = join(USER_DATA_DIR, "privacy.json");
18
- if (!existsSync(filePath)) {
19
- return {};
20
- }
21
- try {
22
- const raw = readFileSync(filePath, "utf-8");
23
- const parsed = JSON.parse(raw);
24
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
25
- console.error(`[PRIVACY] privacy.json 格式错误:应为对象`);
26
- return {};
27
- }
28
- for (const [k, v] of Object.entries(parsed)) {
29
- if (typeof v !== "string") {
30
- console.error(`[PRIVACY] privacy.json 值必须为字符串,跳过 "${k}"`);
31
- delete (parsed as Record<string, unknown>)[k];
32
- }
33
- }
34
- return parsed as PrivacyRules;
35
- } catch (err) {
36
- console.error(`[PRIVACY] 读取 privacy.json 失败: ${(err as Error).message}`);
37
- return {};
38
- }
39
- }
40
-
41
- export function getPrivacyRules(): PrivacyRules {
42
- if (!loaded) {
43
- rules = loadRules();
44
- loaded = true;
45
- }
46
- return rules!;
47
- }
48
-
49
- /** 重新加载规则(热更新用) */
50
- export function reloadPrivacyRules(): void {
51
- loaded = false;
52
- rules = null;
53
- }
54
-
55
- /**
56
- * 对文本应用隐私替换规则。
57
- * 若无规则或文本为空,直接返回原文。
58
- */
59
- export function applyPrivacy(text: string): string {
60
- const r = getPrivacyRules();
61
- if (Object.keys(r).length === 0 || !text) return text;
62
- let result = text;
63
- for (const [from, to] of Object.entries(r)) {
64
- // split+join 替代 replaceAll,避免正则特殊字符问题
65
- result = result.split(from).join(to);
66
- }
67
- return result;
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
+ }