weflow-cli 1.6.4 → 1.8.0

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.
Files changed (205) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +1966 -0
  3. package/OPERATIONS.md +808 -0
  4. package/README.en.md +93 -7
  5. package/README.md +31 -6
  6. package/bin/weflow-cli.ts +1245 -26
  7. package/dist/bin/weflow-cli.js +1349 -72
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +43 -0
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +31 -0
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +18 -4
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +590 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +205 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1089 -58
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +13 -0
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +23 -0
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/messageQuery.d.ts +35 -0
  88. package/dist/src/services/messageQuery.d.ts.map +1 -1
  89. package/dist/src/services/messageQuery.js +61 -0
  90. package/dist/src/services/messageQuery.js.map +1 -1
  91. package/dist/src/services/pythonBridge.d.ts +41 -0
  92. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  93. package/dist/src/services/pythonBridge.js +119 -0
  94. package/dist/src/services/pythonBridge.js.map +1 -0
  95. package/dist/src/services/syncService.d.ts +127 -0
  96. package/dist/src/services/syncService.d.ts.map +1 -0
  97. package/dist/src/services/syncService.js +263 -0
  98. package/dist/src/services/syncService.js.map +1 -0
  99. package/dist/src/services/syncState.d.ts +113 -0
  100. package/dist/src/services/syncState.d.ts.map +1 -0
  101. package/dist/src/services/syncState.js +214 -0
  102. package/dist/src/services/syncState.js.map +1 -0
  103. package/dist/src/services/wechatMessageService.d.ts +25 -0
  104. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  105. package/dist/src/services/wechatMessageService.js +69 -7
  106. package/dist/src/services/wechatMessageService.js.map +1 -1
  107. package/dist/src/utils/atomicWrite.d.ts +4 -0
  108. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  109. package/dist/src/utils/atomicWrite.js +60 -0
  110. package/dist/src/utils/atomicWrite.js.map +1 -0
  111. package/dist/src/utils/dateRange.d.ts +15 -0
  112. package/dist/src/utils/dateRange.d.ts.map +1 -1
  113. package/dist/src/utils/dateRange.js +47 -0
  114. package/dist/src/utils/dateRange.js.map +1 -1
  115. package/dist/src/utils/text.d.ts +18 -0
  116. package/dist/src/utils/text.d.ts.map +1 -0
  117. package/dist/src/utils/text.js +20 -0
  118. package/dist/src/utils/text.js.map +1 -0
  119. package/docs/AI_INTERFACE.md +2 -0
  120. package/docs/DECISIONS.md +1484 -20
  121. package/docs/EXTENDING.md +187 -0
  122. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +338 -0
  123. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  124. package/docs/MCP.md +23 -4
  125. package/docs/PROJECT_STATE.md +299 -96
  126. package/docs/SETUP.md +22 -0
  127. package/docs/SYNC_CONTRACT.md +180 -0
  128. package/mcp-server/index.ts +681 -657
  129. package/package.json +84 -83
  130. package/resources/panel/ball-position.cjs +153 -0
  131. package/resources/panel/index.html +46 -0
  132. package/resources/panel/main.cjs +447 -0
  133. package/resources/panel/mascot-focus.png +0 -0
  134. package/resources/panel/mascot-happy.png +0 -0
  135. package/resources/panel/mascot-sorry.png +0 -0
  136. package/resources/panel/mascot-tired.png +0 -0
  137. package/resources/panel/mascot.png +0 -0
  138. package/resources/panel/package.json +7 -0
  139. package/resources/panel/panel.css +407 -0
  140. package/resources/panel/preload.cjs +72 -0
  141. package/resources/panel/quick-menu.cjs +197 -0
  142. package/resources/panel/renderer.js +441 -0
  143. package/resources/panel/tray-menu.cjs +40 -0
  144. package/resources/panel/tray.png +0 -0
  145. package/scripts/_utils.py +403 -54
  146. package/scripts/annual_report.py +4 -2
  147. package/scripts/article_notes.py +423 -0
  148. package/scripts/backfill_articles.py +438 -0
  149. package/scripts/biz_daily.py +590 -105
  150. package/scripts/chat_notes.py +470 -0
  151. package/scripts/classify_daily.py +10 -18
  152. package/scripts/compile_wiki.py +1059 -46
  153. package/scripts/create_reading_notes.py +199 -14
  154. package/scripts/decide.py +253 -0
  155. package/scripts/draft_reply.py +671 -0
  156. package/scripts/export_chat_html.py +212 -46
  157. package/scripts/extract_todos.py +31 -5
  158. package/scripts/fav_notes.py +318 -0
  159. package/scripts/fix_topics.py +2 -1
  160. package/scripts/generate_ai_report.py +214 -9
  161. package/scripts/generate_html.py +18 -3
  162. package/scripts/generate_review.py +2 -2
  163. package/scripts/jev_client.py +294 -0
  164. package/scripts/jev_probe.py +440 -0
  165. package/scripts/nt_common.py +82 -0
  166. package/scripts/nt_decrypt.py +1755 -1352
  167. package/scripts/pipeline.py +27 -2
  168. package/scripts/promote_ideas.py +19 -4
  169. package/scripts/quality_eval.py +707 -0
  170. package/scripts/rag_chat.py +4 -4
  171. package/scripts/read_image.py +187 -0
  172. package/scripts/reply_debt.py +488 -0
  173. package/scripts/route_cards.py +453 -0
  174. package/scripts/semantic_search.py +208 -49
  175. package/scripts/user_notes.py +315 -0
  176. package/scripts/vault_rag.py +13 -7
  177. package/scripts/vault_search.py +21 -4
  178. package/scripts/wiki_lint.py +397 -0
  179. package/src/core/appMsgFormat.ts +117 -0
  180. package/src/core/ntCore.ts +67 -0
  181. package/src/core/sqlcipherCore.ts +5 -61
  182. package/src/core/wcdbCore.ts +6 -2
  183. package/src/panel/client.ts +49 -0
  184. package/src/panel/endpoint.ts +144 -0
  185. package/src/panel/launch.ts +101 -0
  186. package/src/panel/server.ts +424 -0
  187. package/src/panel/userId.ts +72 -0
  188. package/src/services/assistantDaemon.ts +57 -9
  189. package/src/services/assistantEval.ts +611 -0
  190. package/src/services/assistantMemory.ts +413 -57
  191. package/src/services/assistantPrivacy.ts +18 -4
  192. package/src/services/assistantRouter.ts +191 -0
  193. package/src/services/assistantService.ts +671 -74
  194. package/src/services/assistantTools.ts +1118 -57
  195. package/src/services/assistantTrace.ts +175 -0
  196. package/src/services/chatService.ts +30 -0
  197. package/src/services/configService.ts +403 -385
  198. package/src/services/messageQuery.ts +97 -0
  199. package/src/services/pythonBridge.ts +139 -0
  200. package/src/services/syncService.ts +363 -0
  201. package/src/services/syncState.ts +275 -0
  202. package/src/services/wechatMessageService.ts +73 -7
  203. package/src/utils/atomicWrite.ts +56 -0
  204. package/src/utils/dateRange.ts +46 -0
  205. package/src/utils/text.ts +20 -0
@@ -1,385 +1,403 @@
1
- import { join } from 'path'
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
3
- import { homedir, hostname, userInfo } from 'os'
4
- import crypto from 'crypto'
5
- import { expandHomePath } from '../utils/pathUtils.js'
6
- import type { ConfigData } from '../types.js'
7
-
8
- const LOCK_PREFIX = 'lock:'
9
-
10
- interface CliConfig {
11
- dbPath: string
12
- wxid: string
13
- decryptKey: string
14
- decryptKey3x: string
15
- dataVersion: string
16
- dbPath3x: string
17
- ntDbPath: string
18
- ntKey: string
19
- ntSalt: string
20
- contactDbPath: string
21
- contactKey: string
22
- contactSalt: string
23
- // 微信消息通道
24
- wechatOcToken: string
25
- wechatOcAccountId: string
26
- wechatOcBaseUrl: string
27
- wechatOcSyncBuf: string
28
- /** 持久化的 context_token: wxid -> token (加密 JSON 字符串) */
29
- wechatOcContextTokens: string
30
- // 白名单
31
- whitelist: string[]
32
- // 黑名单 (绝对禁止收发)
33
- blacklist: string[]
34
- /** 白名单带昵称: {wxid, displayName, addedAt}[] */
35
- whitelistEntries: ListEntry[]
36
- /** 黑名单带昵称: {wxid, displayName, addedAt, reason?}[] */
37
- blacklistEntries: ListEntry[]
38
- // Vault & Pipeline
39
- vaultRepo: string
40
- aiEngine: string
41
- // 微信读书
42
- wereadApiKey: string
43
- // AI 引擎 API Key
44
- deepseekApiKey: string
45
- // 朋友圈 (SNS) 数据库
46
- snsDbPath: string
47
- snsKey: string
48
- snsSalt: string
49
- // 收藏 (favorite.db)
50
- favDbPath: string
51
- favKey: string
52
- emoticonSeed: string
53
- favPassphrase: string
54
- // 第二大脑助手
55
- /** 隐私模式: open | balanced | strict,默认 strict */
56
- assistantPrivacy: string
57
- /** 本地推理引擎模型名 (ollama/lmstudio) */
58
- localModel: string
59
- /** 自定义 AI 端点 (OpenAI 兼容中转站); 设置后云端引擎走此地址 */
60
- aiBaseUrl: string
61
- /** 自定义端点使用的模型名 */
62
- aiModel: string
63
- /** 助手白名单 (@im.wechat ID, 逗号/空格分隔); 空 = 拒绝所有人 */
64
- assistantWhitelist: string
65
- /** 官方通道明确提供群 ID 时才使用;空 = 拒绝所有群 */
66
- assistantGroupWhitelist: string
67
- /** 群内是否必须明确 @ 机器人;默认开启 */
68
- assistantGroupRequireMention: string
69
- dailySources: string
70
- /** 公众号类别映射,JSON 对象:公众号名称或 gh_ ID -> 类别 */
71
- dailySourceCategories: string
72
- dailyAiEnabled: string
73
- }
74
-
75
- export interface ListEntry {
76
- wxid: string
77
- displayName?: string
78
- addedAt?: number
79
- reason?: string
80
- }
81
-
82
- /** 需要加密存储的字段 */
83
- const ENCRYPTED_KEYS: ReadonlySet<keyof CliConfig> = new Set([
84
- 'decryptKey', 'decryptKey3x', 'ntKey', 'contactKey',
85
- 'wechatOcToken', 'wereadApiKey', 'deepseekApiKey', 'snsKey',
86
- 'favKey', 'favPassphrase',
87
- ])
88
-
89
- const CONFIG_DIR = join(homedir(), '.weflow-cli')
90
- const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
91
-
92
- export class ConfigService {
93
- private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', dailySources: '', dailySourceCategories: '', dailyAiEnabled: 'true' }
94
-
95
- /** 本进程修改过、待回写的字段 (多进程并发写保护) */
96
- private dirty = new Set<keyof CliConfig>()
97
-
98
- constructor() {
99
- this.load()
100
- }
101
-
102
- private load(): void {
103
- try {
104
- if (existsSync(CONFIG_FILE)) {
105
- const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
106
- const whitelist = Array.isArray(data.whitelist) ? data.whitelist : []
107
- const blacklist = Array.isArray(data.blacklist) ? data.blacklist : []
108
- // entries 优先; 缺失时从旧 string[] 迁移
109
- const whitelistEntries: ListEntry[] = Array.isArray(data.whitelistEntries)
110
- ? data.whitelistEntries
111
- : whitelist.map((wxid: string) => ({ wxid }))
112
- const blacklistEntries: ListEntry[] = Array.isArray(data.blacklistEntries)
113
- ? data.blacklistEntries
114
- : blacklist.map((wxid: string) => ({ wxid }))
115
- this.config = {
116
- dbPath: data.dbPath || '',
117
- wxid: data.wxid || '',
118
- decryptKey: data.decryptKey || '',
119
- decryptKey3x: data.decryptKey3x || '',
120
- dataVersion: data.dataVersion || '',
121
- dbPath3x: data.dbPath3x || '',
122
- ntDbPath: data.ntDbPath || '',
123
- ntKey: data.ntKey || '',
124
- ntSalt: data.ntSalt || '',
125
- contactDbPath: data.contactDbPath || '',
126
- contactKey: data.contactKey || '',
127
- contactSalt: data.contactSalt || '',
128
- wechatOcToken: data.wechatOcToken || '',
129
- wechatOcAccountId: data.wechatOcAccountId || '',
130
- wechatOcBaseUrl: data.wechatOcBaseUrl || '',
131
- wechatOcSyncBuf: data.wechatOcSyncBuf || '',
132
- wechatOcContextTokens: data.wechatOcContextTokens || '',
133
- whitelist,
134
- blacklist,
135
- whitelistEntries,
136
- blacklistEntries,
137
- vaultRepo: data.vaultRepo || '',
138
- aiEngine: data.aiEngine || 'deepseek',
139
- wereadApiKey: data.wereadApiKey || '',
140
- deepseekApiKey: data.deepseekApiKey || '',
141
- snsDbPath: data.snsDbPath || '',
142
- snsKey: data.snsKey || '',
143
- snsSalt: data.snsSalt || '',
144
- favDbPath: data.favDbPath || '',
145
- favKey: data.favKey || '',
146
- favPassphrase: data.favPassphrase || '',
147
- emoticonSeed: data.emoticonSeed || '',
148
- assistantPrivacy: data.assistantPrivacy || 'strict',
149
- localModel: data.localModel || '',
150
- aiBaseUrl: data.aiBaseUrl || '',
151
- aiModel: data.aiModel || '',
152
- assistantWhitelist: data.assistantWhitelist || '',
153
- assistantGroupWhitelist: data.assistantGroupWhitelist || '',
154
- assistantGroupRequireMention: data.assistantGroupRequireMention === 'false' ? 'false' : 'true',
155
- dailySources: data.dailySources || '',
156
- dailySourceCategories: data.dailySourceCategories || '',
157
- dailyAiEnabled: data.dailyAiEnabled === 'false' ? 'false' : 'true',
158
- }
159
- }
160
- } catch (e) {
161
- // 配置文件损坏,输出警告并使用默认值
162
- console.warn(`[weflow-cli] 配置文件解析失败 (${CONFIG_FILE}): ${e instanceof Error ? e.message : e},将使用默认配置`)
163
- }
164
- }
165
-
166
- private save(): void {
167
- try {
168
- if (!existsSync(CONFIG_DIR)) {
169
- mkdirSync(CONFIG_DIR, { recursive: true })
170
- }
171
- // 多进程安全 (守护进程与 CLI 并发): 只回写本进程修改过的字段,
172
- // 其余字段以磁盘最新值保留, 避免旧内存快照覆盖他人写入 (幽灵回写)。
173
- let merged: Record<string, unknown> = { ...this.config }
174
- try {
175
- if (existsSync(CONFIG_FILE) && this.dirty.size > 0) {
176
- const onDisk: Record<string, unknown> = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
177
- merged = { ...onDisk, ...this.config }
178
- for (const key of Object.keys(onDisk)) {
179
- if (this.dirty.has(key as keyof CliConfig)) continue
180
- merged[key] = onDisk[key]
181
- }
182
- }
183
- } catch { /* 磁盘副本不可读时退回整份写入 */ }
184
- // 原子写入: 先写临时文件再改名, 防止进程中途被杀导致配置损坏
185
- const tmpFile = CONFIG_FILE + '.tmp'
186
- writeFileSync(tmpFile, JSON.stringify(merged, null, 2), 'utf8')
187
- renameSync(tmpFile, CONFIG_FILE)
188
- this.dirty.clear()
189
- } catch (e) {
190
- console.error('保存配置失败:', e)
191
- }
192
- }
193
-
194
- get<K extends keyof CliConfig>(key: K): CliConfig[K] {
195
- const raw = this.config[key]
196
- if (ENCRYPTED_KEYS.has(key) && typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
197
- return this.lockDecrypt(raw) as CliConfig[K]
198
- }
199
- if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof raw === 'string') {
200
- return expandHomePath(raw) as CliConfig[K]
201
- }
202
- return raw
203
- }
204
-
205
- set<K extends keyof CliConfig>(key: K, value: CliConfig[K]): void {
206
- if (ENCRYPTED_KEYS.has(key) && typeof value === 'string' && value) {
207
- this.config[key] = this.lockEncrypt(value) as CliConfig[K]
208
- } else if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof value === 'string') {
209
- this.config[key] = expandHomePath(value) as CliConfig[K]
210
- } else {
211
- this.config[key] = value
212
- }
213
- this.dirty.add(key)
214
- this.save()
215
- }
216
-
217
- getAll(): ConfigData {
218
- return {
219
- dbPath: this.get('dbPath'),
220
- wxid: this.get('wxid'),
221
- decryptKey: this.get('decryptKey'),
222
- decryptKey3x: this.get('decryptKey3x'),
223
- dataVersion: (this.config.dataVersion || '4.x') as ConfigData['dataVersion'],
224
- dbPath3x: this.get('dbPath3x'),
225
- ntDbPath: this.config.ntDbPath,
226
- ntKey: this.get('ntKey'),
227
- ntSalt: this.config.ntSalt,
228
- contactDbPath: this.config.contactDbPath,
229
- contactKey: this.get('contactKey'),
230
- contactSalt: this.config.contactSalt,
231
- snsDbPath: this.config.snsDbPath,
232
- snsKey: this.get('snsKey'),
233
- snsSalt: this.config.snsSalt,
234
- }
235
- }
236
-
237
- getWhitelist(): string[] {
238
- return this.config.whitelist || []
239
- }
240
-
241
- setWhitelist(list: string[]): void {
242
- this.config.whitelist = list
243
- this.dirty.add('whitelist')
244
- this.save()
245
- }
246
-
247
- getWhitelistEntries(): ListEntry[] {
248
- return this.config.whitelistEntries || []
249
- }
250
-
251
- setWhitelistEntries(entries: ListEntry[]): void {
252
- this.config.whitelistEntries = entries
253
- // 同步 string[] 视图, 供旧代码使用
254
- this.config.whitelist = entries.map(e => e.wxid)
255
- this.dirty.add('whitelistEntries')
256
- this.dirty.add('whitelist')
257
- this.save()
258
- }
259
-
260
- getBlacklist(): string[] {
261
- return this.config.blacklist || []
262
- }
263
-
264
- setBlacklist(list: string[]): void {
265
- this.config.blacklist = list
266
- this.dirty.add('blacklist')
267
- this.save()
268
- }
269
-
270
- getBlacklistEntries(): ListEntry[] {
271
- return this.config.blacklistEntries || []
272
- }
273
-
274
- setBlacklistEntries(entries: ListEntry[]): void {
275
- this.config.blacklistEntries = entries
276
- this.config.blacklist = entries.map(e => e.wxid)
277
- this.dirty.add('blacklistEntries')
278
- this.dirty.add('blacklist')
279
- this.save()
280
- }
281
-
282
- /** 读取持久化的 context_token: wxid -> token */
283
- getContextTokens(): Record<string, string> {
284
- const raw = this.config.wechatOcContextTokens || ''
285
- if (!raw) return {}
286
- const json = raw.startsWith(LOCK_PREFIX) ? this.lockDecrypt(raw) : raw
287
- if (!json) return {}
288
- try {
289
- const obj = JSON.parse(json)
290
- return (obj && typeof obj === 'object') ? obj as Record<string, string> : {}
291
- } catch {
292
- return {}
293
- }
294
- }
295
-
296
- /** 持久化 context_token 集合 (加密存储) */
297
- setContextTokens(tokens: Record<string, string>): void {
298
- const json = JSON.stringify(tokens || {})
299
- this.config.wechatOcContextTokens = json ? this.lockEncrypt(json) : ''
300
- this.dirty.add('wechatOcContextTokens')
301
- this.save()
302
- }
303
-
304
- /** 增量更新单个 wxid 的 context_token */
305
- upsertContextToken(wxid: string, token: string): void {
306
- if (!wxid || !token) return
307
- const tokens = this.getContextTokens()
308
- if (tokens[wxid] === token) return
309
- tokens[wxid] = token
310
- this.setContextTokens(tokens)
311
- }
312
-
313
- isConfigured(): boolean {
314
- const has4x = !!(this.config.dbPath && this.config.decryptKey)
315
- const has3x = !!(this.config.dbPath3x && this.config.decryptKey3x)
316
- const hasNt = !!(this.config.ntDbPath && this.config.ntKey && this.config.ntSalt)
317
- return has4x || has3x || hasNt
318
- }
319
-
320
- clearDatabaseKeys(): void {
321
- this.clearDatabaseKeysInMemory(true)
322
- }
323
-
324
- maskDatabaseKeysForTesting(): void {
325
- this.clearDatabaseKeysInMemory(false)
326
- }
327
-
328
- private clearDatabaseKeysInMemory(persist: boolean): void {
329
- const keyFields: Array<keyof CliConfig> = [
330
- 'decryptKey', 'decryptKey3x', 'ntKey', 'ntSalt',
331
- 'contactKey', 'contactSalt', 'snsKey', 'snsSalt',
332
- 'favKey', 'favPassphrase',
333
- ]
334
- for (const key of keyFields) {
335
- this.config[key] = '' as never
336
- if (persist) this.dirty.add(key)
337
- }
338
- if (persist) this.save()
339
- }
340
-
341
- clear(): void {
342
- this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', dailySources: '', dailySourceCategories: '', dailyAiEnabled: 'true' }
343
- // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
344
- this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
345
- this.save()
346
- }
347
-
348
- private lockEncrypt(plaintext: string): string {
349
- const machineId = this.getMachineId()
350
- const salt = crypto.randomBytes(16)
351
- const iv = crypto.randomBytes(12)
352
- const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
353
- const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
354
- const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
355
- const authTag = cipher.getAuthTag()
356
- const combined = Buffer.concat([salt, iv, authTag, encrypted])
357
- return LOCK_PREFIX + combined.toString('base64')
358
- }
359
-
360
- private lockDecrypt(stored: string): string {
361
- if (!stored || !stored.startsWith(LOCK_PREFIX)) return stored
362
- try {
363
- const machineId = this.getMachineId()
364
- const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
365
- const salt = combined.subarray(0, 16)
366
- const iv = combined.subarray(16, 28)
367
- const authTag = combined.subarray(28, 44)
368
- const ciphertext = combined.subarray(44)
369
- const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
370
- const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
371
- decipher.setAuthTag(authTag)
372
- const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
373
- return decrypted.toString('utf8')
374
- } catch {
375
- return ''
376
- }
377
- }
378
-
379
- private getMachineId(): string {
380
- // 使用机器名 + 用户名作为机器标识,足够绑定到单台机器
381
- return `${hostname()}-${userInfo().username}-weflow-cli`
382
- }
383
- }
384
-
385
- export const configService = new ConfigService()
1
+ import { join } from 'path'
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs'
3
+ import { homedir, hostname, userInfo } from 'os'
4
+ import crypto from 'crypto'
5
+ import { expandHomePath } from '../utils/pathUtils.js'
6
+ import { writeFileAtomic } from '../utils/atomicWrite.js'
7
+ import type { ConfigData } from '../types.js'
8
+
9
+ const LOCK_PREFIX = 'lock:'
10
+
11
+ interface CliConfig {
12
+ dbPath: string
13
+ wxid: string
14
+ decryptKey: string
15
+ decryptKey3x: string
16
+ dataVersion: string
17
+ dbPath3x: string
18
+ ntDbPath: string
19
+ ntKey: string
20
+ ntSalt: string
21
+ contactDbPath: string
22
+ contactKey: string
23
+ contactSalt: string
24
+ // 微信消息通道
25
+ wechatOcToken: string
26
+ wechatOcAccountId: string
27
+ wechatOcBaseUrl: string
28
+ wechatOcSyncBuf: string
29
+ /** 持久化的 context_token: wxid -> token (加密 JSON 字符串) */
30
+ wechatOcContextTokens: string
31
+ // 白名单
32
+ whitelist: string[]
33
+ // 黑名单 (绝对禁止收发)
34
+ blacklist: string[]
35
+ /** 白名单带昵称: {wxid, displayName, addedAt}[] */
36
+ whitelistEntries: ListEntry[]
37
+ /** 黑名单带昵称: {wxid, displayName, addedAt, reason?}[] */
38
+ blacklistEntries: ListEntry[]
39
+ // Vault & Pipeline
40
+ vaultRepo: string
41
+ aiEngine: string
42
+ // 微信读书
43
+ wereadApiKey: string
44
+ // AI 引擎 API Key
45
+ deepseekApiKey: string
46
+ // TypeSafe (Jev) 决策模型 API Key —— 日报的主题/相关度判断用它
47
+ typesafeApiKey: string
48
+ // 阿里云百炼(DashScope)API Key —— 语义检索 / RAG 的 embedding 用它
49
+ dashscopeApiKey: string
50
+ // 朋友圈 (SNS) 数据库
51
+ snsDbPath: string
52
+ snsKey: string
53
+ snsSalt: string
54
+ // 收藏 (favorite.db)
55
+ favDbPath: string
56
+ favKey: string
57
+ emoticonSeed: string
58
+ favPassphrase: string
59
+ // 第二大脑助手
60
+ /** 隐私模式: open | balanced | strict,默认 strict */
61
+ assistantPrivacy: string
62
+ /** 本地推理引擎模型名 (ollama/lmstudio) */
63
+ localModel: string
64
+ /** 自定义 AI 端点 (OpenAI 兼容中转站); 设置后云端引擎走此地址 */
65
+ aiBaseUrl: string
66
+ /** 自定义端点使用的模型名 */
67
+ aiModel: string
68
+ /** 助手白名单 (@im.wechat ID, 逗号/空格分隔); 空 = 拒绝所有人 */
69
+ assistantWhitelist: string
70
+ /** 官方通道明确提供群 ID 时才使用;空 = 拒绝所有群 */
71
+ assistantGroupWhitelist: string
72
+ /** 群内是否必须明确 @ 机器人;默认开启 */
73
+ assistantGroupRequireMention: string
74
+ /** 助手单轮快路径(D-035):off(默认,与今天逐字段一致) | log(只记「本来会走哪条」) | on */
75
+ assistantFastRoute: string
76
+ /** 本机面板用哪个记忆桶(空 = 自动:白名单恰好一条就用它)。见 src/panel/userId.ts */
77
+ assistantPanelUser: string
78
+ quickReplyContacts: string
79
+ dailySources: string
80
+ /** 公众号类别映射,JSON 对象:公众号名称或 gh_ ID -> 类别 */
81
+ dailySourceCategories: string
82
+ /** 不在日报里出现的主题,逗号分隔(如 新闻,投资);展示层开关,改它不用重抓 */
83
+ dailyExcludeTopics: string
84
+ dailyAiEnabled: string
85
+ }
86
+
87
+ export interface ListEntry {
88
+ wxid: string
89
+ displayName?: string
90
+ addedAt?: number
91
+ reason?: string
92
+ }
93
+
94
+ /** 需要加密存储的字段 */
95
+ const ENCRYPTED_KEYS: ReadonlySet<keyof CliConfig> = new Set([
96
+ 'decryptKey', 'decryptKey3x', 'ntKey', 'contactKey',
97
+ 'wechatOcToken', 'wereadApiKey', 'deepseekApiKey', 'typesafeApiKey', 'dashscopeApiKey', 'snsKey',
98
+ 'favKey', 'favPassphrase',
99
+ ])
100
+
101
+ const CONFIG_DIR = join(homedir(), '.weflow-cli')
102
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
103
+
104
+ export class ConfigService {
105
+ private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
106
+
107
+ /** 本进程修改过、待回写的字段 (多进程并发写保护) */
108
+ private dirty = new Set<keyof CliConfig>()
109
+
110
+ constructor() {
111
+ this.load()
112
+ }
113
+
114
+ private load(): void {
115
+ try {
116
+ if (existsSync(CONFIG_FILE)) {
117
+ const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
118
+ const whitelist = Array.isArray(data.whitelist) ? data.whitelist : []
119
+ const blacklist = Array.isArray(data.blacklist) ? data.blacklist : []
120
+ // entries 优先; 缺失时从旧 string[] 迁移
121
+ const whitelistEntries: ListEntry[] = Array.isArray(data.whitelistEntries)
122
+ ? data.whitelistEntries
123
+ : whitelist.map((wxid: string) => ({ wxid }))
124
+ const blacklistEntries: ListEntry[] = Array.isArray(data.blacklistEntries)
125
+ ? data.blacklistEntries
126
+ : blacklist.map((wxid: string) => ({ wxid }))
127
+ this.config = {
128
+ dbPath: data.dbPath || '',
129
+ wxid: data.wxid || '',
130
+ decryptKey: data.decryptKey || '',
131
+ decryptKey3x: data.decryptKey3x || '',
132
+ dataVersion: data.dataVersion || '',
133
+ dbPath3x: data.dbPath3x || '',
134
+ ntDbPath: data.ntDbPath || '',
135
+ ntKey: data.ntKey || '',
136
+ ntSalt: data.ntSalt || '',
137
+ contactDbPath: data.contactDbPath || '',
138
+ contactKey: data.contactKey || '',
139
+ contactSalt: data.contactSalt || '',
140
+ wechatOcToken: data.wechatOcToken || '',
141
+ wechatOcAccountId: data.wechatOcAccountId || '',
142
+ wechatOcBaseUrl: data.wechatOcBaseUrl || '',
143
+ wechatOcSyncBuf: data.wechatOcSyncBuf || '',
144
+ wechatOcContextTokens: data.wechatOcContextTokens || '',
145
+ whitelist,
146
+ blacklist,
147
+ whitelistEntries,
148
+ blacklistEntries,
149
+ vaultRepo: data.vaultRepo || '',
150
+ aiEngine: data.aiEngine || 'deepseek',
151
+ wereadApiKey: data.wereadApiKey || '',
152
+ deepseekApiKey: data.deepseekApiKey || '',
153
+ typesafeApiKey: data.typesafeApiKey || '',
154
+ dashscopeApiKey: data.dashscopeApiKey || '',
155
+ snsDbPath: data.snsDbPath || '',
156
+ snsKey: data.snsKey || '',
157
+ snsSalt: data.snsSalt || '',
158
+ favDbPath: data.favDbPath || '',
159
+ favKey: data.favKey || '',
160
+ favPassphrase: data.favPassphrase || '',
161
+ emoticonSeed: data.emoticonSeed || '',
162
+ assistantPrivacy: data.assistantPrivacy || 'strict',
163
+ localModel: data.localModel || '',
164
+ aiBaseUrl: data.aiBaseUrl || '',
165
+ aiModel: data.aiModel || '',
166
+ assistantWhitelist: data.assistantWhitelist || '',
167
+ assistantGroupWhitelist: data.assistantGroupWhitelist || '',
168
+ assistantGroupRequireMention: data.assistantGroupRequireMention === 'false' ? 'false' : 'true',
169
+ assistantFastRoute: data.assistantFastRoute || 'off',
170
+ assistantPanelUser: data.assistantPanelUser || '',
171
+ quickReplyContacts: data.quickReplyContacts || '',
172
+ dailySources: data.dailySources || '',
173
+ dailySourceCategories: data.dailySourceCategories || '',
174
+ dailyExcludeTopics: data.dailyExcludeTopics || '',
175
+ dailyAiEnabled: data.dailyAiEnabled === 'false' ? 'false' : 'true',
176
+ }
177
+ }
178
+ } catch (e) {
179
+ // 配置文件损坏,输出警告并使用默认值
180
+ console.warn(`[weflow-cli] 配置文件解析失败 (${CONFIG_FILE}): ${e instanceof Error ? e.message : e},将使用默认配置`)
181
+ }
182
+ }
183
+
184
+ private save(): void {
185
+ try {
186
+ if (!existsSync(CONFIG_DIR)) {
187
+ mkdirSync(CONFIG_DIR, { recursive: true })
188
+ }
189
+ // 多进程安全 (守护进程与 CLI 并发): 只回写本进程修改过的字段,
190
+ // 其余字段以磁盘最新值保留, 避免旧内存快照覆盖他人写入 (幽灵回写)。
191
+ let merged: Record<string, unknown> = { ...this.config }
192
+ try {
193
+ if (existsSync(CONFIG_FILE) && this.dirty.size > 0) {
194
+ const onDisk: Record<string, unknown> = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
195
+ merged = { ...onDisk, ...this.config }
196
+ for (const key of Object.keys(onDisk)) {
197
+ if (this.dirty.has(key as keyof CliConfig)) continue
198
+ merged[key] = onDisk[key]
199
+ }
200
+ }
201
+ } catch { /* 磁盘副本不可读时退回整份写入 */ }
202
+ // 原子写入: 先写临时文件再改名, 防止进程中途被杀导致配置损坏。
203
+ // 改名在 Windows 上会因"文件正被别的句柄打开"而 EPERM(见 atomicWrite 的注释),
204
+ // 那里重试并兜底,所以配置不会因为一个索引器扫过就静默没存上
205
+ writeFileAtomic(CONFIG_FILE, JSON.stringify(merged, null, 2))
206
+ this.dirty.clear()
207
+ } catch (e) {
208
+ console.error('保存配置失败:', e)
209
+ }
210
+ }
211
+
212
+ get<K extends keyof CliConfig>(key: K): CliConfig[K] {
213
+ const raw = this.config[key]
214
+ if (ENCRYPTED_KEYS.has(key) && typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
215
+ return this.lockDecrypt(raw) as CliConfig[K]
216
+ }
217
+ if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof raw === 'string') {
218
+ return expandHomePath(raw) as CliConfig[K]
219
+ }
220
+ return raw
221
+ }
222
+
223
+ set<K extends keyof CliConfig>(key: K, value: CliConfig[K]): void {
224
+ if (ENCRYPTED_KEYS.has(key) && typeof value === 'string' && value) {
225
+ this.config[key] = this.lockEncrypt(value) as CliConfig[K]
226
+ } else if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof value === 'string') {
227
+ this.config[key] = expandHomePath(value) as CliConfig[K]
228
+ } else {
229
+ this.config[key] = value
230
+ }
231
+ this.dirty.add(key)
232
+ this.save()
233
+ }
234
+
235
+ getAll(): ConfigData {
236
+ return {
237
+ dbPath: this.get('dbPath'),
238
+ wxid: this.get('wxid'),
239
+ decryptKey: this.get('decryptKey'),
240
+ decryptKey3x: this.get('decryptKey3x'),
241
+ dataVersion: (this.config.dataVersion || '4.x') as ConfigData['dataVersion'],
242
+ dbPath3x: this.get('dbPath3x'),
243
+ ntDbPath: this.config.ntDbPath,
244
+ ntKey: this.get('ntKey'),
245
+ ntSalt: this.config.ntSalt,
246
+ contactDbPath: this.config.contactDbPath,
247
+ contactKey: this.get('contactKey'),
248
+ contactSalt: this.config.contactSalt,
249
+ snsDbPath: this.config.snsDbPath,
250
+ snsKey: this.get('snsKey'),
251
+ snsSalt: this.config.snsSalt,
252
+ }
253
+ }
254
+
255
+ getWhitelist(): string[] {
256
+ return this.config.whitelist || []
257
+ }
258
+
259
+ setWhitelist(list: string[]): void {
260
+ this.config.whitelist = list
261
+ this.dirty.add('whitelist')
262
+ this.save()
263
+ }
264
+
265
+ getWhitelistEntries(): ListEntry[] {
266
+ return this.config.whitelistEntries || []
267
+ }
268
+
269
+ setWhitelistEntries(entries: ListEntry[]): void {
270
+ this.config.whitelistEntries = entries
271
+ // 同步 string[] 视图, 供旧代码使用
272
+ this.config.whitelist = entries.map(e => e.wxid)
273
+ this.dirty.add('whitelistEntries')
274
+ this.dirty.add('whitelist')
275
+ this.save()
276
+ }
277
+
278
+ getBlacklist(): string[] {
279
+ return this.config.blacklist || []
280
+ }
281
+
282
+ setBlacklist(list: string[]): void {
283
+ this.config.blacklist = list
284
+ this.dirty.add('blacklist')
285
+ this.save()
286
+ }
287
+
288
+ getBlacklistEntries(): ListEntry[] {
289
+ return this.config.blacklistEntries || []
290
+ }
291
+
292
+ setBlacklistEntries(entries: ListEntry[]): void {
293
+ this.config.blacklistEntries = entries
294
+ this.config.blacklist = entries.map(e => e.wxid)
295
+ this.dirty.add('blacklistEntries')
296
+ this.dirty.add('blacklist')
297
+ this.save()
298
+ }
299
+
300
+ /** 读取持久化的 context_token: wxid -> token */
301
+ getContextTokens(): Record<string, string> {
302
+ const raw = this.config.wechatOcContextTokens || ''
303
+ if (!raw) return {}
304
+ const json = raw.startsWith(LOCK_PREFIX) ? this.lockDecrypt(raw) : raw
305
+ if (!json) return {}
306
+ try {
307
+ const obj = JSON.parse(json)
308
+ return (obj && typeof obj === 'object') ? obj as Record<string, string> : {}
309
+ } catch {
310
+ return {}
311
+ }
312
+ }
313
+
314
+ /** 持久化 context_token 集合 (加密存储) */
315
+ setContextTokens(tokens: Record<string, string>): void {
316
+ const json = JSON.stringify(tokens || {})
317
+ this.config.wechatOcContextTokens = json ? this.lockEncrypt(json) : ''
318
+ this.dirty.add('wechatOcContextTokens')
319
+ this.save()
320
+ }
321
+
322
+ /** 增量更新单个 wxid 的 context_token */
323
+ upsertContextToken(wxid: string, token: string): void {
324
+ if (!wxid || !token) return
325
+ const tokens = this.getContextTokens()
326
+ if (tokens[wxid] === token) return
327
+ tokens[wxid] = token
328
+ this.setContextTokens(tokens)
329
+ }
330
+
331
+ isConfigured(): boolean {
332
+ const has4x = !!(this.config.dbPath && this.config.decryptKey)
333
+ const has3x = !!(this.config.dbPath3x && this.config.decryptKey3x)
334
+ const hasNt = !!(this.config.ntDbPath && this.config.ntKey && this.config.ntSalt)
335
+ return has4x || has3x || hasNt
336
+ }
337
+
338
+ clearDatabaseKeys(): void {
339
+ this.clearDatabaseKeysInMemory(true)
340
+ }
341
+
342
+ maskDatabaseKeysForTesting(): void {
343
+ this.clearDatabaseKeysInMemory(false)
344
+ }
345
+
346
+ private clearDatabaseKeysInMemory(persist: boolean): void {
347
+ const keyFields: Array<keyof CliConfig> = [
348
+ 'decryptKey', 'decryptKey3x', 'ntKey', 'ntSalt',
349
+ 'contactKey', 'contactSalt', 'snsKey', 'snsSalt',
350
+ 'favKey', 'favPassphrase',
351
+ ]
352
+ for (const key of keyFields) {
353
+ this.config[key] = '' as never
354
+ if (persist) this.dirty.add(key)
355
+ }
356
+ if (persist) this.save()
357
+ }
358
+
359
+ clear(): void {
360
+ this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
361
+ // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
362
+ this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
363
+ this.save()
364
+ }
365
+
366
+ private lockEncrypt(plaintext: string): string {
367
+ const machineId = this.getMachineId()
368
+ const salt = crypto.randomBytes(16)
369
+ const iv = crypto.randomBytes(12)
370
+ const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
371
+ const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
372
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
373
+ const authTag = cipher.getAuthTag()
374
+ const combined = Buffer.concat([salt, iv, authTag, encrypted])
375
+ return LOCK_PREFIX + combined.toString('base64')
376
+ }
377
+
378
+ private lockDecrypt(stored: string): string {
379
+ if (!stored || !stored.startsWith(LOCK_PREFIX)) return stored
380
+ try {
381
+ const machineId = this.getMachineId()
382
+ const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
383
+ const salt = combined.subarray(0, 16)
384
+ const iv = combined.subarray(16, 28)
385
+ const authTag = combined.subarray(28, 44)
386
+ const ciphertext = combined.subarray(44)
387
+ const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
388
+ const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
389
+ decipher.setAuthTag(authTag)
390
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
391
+ return decrypted.toString('utf8')
392
+ } catch {
393
+ return ''
394
+ }
395
+ }
396
+
397
+ private getMachineId(): string {
398
+ // 使用机器名 + 用户名作为机器标识,足够绑定到单台机器
399
+ return `${hostname()}-${userInfo().username}-weflow-cli`
400
+ }
401
+ }
402
+
403
+ export const configService = new ConfigService()