weflow-cli 1.5.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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/bin/weflow-cli-electron.cjs +59 -0
  4. package/bin/weflow-cli.ts +2311 -0
  5. package/cli.cjs +3 -0
  6. package/dist/bin/weflow-cli.d.ts +3 -0
  7. package/dist/bin/weflow-cli.d.ts.map +1 -0
  8. package/dist/bin/weflow-cli.js +2270 -0
  9. package/dist/bin/weflow-cli.js.map +1 -0
  10. package/dist/mcp-server/index.d.ts +3 -0
  11. package/dist/mcp-server/index.d.ts.map +1 -0
  12. package/dist/mcp-server/index.js +690 -0
  13. package/dist/mcp-server/index.js.map +1 -0
  14. package/dist/src/core/dbPathService.d.ts +40 -0
  15. package/dist/src/core/dbPathService.d.ts.map +1 -0
  16. package/dist/src/core/dbPathService.js +324 -0
  17. package/dist/src/core/dbPathService.js.map +1 -0
  18. package/dist/src/core/keyService.d.ts +59 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -0
  20. package/dist/src/core/keyService.js +368 -0
  21. package/dist/src/core/keyService.js.map +1 -0
  22. package/dist/src/core/ntCore.d.ts +62 -0
  23. package/dist/src/core/ntCore.d.ts.map +1 -0
  24. package/dist/src/core/ntCore.js +191 -0
  25. package/dist/src/core/ntCore.js.map +1 -0
  26. package/dist/src/core/sqlcipherCore.d.ts +119 -0
  27. package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
  28. package/dist/src/core/sqlcipherCore.js +693 -0
  29. package/dist/src/core/sqlcipherCore.js.map +1 -0
  30. package/dist/src/core/wcdbCore.d.ts +738 -0
  31. package/dist/src/core/wcdbCore.d.ts.map +1 -0
  32. package/dist/src/core/wcdbCore.js +4545 -0
  33. package/dist/src/core/wcdbCore.js.map +1 -0
  34. package/dist/src/core/wechatClient.d.ts +23 -0
  35. package/dist/src/core/wechatClient.d.ts.map +1 -0
  36. package/dist/src/core/wechatClient.js +166 -0
  37. package/dist/src/core/wechatClient.js.map +1 -0
  38. package/dist/src/services/chatService.d.ts +25 -0
  39. package/dist/src/services/chatService.d.ts.map +1 -0
  40. package/dist/src/services/chatService.js +291 -0
  41. package/dist/src/services/chatService.js.map +1 -0
  42. package/dist/src/services/configService.d.ts +43 -0
  43. package/dist/src/services/configService.d.ts.map +1 -0
  44. package/dist/src/services/configService.js +156 -0
  45. package/dist/src/services/configService.js.map +1 -0
  46. package/dist/src/services/exportService.d.ts +30 -0
  47. package/dist/src/services/exportService.d.ts.map +1 -0
  48. package/dist/src/services/exportService.js +273 -0
  49. package/dist/src/services/exportService.js.map +1 -0
  50. package/dist/src/services/wechat-formatter.d.ts +69 -0
  51. package/dist/src/services/wechat-formatter.d.ts.map +1 -0
  52. package/dist/src/services/wechat-formatter.js +248 -0
  53. package/dist/src/services/wechat-formatter.js.map +1 -0
  54. package/dist/src/services/wechatMessageService.d.ts +28 -0
  55. package/dist/src/services/wechatMessageService.d.ts.map +1 -0
  56. package/dist/src/services/wechatMessageService.js +341 -0
  57. package/dist/src/services/wechatMessageService.js.map +1 -0
  58. package/dist/src/services/wereadService.d.ts +206 -0
  59. package/dist/src/services/wereadService.d.ts.map +1 -0
  60. package/dist/src/services/wereadService.js +145 -0
  61. package/dist/src/services/wereadService.js.map +1 -0
  62. package/dist/src/services/whitelistService.d.ts +20 -0
  63. package/dist/src/services/whitelistService.d.ts.map +1 -0
  64. package/dist/src/services/whitelistService.js +60 -0
  65. package/dist/src/services/whitelistService.js.map +1 -0
  66. package/dist/src/types.d.ts +140 -0
  67. package/dist/src/types.d.ts.map +1 -0
  68. package/dist/src/types.js +2 -0
  69. package/dist/src/types.js.map +1 -0
  70. package/dist/src/utils/errors.d.ts +25 -0
  71. package/dist/src/utils/errors.d.ts.map +1 -0
  72. package/dist/src/utils/errors.js +43 -0
  73. package/dist/src/utils/errors.js.map +1 -0
  74. package/dist/src/utils/pathUtils.d.ts +5 -0
  75. package/dist/src/utils/pathUtils.d.ts.map +1 -0
  76. package/dist/src/utils/pathUtils.js +16 -0
  77. package/dist/src/utils/pathUtils.js.map +1 -0
  78. package/dist/src/utils/pythonRunner.d.ts +27 -0
  79. package/dist/src/utils/pythonRunner.d.ts.map +1 -0
  80. package/dist/src/utils/pythonRunner.js +62 -0
  81. package/dist/src/utils/pythonRunner.js.map +1 -0
  82. package/dist/src/utils/talkerUtils.d.ts +14 -0
  83. package/dist/src/utils/talkerUtils.d.ts.map +1 -0
  84. package/dist/src/utils/talkerUtils.js +73 -0
  85. package/dist/src/utils/talkerUtils.js.map +1 -0
  86. package/mcp-server/index.ts +757 -0
  87. package/package.json +72 -0
  88. package/resources/key/win32/x64/wx_key.dll +0 -0
  89. package/resources/wcdb/win32/x64/SDL2.dll +0 -0
  90. package/resources/wcdb/win32/x64/WCDB.dll +0 -0
  91. package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
  92. package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
  93. package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
  94. package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
  95. package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
  96. package/scripts/_batch_link_sources.py +73 -0
  97. package/scripts/_utils.py +531 -0
  98. package/scripts/annual_report.py +796 -0
  99. package/scripts/auto_tag.py +128 -0
  100. package/scripts/biz_daily.py +896 -0
  101. package/scripts/chat_report.py +397 -0
  102. package/scripts/chat_stats.py +702 -0
  103. package/scripts/classify_daily.py +411 -0
  104. package/scripts/compile_wiki.py +258 -0
  105. package/scripts/create_reading_notes.py +370 -0
  106. package/scripts/enrich_backlinks.py +163 -0
  107. package/scripts/export_chat_html.py +766 -0
  108. package/scripts/extract_3x_key.py +86 -0
  109. package/scripts/extract_todos.py +404 -0
  110. package/scripts/fav_server.py +392 -0
  111. package/scripts/fix_topics.py +111 -0
  112. package/scripts/generate_ai_report.py +367 -0
  113. package/scripts/generate_html.py +1372 -0
  114. package/scripts/generate_review.py +170 -0
  115. package/scripts/mcp_bridge.py +398 -0
  116. package/scripts/nt_decrypt.py +562 -0
  117. package/scripts/pipeline.py +145 -0
  118. package/scripts/promote_all.py +310 -0
  119. package/scripts/promote_ideas.py +297 -0
  120. package/scripts/rag_chat.py +191 -0
  121. package/scripts/scan_decrypt_4x.py +329 -0
  122. package/scripts/semantic_search.py +431 -0
  123. package/scripts/sync_fav.py +146 -0
  124. package/scripts/sync_weread.py +192 -0
  125. package/scripts/vault_rag.py +139 -0
  126. package/scripts/vault_search.py +141 -0
  127. package/src/core/dbPathService.ts +348 -0
  128. package/src/core/keyService.ts +409 -0
  129. package/src/core/ntCore.ts +224 -0
  130. package/src/core/sqlcipherCore.ts +764 -0
  131. package/src/core/wcdbCore.ts +4568 -0
  132. package/src/core/wechatClient.ts +204 -0
  133. package/src/lz4.d.ts +10 -0
  134. package/src/services/chatService.ts +296 -0
  135. package/src/services/configService.ts +197 -0
  136. package/src/services/exportService.ts +305 -0
  137. package/src/services/wechat-formatter.ts +348 -0
  138. package/src/services/wechatMessageService.ts +383 -0
  139. package/src/services/wereadService.ts +308 -0
  140. package/src/services/whitelistService.ts +72 -0
  141. package/src/types.ts +141 -0
  142. package/src/utils/errors.ts +42 -0
  143. package/src/utils/pathUtils.ts +16 -0
  144. package/src/utils/pythonRunner.ts +81 -0
  145. package/src/utils/talkerUtils.ts +84 -0
@@ -0,0 +1,197 @@
1
+ import { join } from 'path'
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } 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
+ // 白名单
29
+ whitelist: string[]
30
+ // Vault & Pipeline
31
+ vaultRepo: string
32
+ aiEngine: string
33
+ // 微信读书
34
+ wereadApiKey: string
35
+ // AI 引擎 API Key
36
+ deepseekApiKey: string
37
+ }
38
+
39
+ /** 需要加密存储的字段 */
40
+ const ENCRYPTED_KEYS: ReadonlySet<keyof CliConfig> = new Set([
41
+ 'decryptKey', 'decryptKey3x', 'ntKey', 'contactKey',
42
+ 'wechatOcToken', 'wereadApiKey', 'deepseekApiKey',
43
+ ])
44
+
45
+ const CONFIG_DIR = join(homedir(), '.weflow-cli')
46
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
47
+
48
+ export class ConfigService {
49
+ private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', whitelist: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '' }
50
+
51
+ constructor() {
52
+ this.load()
53
+ }
54
+
55
+ private load(): void {
56
+ try {
57
+ if (existsSync(CONFIG_FILE)) {
58
+ const data = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
59
+ this.config = {
60
+ dbPath: data.dbPath || '',
61
+ wxid: data.wxid || '',
62
+ decryptKey: data.decryptKey || '',
63
+ decryptKey3x: data.decryptKey3x || '',
64
+ dataVersion: data.dataVersion || '',
65
+ dbPath3x: data.dbPath3x || '',
66
+ ntDbPath: data.ntDbPath || '',
67
+ ntKey: data.ntKey || '',
68
+ ntSalt: data.ntSalt || '',
69
+ contactDbPath: data.contactDbPath || '',
70
+ contactKey: data.contactKey || '',
71
+ contactSalt: data.contactSalt || '',
72
+ wechatOcToken: data.wechatOcToken || '',
73
+ wechatOcAccountId: data.wechatOcAccountId || '',
74
+ wechatOcBaseUrl: data.wechatOcBaseUrl || '',
75
+ wechatOcSyncBuf: data.wechatOcSyncBuf || '',
76
+ whitelist: Array.isArray(data.whitelist) ? data.whitelist : [],
77
+ vaultRepo: data.vaultRepo || '',
78
+ aiEngine: data.aiEngine || 'deepseek',
79
+ wereadApiKey: data.wereadApiKey || '',
80
+ deepseekApiKey: data.deepseekApiKey || '',
81
+ }
82
+ }
83
+ } catch (e) {
84
+ // 配置文件损坏,输出警告并使用默认值
85
+ console.warn(`[weflow-cli] 配置文件解析失败 (${CONFIG_FILE}): ${e instanceof Error ? e.message : e},将使用默认配置`)
86
+ }
87
+ }
88
+
89
+ private save(): void {
90
+ try {
91
+ if (!existsSync(CONFIG_DIR)) {
92
+ mkdirSync(CONFIG_DIR, { recursive: true })
93
+ }
94
+ writeFileSync(CONFIG_FILE, JSON.stringify(this.config, null, 2), 'utf8')
95
+ } catch (e) {
96
+ console.error('保存配置失败:', e)
97
+ }
98
+ }
99
+
100
+ get<K extends keyof CliConfig>(key: K): CliConfig[K] {
101
+ const raw = this.config[key]
102
+ if (ENCRYPTED_KEYS.has(key) && typeof raw === 'string' && raw.startsWith(LOCK_PREFIX)) {
103
+ return this.lockDecrypt(raw) as CliConfig[K]
104
+ }
105
+ if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof raw === 'string') {
106
+ return expandHomePath(raw) as CliConfig[K]
107
+ }
108
+ return raw
109
+ }
110
+
111
+ set<K extends keyof CliConfig>(key: K, value: CliConfig[K]): void {
112
+ if (ENCRYPTED_KEYS.has(key) && typeof value === 'string' && value) {
113
+ this.config[key] = this.lockEncrypt(value) as CliConfig[K]
114
+ } else if ((key === 'dbPath' || key === 'dbPath3x' || key === 'ntDbPath') && typeof value === 'string') {
115
+ this.config[key] = expandHomePath(value) as CliConfig[K]
116
+ } else {
117
+ this.config[key] = value
118
+ }
119
+ this.save()
120
+ }
121
+
122
+ getAll(): ConfigData {
123
+ return {
124
+ dbPath: this.get('dbPath'),
125
+ wxid: this.get('wxid'),
126
+ decryptKey: this.get('decryptKey'),
127
+ decryptKey3x: this.get('decryptKey3x'),
128
+ dataVersion: (this.config.dataVersion || '4.x') as ConfigData['dataVersion'],
129
+ dbPath3x: this.get('dbPath3x'),
130
+ ntDbPath: this.config.ntDbPath,
131
+ ntKey: this.get('ntKey'),
132
+ ntSalt: this.config.ntSalt,
133
+ contactDbPath: this.config.contactDbPath,
134
+ contactKey: this.get('contactKey'),
135
+ contactSalt: this.config.contactSalt,
136
+ }
137
+ }
138
+
139
+ getWhitelist(): string[] {
140
+ return this.config.whitelist || []
141
+ }
142
+
143
+ setWhitelist(list: string[]): void {
144
+ this.config.whitelist = list
145
+ this.save()
146
+ }
147
+
148
+ isConfigured(): boolean {
149
+ const has4x = !!(this.config.dbPath && this.config.decryptKey)
150
+ const has3x = !!(this.config.dbPath3x && this.config.decryptKey3x)
151
+ const hasNt = !!(this.config.ntDbPath && this.config.ntKey && this.config.ntSalt)
152
+ return has4x || has3x || hasNt
153
+ }
154
+
155
+ clear(): void {
156
+ this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', whitelist: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '' }
157
+ this.save()
158
+ }
159
+
160
+ private lockEncrypt(plaintext: string): string {
161
+ const machineId = this.getMachineId()
162
+ const salt = crypto.randomBytes(16)
163
+ const iv = crypto.randomBytes(12)
164
+ const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
165
+ const cipher = crypto.createCipheriv('aes-256-gcm', derivedKey, iv)
166
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
167
+ const authTag = cipher.getAuthTag()
168
+ const combined = Buffer.concat([salt, iv, authTag, encrypted])
169
+ return LOCK_PREFIX + combined.toString('base64')
170
+ }
171
+
172
+ private lockDecrypt(stored: string): string {
173
+ if (!stored || !stored.startsWith(LOCK_PREFIX)) return stored
174
+ try {
175
+ const machineId = this.getMachineId()
176
+ const combined = Buffer.from(stored.slice(LOCK_PREFIX.length), 'base64')
177
+ const salt = combined.subarray(0, 16)
178
+ const iv = combined.subarray(16, 28)
179
+ const authTag = combined.subarray(28, 44)
180
+ const ciphertext = combined.subarray(44)
181
+ const derivedKey = crypto.pbkdf2Sync(machineId, salt, 100000, 32, 'sha256')
182
+ const decipher = crypto.createDecipheriv('aes-256-gcm', derivedKey, iv)
183
+ decipher.setAuthTag(authTag)
184
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
185
+ return decrypted.toString('utf8')
186
+ } catch {
187
+ return ''
188
+ }
189
+ }
190
+
191
+ private getMachineId(): string {
192
+ // 使用机器名 + 用户名作为机器标识,足够绑定到单台机器
193
+ return `${hostname()}-${userInfo().username}-weflow-cli`
194
+ }
195
+ }
196
+
197
+ export const configService = new ConfigService()
@@ -0,0 +1,305 @@
1
+ import { join, basename, dirname } from 'path'
2
+ import { existsSync, mkdirSync, writeFileSync } from 'fs'
3
+ import { execFile } from 'child_process'
4
+ import { promisify } from 'util'
5
+ import { fileURLToPath } from 'url'
6
+ import { chatService } from './chatService.js'
7
+ import { configService } from './configService.js'
8
+ import type { Message } from '../types.js'
9
+
10
+ const execFileAsync = promisify(execFile)
11
+ const __filename = fileURLToPath(import.meta.url)
12
+ const __dirname = dirname(__filename)
13
+
14
+ export class ExportService {
15
+ async exportJson(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
16
+ try {
17
+ const messages = await chatService.getMessages(talker, limit)
18
+ if (messages.length === 0) {
19
+ return { success: false, error: '未找到消息' }
20
+ }
21
+
22
+ const dir = this.ensureOutputDir(outputDir)
23
+ const filePath = join(dir, `${talker}_messages.json`)
24
+ writeFileSync(filePath, JSON.stringify(messages, null, 2), 'utf8')
25
+ return { success: true, path: filePath }
26
+ } catch (e) {
27
+ return { success: false, error: String(e) }
28
+ }
29
+ }
30
+
31
+ async exportTxt(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
32
+ try {
33
+ const messages = await chatService.getMessages(talker, limit)
34
+ if (messages.length === 0) {
35
+ return { success: false, error: '未找到消息' }
36
+ }
37
+
38
+ const lines = messages.map(m => {
39
+ const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
40
+ const sender = m.isSend ? '我' : (m.senderUsername || talker)
41
+ const content = m.parsedContent || m.rawContent || ''
42
+ return `[${time}] ${sender}: ${content}`
43
+ })
44
+
45
+ const dir = this.ensureOutputDir(outputDir)
46
+ const filePath = join(dir, `${talker}_messages.txt`)
47
+ writeFileSync(filePath, lines.join('\n'), 'utf8')
48
+ return { success: true, path: filePath }
49
+ } catch (e) {
50
+ return { success: false, error: String(e) }
51
+ }
52
+ }
53
+
54
+ async exportHtml(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
55
+ try {
56
+ // Use Python export_chat_html.py for rich HTML with images
57
+ const cfg = configService.getAll()
58
+ const db = cfg.ntDbPath
59
+ const key = cfg.ntKey
60
+ const salt = cfg.ntSalt
61
+
62
+ if (!db || !key || !salt) {
63
+ // Fallback: basic HTML
64
+ return this.exportHtmlBasic(talker, outputDir, limit)
65
+ }
66
+
67
+ // Resolve Python script path (dist/src/services → package root)
68
+ const pkgRoot = join(__dirname, '..', '..', '..')
69
+ const script = join(pkgRoot, 'scripts', 'export_chat_html.py')
70
+
71
+ // Cache dir for image thumbnails
72
+ const cacheDir = cfg.contactDbPath
73
+ ? join(dirname(dirname(cfg.contactDbPath)), 'cache')
74
+ : ''
75
+
76
+ // Resolve display name for filename (prefer remark/nickname over wxid)
77
+ let displayName = ''
78
+ try {
79
+ const sessions = await chatService.listSessions()
80
+ const match = sessions.find(s => s.username === talker)
81
+ if (match?.displayName && match.displayName !== talker) {
82
+ displayName = match.displayName
83
+ }
84
+ } catch {}
85
+
86
+ const args: string[] = [
87
+ script,
88
+ '--db', db,
89
+ '--key', key,
90
+ '--salt', salt,
91
+ '--talker', talker,
92
+ '--out', outputDir || `./output`,
93
+ '--single',
94
+ '--parts', '1',
95
+ ]
96
+ if (displayName) {
97
+ args.push('--name', displayName)
98
+ }
99
+ if (cacheDir && existsSync(cacheDir)) {
100
+ args.push('--cache-dir', cacheDir)
101
+ }
102
+
103
+ console.log(` Exporting HTML via Python...`)
104
+ const { stdout } = await execFileAsync('python', args, {
105
+ timeout: 300_000,
106
+ maxBuffer: 50 * 1024 * 1024,
107
+ })
108
+
109
+ // Parse JSON result from Python output
110
+ const lines = stdout.split('\n').filter((l: string) => l.trim())
111
+ for (let i = lines.length - 1; i >= 0; i--) {
112
+ try {
113
+ const result = JSON.parse(lines[i])
114
+ if (result.success && result.files?.length > 0) {
115
+ return { success: true, path: result.files[0] }
116
+ }
117
+ } catch {}
118
+ }
119
+
120
+ return { success: false, error: 'Python export succeeded but no output found' }
121
+ } catch (e: any) {
122
+ console.error(` Python export failed: ${e.message}`)
123
+ // Fallback to basic HTML
124
+ return this.exportHtmlBasic(talker, outputDir, limit)
125
+ }
126
+ }
127
+
128
+ private async exportHtmlBasic(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
129
+ const messages = await chatService.getMessages(talker, limit)
130
+ if (messages.length === 0) {
131
+ return { success: false, error: '未找到消息' }
132
+ }
133
+
134
+ const html = this.buildHtml(talker, messages)
135
+ const dir = this.ensureOutputDir(outputDir)
136
+ const filePath = join(dir, `${talker}_messages.html`)
137
+ writeFileSync(filePath, html, 'utf8')
138
+ return { success: true, path: filePath }
139
+ }
140
+
141
+ async exportExcel(talker: string, outputDir: string, limit = 0): Promise<{ success: boolean; path?: string; error?: string }> {
142
+ try {
143
+ const ExcelJS = await import('exceljs')
144
+ const messages = await chatService.getMessages(talker, limit)
145
+ if (messages.length === 0) {
146
+ return { success: false, error: '未找到消息' }
147
+ }
148
+
149
+ const workbook = new ExcelJS.Workbook()
150
+ const sheet = workbook.addWorksheet('聊天记录')
151
+
152
+ sheet.columns = [
153
+ { header: '时间', key: 'time', width: 20 },
154
+ { header: '发送者', key: 'sender', width: 15 },
155
+ { header: '类型', key: 'type', width: 10 },
156
+ { header: '内容', key: 'content', width: 80 },
157
+ { header: '消息ID', key: 'msgId', width: 15 }
158
+ ]
159
+
160
+ for (const m of messages) {
161
+ sheet.addRow({
162
+ time: new Date(m.createTime * 1000).toLocaleString('zh-CN'),
163
+ sender: m.isSend ? '我' : (m.senderUsername || talker),
164
+ type: this.getMessageTypeName(m.localType),
165
+ content: m.parsedContent || m.rawContent || '',
166
+ msgId: m.localId
167
+ })
168
+ }
169
+
170
+ const dir = this.ensureOutputDir(outputDir)
171
+ const filePath = join(dir, `${talker}_messages.xlsx`)
172
+ await workbook.xlsx.writeFile(filePath)
173
+ return { success: true, path: filePath }
174
+ } catch (e) {
175
+ return { success: false, error: String(e) }
176
+ }
177
+ }
178
+
179
+ private buildHtml(talker: string, messages: Message[]): string {
180
+ const rows = messages.map(m => {
181
+ const time = new Date(m.createTime * 1000).toLocaleString('zh-CN')
182
+ const sender = m.isSend ? '我' : this.escapeHtml(m.senderUsername || talker)
183
+ const contentType = m.localType
184
+ const contentHtml = this.renderMessageContent(m)
185
+ const align = m.isSend ? 'right' : 'left'
186
+ const bgColor = m.isSend ? '#95ec69' : '#ffffff'
187
+
188
+ return `<div style="text-align:${align};margin:8px 0;">
189
+ <div style="display:inline-block;background:${bgColor};padding:8px 12px;border-radius:8px;max-width:80%;">
190
+ <div style="font-size:12px;color:#999;">${sender} · ${time}</div>
191
+ <div style="margin-top:4px;white-space:pre-wrap;word-break:break-all;">${contentHtml}</div>
192
+ </div>
193
+ </div>`
194
+ }).join('\n')
195
+
196
+ return `<!DOCTYPE html>
197
+ <html lang="zh-CN">
198
+ <head>
199
+ <meta charset="UTF-8">
200
+ <title>聊天记录 - ${this.escapeHtml(talker)}</title>
201
+ <style>
202
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f0f0; padding: 20px; }
203
+ .container { max-width: 820px; margin: 0 auto; background: #f5f5f5; padding: 20px; border-radius: 12px; }
204
+ .msg-image { color:#888; }
205
+ .msg-image img { max-width:240px; max-height:240px; border-radius:4px; margin-top:6px; display:block; }
206
+ .msg-app { margin:0; }
207
+ .msg-app .app-title { font-size:14px; font-weight:600; color:#333; }
208
+ .msg-app .app-desc { font-size:12px; color:#999; margin-top:2px; }
209
+ .msg-app .app-url { display:block; margin-top:6px; padding:6px 10px; background:#f0f0f0; border-left:3px solid #07c160; text-decoration:none; color:#576b95; border-radius:0 4px 4px 0; font-size:13px; }
210
+ .msg-app .app-file { color:#07c160; font-weight:600; }
211
+ .msg-sys { color:#bbb; font-size:13px; }
212
+ .msg-media { color:#888; }
213
+ </style>
214
+ </head>
215
+ <body>
216
+ <div class="container">
217
+ <h2 style="text-align:center;">聊天记录 - ${this.escapeHtml(talker)}</h2>
218
+ <p style="text-align:center;color:#999;">共 ${messages.length} 条消息</p>
219
+ ${rows}
220
+ </div>
221
+ </body>
222
+ </html>`
223
+ }
224
+
225
+ private renderMessageContent(m: Message): string {
226
+ switch (m.localType) {
227
+ case 1:
228
+ return this.escapeHtml(m.parsedContent || m.rawContent || '')
229
+
230
+ case 3: {
231
+ let html = `<span class="msg-media">[图片]</span>`
232
+ if (m.imageFileName) {
233
+ html = `<span class="msg-media">[图片: ${this.escapeHtml(m.imageFileName)}]</span>`
234
+ }
235
+ if (m.imageBase64) {
236
+ html += `<br><img src="data:image/jpeg;base64,${m.imageBase64}" />`
237
+ }
238
+ return html
239
+ }
240
+
241
+ case 34:
242
+ return '<span class="msg-media">[语音]</span>'
243
+
244
+ case 43:
245
+ return '<span class="msg-media">[视频]</span>'
246
+
247
+ case 47:
248
+ return this.escapeHtml(m.parsedContent || '[表情]')
249
+
250
+ case 49: {
251
+ let html = '<div class="msg-app">'
252
+ if (m.appTitle) {
253
+ html += `<div class="app-title">${this.escapeHtml(m.appTitle)}</div>`
254
+ }
255
+ if (m.appDescription) {
256
+ html += `<div class="app-desc">${this.escapeHtml(m.appDescription)}</div>`
257
+ }
258
+ if (m.appUrl) {
259
+ html += `<a class="app-url" href="${this.escapeHtml(m.appUrl)}" target="_blank">${this.escapeHtml(m.appUrl)}</a>`
260
+ }
261
+ html += '</div>'
262
+ if (!m.appTitle && !m.appDescription && !m.appUrl) {
263
+ html = this.escapeHtml(m.parsedContent || '[链接/文件]')
264
+ }
265
+ return html
266
+ }
267
+
268
+ case 50:
269
+ return '<span class="msg-media">[语音通话]</span>'
270
+
271
+ case 10000:
272
+ return `<span class="msg-sys">${this.escapeHtml(m.parsedContent || '')}</span>`
273
+
274
+ default:
275
+ return this.escapeHtml(m.parsedContent || m.rawContent || '')
276
+ }
277
+ }
278
+
279
+ private escapeHtml(str: string): string {
280
+ return str
281
+ .replace(/&/g, '&amp;')
282
+ .replace(/</g, '&lt;')
283
+ .replace(/>/g, '&gt;')
284
+ .replace(/"/g, '&quot;')
285
+ }
286
+
287
+ private getMessageTypeName(type: number): string {
288
+ const map: Record<number, string> = {
289
+ 1: '文本', 3: '图片', 34: '语音', 42: '名片',
290
+ 43: '视频', 47: '表情', 48: '位置', 49: '链接/文件',
291
+ 50: '语音通话', 10000: '系统', 10002: '引用'
292
+ }
293
+ return map[type] || `类型${type}`
294
+ }
295
+
296
+ private ensureOutputDir(dir: string): string {
297
+ const resolved = dir || './output'
298
+ if (!existsSync(resolved)) {
299
+ mkdirSync(resolved, { recursive: true })
300
+ }
301
+ return resolved
302
+ }
303
+ }
304
+
305
+ export const exportService = new ExportService()