weflow-cli 1.15.1 → 1.17.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 (100) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/OPERATIONS.md +16 -1
  3. package/README.en.md +21 -2
  4. package/README.md +34 -6
  5. package/bin/weflow-cli.ts +389 -6
  6. package/dist/bin/weflow-cli.js +422 -8
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/core/ntCore.d.ts +47 -0
  9. package/dist/src/core/ntCore.d.ts.map +1 -1
  10. package/dist/src/core/ntCore.js +24 -0
  11. package/dist/src/core/ntCore.js.map +1 -1
  12. package/dist/src/services/assistantService.d.ts +8 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +47 -8
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +8 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +67 -11
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/configService.d.ts.map +1 -1
  21. package/dist/src/services/configService.js +3 -2
  22. package/dist/src/services/configService.js.map +1 -1
  23. package/dist/src/services/cursorProbe.d.ts +44 -0
  24. package/dist/src/services/cursorProbe.d.ts.map +1 -0
  25. package/dist/src/services/cursorProbe.js +183 -0
  26. package/dist/src/services/cursorProbe.js.map +1 -0
  27. package/dist/src/services/qchatImport.d.ts +89 -0
  28. package/dist/src/services/qchatImport.d.ts.map +1 -0
  29. package/dist/src/services/qchatImport.js +273 -0
  30. package/dist/src/services/qchatImport.js.map +1 -0
  31. package/dist/src/services/setupVenv.d.ts +43 -0
  32. package/dist/src/services/setupVenv.d.ts.map +1 -0
  33. package/dist/src/services/setupVenv.js +198 -0
  34. package/dist/src/services/setupVenv.js.map +1 -0
  35. package/dist/src/utils/python.d.ts +10 -0
  36. package/dist/src/utils/python.d.ts.map +1 -1
  37. package/dist/src/utils/python.js +27 -4
  38. package/dist/src/utils/python.js.map +1 -1
  39. package/docs/DATA_CONTRACT.md +25 -0
  40. package/docs/DECISIONS.md +92 -30
  41. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  42. package/docs/HEALTH-CHECK.md +104 -104
  43. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  44. package/docs/NEXT_ACTIONS.md +236 -236
  45. package/docs/NPM-PUBLISH.md +130 -130
  46. package/docs/POSITIONING.md +68 -68
  47. package/docs/PROJECT_STATE.md +6 -3
  48. package/docs/REPLAY.md +169 -0
  49. package/docs/SYNC_CONTRACT.md +197 -180
  50. package/package.json +89 -88
  51. package/requirements-3x.txt +4 -4
  52. package/requirements-voice.txt +15 -15
  53. package/resources/js/graph3d/NOTICE.txt +35 -35
  54. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  55. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  56. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  57. package/resources/js/graph3d/d3-octree.min.js +2 -2
  58. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  59. package/resources/js/graph3d/d3-timer.min.js +2 -2
  60. package/resources/js/graph3d/three.min.js +6 -6
  61. package/resources/js/graph3d/viewer2d.js +550 -448
  62. package/resources/panel/quick-menu.cjs +197 -197
  63. package/scripts/_utils.py +1338 -1338
  64. package/scripts/article_notes.py +423 -423
  65. package/scripts/bonds.py +11 -0
  66. package/scripts/chat_notes.py +86 -3
  67. package/scripts/check_readme_commands.py +427 -426
  68. package/scripts/contact_schema.py +339 -339
  69. package/scripts/create_reading_notes.py +11 -11
  70. package/scripts/daily_task.ps1 +213 -177
  71. package/scripts/fav_notes.py +321 -321
  72. package/scripts/graph_2d.py +156 -151
  73. package/scripts/graph_3d.py +672 -646
  74. package/scripts/health_check.py +473 -473
  75. package/scripts/imported_chats.py +68 -0
  76. package/scripts/install_tasks.ps1 +105 -91
  77. package/scripts/leads.py +179 -0
  78. package/scripts/network.py +206 -206
  79. package/scripts/nt_decrypt.py +135 -1
  80. package/scripts/on_this_day.py +178 -178
  81. package/scripts/opinion_timeline.py +138 -138
  82. package/scripts/promises.py +185 -185
  83. package/scripts/replay.py +803 -0
  84. package/scripts/run_hidden.vbs +25 -0
  85. package/scripts/semantic_search.py +73 -5
  86. package/scripts/user_notes.py +315 -315
  87. package/scripts/voice_transcribe.py +139 -139
  88. package/scripts/watch_issues.py +212 -212
  89. package/scripts/watch_mail.py +131 -131
  90. package/src/core/ntCore.ts +59 -0
  91. package/src/services/assistantService.ts +48 -8
  92. package/src/services/assistantTools.ts +73 -10
  93. package/src/services/configService.ts +3 -2
  94. package/src/services/cursorProbe.ts +222 -0
  95. package/src/services/qchatImport.ts +327 -0
  96. package/src/services/setupVenv.ts +238 -0
  97. package/src/services/syncService.ts +363 -363
  98. package/src/services/syncState.ts +275 -275
  99. package/src/services/wechatMedia.ts +166 -166
  100. package/src/utils/python.ts +33 -4
@@ -0,0 +1,222 @@
1
+ /**
2
+ * 稳定增量游标的"路径可见"层(D-027)。
3
+ *
4
+ * `capabilities --json` 的 `stableCursor: false` 一直是一句结论;这里把它背后的
5
+ * 判据拆开,逐条给出当前状态与差在哪,让下游(she-love-me、MCP 客户端)能看见
6
+ * false → true 要满足什么,而不是等它某天悄悄变 true。
7
+ *
8
+ * 两类事实,来源不同、都必须如实标注:
9
+ * - **代码事实**(静态):每个后端有没有时间窗下推、有没有覆盖报告、合并顺序键是什么。
10
+ * 这些写在下面的表里,改读取器时要同步改。
11
+ * - **数据事实**(实测):NT 库里每张消息表有没有锚点列、采样的行带不带非零
12
+ * `server_id`。这些只有开库才知道,由 `sync cursor-probe` 现场 probing,采样
13
+ * 有界且结果里写明 `exhaustive: false`。
14
+ */
15
+ import { existsSync } from 'fs'
16
+ import { join } from 'path'
17
+ import { configService } from './configService.js'
18
+ import { NtCore } from '../core/ntCore.js'
19
+ import type { NtCursorProbeResult, NtCursorProbeShard, NtCursorProbeSample } from '../core/ntCore.js'
20
+
21
+ export type MessageBackend = 'nt' | 'raw-sqlite-4x' | 'wcdb-4x' | 'legacy-3x'
22
+
23
+ export interface CursorCriterion {
24
+ id: string
25
+ /** 这条判据满足没有:true/false 是已核实的结论,null 是"当前无法判定" */
26
+ met: boolean | null
27
+ detail: string
28
+ }
29
+
30
+ export interface CursorProbeReport {
31
+ success: boolean
32
+ schema: 'weflow-cursor-probe/v1'
33
+ /** 结论不动:探针是让路径可见,不是替 D-027 翻案 */
34
+ stableCursor: false
35
+ backend: MessageBackend | null
36
+ backendNote: string
37
+ /** 数据事实那一半跑了没有(未初始化 / 非 NT 后端 = false) */
38
+ probedLive: boolean
39
+ criteria: CursorCriterion[]
40
+ /** NT 实测明细;probedLive=false 时为 undefined */
41
+ shards?: NtCursorProbeShard[]
42
+ sample?: NtCursorProbeSample
43
+ error?: string
44
+ }
45
+
46
+ /**
47
+ * 静态判断"这台机器会走哪条消息后端"。**镜像 `chatService.connect4x` 的尝试顺序**
48
+ * (先已解密 MSG0,再 NT,最后 WCDB),但只看配置与文件存在性、不开库——真正的
49
+ * 开库判定在 connect 里,而探测命令不该为了报一个名字去连数据库。
50
+ */
51
+ export function likelyMessageBackend(): { backend: MessageBackend | null; note: string } {
52
+ const version = configService.get('dataVersion')
53
+ const dbPath = configService.get('dbPath')
54
+ const wxid = configService.get('wxid')
55
+ const dbPath3x = configService.get('dbPath3x')
56
+ const key3x = configService.get('decryptKey3x')
57
+ const ntDbPath = configService.get('ntDbPath')
58
+ const ntKey = configService.get('ntKey')
59
+ const ntSalt = configService.get('ntSalt')
60
+
61
+ if (version === '3.x' || (!version && dbPath3x && key3x)) {
62
+ return { backend: 'legacy-3x', note: '按 3.x 配置判定(sqlcipher + MSG 库)' }
63
+ }
64
+ if (version !== '4.x' && !dbPath && !(ntDbPath && ntKey && ntSalt)) {
65
+ return { backend: null, note: '未初始化:没有可判定的消息后端(先运行 weflow-cli init)' }
66
+ }
67
+ if (dbPath && wxid && existsSync(join(dbPath, wxid, 'Msg', 'Multi', 'MSG0_decrypted.db'))) {
68
+ return { backend: 'raw-sqlite-4x', note: '发现已解密的 MSG0_decrypted.db(connect4x 的第一优先)' }
69
+ }
70
+ if (ntDbPath && ntKey && ntSalt && existsSync(ntDbPath)) {
71
+ return { backend: 'nt', note: 'NT 配置完整(db_storage/message 分片)' }
72
+ }
73
+ if (dbPath) {
74
+ return { backend: 'wcdb-4x', note: '无解密 MSG0、无 NT 配置,connect4x 的最后兜底是 WCDB API' }
75
+ }
76
+ return { backend: null, note: '4.x 配置不完整:既无 dbPath 也无 NT 三件套' }
77
+ }
78
+
79
+ /** 各后端的**代码事实**。改读取器(下推 / 报告 / 顺序键)时同步改这张表。 */
80
+ export const BACKEND_CAPS: Record<MessageBackend, {
81
+ identity: string
82
+ windowPushdown: boolean
83
+ coverageReport: boolean
84
+ liveProbe: boolean
85
+ }> = {
86
+ 'nt': {
87
+ identity: 'server_id(缺失时退化为 localId+createTime,后者不跨分片)',
88
+ windowPushdown: true, // D-030: --from/--to 推进 SQL
89
+ coverageReport: true, // 分片报告(connect_message_shards_detailed)
90
+ liveProbe: true,
91
+ },
92
+ 'raw-sqlite-4x': {
93
+ identity: 'MsgSvrID 列被读取,但覆盖率从未实测',
94
+ windowPushdown: false,
95
+ coverageReport: false,
96
+ liveProbe: false,
97
+ },
98
+ 'wcdb-4x': {
99
+ identity: 'server_id 随原生 API 返回,未实测;另有按时间的原生游标未接入 chatService',
100
+ windowPushdown: false,
101
+ coverageReport: false,
102
+ liveProbe: false,
103
+ },
104
+ 'legacy-3x': {
105
+ identity: 'MsgSvrID 列被读取,但覆盖率从未实测',
106
+ windowPushdown: false,
107
+ coverageReport: false,
108
+ liveProbe: false,
109
+ },
110
+ }
111
+
112
+ export async function probeCursorReadiness(opts: {
113
+ tablesPerShard?: number
114
+ rowsPerTable?: number
115
+ } = {}): Promise<CursorProbeReport> {
116
+ const { backend, note } = likelyMessageBackend()
117
+
118
+ const criteria: CursorCriterion[] = []
119
+ let live: NtCursorProbeResult | null = null
120
+ let shards: NtCursorProbeShard[] | undefined
121
+ let sample: NtCursorProbeSample | undefined
122
+
123
+ if (backend === 'nt') {
124
+ const ntDbPath = configService.get('ntDbPath')!
125
+ const ntKey = configService.get('ntKey')!
126
+ const ntSalt = configService.get('ntSalt')!
127
+ live = await new NtCore(ntDbPath, ntKey, ntSalt).cursorProbe(opts)
128
+ if (live.success) {
129
+ shards = live.shards
130
+ sample = live.sample
131
+ }
132
+ }
133
+
134
+ const caps = backend ? BACKEND_CAPS[backend] : null
135
+
136
+ // 1. stable-identity
137
+ if (backend === null) {
138
+ criteria.push({
139
+ id: 'stable-identity',
140
+ met: null,
141
+ detail: '没有可判定的后端',
142
+ })
143
+ } else if (backend === 'nt') {
144
+ if (!live?.success || !sample) {
145
+ criteria.push({
146
+ id: 'stable-identity',
147
+ met: null,
148
+ detail: `NT 实测没有跑成:${live?.error ?? '无结果'}`,
149
+ })
150
+ } else {
151
+ const failed = (live.shards ?? []).filter(s => !s.opened)
152
+ const anchorBad = live.schemaAnchorMissingTables ?? 0
153
+ const parts: string[] = []
154
+ if (failed.length) parts.push(`${failed.length} 个分片没打开`)
155
+ if (anchorBad > 0) parts.push(`${anchorBad} 组消息表缺锚点列`)
156
+ if (sample.serverIdMissing > 0) parts.push(`采样 ${sample.rowsSampled} 行里 ${sample.serverIdMissing} 行没有非零 server_id(这些行的身份退化为按分片的 localId+createTime)`)
157
+ if (sample.duplicateServerIds > 0) parts.push(`采样里 ${sample.duplicateServerIds} 个 server_id 出现在多个位置`)
158
+ criteria.push({
159
+ id: 'stable-identity',
160
+ met: parts.length === 0,
161
+ detail: parts.length
162
+ ? parts.join(';')
163
+ : `采样 ${sample.rowsSampled} 行(${sample.tablesSampled}/${sample.tablesTotal} 张表)全部带非零 server_id 且无重复;schema 全量检查 ${anchorBad} 组缺列。注意 exhaustive=false,这是采样结论`,
164
+ })
165
+ }
166
+ } else {
167
+ criteria.push({
168
+ id: 'stable-identity',
169
+ met: null,
170
+ detail: `${backend}:${caps!.identity};该后端没有实测探针`,
171
+ })
172
+ }
173
+
174
+ // 2. globally-comparable-order(代码事实,对所有后端同为 false)
175
+ criteria.push({
176
+ id: 'globally-comparable-order',
177
+ met: false,
178
+ detail: '读取器跨分片合并的顺序键是 (createTime, localId),localId 是分片内编号、不可跨分片比较;换成 (createTime, serverId) 是稳定游标在读取器侧的前提',
179
+ })
180
+
181
+ // 3. window-pushdown
182
+ criteria.push({
183
+ id: 'window-pushdown',
184
+ met: caps ? caps.windowPushdown : null,
185
+ detail: caps
186
+ ? (caps.windowPushdown
187
+ ? `${backend}:--from/--to 已下推进 SQL(D-030)`
188
+ : `${backend}:没有时间窗下推,窗口过滤全靠调用方拉全量后自筛`)
189
+ : '没有可判定的后端',
190
+ })
191
+
192
+ // 4. coverage-report
193
+ criteria.push({
194
+ id: 'coverage-report',
195
+ met: caps ? caps.coverageReport : null,
196
+ detail: caps
197
+ ? (caps.coverageReport
198
+ ? `${backend}:每分片读取结果都带覆盖报告(complete/unverified/partial)`
199
+ : `${backend}:无法报告读取是否完整,sync 在它上面永远是 unverified`)
200
+ : '没有可判定的后端',
201
+ })
202
+
203
+ // 5. every-backend(D-027 的总门)
204
+ const others = (Object.keys(BACKEND_CAPS) as MessageBackend[]).filter(b => b !== backend)
205
+ criteria.push({
206
+ id: 'every-backend',
207
+ met: false,
208
+ detail: `D-027:稳定游标要所有消息后端同时满足上述判据。当前后端 ${backend ?? '(未配置)'};其余后端(${others.join('、')})各自缺下推与覆盖报告`,
209
+ })
210
+
211
+ return {
212
+ success: true,
213
+ schema: 'weflow-cursor-probe/v1',
214
+ stableCursor: false,
215
+ backend,
216
+ backendNote: note,
217
+ probedLive: !!(backend === 'nt' && live?.success),
218
+ criteria,
219
+ ...(shards ? { shards } : {}),
220
+ ...(sample ? { sample } : {}),
221
+ }
222
+ }
@@ -0,0 +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
+ }