weflow-cli 1.16.0 → 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.
- package/CHANGELOG.md +42 -0
- package/README.md +16 -4
- package/bin/weflow-cli.ts +348 -6
- package/dist/bin/weflow-cli.js +381 -8
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/core/ntCore.d.ts +47 -0
- package/dist/src/core/ntCore.d.ts.map +1 -1
- package/dist/src/core/ntCore.js +24 -0
- package/dist/src/core/ntCore.js.map +1 -1
- package/dist/src/services/assistantService.d.ts +8 -1
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +47 -8
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantTools.d.ts +8 -0
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +67 -11
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +3 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/cursorProbe.d.ts +44 -0
- package/dist/src/services/cursorProbe.d.ts.map +1 -0
- package/dist/src/services/cursorProbe.js +183 -0
- package/dist/src/services/cursorProbe.js.map +1 -0
- package/dist/src/services/qchatImport.d.ts +89 -0
- package/dist/src/services/qchatImport.d.ts.map +1 -0
- package/dist/src/services/qchatImport.js +273 -0
- package/dist/src/services/qchatImport.js.map +1 -0
- package/dist/src/services/setupVenv.d.ts +43 -0
- package/dist/src/services/setupVenv.d.ts.map +1 -0
- package/dist/src/services/setupVenv.js +198 -0
- package/dist/src/services/setupVenv.js.map +1 -0
- package/dist/src/utils/python.d.ts +10 -0
- package/dist/src/utils/python.d.ts.map +1 -1
- package/dist/src/utils/python.js +27 -4
- package/dist/src/utils/python.js.map +1 -1
- package/docs/DATA_CONTRACT.md +25 -0
- package/docs/DECISIONS.md +92 -30
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
- package/docs/HEALTH-CHECK.md +104 -104
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
- package/docs/NEXT_ACTIONS.md +236 -236
- package/docs/NPM-PUBLISH.md +130 -130
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +6 -3
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- package/requirements-3x.txt +4 -4
- package/requirements-voice.txt +15 -15
- package/resources/js/graph3d/NOTICE.txt +35 -35
- package/resources/js/graph3d/d3-binarytree.min.js +2 -2
- package/resources/js/graph3d/d3-dispatch.min.js +2 -2
- package/resources/js/graph3d/d3-force-3d.min.js +2 -2
- package/resources/js/graph3d/d3-octree.min.js +2 -2
- package/resources/js/graph3d/d3-quadtree.min.js +2 -2
- package/resources/js/graph3d/d3-timer.min.js +2 -2
- package/resources/js/graph3d/three.min.js +6 -6
- package/resources/js/graph3d/viewer2d.js +550 -448
- package/resources/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/article_notes.py +423 -423
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_task.ps1 +213 -213
- package/scripts/fav_notes.py +321 -321
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -0
- package/scripts/install_tasks.ps1 +105 -105
- package/scripts/leads.py +179 -179
- package/scripts/network.py +206 -206
- package/scripts/nt_decrypt.py +135 -1
- package/scripts/on_this_day.py +178 -178
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +73 -5
- package/scripts/user_notes.py +315 -315
- package/scripts/voice_transcribe.py +139 -139
- package/scripts/watch_issues.py +212 -212
- package/scripts/watch_mail.py +131 -131
- package/src/core/ntCore.ts +59 -0
- package/src/services/assistantService.ts +48 -8
- package/src/services/assistantTools.ts +73 -10
- package/src/services/configService.ts +3 -2
- package/src/services/cursorProbe.ts +222 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatMedia.ts +166 -166
- 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
|
+
}
|