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.
- package/CHANGELOG.md +46 -0
- package/README.md +3 -0
- package/bin/weflow-cli.ts +167 -0
- package/dist/bin/weflow-cli.js +199 -0
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/src/services/diagnosticReport.d.ts +6 -0
- package/dist/src/services/diagnosticReport.d.ts.map +1 -1
- package/dist/src/services/diagnosticReport.js +16 -0
- package/dist/src/services/diagnosticReport.js.map +1 -1
- package/dist/src/services/wechatCompat.d.ts +29 -0
- package/dist/src/services/wechatCompat.d.ts.map +1 -0
- package/dist/src/services/wechatCompat.js +65 -0
- package/dist/src/services/wechatCompat.js.map +1 -0
- package/docs/DATA_CONTRACT.md +25 -25
- package/docs/DECISIONS.md +26 -0
- 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/NT_DATABASES.md +47 -0
- package/docs/POSITIONING.md +68 -68
- package/docs/PROJECT_STATE.md +3 -2
- package/docs/RELEASING.md +4 -0
- package/package.json +1 -1
- 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/panel/quick-menu.cjs +197 -197
- package/scripts/_utils.py +1338 -1338
- package/scripts/annual_report.py +3 -1
- package/scripts/article_notes.py +423 -423
- package/scripts/contact_schema.py +339 -339
- package/scripts/create_reading_notes.py +11 -11
- package/scripts/daily_mail.py +51 -5
- package/scripts/daily_task.ps1 +216 -213
- package/scripts/extract_todos.py +3 -1
- package/scripts/fav_notes.py +321 -321
- package/scripts/health_check.py +473 -473
- package/scripts/imported_chats.py +68 -68
- package/scripts/install_tasks.ps1 +105 -105
- package/scripts/key_cache.py +232 -0
- package/scripts/leads.py +179 -179
- package/scripts/mcp_bridge.py +3 -1
- package/scripts/media_index.py +150 -0
- package/scripts/network.py +206 -206
- package/scripts/nt_common.py +32 -0
- package/scripts/nt_decrypt.py +2023 -1954
- package/scripts/opinion_timeline.py +138 -138
- package/scripts/replay.py +803 -803
- package/scripts/run_hidden.vbs +24 -24
- package/scripts/semantic_search.py +5 -1
- 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/scripts/wechat_avatar.py +185 -0
- package/scripts/wechat_ledger.py +190 -0
- package/src/services/cursorProbe.ts +222 -222
- package/src/services/diagnosticReport.ts +22 -0
- package/src/services/qchatImport.ts +327 -327
- package/src/services/setupVenv.ts +238 -238
- package/src/services/syncService.ts +363 -363
- package/src/services/syncState.ts +275 -275
- package/src/services/wechatCompat.ts +67 -0
- 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
|
+
}
|