weflow-cli 1.16.0 → 1.19.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 +72 -0
- package/README.md +19 -4
- package/bin/weflow-cli.ts +515 -6
- package/dist/bin/weflow-cli.js +580 -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/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/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/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/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 +118 -30
- package/docs/NT_DATABASES.md +47 -0
- package/docs/PROJECT_STATE.md +7 -3
- package/docs/RELEASING.md +4 -0
- package/docs/REPLAY.md +169 -0
- package/docs/SYNC_CONTRACT.md +197 -180
- package/package.json +89 -88
- package/resources/js/graph3d/viewer2d.js +550 -448
- package/scripts/annual_report.py +3 -1
- package/scripts/chat_notes.py +86 -3
- package/scripts/check_readme_commands.py +427 -426
- package/scripts/extract_todos.py +3 -1
- package/scripts/graph_2d.py +156 -151
- package/scripts/graph_3d.py +672 -646
- package/scripts/imported_chats.py +68 -0
- package/scripts/mcp_bridge.py +3 -1
- package/scripts/media_index.py +150 -0
- package/scripts/nt_common.py +32 -0
- package/scripts/nt_decrypt.py +154 -16
- package/scripts/on_this_day.py +178 -178
- package/scripts/promises.py +185 -185
- package/scripts/replay.py +803 -0
- package/scripts/run_hidden.vbs +25 -0
- package/scripts/semantic_search.py +78 -6
- package/scripts/wechat_avatar.py +185 -0
- package/scripts/wechat_ledger.py +190 -0
- 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/diagnosticReport.ts +22 -0
- package/src/services/qchatImport.ts +327 -0
- package/src/services/setupVenv.ts +238 -0
- package/src/services/wechatCompat.ts +67 -0
- package/src/utils/python.ts +33 -4
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `weflow-cli setup` —— 自管 Python venv。
|
|
3
|
+
*
|
|
4
|
+
* Issues 里最大的两类环境噪音:PowerShell 执行策略拦 `.ps1`(.cmd 包装绕开),
|
|
5
|
+
* 和 PATH 上只有微软商店的 python 占位程序(一跑 exit 9009,`getPythonCommand()`
|
|
6
|
+
* 兜底返回裸名 `python` 时正好交给它)。后者真正的根因是"这台机器没有一个能
|
|
7
|
+
* `import sqlcipher3` 的 Python"——本命令一次性解决:在 `~/.weflow-cli/venv`
|
|
8
|
+
* 建一个 venv、装上 requirements,之后 `getPythonCommand()` 优先用它。
|
|
9
|
+
*
|
|
10
|
+
* Windows 上 `sqlcipher3` 被替换为 `sqlcipher3-wheels`(预编译轮,import 名
|
|
11
|
+
* 相同):源码包要 OpenSSL 头文件 + MSVC,正是要消掉的那类支持负担。替换会
|
|
12
|
+
* 写进计划与输出,不藏在背后。
|
|
13
|
+
*/
|
|
14
|
+
import { execFile } from 'child_process'
|
|
15
|
+
import { promisify } from 'util'
|
|
16
|
+
import { existsSync, readFileSync, rmSync } from 'fs'
|
|
17
|
+
import { basename, join } from 'path'
|
|
18
|
+
import { homedir } from 'os'
|
|
19
|
+
import { resolvePackageRoot } from '../utils/packageRoot.js'
|
|
20
|
+
import { clearPythonCommandCache, getPythonCommand } from '../utils/python.js'
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile)
|
|
23
|
+
|
|
24
|
+
export const VENV_DIR = join(homedir(), '.weflow-cli', 'venv')
|
|
25
|
+
|
|
26
|
+
export interface SetupPlan {
|
|
27
|
+
venvDir: string
|
|
28
|
+
venvPython: string
|
|
29
|
+
/** 用来建 venv 的解释器;null = 这台机器连可运行的 Python 都没有 */
|
|
30
|
+
creator: string | null
|
|
31
|
+
recreate: boolean
|
|
32
|
+
requirementFiles: string[]
|
|
33
|
+
/** 解析后的安装清单(注释/空行已去掉,win32 的替换已做) */
|
|
34
|
+
packages: string[]
|
|
35
|
+
/** 替换说明,如 `sqlcipher3 -> sqlcipher3-binary (Windows 预编译轮)` */
|
|
36
|
+
substitutions: string[]
|
|
37
|
+
createsUnder: '~/.weflow-cli/venv'
|
|
38
|
+
network: true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SetupStep {
|
|
42
|
+
name: string
|
|
43
|
+
ok: boolean
|
|
44
|
+
/** 一步的补充事实(版本号、跳过原因等),不放大段日志 */
|
|
45
|
+
detail?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SetupResult {
|
|
49
|
+
success: boolean
|
|
50
|
+
steps: SetupStep[]
|
|
51
|
+
venvPython: string
|
|
52
|
+
/** 安装完清缓存后 getPythonCommand() 的实际选择——应等于 venvPython */
|
|
53
|
+
effectivePython: string | null
|
|
54
|
+
error?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** requirements 文件 → 去注释/空行的依赖列表。 */
|
|
58
|
+
export function parseRequirements(text: string): string[] {
|
|
59
|
+
return text
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map(line => line.trim())
|
|
62
|
+
.filter(line => line && !line.startsWith('#'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** win32 下把源码包换成预编译轮;其余平台原样。 */
|
|
66
|
+
export function resolvePackages(names: string[], platform: string): { packages: string[]; substitutions: string[] } {
|
|
67
|
+
if (platform !== 'win32') return { packages: [...names], substitutions: [] }
|
|
68
|
+
// Windows 上的预编译轮是 `sqlcipher3-wheels`(cp310+ 的 win32/amd64/arm64 都有)。
|
|
69
|
+
// `sqlcipher3-binary` 只有 Linux/macOS 轮——CI 的 ubuntu 能装它,Windows 上
|
|
70
|
+
// pip 直接报 "No matching distribution"(2026-10-23 实测)。
|
|
71
|
+
const packages = names.map(n => (n === 'sqlcipher3' ? 'sqlcipher3-wheels' : n))
|
|
72
|
+
const substitutions = packages.some(p => p === 'sqlcipher3-wheels') && names.some(n => n === 'sqlcipher3')
|
|
73
|
+
? ['sqlcipher3 -> sqlcipher3-wheels (Windows 预编译轮,import 名相同;源码包要 OpenSSL 头文件 + MSVC)']
|
|
74
|
+
: []
|
|
75
|
+
return { packages, substitutions }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 建一个"能用来创建 venv"的解释器探测:PATH 上的 python/py 逐个 `--version`。
|
|
80
|
+
* 商店占位程序在这一步就会被排掉(`--version` 以 9009 退出)。
|
|
81
|
+
*/
|
|
82
|
+
async function findCreator(): Promise<{ creator: string | null; version: string | null }> {
|
|
83
|
+
const names = process.platform === 'win32'
|
|
84
|
+
? ['python', 'python3', 'py']
|
|
85
|
+
: ['python3', 'python']
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
try {
|
|
88
|
+
const { stdout } = await execFileAsync(name, ['--version'], { windowsHide: true, timeout: 10_000 })
|
|
89
|
+
const version = stdout.trim().split('\n')[0] || null
|
|
90
|
+
return { creator: name, version }
|
|
91
|
+
} catch {
|
|
92
|
+
// 这个名字跑不起来(商店占位程序 9009 也会落到这里),试下一个
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { creator: null, version: null }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function planSetup(opts: {
|
|
99
|
+
recreate?: boolean
|
|
100
|
+
withVoice?: boolean
|
|
101
|
+
with3x?: boolean
|
|
102
|
+
} = {}): Promise<SetupPlan> {
|
|
103
|
+
const pkgRoot = resolvePackageRoot(import.meta.url)
|
|
104
|
+
const files = ['requirements.txt']
|
|
105
|
+
if (opts.with3x) files.push('requirements-3x.txt')
|
|
106
|
+
if (opts.withVoice) files.push('requirements-voice.txt')
|
|
107
|
+
|
|
108
|
+
const names: string[] = []
|
|
109
|
+
for (const f of files) {
|
|
110
|
+
names.push(...parseRequirements(readFileSync(join(pkgRoot, f), 'utf-8')))
|
|
111
|
+
}
|
|
112
|
+
const { packages, substitutions } = resolvePackages([...new Set(names)], process.platform)
|
|
113
|
+
const { creator } = await findCreator()
|
|
114
|
+
const venvPython = process.platform === 'win32'
|
|
115
|
+
? join(VENV_DIR, 'Scripts', 'python.exe')
|
|
116
|
+
: join(VENV_DIR, 'bin', 'python')
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
venvDir: VENV_DIR,
|
|
120
|
+
venvPython,
|
|
121
|
+
creator,
|
|
122
|
+
recreate: !!opts.recreate,
|
|
123
|
+
requirementFiles: files,
|
|
124
|
+
packages,
|
|
125
|
+
substitutions,
|
|
126
|
+
createsUnder: '~/.weflow-cli/venv',
|
|
127
|
+
network: true,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runSetup(plan: SetupPlan, onLine?: (line: string) => void): Promise<SetupResult> {
|
|
132
|
+
const steps: SetupStep[] = []
|
|
133
|
+
const say = (line: string) => onLine?.(line)
|
|
134
|
+
|
|
135
|
+
if (!plan.creator) {
|
|
136
|
+
return {
|
|
137
|
+
success: false,
|
|
138
|
+
steps: [{ name: 'find-python', ok: false, detail: 'PATH 上没有可运行的 Python(商店占位程序不算)——先装一个真正的 Python 3.10+ 再运行 setup' }],
|
|
139
|
+
venvPython: plan.venvPython,
|
|
140
|
+
effectivePython: null,
|
|
141
|
+
error: 'NO_USABLE_PYTHON',
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 1. 重建或创建 venv
|
|
146
|
+
if (plan.recreate && existsSync(plan.venvDir)) {
|
|
147
|
+
rmSync(plan.venvDir, { recursive: true, force: true })
|
|
148
|
+
steps.push({ name: 'remove-existing-venv', ok: true, detail: basename(plan.venvDir) })
|
|
149
|
+
say(`已删除旧 venv: ${plan.venvDir}`)
|
|
150
|
+
}
|
|
151
|
+
if (!existsSync(plan.venvDir)) {
|
|
152
|
+
try {
|
|
153
|
+
await execFileAsync(plan.creator, ['-m', 'venv', plan.venvDir], { windowsHide: true, timeout: 180_000 })
|
|
154
|
+
steps.push({ name: 'create-venv', ok: true, detail: `由 ${plan.creator} 创建` })
|
|
155
|
+
say(`已创建 venv: ${plan.venvDir}`)
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
steps: [...steps, { name: 'create-venv', ok: false, detail: String(e?.message || e).slice(0, 200) }],
|
|
160
|
+
venvPython: plan.venvPython,
|
|
161
|
+
effectivePython: null,
|
|
162
|
+
error: 'VENV_CREATE_FAILED',
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
steps.push({ name: 'create-venv', ok: true, detail: '已存在,跳过(--recreate 可重建)' })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 2. 装依赖(走 venv 自己的 pip;PyPI 网络请求就是这一步)。
|
|
170
|
+
// 失败重试一次:PyPI 的瞬时网络错误(本机 2026-10-23 实测过一次整批失败、
|
|
171
|
+
// 逐包重装全过)不值得让用户从头再来——重试前说一声。
|
|
172
|
+
const pipArgs = ['-m', 'pip', 'install', '--disable-pip-version-check', ...plan.packages]
|
|
173
|
+
const pipDetail = (e: unknown): string => {
|
|
174
|
+
const err = e as { stderr?: string; message?: string }
|
|
175
|
+
const tail = (err?.stderr || '').split('\n').map(l => l.trim()).filter(Boolean).slice(-3).join(' | ')
|
|
176
|
+
return (tail || String(err?.message || e)).slice(0, 300)
|
|
177
|
+
}
|
|
178
|
+
let installed = false
|
|
179
|
+
let pipError: unknown = null
|
|
180
|
+
for (let attempt = 1; attempt <= 2 && !installed; attempt++) {
|
|
181
|
+
try {
|
|
182
|
+
say(attempt === 1
|
|
183
|
+
? `安装 ${plan.packages.length} 个包(来自 ${plan.requirementFiles.join('、')})...`
|
|
184
|
+
: `重试安装(第 1 次失败:${pipDetail(pipError)})...`)
|
|
185
|
+
await execFileAsync(plan.venvPython, pipArgs, {
|
|
186
|
+
windowsHide: true,
|
|
187
|
+
timeout: 600_000,
|
|
188
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
189
|
+
})
|
|
190
|
+
installed = true
|
|
191
|
+
} catch (e) {
|
|
192
|
+
pipError = e
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (!installed) {
|
|
196
|
+
return {
|
|
197
|
+
success: false,
|
|
198
|
+
steps: [...steps, { name: 'pip-install', ok: false, detail: pipDetail(pipError) }],
|
|
199
|
+
venvPython: plan.venvPython,
|
|
200
|
+
effectivePython: null,
|
|
201
|
+
error: 'PIP_INSTALL_FAILED',
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
steps.push({ name: 'pip-install', ok: true, detail: `${plan.packages.length} 个包(${plan.requirementFiles.join('、')})` })
|
|
205
|
+
for (const s of plan.substitutions) say(` 替换: ${s}`)
|
|
206
|
+
|
|
207
|
+
// 3. 验收:venv 里真能 import sqlcipher3(NT 解密的核心依赖)
|
|
208
|
+
let verified = false
|
|
209
|
+
try {
|
|
210
|
+
await execFileAsync(plan.venvPython, ['-c', 'import sqlcipher3'], { windowsHide: true, timeout: 30_000 })
|
|
211
|
+
verified = true
|
|
212
|
+
} catch {
|
|
213
|
+
verified = false
|
|
214
|
+
}
|
|
215
|
+
steps.push({
|
|
216
|
+
name: 'verify-sqlcipher3',
|
|
217
|
+
ok: verified,
|
|
218
|
+
detail: verified ? 'import sqlcipher3 通过' : 'import sqlcipher3 失败——pip 报成功但模块不可用,把上面 pip 的完整输出贴到 issue',
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// 4. 确认运行时真的会选它(清掉模块级缓存再看选择)
|
|
222
|
+
clearPythonCommandCache()
|
|
223
|
+
const effective = getPythonCommand()
|
|
224
|
+
const wiringOk = existsSync(plan.venvPython) && effective === plan.venvPython
|
|
225
|
+
steps.push({
|
|
226
|
+
name: 'runtime-prefers-venv',
|
|
227
|
+
ok: wiringOk,
|
|
228
|
+
detail: wiringOk ? 'getPythonCommand() 已指向自管 venv' : `getPythonCommand() 选了 ${effective}——venv 未通过探测(sqlcipher3 不可用时按原顺序回退 PATH)`,
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
success: steps.every(s => s.ok),
|
|
233
|
+
steps,
|
|
234
|
+
venvPython: plan.venvPython,
|
|
235
|
+
effectivePython: effective,
|
|
236
|
+
...(verified && !wiringOk ? { error: 'VENV_NOT_PREFERRED' } : {}),
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 微信版本兼容:**单一事实源** + 纯比较。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-10-25 起立此规矩):微信 4.x 几周一更,而"这版 weflow-cli 在哪个微信版本上
|
|
5
|
+
* 验证过"原来只写在 PROJECT_STATE 的一段散文里——CHANGELOG 不记、机器读不到,用户升级
|
|
6
|
+
* 微信后读不出数据时,第一件事("你的微信比我们验证过的新吗")没人答得上来。
|
|
7
|
+
*
|
|
8
|
+
* 三条接线,都只从这读、别处不许再写一份:
|
|
9
|
+
* - `capabilities --json` 的 `compatibility.wechat`(机器可读);
|
|
10
|
+
* - `check`(含 `--json` / `--report`)拿本机**实际加载**的版本与此比对,给出四种判定之一;
|
|
11
|
+
* - `docs/RELEASING.md` 的发布清单要求每个 CHANGELOG 版本段记录验证版本(来源就是这里)。
|
|
12
|
+
*
|
|
13
|
+
* 更新 `WECHAT_VERIFIED` 的门槛:**必须伴随一次真实数据验证**(不只是"进程起来了"),
|
|
14
|
+
* 验证日期与读了什么写进 `verifiedHow`。没验证就不许改——这正是这个常量存在的意义。
|
|
15
|
+
*/
|
|
16
|
+
export const WECHAT_VERIFIED = {
|
|
17
|
+
version: '4.1.15.56',
|
|
18
|
+
verifiedAt: '2026-10-26',
|
|
19
|
+
verifiedHow: '升级后不重新提钥,用旧密钥实读真实数据:sessions / messages / contacts / '
|
|
20
|
+
+ 'daily-stats / fav / sns stats 全部返回数据(2026-10-19,记录见 PROJECT_STATE「Initialization」);'
|
|
21
|
+
+ '2026-10-26 复核:sessions 实读仍正常、check 判定 verified',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
export type CompatVerdict =
|
|
25
|
+
| 'verified' // 本机加载的就是验证过的版本
|
|
26
|
+
| 'newer-than-verified' // 本机更新:可能不兼容,读不出数据先怀疑这里
|
|
27
|
+
| 'older-than-verified' // 本机更旧:通常是兼容的(向下兼容是常态,但没验证过也说不上保证)
|
|
28
|
+
| 'unknown' // 微信没在跑 / 版本探测失败 / 版本串形状不对——"不知道"不是"没问题"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 四段数字逐段比较(`4.1.15.56` vs `4.1.15.60`)。缺段按 0 处理(`4.1.15` < `4.1.15.56`);
|
|
32
|
+
* 任一侧不是纯数字点分串就不猜——返回 `unknown`。纯函数。
|
|
33
|
+
*/
|
|
34
|
+
export function compareWechatVersions(live: string | null | undefined, verified: string): CompatVerdict {
|
|
35
|
+
const parse = (v: string): number[] | null => {
|
|
36
|
+
const parts = v.split('.')
|
|
37
|
+
if (!parts.length || !parts.every(p => /^\d+$/.test(p))) return null
|
|
38
|
+
return parts.map(Number)
|
|
39
|
+
}
|
|
40
|
+
if (!live) return 'unknown'
|
|
41
|
+
const a = parse(live)
|
|
42
|
+
const b = parse(verified)
|
|
43
|
+
if (!a || !b) return 'unknown'
|
|
44
|
+
const len = Math.max(a.length, b.length)
|
|
45
|
+
for (let i = 0; i < len; i++) {
|
|
46
|
+
const x = a[i] ?? 0
|
|
47
|
+
const y = b[i] ?? 0
|
|
48
|
+
if (x > y) return 'newer-than-verified'
|
|
49
|
+
if (x < y) return 'older-than-verified'
|
|
50
|
+
}
|
|
51
|
+
return 'verified'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 判定的人类可读一句话(check 人类输出与 --report 共用,措辞只有这一份)。 */
|
|
55
|
+
export function describeCompatVerdict(verdict: CompatVerdict, verifiedVersion: string): string {
|
|
56
|
+
switch (verdict) {
|
|
57
|
+
case 'verified':
|
|
58
|
+
return `本机加载的微信就是本版本验证过的 ${verifiedVersion}`
|
|
59
|
+
case 'newer-than-verified':
|
|
60
|
+
return `本机微信比本版本验证过的 ${verifiedVersion} 更新——如果读不出数据,先怀疑这里`
|
|
61
|
+
+ '(去 GitHub 看有没有更新版本)'
|
|
62
|
+
case 'older-than-verified':
|
|
63
|
+
return `本机微信比本版本验证过的 ${verifiedVersion} 更旧——一般兼容,但没有专门验证过`
|
|
64
|
+
default:
|
|
65
|
+
return `无法判定(微信没在运行或版本探测失败);本版本验证过的是 ${verifiedVersion}`
|
|
66
|
+
}
|
|
67
|
+
}
|