chatccc 0.2.176 → 0.2.178
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 +3 -3
- package/agent-prompts/claude_specific.md +45 -45
- package/agent-prompts/codex_specific.md +2 -2
- package/agent-prompts/cursor_specific.md +2 -2
- package/im-skills/feishu-skill/receive-send-file.md +63 -63
- package/im-skills/feishu-skill/receive-send-image.md +24 -24
- package/package.json +1 -1
- package/src/__tests__/cardkit.test.ts +60 -60
- package/src/__tests__/claude-adapter.test.ts +592 -592
- package/src/__tests__/config-reload.test.ts +18 -18
- package/src/__tests__/feishu-api.test.ts +60 -60
- package/src/__tests__/feishu-avatar.test.ts +129 -129
- package/src/__tests__/feishu-platform.test.ts +16 -16
- package/src/__tests__/format-message.test.ts +272 -272
- package/src/__tests__/orchestrator.test.ts +93 -93
- package/src/__tests__/privacy.test.ts +198 -198
- package/src/__tests__/web-ui.test.ts +92 -92
- package/src/adapters/claude-adapter.ts +566 -566
- package/src/adapters/claude-session-meta-store.ts +120 -120
- package/src/agent-stop-stuck.ts +129 -129
- package/src/cards.ts +4 -4
- package/src/feishu-api.ts +7 -6
- package/src/feishu-platform.ts +15 -15
- package/src/format-message.ts +213 -213
- package/src/litellm-proxy.ts +374 -374
- package/src/orchestrator.ts +112 -112
- package/src/privacy.ts +118 -118
- package/src/session-chat-binding.ts +6 -6
- package/src/sim-platform.ts +14 -14
- package/src/web-ui.ts +6 -6
|
@@ -6,8 +6,8 @@ import { join } from "node:path";
|
|
|
6
6
|
import type { PlatformAdapter } from "../platform-adapter.ts";
|
|
7
7
|
import type { SessionInfo, ToolAdapter } from "../adapters/adapter-interface.ts";
|
|
8
8
|
|
|
9
|
-
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
-
const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
|
|
9
|
+
const mockStreamStates = new Map<string, { status: "running" | "done" | "stopped"; finalReply: string }>();
|
|
10
|
+
const mockGetCodexUsageSummary = vi.hoisted(() => vi.fn());
|
|
11
11
|
|
|
12
12
|
vi.mock("../im-skills.ts", () => ({
|
|
13
13
|
buildImSkillsPrompt: async () => "",
|
|
@@ -15,7 +15,7 @@ vi.mock("../im-skills.ts", () => ({
|
|
|
15
15
|
exportSkillSubDocs: async () => {},
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
|
-
vi.mock("../stream-state.ts", () => ({
|
|
18
|
+
vi.mock("../stream-state.ts", () => ({
|
|
19
19
|
readStreamState: async (sessionId: string) => {
|
|
20
20
|
const state = mockStreamStates.get(sessionId);
|
|
21
21
|
if (!state) return null;
|
|
@@ -50,14 +50,14 @@ vi.mock("../stream-state.ts", () => ({
|
|
|
50
50
|
cwd,
|
|
51
51
|
tool,
|
|
52
52
|
}),
|
|
53
|
-
fixStaleStreamStates: async () => {},
|
|
54
|
-
}));
|
|
55
|
-
|
|
56
|
-
vi.mock("../feishu-platform.ts", () => ({
|
|
57
|
-
getCodexUsageSummary: mockGetCodexUsageSummary,
|
|
58
|
-
getTenantAccessToken: vi.fn(async () => "tenant-token"),
|
|
59
|
-
sendPostMessage: vi.fn(async () => true),
|
|
60
|
-
}));
|
|
53
|
+
fixStaleStreamStates: async () => {},
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("../feishu-platform.ts", () => ({
|
|
57
|
+
getCodexUsageSummary: mockGetCodexUsageSummary,
|
|
58
|
+
getTenantAccessToken: vi.fn(async () => "tenant-token"),
|
|
59
|
+
sendPostMessage: vi.fn(async () => true),
|
|
60
|
+
}));
|
|
61
61
|
|
|
62
62
|
import { handleCommand } from "../orchestrator.ts";
|
|
63
63
|
import {
|
|
@@ -120,14 +120,14 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
120
120
|
_setSessionToolsFileForTest(join(tempDir, "sessions.json"));
|
|
121
121
|
resetState();
|
|
122
122
|
resetBindingState();
|
|
123
|
-
mockStreamStates.clear();
|
|
124
|
-
mockGetCodexUsageSummary.mockReset();
|
|
125
|
-
mockGetCodexUsageSummary.mockResolvedValue({
|
|
126
|
-
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
127
|
-
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
128
|
-
});
|
|
129
|
-
_setAdapterForToolForTest("claude", mockAdapter());
|
|
130
|
-
});
|
|
123
|
+
mockStreamStates.clear();
|
|
124
|
+
mockGetCodexUsageSummary.mockReset();
|
|
125
|
+
mockGetCodexUsageSummary.mockResolvedValue({
|
|
126
|
+
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
127
|
+
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
128
|
+
});
|
|
129
|
+
_setAdapterForToolForTest("claude", mockAdapter());
|
|
130
|
+
});
|
|
131
131
|
|
|
132
132
|
afterEach(async () => {
|
|
133
133
|
resetState();
|
|
@@ -249,7 +249,7 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
249
249
|
expect(registry["feishu-group"]?.sessionId).toBe("sid-feishu-new");
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
252
|
+
it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
|
|
253
253
|
const platform = mockPlatform("feishu");
|
|
254
254
|
await recordSessionRegistry({
|
|
255
255
|
chatId: "feishu-p2p",
|
|
@@ -264,76 +264,76 @@ describe("handleCommand WeChat processing ack", () => {
|
|
|
264
264
|
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
265
265
|
expect(platform.sendRawCard).toHaveBeenCalled();
|
|
266
266
|
const registry = await loadSessionRegistryForBinding();
|
|
267
|
-
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
it("handles /usage without creating a new Feishu group", async () => {
|
|
271
|
-
const platform = mockPlatform("feishu");
|
|
272
|
-
mockGetCodexUsageSummary.mockResolvedValue({
|
|
273
|
-
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
274
|
-
weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
278
|
-
|
|
279
|
-
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
280
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
281
|
-
"feishu-p2p",
|
|
282
|
-
"Codex Usage",
|
|
283
|
-
expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
|
|
284
|
-
"blue",
|
|
285
|
-
);
|
|
286
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
287
|
-
"feishu-p2p",
|
|
288
|
-
"Codex Usage",
|
|
289
|
-
expect.stringContaining("约 2小时52分钟后"),
|
|
290
|
-
"blue",
|
|
291
|
-
);
|
|
292
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
293
|
-
"feishu-p2p",
|
|
294
|
-
"Codex Usage",
|
|
295
|
-
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
296
|
-
"blue",
|
|
297
|
-
);
|
|
298
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
299
|
-
"feishu-p2p",
|
|
300
|
-
"Codex Usage",
|
|
301
|
-
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
302
|
-
"blue",
|
|
303
|
-
);
|
|
304
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
305
|
-
"feishu-p2p",
|
|
306
|
-
"Codex Usage",
|
|
307
|
-
expect.stringContaining("约 3天18小时17分钟后"),
|
|
308
|
-
"blue",
|
|
309
|
-
);
|
|
310
|
-
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
311
|
-
"feishu-p2p",
|
|
312
|
-
"Codex Usage",
|
|
313
|
-
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
314
|
-
"blue",
|
|
315
|
-
);
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it("only advertises /usage in new Codex session ready messages", async () => {
|
|
319
|
-
const codexPlatform = mockPlatform("feishu");
|
|
320
|
-
_setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
|
|
321
|
-
|
|
322
|
-
await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
323
|
-
|
|
324
|
-
expect(codexPlatform.sendCard).toHaveBeenCalledWith(
|
|
325
|
-
"feishu-group",
|
|
326
|
-
"Codex Session Ready",
|
|
327
|
-
expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
|
|
328
|
-
"green",
|
|
329
|
-
);
|
|
330
|
-
|
|
331
|
-
const claudePlatform = mockPlatform("feishu");
|
|
332
|
-
await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
|
|
333
|
-
|
|
334
|
-
const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
|
|
335
|
-
([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
|
|
336
|
-
);
|
|
337
|
-
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
338
|
-
});
|
|
339
|
-
});
|
|
267
|
+
expect(registry["feishu-p2p"]).toBeUndefined();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("handles /usage without creating a new Feishu group", async () => {
|
|
271
|
+
const platform = mockPlatform("feishu");
|
|
272
|
+
mockGetCodexUsageSummary.mockResolvedValue({
|
|
273
|
+
fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
|
|
274
|
+
weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
278
|
+
|
|
279
|
+
expect(platform.createGroup).not.toHaveBeenCalled();
|
|
280
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
281
|
+
"feishu-p2p",
|
|
282
|
+
"Codex Usage",
|
|
283
|
+
expect.stringContaining("**5h:** 已用 37%,剩余 63%,重置:"),
|
|
284
|
+
"blue",
|
|
285
|
+
);
|
|
286
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
287
|
+
"feishu-p2p",
|
|
288
|
+
"Codex Usage",
|
|
289
|
+
expect.stringContaining("约 2小时52分钟后"),
|
|
290
|
+
"blue",
|
|
291
|
+
);
|
|
292
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
293
|
+
"feishu-p2p",
|
|
294
|
+
"Codex Usage",
|
|
295
|
+
expect.stringContaining("[███████░░░░░░░░░░░░░]"),
|
|
296
|
+
"blue",
|
|
297
|
+
);
|
|
298
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
299
|
+
"feishu-p2p",
|
|
300
|
+
"Codex Usage",
|
|
301
|
+
expect.stringContaining("**周:** 已用 12%,剩余 88%,重置:"),
|
|
302
|
+
"blue",
|
|
303
|
+
);
|
|
304
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
305
|
+
"feishu-p2p",
|
|
306
|
+
"Codex Usage",
|
|
307
|
+
expect.stringContaining("约 3天18小时17分钟后"),
|
|
308
|
+
"blue",
|
|
309
|
+
);
|
|
310
|
+
expect(platform.sendCard).toHaveBeenCalledWith(
|
|
311
|
+
"feishu-p2p",
|
|
312
|
+
"Codex Usage",
|
|
313
|
+
expect.stringContaining("[██░░░░░░░░░░░░░░░░░░]"),
|
|
314
|
+
"blue",
|
|
315
|
+
);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("only advertises /usage in new Codex session ready messages", async () => {
|
|
319
|
+
const codexPlatform = mockPlatform("feishu");
|
|
320
|
+
_setAdapterForToolForTest("codex", mockAdapter("sid-codex"));
|
|
321
|
+
|
|
322
|
+
await handleCommand(codexPlatform, "/new codex", "feishu-p2p", "ou-user", Date.now(), "p2p");
|
|
323
|
+
|
|
324
|
+
expect(codexPlatform.sendCard).toHaveBeenCalledWith(
|
|
325
|
+
"feishu-group",
|
|
326
|
+
"Codex Session Ready",
|
|
327
|
+
expect.stringContaining("发送 **/usage** 查看 Codex 5h 和周用量。"),
|
|
328
|
+
"green",
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
const claudePlatform = mockPlatform("feishu");
|
|
332
|
+
await handleCommand(claudePlatform, "/new claude", "feishu-p2p-2", "ou-user", Date.now(), "p2p");
|
|
333
|
+
|
|
334
|
+
const claudeReadyCall = vi.mocked(claudePlatform.sendCard).mock.calls.find(
|
|
335
|
+
([chatId, title]) => chatId === "feishu-group" && title === "Claude Code Session Ready",
|
|
336
|
+
);
|
|
337
|
+
expect(claudeReadyCall?.[2]).not.toContain("/usage");
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -1,198 +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
|
-
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
|
-
});
|
|
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
|
+
});
|