weflow-cli 1.17.0 → 1.19.1

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 (73) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +3 -2
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_mail.py +51 -5
  43. package/scripts/daily_task.ps1 +216 -213
  44. package/scripts/extract_todos.py +3 -1
  45. package/scripts/fav_notes.py +321 -321
  46. package/scripts/health_check.py +473 -473
  47. package/scripts/imported_chats.py +68 -68
  48. package/scripts/install_tasks.ps1 +105 -105
  49. package/scripts/key_cache.py +232 -0
  50. package/scripts/leads.py +179 -179
  51. package/scripts/mcp_bridge.py +3 -1
  52. package/scripts/media_index.py +150 -0
  53. package/scripts/network.py +206 -206
  54. package/scripts/nt_common.py +32 -0
  55. package/scripts/nt_decrypt.py +2023 -1954
  56. package/scripts/opinion_timeline.py +138 -138
  57. package/scripts/replay.py +803 -803
  58. package/scripts/run_hidden.vbs +24 -24
  59. package/scripts/semantic_search.py +5 -1
  60. package/scripts/user_notes.py +315 -315
  61. package/scripts/voice_transcribe.py +139 -139
  62. package/scripts/watch_issues.py +212 -212
  63. package/scripts/watch_mail.py +131 -131
  64. package/scripts/wechat_avatar.py +185 -0
  65. package/scripts/wechat_ledger.py +190 -0
  66. package/src/services/cursorProbe.ts +222 -222
  67. package/src/services/diagnosticReport.ts +22 -0
  68. package/src/services/qchatImport.ts +327 -327
  69. package/src/services/setupVenv.ts +238 -238
  70. package/src/services/syncService.ts +363 -363
  71. package/src/services/syncState.ts +275 -275
  72. package/src/services/wechatCompat.ts +67 -0
  73. package/src/services/wechatMedia.ts +166 -166
@@ -1,327 +1,327 @@
1
- /**
2
- * `weflow-cli import qchat` —— 读 qchat-cli 的导出文件,归一化落进本地存储(D-129)。
3
- *
4
- * 边界是**文件契约**,不是进程耦合:qchat-cli 一行不用改,我们读它 `qce export --format json`
5
- * 产出的那份 JSON(形状见 `parseQchatExport`)。落盘后的存储(`output/imports/qq/sessions/`)
6
- * 是 weflow 这边的私有形状 `weflow-import/qq-v1`,字段语义与 `weflow-message/v1` 对齐
7
- * (serverId/createTime/parsedContent/rawContent/13 个稳定 messageType)——一套词汇表两边用。
8
- *
9
- * QQ 的 `messageId` 是服务端全局唯一(OneBot 语义),所以去重/合并就靠它,不需要微信侧那套
10
- * 重叠时间窗;这一点对 D-027 也成立:qq 来源天然满足 stable-identity 判据。
11
- *
12
- * 隐私边界:导入的是用户本人授权的 QQ 数据(qchat-cli 侧靠 NapCatQQ/OneBot 授权),落盘后
13
- * 继承 weflow 的纪律——QQ 号与 wxid 同级敏感,不进公开输出;下游管道(搜索/知识卡)各自
14
- * 已有的隐私门对它照常生效。
15
- */
16
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
17
- import { join } from 'path'
18
- import { resolvePackageRoot } from '../utils/packageRoot.js'
19
-
20
- /** 存储根。`WEFLOW_IMPORT_ROOT` 可整体指到别处(测试用,Python 侧读同一个变量)。 */
21
- export function importRoot(): string {
22
- if (process.env.WEFLOW_IMPORT_ROOT) return process.env.WEFLOW_IMPORT_ROOT
23
- return join(resolvePackageRoot(import.meta.url), 'output', 'imports', 'qq')
24
- }
25
-
26
- function sessionsDir(): string {
27
- return join(importRoot(), 'sessions')
28
- }
29
-
30
- export const IMPORT_SCHEMA = 'weflow-import/qq-v1'
31
-
32
- /** 与 weflow-message/v1 的 messageType 同一张表(13 个稳定名)。 */
33
- export type MessageType =
34
- | 'text' | 'image' | 'voice' | 'card' | 'video' | 'emoji' | 'location'
35
- | 'link' | 'call' | 'system' | 'quote' | 'other'
36
-
37
- export interface ImportedQqMessage {
38
- /** qchat 的 messageId——全局唯一,去重/合并就靠它 */
39
- serverId: string
40
- messageSeq?: number
41
- /** unix 秒(qchat 的 timestamp 字段) */
42
- createTime: number
43
- /** 仅当导入时给了 --own-id 才有;不给就是未知,消费方按"未知"处理而不是猜 */
44
- isSend?: boolean
45
- /** 发送者 QQ 号(敏感,同 wxid 级) */
46
- senderUsername: string
47
- /** 群名片优先,其次昵称,最后落回 QQ 号 */
48
- senderName: string
49
- messageType: MessageType
50
- /** qchat 的 content(已拍平的文本) */
51
- parsedContent: string
52
- /** qchat 的 rawMessage */
53
- rawContent: string
54
- }
55
-
56
- export interface ImportedQqSessionFile {
57
- schema: typeof IMPORT_SCHEMA
58
- session: { id: string; name: string; type: string }
59
- importedAt: string
60
- sourceFiles: string[]
61
- messages: ImportedQqMessage[]
62
- }
63
-
64
- export interface QchatFilePlan {
65
- file: string
66
- ok: boolean
67
- reason?: string
68
- sessions: Array<{ id: string; name: string; type: string; messages: number }>
69
- }
70
-
71
- export interface ImportPlan {
72
- files: QchatFilePlan[]
73
- sessionsFound: number
74
- messagesTotal: number
75
- storeRoot: string
76
- }
77
-
78
- export interface ImportOutcome {
79
- success: boolean
80
- sessions: Array<{ id: string; name: string; added: number; updated: number; unchanged: number; total: number }>
81
- filesOk: number
82
- filesSkipped: number
83
- storeRoot: string
84
- }
85
-
86
- /** OneBot 消息段 → weflow 的 13 个稳定类型。纯函数,映射表只有这一份。 */
87
- export function qqSegmentMessageType(segments: unknown[]): MessageType {
88
- if (!Array.isArray(segments) || !segments.length) return 'text'
89
- const priority: Array<[string, MessageType]> = [
90
- ['record', 'voice'],
91
- ['video', 'video'],
92
- ['image', 'image'],
93
- ['reply', 'quote'],
94
- ['location', 'location'],
95
- ['json', 'card'],
96
- ['xml', 'card'],
97
- ['contact', 'card'],
98
- ['file', 'card'],
99
- ['face', 'emoji'],
100
- ]
101
- const kinds = new Set(segments.map(s => String((s as any)?.type || '')))
102
- for (const [seg, type] of priority) {
103
- if (kinds.has(seg)) return type
104
- }
105
- return 'text'
106
- }
107
-
108
- /**
109
- * qchat 导出 JSON 的形状识别与解析。判据是结构性的:顶层 `session` 对象 + `messages`
110
- * 数组、且消息带 `messageId` 与 `timestamp`——认不出来就报错并给出原因,绝不猜。
111
- */
112
- export function parseQchatExport(text: string, file: string): QchatFilePlan {
113
- const fail = (reason: string): QchatFilePlan => ({
114
- file, ok: false, reason, sessions: [],
115
- })
116
- let data: any
117
- try {
118
- data = JSON.parse(text)
119
- } catch (e: any) {
120
- return fail(`不是合法 JSON(${String(e?.message || e).slice(0, 80)})`)
121
- }
122
- const session = data?.session
123
- const messages = data?.messages
124
- if (!session || typeof session !== 'object' || !Array.isArray(messages)) {
125
- return fail('不是 qchat 导出形状(缺 session/messages;应为 `qce export --format json` 的产物)')
126
- }
127
- const id = String(session.id ?? '')
128
- if (!id) return fail('session.id 为空')
129
- let count = 0
130
- for (const m of messages) {
131
- if (m && (m.messageId !== undefined) && (m.timestamp !== undefined)) count += 1
132
- }
133
- if (!count && messages.length) {
134
- return fail(`${messages.length} 条消息里没有一条带 messageId+timestamp——大概是别的格式的 JSON`)
135
- }
136
- return {
137
- file, ok: true,
138
- sessions: [{
139
- id,
140
- name: String(session.name || id),
141
- type: String(session.type || 'private'),
142
- messages: messages.length,
143
- }],
144
- }
145
- }
146
-
147
- /** 一条 qchat 消息 → 存储形状。字段映射只有这一处。 */
148
- export function normalizeQchatMessage(raw: any, ownId?: string): ImportedQqMessage | null {
149
- if (!raw || raw.messageId === undefined || raw.timestamp === undefined) return null
150
- const senderId = String(raw?.sender?.id ?? '')
151
- const senderName = String(raw?.sender?.card || raw?.sender?.name || senderId)
152
- const msg: ImportedQqMessage = {
153
- serverId: String(raw.messageId),
154
- ...(raw.message_seq !== undefined ? { messageSeq: Number(raw.message_seq) } : {}),
155
- createTime: Number(raw.timestamp),
156
- ...(ownId ? { isSend: senderId === ownId } : {}),
157
- senderUsername: senderId,
158
- senderName,
159
- messageType: qqSegmentMessageType(raw.message),
160
- parsedContent: String(raw.content ?? ''),
161
- rawContent: String(raw.rawMessage ?? ''),
162
- }
163
- return msg
164
- }
165
-
166
- function safeSessionFile(id: string): string {
167
- const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
168
- return `${safe || 'session'}.json`
169
- }
170
-
171
- function readStoreSession(id: string): ImportedQqSessionFile | null {
172
- const path = join(sessionsDir(), safeSessionFile(id))
173
- if (!existsSync(path)) return null
174
- try {
175
- const data = JSON.parse(readFileSync(path, 'utf-8'))
176
- if (data?.schema === IMPORT_SCHEMA && Array.isArray(data?.messages)) return data
177
- } catch { /* 损坏的存储文件按"没有"处理,重导入会覆盖修复 */ }
178
- return null
179
- }
180
-
181
- /** 收集输入路径下的候选文件(单文件或目录里的 *.json)。 */
182
- function candidateFiles(input: string): string[] {
183
- if (!existsSync(input)) return []
184
- // readdirSync 只对目录成立:文件会抛,正好当探测用
185
- try {
186
- const entries = readdirSync(input)
187
- return entries.filter(f => f.toLowerCase().endsWith('.json'))
188
- .map(f => join(input, f))
189
- } catch {
190
- return [input]
191
- }
192
- }
193
-
194
- export function planImportQchat(input: string): ImportPlan {
195
- const files = candidateFiles(input)
196
- const plans: QchatFilePlan[] = []
197
- if (!files.length) {
198
- return {
199
- files: [{ file: input, ok: false, reason: '输入不存在,或目录里没有 .json 文件', sessions: [] }],
200
- sessionsFound: 0, messagesTotal: 0, storeRoot: sessionsDir(),
201
- }
202
- }
203
- for (const file of files) {
204
- let plan: QchatFilePlan
205
- try {
206
- plan = parseQchatExport(readFileSync(file, 'utf-8'), file)
207
- } catch (e: any) {
208
- plan = { file, ok: false, reason: `读不了(${String(e?.message || e).slice(0, 80)})`, sessions: [] }
209
- }
210
- plans.push(plan)
211
- }
212
- const sessionIds = new Set(plans.flatMap(p => p.ok ? p.sessions.map(s => s.id) : []))
213
- const messagesTotal = plans.flatMap(p => p.sessions).reduce((n, s) => n + s.messages, 0)
214
- return {
215
- files: plans,
216
- sessionsFound: sessionIds.size,
217
- messagesTotal,
218
- storeRoot: sessionsDir(),
219
- }
220
- }
221
-
222
- export function runImportQchat(input: string, ownId?: string): ImportOutcome {
223
- const plan = planImportQchat(input)
224
- const okFiles = plan.files.filter(f => f.ok)
225
- if (!okFiles.length) {
226
- return { success: false, sessions: [], filesOk: 0, filesSkipped: plan.files.length, storeRoot: plan.storeRoot }
227
- }
228
- mkdirSync(sessionsDir(), { recursive: true })
229
- const merged = new Map<string, { existing: ImportedQqSessionFile | null; byId: Map<string, ImportedQqMessage>; sourceFiles: Set<string>; meta: { id: string; name: string; type: string } }>()
230
- for (const f of okFiles) {
231
- let data: any
232
- try {
233
- data = JSON.parse(readFileSync(f.file, 'utf-8'))
234
- } catch {
235
- continue // plan 已验证过可解析;这里的失败按跳过处理(计数在 filesSkipped)
236
- }
237
- const id = String(data.session.id)
238
- let entry = merged.get(id)
239
- if (!entry) {
240
- entry = {
241
- existing: readStoreSession(id),
242
- byId: new Map((readStoreSession(id)?.messages ?? []).map(m => [m.serverId, m])),
243
- sourceFiles: new Set(readStoreSession(id)?.sourceFiles ?? []),
244
- meta: { id, name: String(data.session.name || id), type: String(data.session.type || 'private') },
245
- }
246
- merged.set(id, entry)
247
- }
248
- entry.sourceFiles.add(f.file)
249
- for (const raw of data.messages ?? []) {
250
- const msg = normalizeQchatMessage(raw, ownId)
251
- if (msg) entry.byId.set(msg.serverId, msg)
252
- }
253
- }
254
-
255
- const sessions = []
256
- for (const [id, entry] of merged) {
257
- const existing = entry.existing
258
- const before = new Map((existing?.messages ?? []).map(m => [m.serverId, m]))
259
- const messages = [...entry.byId.values()].sort((a, b) => a.createTime - b.createTime || a.serverId.localeCompare(b.serverId))
260
- const file: ImportedQqSessionFile = {
261
- schema: IMPORT_SCHEMA,
262
- session: entry.meta,
263
- importedAt: new Date().toISOString(),
264
- sourceFiles: [...entry.sourceFiles],
265
- messages,
266
- }
267
- writeFileSync(join(sessionsDir(), safeSessionFile(id)), JSON.stringify(file, null, 1), 'utf-8')
268
- let added = 0, updated = 0, unchanged = 0
269
- for (const m of messages) {
270
- const old = before.get(m.serverId)
271
- if (!old) added += 1
272
- else if (JSON.stringify(old) !== JSON.stringify(m)) updated += 1
273
- else unchanged += 1
274
- }
275
- sessions.push({ id: entry.meta.id, name: entry.meta.name, added, updated, unchanged, total: messages.length })
276
- }
277
-
278
- return {
279
- success: true,
280
- sessions,
281
- filesOk: okFiles.length,
282
- filesSkipped: plan.files.length - okFiles.length,
283
- storeRoot: plan.storeRoot,
284
- }
285
- }
286
-
287
- export interface ImportListEntry {
288
- id: string
289
- name: string
290
- type: string
291
- messages: number
292
- oldestCreateTime: number | null
293
- newestCreateTime: number | null
294
- importedAt: string
295
- }
296
-
297
- export function listImports(): ImportListEntry[] {
298
- const dir = sessionsDir()
299
- if (!existsSync(dir)) return []
300
- const out: ImportListEntry[] = []
301
- for (const f of readdirSync(dir)) {
302
- if (!f.endsWith('.json')) continue
303
- try {
304
- const data: any = JSON.parse(readFileSync(join(dir, f), 'utf-8'))
305
- if (data?.schema !== IMPORT_SCHEMA) continue
306
- const times = (data.messages ?? []).map((m: any) => Number(m.createTime) || 0).filter(Boolean)
307
- out.push({
308
- id: String(data.session?.id ?? ''),
309
- name: String(data.session?.name ?? ''),
310
- type: String(data.session?.type ?? ''),
311
- messages: (data.messages ?? []).length,
312
- oldestCreateTime: times.length ? Math.min(...times) : null,
313
- newestCreateTime: times.length ? Math.max(...times) : null,
314
- importedAt: String(data.importedAt ?? ''),
315
- })
316
- } catch { /* 单个损坏文件不挡住清单 */ }
317
- }
318
- return out.sort((a, b) => b.newestCreateTime! - a.newestCreateTime!)
319
- }
320
-
321
- /** 读回一个会话(验证导入用)。limit=0 是全部。 */
322
- export function readImportedSession(id: string, limit = 0): ImportedQqSessionFile | null {
323
- const data = readStoreSession(id)
324
- if (!data) return null
325
- if (limit > 0) return { ...data, messages: data.messages.slice(-limit) }
326
- return data
327
- }
1
+ /**
2
+ * `weflow-cli import qchat` —— 读 qchat-cli 的导出文件,归一化落进本地存储(D-129)。
3
+ *
4
+ * 边界是**文件契约**,不是进程耦合:qchat-cli 一行不用改,我们读它 `qce export --format json`
5
+ * 产出的那份 JSON(形状见 `parseQchatExport`)。落盘后的存储(`output/imports/qq/sessions/`)
6
+ * 是 weflow 这边的私有形状 `weflow-import/qq-v1`,字段语义与 `weflow-message/v1` 对齐
7
+ * (serverId/createTime/parsedContent/rawContent/13 个稳定 messageType)——一套词汇表两边用。
8
+ *
9
+ * QQ 的 `messageId` 是服务端全局唯一(OneBot 语义),所以去重/合并就靠它,不需要微信侧那套
10
+ * 重叠时间窗;这一点对 D-027 也成立:qq 来源天然满足 stable-identity 判据。
11
+ *
12
+ * 隐私边界:导入的是用户本人授权的 QQ 数据(qchat-cli 侧靠 NapCatQQ/OneBot 授权),落盘后
13
+ * 继承 weflow 的纪律——QQ 号与 wxid 同级敏感,不进公开输出;下游管道(搜索/知识卡)各自
14
+ * 已有的隐私门对它照常生效。
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { resolvePackageRoot } from '../utils/packageRoot.js'
19
+
20
+ /** 存储根。`WEFLOW_IMPORT_ROOT` 可整体指到别处(测试用,Python 侧读同一个变量)。 */
21
+ export function importRoot(): string {
22
+ if (process.env.WEFLOW_IMPORT_ROOT) return process.env.WEFLOW_IMPORT_ROOT
23
+ return join(resolvePackageRoot(import.meta.url), 'output', 'imports', 'qq')
24
+ }
25
+
26
+ function sessionsDir(): string {
27
+ return join(importRoot(), 'sessions')
28
+ }
29
+
30
+ export const IMPORT_SCHEMA = 'weflow-import/qq-v1'
31
+
32
+ /** 与 weflow-message/v1 的 messageType 同一张表(13 个稳定名)。 */
33
+ export type MessageType =
34
+ | 'text' | 'image' | 'voice' | 'card' | 'video' | 'emoji' | 'location'
35
+ | 'link' | 'call' | 'system' | 'quote' | 'other'
36
+
37
+ export interface ImportedQqMessage {
38
+ /** qchat 的 messageId——全局唯一,去重/合并就靠它 */
39
+ serverId: string
40
+ messageSeq?: number
41
+ /** unix 秒(qchat 的 timestamp 字段) */
42
+ createTime: number
43
+ /** 仅当导入时给了 --own-id 才有;不给就是未知,消费方按"未知"处理而不是猜 */
44
+ isSend?: boolean
45
+ /** 发送者 QQ 号(敏感,同 wxid 级) */
46
+ senderUsername: string
47
+ /** 群名片优先,其次昵称,最后落回 QQ 号 */
48
+ senderName: string
49
+ messageType: MessageType
50
+ /** qchat 的 content(已拍平的文本) */
51
+ parsedContent: string
52
+ /** qchat 的 rawMessage */
53
+ rawContent: string
54
+ }
55
+
56
+ export interface ImportedQqSessionFile {
57
+ schema: typeof IMPORT_SCHEMA
58
+ session: { id: string; name: string; type: string }
59
+ importedAt: string
60
+ sourceFiles: string[]
61
+ messages: ImportedQqMessage[]
62
+ }
63
+
64
+ export interface QchatFilePlan {
65
+ file: string
66
+ ok: boolean
67
+ reason?: string
68
+ sessions: Array<{ id: string; name: string; type: string; messages: number }>
69
+ }
70
+
71
+ export interface ImportPlan {
72
+ files: QchatFilePlan[]
73
+ sessionsFound: number
74
+ messagesTotal: number
75
+ storeRoot: string
76
+ }
77
+
78
+ export interface ImportOutcome {
79
+ success: boolean
80
+ sessions: Array<{ id: string; name: string; added: number; updated: number; unchanged: number; total: number }>
81
+ filesOk: number
82
+ filesSkipped: number
83
+ storeRoot: string
84
+ }
85
+
86
+ /** OneBot 消息段 → weflow 的 13 个稳定类型。纯函数,映射表只有这一份。 */
87
+ export function qqSegmentMessageType(segments: unknown[]): MessageType {
88
+ if (!Array.isArray(segments) || !segments.length) return 'text'
89
+ const priority: Array<[string, MessageType]> = [
90
+ ['record', 'voice'],
91
+ ['video', 'video'],
92
+ ['image', 'image'],
93
+ ['reply', 'quote'],
94
+ ['location', 'location'],
95
+ ['json', 'card'],
96
+ ['xml', 'card'],
97
+ ['contact', 'card'],
98
+ ['file', 'card'],
99
+ ['face', 'emoji'],
100
+ ]
101
+ const kinds = new Set(segments.map(s => String((s as any)?.type || '')))
102
+ for (const [seg, type] of priority) {
103
+ if (kinds.has(seg)) return type
104
+ }
105
+ return 'text'
106
+ }
107
+
108
+ /**
109
+ * qchat 导出 JSON 的形状识别与解析。判据是结构性的:顶层 `session` 对象 + `messages`
110
+ * 数组、且消息带 `messageId` 与 `timestamp`——认不出来就报错并给出原因,绝不猜。
111
+ */
112
+ export function parseQchatExport(text: string, file: string): QchatFilePlan {
113
+ const fail = (reason: string): QchatFilePlan => ({
114
+ file, ok: false, reason, sessions: [],
115
+ })
116
+ let data: any
117
+ try {
118
+ data = JSON.parse(text)
119
+ } catch (e: any) {
120
+ return fail(`不是合法 JSON(${String(e?.message || e).slice(0, 80)})`)
121
+ }
122
+ const session = data?.session
123
+ const messages = data?.messages
124
+ if (!session || typeof session !== 'object' || !Array.isArray(messages)) {
125
+ return fail('不是 qchat 导出形状(缺 session/messages;应为 `qce export --format json` 的产物)')
126
+ }
127
+ const id = String(session.id ?? '')
128
+ if (!id) return fail('session.id 为空')
129
+ let count = 0
130
+ for (const m of messages) {
131
+ if (m && (m.messageId !== undefined) && (m.timestamp !== undefined)) count += 1
132
+ }
133
+ if (!count && messages.length) {
134
+ return fail(`${messages.length} 条消息里没有一条带 messageId+timestamp——大概是别的格式的 JSON`)
135
+ }
136
+ return {
137
+ file, ok: true,
138
+ sessions: [{
139
+ id,
140
+ name: String(session.name || id),
141
+ type: String(session.type || 'private'),
142
+ messages: messages.length,
143
+ }],
144
+ }
145
+ }
146
+
147
+ /** 一条 qchat 消息 → 存储形状。字段映射只有这一处。 */
148
+ export function normalizeQchatMessage(raw: any, ownId?: string): ImportedQqMessage | null {
149
+ if (!raw || raw.messageId === undefined || raw.timestamp === undefined) return null
150
+ const senderId = String(raw?.sender?.id ?? '')
151
+ const senderName = String(raw?.sender?.card || raw?.sender?.name || senderId)
152
+ const msg: ImportedQqMessage = {
153
+ serverId: String(raw.messageId),
154
+ ...(raw.message_seq !== undefined ? { messageSeq: Number(raw.message_seq) } : {}),
155
+ createTime: Number(raw.timestamp),
156
+ ...(ownId ? { isSend: senderId === ownId } : {}),
157
+ senderUsername: senderId,
158
+ senderName,
159
+ messageType: qqSegmentMessageType(raw.message),
160
+ parsedContent: String(raw.content ?? ''),
161
+ rawContent: String(raw.rawMessage ?? ''),
162
+ }
163
+ return msg
164
+ }
165
+
166
+ function safeSessionFile(id: string): string {
167
+ const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
168
+ return `${safe || 'session'}.json`
169
+ }
170
+
171
+ function readStoreSession(id: string): ImportedQqSessionFile | null {
172
+ const path = join(sessionsDir(), safeSessionFile(id))
173
+ if (!existsSync(path)) return null
174
+ try {
175
+ const data = JSON.parse(readFileSync(path, 'utf-8'))
176
+ if (data?.schema === IMPORT_SCHEMA && Array.isArray(data?.messages)) return data
177
+ } catch { /* 损坏的存储文件按"没有"处理,重导入会覆盖修复 */ }
178
+ return null
179
+ }
180
+
181
+ /** 收集输入路径下的候选文件(单文件或目录里的 *.json)。 */
182
+ function candidateFiles(input: string): string[] {
183
+ if (!existsSync(input)) return []
184
+ // readdirSync 只对目录成立:文件会抛,正好当探测用
185
+ try {
186
+ const entries = readdirSync(input)
187
+ return entries.filter(f => f.toLowerCase().endsWith('.json'))
188
+ .map(f => join(input, f))
189
+ } catch {
190
+ return [input]
191
+ }
192
+ }
193
+
194
+ export function planImportQchat(input: string): ImportPlan {
195
+ const files = candidateFiles(input)
196
+ const plans: QchatFilePlan[] = []
197
+ if (!files.length) {
198
+ return {
199
+ files: [{ file: input, ok: false, reason: '输入不存在,或目录里没有 .json 文件', sessions: [] }],
200
+ sessionsFound: 0, messagesTotal: 0, storeRoot: sessionsDir(),
201
+ }
202
+ }
203
+ for (const file of files) {
204
+ let plan: QchatFilePlan
205
+ try {
206
+ plan = parseQchatExport(readFileSync(file, 'utf-8'), file)
207
+ } catch (e: any) {
208
+ plan = { file, ok: false, reason: `读不了(${String(e?.message || e).slice(0, 80)})`, sessions: [] }
209
+ }
210
+ plans.push(plan)
211
+ }
212
+ const sessionIds = new Set(plans.flatMap(p => p.ok ? p.sessions.map(s => s.id) : []))
213
+ const messagesTotal = plans.flatMap(p => p.sessions).reduce((n, s) => n + s.messages, 0)
214
+ return {
215
+ files: plans,
216
+ sessionsFound: sessionIds.size,
217
+ messagesTotal,
218
+ storeRoot: sessionsDir(),
219
+ }
220
+ }
221
+
222
+ export function runImportQchat(input: string, ownId?: string): ImportOutcome {
223
+ const plan = planImportQchat(input)
224
+ const okFiles = plan.files.filter(f => f.ok)
225
+ if (!okFiles.length) {
226
+ return { success: false, sessions: [], filesOk: 0, filesSkipped: plan.files.length, storeRoot: plan.storeRoot }
227
+ }
228
+ mkdirSync(sessionsDir(), { recursive: true })
229
+ const merged = new Map<string, { existing: ImportedQqSessionFile | null; byId: Map<string, ImportedQqMessage>; sourceFiles: Set<string>; meta: { id: string; name: string; type: string } }>()
230
+ for (const f of okFiles) {
231
+ let data: any
232
+ try {
233
+ data = JSON.parse(readFileSync(f.file, 'utf-8'))
234
+ } catch {
235
+ continue // plan 已验证过可解析;这里的失败按跳过处理(计数在 filesSkipped)
236
+ }
237
+ const id = String(data.session.id)
238
+ let entry = merged.get(id)
239
+ if (!entry) {
240
+ entry = {
241
+ existing: readStoreSession(id),
242
+ byId: new Map((readStoreSession(id)?.messages ?? []).map(m => [m.serverId, m])),
243
+ sourceFiles: new Set(readStoreSession(id)?.sourceFiles ?? []),
244
+ meta: { id, name: String(data.session.name || id), type: String(data.session.type || 'private') },
245
+ }
246
+ merged.set(id, entry)
247
+ }
248
+ entry.sourceFiles.add(f.file)
249
+ for (const raw of data.messages ?? []) {
250
+ const msg = normalizeQchatMessage(raw, ownId)
251
+ if (msg) entry.byId.set(msg.serverId, msg)
252
+ }
253
+ }
254
+
255
+ const sessions = []
256
+ for (const [id, entry] of merged) {
257
+ const existing = entry.existing
258
+ const before = new Map((existing?.messages ?? []).map(m => [m.serverId, m]))
259
+ const messages = [...entry.byId.values()].sort((a, b) => a.createTime - b.createTime || a.serverId.localeCompare(b.serverId))
260
+ const file: ImportedQqSessionFile = {
261
+ schema: IMPORT_SCHEMA,
262
+ session: entry.meta,
263
+ importedAt: new Date().toISOString(),
264
+ sourceFiles: [...entry.sourceFiles],
265
+ messages,
266
+ }
267
+ writeFileSync(join(sessionsDir(), safeSessionFile(id)), JSON.stringify(file, null, 1), 'utf-8')
268
+ let added = 0, updated = 0, unchanged = 0
269
+ for (const m of messages) {
270
+ const old = before.get(m.serverId)
271
+ if (!old) added += 1
272
+ else if (JSON.stringify(old) !== JSON.stringify(m)) updated += 1
273
+ else unchanged += 1
274
+ }
275
+ sessions.push({ id: entry.meta.id, name: entry.meta.name, added, updated, unchanged, total: messages.length })
276
+ }
277
+
278
+ return {
279
+ success: true,
280
+ sessions,
281
+ filesOk: okFiles.length,
282
+ filesSkipped: plan.files.length - okFiles.length,
283
+ storeRoot: plan.storeRoot,
284
+ }
285
+ }
286
+
287
+ export interface ImportListEntry {
288
+ id: string
289
+ name: string
290
+ type: string
291
+ messages: number
292
+ oldestCreateTime: number | null
293
+ newestCreateTime: number | null
294
+ importedAt: string
295
+ }
296
+
297
+ export function listImports(): ImportListEntry[] {
298
+ const dir = sessionsDir()
299
+ if (!existsSync(dir)) return []
300
+ const out: ImportListEntry[] = []
301
+ for (const f of readdirSync(dir)) {
302
+ if (!f.endsWith('.json')) continue
303
+ try {
304
+ const data: any = JSON.parse(readFileSync(join(dir, f), 'utf-8'))
305
+ if (data?.schema !== IMPORT_SCHEMA) continue
306
+ const times = (data.messages ?? []).map((m: any) => Number(m.createTime) || 0).filter(Boolean)
307
+ out.push({
308
+ id: String(data.session?.id ?? ''),
309
+ name: String(data.session?.name ?? ''),
310
+ type: String(data.session?.type ?? ''),
311
+ messages: (data.messages ?? []).length,
312
+ oldestCreateTime: times.length ? Math.min(...times) : null,
313
+ newestCreateTime: times.length ? Math.max(...times) : null,
314
+ importedAt: String(data.importedAt ?? ''),
315
+ })
316
+ } catch { /* 单个损坏文件不挡住清单 */ }
317
+ }
318
+ return out.sort((a, b) => b.newestCreateTime! - a.newestCreateTime!)
319
+ }
320
+
321
+ /** 读回一个会话(验证导入用)。limit=0 是全部。 */
322
+ export function readImportedSession(id: string, limit = 0): ImportedQqSessionFile | null {
323
+ const data = readStoreSession(id)
324
+ if (!data) return null
325
+ if (limit > 0) return { ...data, messages: data.messages.slice(-limit) }
326
+ return data
327
+ }