chatccc 0.2.53 → 0.2.54

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.53",
3
+ "version": "0.2.54",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -0,0 +1,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
+ // 在 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
+ });
package/src/feishu-api.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  CODEX_SESSION_PREFIX,
16
16
  ts,
17
17
  } from "./config.ts";
18
+ import { applyPrivacy } from "./privacy.ts";
18
19
  import { buildHelpCard } from "./cards.ts";
19
20
 
20
21
  // ---------------------------------------------------------------------------
@@ -536,9 +537,10 @@ export async function sendTextReply(
536
537
  chatId: string,
537
538
  text: string
538
539
  ): Promise<boolean> {
540
+ const safeText = applyPrivacy(text);
539
541
  const card = JSON.stringify({
540
542
  config: { wide_screen_mode: true },
541
- elements: [{ tag: "markdown", content: text }],
543
+ elements: [{ tag: "markdown", content: safeText }],
542
544
  });
543
545
  try {
544
546
  const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
@@ -778,10 +780,12 @@ export async function sendCardReply(
778
780
  content: string,
779
781
  template = "green"
780
782
  ): Promise<boolean> {
783
+ const safeTitle = applyPrivacy(title);
784
+ const safeContent = applyPrivacy(content);
781
785
  const card = JSON.stringify({
782
786
  config: { wide_screen_mode: true },
783
- header: { template, title: { content: title, tag: "plain_text" } },
784
- elements: [{ tag: "div", text: { tag: "lark_md", content } }],
787
+ header: { template, title: { content: safeTitle, tag: "plain_text" } },
788
+ elements: [{ tag: "div", text: { tag: "lark_md", content: safeContent } }],
785
789
  });
786
790
 
787
791
  try {
package/src/privacy.ts ADDED
@@ -0,0 +1,68 @@
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
+ }
@@ -24,6 +24,7 @@ import type { PlatformAdapter } from "./platform-adapter.ts";
24
24
  import { setupFileLogging } from "./shared.ts";
25
25
  import { appendChatLog } from "./config.ts";
26
26
  import { cardJsonToPlainText } from "./card-plain-text.ts";
27
+ import { applyPrivacy } from "./privacy.ts";
27
28
 
28
29
  interface TerminalQrRenderer {
29
30
  generate(
@@ -146,6 +147,8 @@ export function createWechatAdapter(
146
147
  const wire = getWire();
147
148
  if (!wire) return false;
148
149
 
150
+ text = applyPrivacy(text);
151
+
149
152
  // 微信 claw 连发限制:统计用户未回复时已连发条数
150
153
  const count = (consecutiveSendCount.get(chatId) ?? 0) + 1;
151
154
  consecutiveSendCount.set(chatId, count);