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,275 +1,275 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 同步检查点与任务记录的本地状态。
|
|
3
|
-
*
|
|
4
|
-
* 两个位置, 生命周期不同 (见 docs/SYNC_CONTRACT.md):
|
|
5
|
-
* ~/.weflow-cli/sync/<source>-<name>-<hash>.json 跨运行的检查点, 每会话一份
|
|
6
|
-
* ~/.weflow-cli/jobs/<jobId>.json 单次运行的记录
|
|
7
|
-
*
|
|
8
|
-
* 按会话分文件而不是一个全局文件: 全局文件要求跨会话读改写并合并, 分文件则每次
|
|
9
|
-
* 写入都是整文件原子替换 (沿用 assistantMemory.ts 的 tmp + rename 做法)。
|
|
10
|
-
*
|
|
11
|
-
* 两条本模块特有的规矩:
|
|
12
|
-
* - 遇到不认识的 schema 一律拒绝覆盖并报错, 不猜测字段含义;
|
|
13
|
-
* - 写盘前统一过一遍脱敏, 保证密钥/绝对路径不会进状态文件 (D-001 / D-002 / D-025)。
|
|
14
|
-
*/
|
|
15
|
-
import { createHash } from 'crypto'
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
|
|
17
|
-
import { join } from 'path'
|
|
18
|
-
import os from 'os'
|
|
19
|
-
|
|
20
|
-
export const SYNC_SCHEMA = 'weflow-sync/v1'
|
|
21
|
-
export const JOB_SCHEMA = 'weflow-job/v1'
|
|
22
|
-
export const SYNC_SOURCE = 'wechat-nt'
|
|
23
|
-
export const DEFAULT_OVERLAP_SECONDS = 300
|
|
24
|
-
|
|
25
|
-
/** 单次运行的结局。partial 是本项目新增的一档——部分完成不算成功。 */
|
|
26
|
-
export type JobState = 'planned' | 'waiting_confirmation' | 'running' | 'paused'
|
|
27
|
-
| 'completed' | 'partial' | 'failed' | 'cancelled'
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* 覆盖可信度。这是对「有没有读全」的正面回答, 不是内部细节。
|
|
31
|
-
*
|
|
32
|
-
* - complete 扫到的分片全部打开、打开的全读成功, 且没有更多
|
|
33
|
-
* - unverified 有分片打不开, 但打开的都读成功——已读部分可信, 未打开的分片内容无法判断
|
|
34
|
-
* - partial 已打开的分片读失败, 或还有更多, 或分页提前结束
|
|
35
|
-
*/
|
|
36
|
-
export type SyncCoverage = 'complete' | 'unverified' | 'partial'
|
|
37
|
-
|
|
38
|
-
export interface SyncShardItem {
|
|
39
|
-
/** 仅 basename——绝不落绝对路径 */
|
|
40
|
-
name: string
|
|
41
|
-
opened: boolean
|
|
42
|
-
hasTalkerTable: boolean | null
|
|
43
|
-
rowsForTalker: number | null
|
|
44
|
-
/** null | KEY_REJECTED | OPEN_FAILED | READ_FAILED */
|
|
45
|
-
reason: string | null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface SyncState {
|
|
49
|
-
schema: string
|
|
50
|
-
schemaVersion: number
|
|
51
|
-
source: string
|
|
52
|
-
scope: string
|
|
53
|
-
talker: string
|
|
54
|
-
lastSuccessfulRun: string | null
|
|
55
|
-
lastAttempt: string | null
|
|
56
|
-
coveredFrom: string | null
|
|
57
|
-
coveredTo: string | null
|
|
58
|
-
checkpoint: { newestCreateTime: number | null; overlapSeconds: number }
|
|
59
|
-
recordsRead: number
|
|
60
|
-
recordsDeduplicated: number
|
|
61
|
-
recordsReturned: number
|
|
62
|
-
shardsRead: number
|
|
63
|
-
shardsFailed: number
|
|
64
|
-
shards: { scanned: number; opened: number; failed: number; items: SyncShardItem[] }
|
|
65
|
-
coverage: SyncCoverage
|
|
66
|
-
mayHaveMore: boolean
|
|
67
|
-
warnings: string[]
|
|
68
|
-
updatedAt: string | null
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export interface JobRecord {
|
|
72
|
-
schema: string
|
|
73
|
-
jobId: string
|
|
74
|
-
kind: string
|
|
75
|
-
state: JobState
|
|
76
|
-
createdAt: string
|
|
77
|
-
updatedAt: string
|
|
78
|
-
window: { from: number | null; to: number | null } | null
|
|
79
|
-
progress: { completed: number; total: number } | null
|
|
80
|
-
resumeable: boolean
|
|
81
|
-
result: Record<string, unknown> | null
|
|
82
|
-
error: string | null
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** 未知 schema 时不覆盖原文件, 交由调用方决定如何处理。 */
|
|
86
|
-
export class UnknownSchemaError extends Error {
|
|
87
|
-
constructor(public readonly path: string, public readonly found: unknown) {
|
|
88
|
-
super(`不认识的 schema: ${String(found)} (${path})`)
|
|
89
|
-
this.name = 'UnknownSchemaError'
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const CONFIG_DIR = join(os.homedir(), '.weflow-cli')
|
|
94
|
-
const SYNC_DIR = join(CONFIG_DIR, 'sync')
|
|
95
|
-
const JOBS_DIR = join(CONFIG_DIR, 'jobs')
|
|
96
|
-
|
|
97
|
-
const FORBIDDEN_KEYS = /^(ntKey|ntSalt|decryptKey|favKey|favPassphrase|contactKey|snsKey|wechatOcToken|apiKey|password|auth)$/i
|
|
98
|
-
/** 密钥形状的十六进制串 (>=32 hex)。 */
|
|
99
|
-
const HEX_KEY = /\b[0-9a-f]{32,}\b/gi
|
|
100
|
-
/** Windows 盘符、UNC、以及 POSIX 绝对路径。三类都要, 早期只处理了第一类。 */
|
|
101
|
-
const ABS_PATH = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|\/(?:Users|home|root|var|tmp|opt|mnt)\/[^\s"']*)/g
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* 递归剔除不该落盘的字段。
|
|
105
|
-
*
|
|
106
|
-
* 状态文件是本机产物, 但一旦被日志、issue 或截图带出去就是泄露, 所以在写入
|
|
107
|
-
* 这一层兜底, 而不是指望每个调用方都记得。
|
|
108
|
-
*/
|
|
109
|
-
export function sanitizeForState(value: unknown): unknown {
|
|
110
|
-
if (typeof value === 'string') {
|
|
111
|
-
return value.replace(HEX_KEY, '<redacted>').replace(ABS_PATH, '<path>')
|
|
112
|
-
}
|
|
113
|
-
if (Array.isArray(value)) return value.map(sanitizeForState)
|
|
114
|
-
if (value && typeof value === 'object') {
|
|
115
|
-
const out: Record<string, unknown> = {}
|
|
116
|
-
for (const [key, item] of Object.entries(value)) {
|
|
117
|
-
if (FORBIDDEN_KEYS.test(key)) continue
|
|
118
|
-
out[key] = sanitizeForState(item)
|
|
119
|
-
}
|
|
120
|
-
return out
|
|
121
|
-
}
|
|
122
|
-
return value
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* 文件名里只放一个可读前缀和一个 hash。
|
|
127
|
-
*
|
|
128
|
-
* hash 后缀让 `wxid_a@chatroom` 与 `wxid_a_chatroom` 落到不同文件, 而显示名
|
|
129
|
-
* 不会出现在目录列表里。
|
|
130
|
-
*/
|
|
131
|
-
export function talkerSlug(talker: string): string {
|
|
132
|
-
const readable = talker.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60)
|
|
133
|
-
const digest = createHash('sha256').update(talker).digest('hex').slice(0, 8)
|
|
134
|
-
return `${readable}-${digest}`
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function writeAtomic(path: string, payload: unknown): void {
|
|
138
|
-
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
|
|
139
|
-
const tmp = `${path}.tmp`
|
|
140
|
-
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
141
|
-
renameSync(tmp, path)
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function readJson(path: string): unknown {
|
|
145
|
-
try {
|
|
146
|
-
return JSON.parse(readFileSync(path, 'utf8'))
|
|
147
|
-
} catch {
|
|
148
|
-
return null
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function emptySyncState(
|
|
153
|
-
talker: string,
|
|
154
|
-
scope = talker,
|
|
155
|
-
source = SYNC_SOURCE,
|
|
156
|
-
overlapSeconds = DEFAULT_OVERLAP_SECONDS,
|
|
157
|
-
): SyncState {
|
|
158
|
-
return {
|
|
159
|
-
schema: SYNC_SCHEMA,
|
|
160
|
-
schemaVersion: 1,
|
|
161
|
-
source,
|
|
162
|
-
scope,
|
|
163
|
-
talker,
|
|
164
|
-
lastSuccessfulRun: null,
|
|
165
|
-
lastAttempt: null,
|
|
166
|
-
coveredFrom: null,
|
|
167
|
-
coveredTo: null,
|
|
168
|
-
checkpoint: { newestCreateTime: null, overlapSeconds },
|
|
169
|
-
recordsRead: 0,
|
|
170
|
-
recordsDeduplicated: 0,
|
|
171
|
-
recordsReturned: 0,
|
|
172
|
-
shardsRead: 0,
|
|
173
|
-
shardsFailed: 0,
|
|
174
|
-
shards: { scanned: 0, opened: 0, failed: 0, items: [] },
|
|
175
|
-
coverage: 'unverified',
|
|
176
|
-
mayHaveMore: false,
|
|
177
|
-
warnings: [],
|
|
178
|
-
updatedAt: null,
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export class SyncStateStore {
|
|
183
|
-
constructor(
|
|
184
|
-
private readonly syncDir: string = SYNC_DIR,
|
|
185
|
-
private readonly jobsDir: string = JOBS_DIR,
|
|
186
|
-
) {}
|
|
187
|
-
|
|
188
|
-
statePath(source: string, talker: string): string {
|
|
189
|
-
return join(this.syncDir, `${source}-${talkerSlug(talker)}.json`)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** 读取检查点。无记录返回 null; schema 不认识则抛错, 不猜测。 */
|
|
193
|
-
read(source: string, talker: string): SyncState | null {
|
|
194
|
-
const path = this.statePath(source, talker)
|
|
195
|
-
const raw = readJson(path)
|
|
196
|
-
if (raw === null) return null
|
|
197
|
-
const state = raw as SyncState
|
|
198
|
-
if (state.schema !== SYNC_SCHEMA) throw new UnknownSchemaError(path, state.schema)
|
|
199
|
-
return state
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
write(state: SyncState): void {
|
|
203
|
-
const path = this.statePath(state.source, state.talker)
|
|
204
|
-
if (!existsSync(this.syncDir)) mkdirSync(this.syncDir, { recursive: true })
|
|
205
|
-
writeAtomic(path, sanitizeForState({ ...state, schema: SYNC_SCHEMA }))
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** 所有会话的检查点, 最近尝试在前。目录缺失或单个文件损坏都不影响其余。 */
|
|
209
|
-
list(): SyncState[] {
|
|
210
|
-
try {
|
|
211
|
-
if (!existsSync(this.syncDir)) return []
|
|
212
|
-
const states: SyncState[] = []
|
|
213
|
-
for (const name of readdirSync(this.syncDir)) {
|
|
214
|
-
if (!name.endsWith('.json')) continue
|
|
215
|
-
const raw = readJson(join(this.syncDir, name))
|
|
216
|
-
if (!raw || (raw as SyncState).schema !== SYNC_SCHEMA) continue
|
|
217
|
-
states.push(raw as SyncState)
|
|
218
|
-
}
|
|
219
|
-
return states.sort((a, b) => String(b.lastAttempt ?? '').localeCompare(String(a.lastAttempt ?? '')))
|
|
220
|
-
} catch {
|
|
221
|
-
return []
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
jobPath(jobId: string): string {
|
|
226
|
-
return join(this.jobsDir, `${jobId}.json`)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
writeJob(job: JobRecord): void {
|
|
230
|
-
if (!existsSync(this.jobsDir)) mkdirSync(this.jobsDir, { recursive: true })
|
|
231
|
-
writeAtomic(this.jobPath(job.jobId), sanitizeForState({ ...job, schema: JOB_SCHEMA }))
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
readJob(jobId: string): JobRecord | null {
|
|
235
|
-
const path = this.jobPath(jobId)
|
|
236
|
-
const raw = readJson(path)
|
|
237
|
-
if (raw === null) return null
|
|
238
|
-
const job = raw as JobRecord
|
|
239
|
-
if (job.schema !== JOB_SCHEMA) throw new UnknownSchemaError(path, job.schema)
|
|
240
|
-
return job
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** 最近的 job 记录, 最新在前。 */
|
|
244
|
-
recentJobs(limit = 10): JobRecord[] {
|
|
245
|
-
try {
|
|
246
|
-
if (!existsSync(this.jobsDir)) return []
|
|
247
|
-
const ids = readdirSync(this.jobsDir)
|
|
248
|
-
.filter(name => name.endsWith('.json'))
|
|
249
|
-
.map(name => name.slice(0, -'.json'.length))
|
|
250
|
-
.sort()
|
|
251
|
-
.reverse()
|
|
252
|
-
.slice(0, limit)
|
|
253
|
-
const jobs: JobRecord[] = []
|
|
254
|
-
for (const id of ids) {
|
|
255
|
-
const job = this.readJob(id)
|
|
256
|
-
if (job) jobs.push(job)
|
|
257
|
-
}
|
|
258
|
-
return jobs
|
|
259
|
-
} catch {
|
|
260
|
-
return []
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
export const syncStateStore = new SyncStateStore()
|
|
266
|
-
|
|
267
|
-
/** jobId 形如 job_20260919_0001。同日按时间戳后四位递增, 保证可排序。 */
|
|
268
|
-
export function makeJobId(now = new Date()): string {
|
|
269
|
-
const stamp = [
|
|
270
|
-
now.getFullYear(),
|
|
271
|
-
String(now.getMonth() + 1).padStart(2, '0'),
|
|
272
|
-
String(now.getDate()).padStart(2, '0'),
|
|
273
|
-
].join('')
|
|
274
|
-
return `job_${stamp}_${String(now.getTime() % 10000).padStart(4, '0')}`
|
|
275
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 同步检查点与任务记录的本地状态。
|
|
3
|
+
*
|
|
4
|
+
* 两个位置, 生命周期不同 (见 docs/SYNC_CONTRACT.md):
|
|
5
|
+
* ~/.weflow-cli/sync/<source>-<name>-<hash>.json 跨运行的检查点, 每会话一份
|
|
6
|
+
* ~/.weflow-cli/jobs/<jobId>.json 单次运行的记录
|
|
7
|
+
*
|
|
8
|
+
* 按会话分文件而不是一个全局文件: 全局文件要求跨会话读改写并合并, 分文件则每次
|
|
9
|
+
* 写入都是整文件原子替换 (沿用 assistantMemory.ts 的 tmp + rename 做法)。
|
|
10
|
+
*
|
|
11
|
+
* 两条本模块特有的规矩:
|
|
12
|
+
* - 遇到不认识的 schema 一律拒绝覆盖并报错, 不猜测字段含义;
|
|
13
|
+
* - 写盘前统一过一遍脱敏, 保证密钥/绝对路径不会进状态文件 (D-001 / D-002 / D-025)。
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'crypto'
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
|
|
17
|
+
import { join } from 'path'
|
|
18
|
+
import os from 'os'
|
|
19
|
+
|
|
20
|
+
export const SYNC_SCHEMA = 'weflow-sync/v1'
|
|
21
|
+
export const JOB_SCHEMA = 'weflow-job/v1'
|
|
22
|
+
export const SYNC_SOURCE = 'wechat-nt'
|
|
23
|
+
export const DEFAULT_OVERLAP_SECONDS = 300
|
|
24
|
+
|
|
25
|
+
/** 单次运行的结局。partial 是本项目新增的一档——部分完成不算成功。 */
|
|
26
|
+
export type JobState = 'planned' | 'waiting_confirmation' | 'running' | 'paused'
|
|
27
|
+
| 'completed' | 'partial' | 'failed' | 'cancelled'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 覆盖可信度。这是对「有没有读全」的正面回答, 不是内部细节。
|
|
31
|
+
*
|
|
32
|
+
* - complete 扫到的分片全部打开、打开的全读成功, 且没有更多
|
|
33
|
+
* - unverified 有分片打不开, 但打开的都读成功——已读部分可信, 未打开的分片内容无法判断
|
|
34
|
+
* - partial 已打开的分片读失败, 或还有更多, 或分页提前结束
|
|
35
|
+
*/
|
|
36
|
+
export type SyncCoverage = 'complete' | 'unverified' | 'partial'
|
|
37
|
+
|
|
38
|
+
export interface SyncShardItem {
|
|
39
|
+
/** 仅 basename——绝不落绝对路径 */
|
|
40
|
+
name: string
|
|
41
|
+
opened: boolean
|
|
42
|
+
hasTalkerTable: boolean | null
|
|
43
|
+
rowsForTalker: number | null
|
|
44
|
+
/** null | KEY_REJECTED | OPEN_FAILED | READ_FAILED */
|
|
45
|
+
reason: string | null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SyncState {
|
|
49
|
+
schema: string
|
|
50
|
+
schemaVersion: number
|
|
51
|
+
source: string
|
|
52
|
+
scope: string
|
|
53
|
+
talker: string
|
|
54
|
+
lastSuccessfulRun: string | null
|
|
55
|
+
lastAttempt: string | null
|
|
56
|
+
coveredFrom: string | null
|
|
57
|
+
coveredTo: string | null
|
|
58
|
+
checkpoint: { newestCreateTime: number | null; overlapSeconds: number }
|
|
59
|
+
recordsRead: number
|
|
60
|
+
recordsDeduplicated: number
|
|
61
|
+
recordsReturned: number
|
|
62
|
+
shardsRead: number
|
|
63
|
+
shardsFailed: number
|
|
64
|
+
shards: { scanned: number; opened: number; failed: number; items: SyncShardItem[] }
|
|
65
|
+
coverage: SyncCoverage
|
|
66
|
+
mayHaveMore: boolean
|
|
67
|
+
warnings: string[]
|
|
68
|
+
updatedAt: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface JobRecord {
|
|
72
|
+
schema: string
|
|
73
|
+
jobId: string
|
|
74
|
+
kind: string
|
|
75
|
+
state: JobState
|
|
76
|
+
createdAt: string
|
|
77
|
+
updatedAt: string
|
|
78
|
+
window: { from: number | null; to: number | null } | null
|
|
79
|
+
progress: { completed: number; total: number } | null
|
|
80
|
+
resumeable: boolean
|
|
81
|
+
result: Record<string, unknown> | null
|
|
82
|
+
error: string | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 未知 schema 时不覆盖原文件, 交由调用方决定如何处理。 */
|
|
86
|
+
export class UnknownSchemaError extends Error {
|
|
87
|
+
constructor(public readonly path: string, public readonly found: unknown) {
|
|
88
|
+
super(`不认识的 schema: ${String(found)} (${path})`)
|
|
89
|
+
this.name = 'UnknownSchemaError'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const CONFIG_DIR = join(os.homedir(), '.weflow-cli')
|
|
94
|
+
const SYNC_DIR = join(CONFIG_DIR, 'sync')
|
|
95
|
+
const JOBS_DIR = join(CONFIG_DIR, 'jobs')
|
|
96
|
+
|
|
97
|
+
const FORBIDDEN_KEYS = /^(ntKey|ntSalt|decryptKey|favKey|favPassphrase|contactKey|snsKey|wechatOcToken|apiKey|password|auth)$/i
|
|
98
|
+
/** 密钥形状的十六进制串 (>=32 hex)。 */
|
|
99
|
+
const HEX_KEY = /\b[0-9a-f]{32,}\b/gi
|
|
100
|
+
/** Windows 盘符、UNC、以及 POSIX 绝对路径。三类都要, 早期只处理了第一类。 */
|
|
101
|
+
const ABS_PATH = /(?:[A-Za-z]:[\\/][^\s"']*|\\\\[^\s"']+|\/(?:Users|home|root|var|tmp|opt|mnt)\/[^\s"']*)/g
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 递归剔除不该落盘的字段。
|
|
105
|
+
*
|
|
106
|
+
* 状态文件是本机产物, 但一旦被日志、issue 或截图带出去就是泄露, 所以在写入
|
|
107
|
+
* 这一层兜底, 而不是指望每个调用方都记得。
|
|
108
|
+
*/
|
|
109
|
+
export function sanitizeForState(value: unknown): unknown {
|
|
110
|
+
if (typeof value === 'string') {
|
|
111
|
+
return value.replace(HEX_KEY, '<redacted>').replace(ABS_PATH, '<path>')
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(value)) return value.map(sanitizeForState)
|
|
114
|
+
if (value && typeof value === 'object') {
|
|
115
|
+
const out: Record<string, unknown> = {}
|
|
116
|
+
for (const [key, item] of Object.entries(value)) {
|
|
117
|
+
if (FORBIDDEN_KEYS.test(key)) continue
|
|
118
|
+
out[key] = sanitizeForState(item)
|
|
119
|
+
}
|
|
120
|
+
return out
|
|
121
|
+
}
|
|
122
|
+
return value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 文件名里只放一个可读前缀和一个 hash。
|
|
127
|
+
*
|
|
128
|
+
* hash 后缀让 `wxid_a@chatroom` 与 `wxid_a_chatroom` 落到不同文件, 而显示名
|
|
129
|
+
* 不会出现在目录列表里。
|
|
130
|
+
*/
|
|
131
|
+
export function talkerSlug(talker: string): string {
|
|
132
|
+
const readable = talker.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 60)
|
|
133
|
+
const digest = createHash('sha256').update(talker).digest('hex').slice(0, 8)
|
|
134
|
+
return `${readable}-${digest}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeAtomic(path: string, payload: unknown): void {
|
|
138
|
+
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true })
|
|
139
|
+
const tmp = `${path}.tmp`
|
|
140
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8')
|
|
141
|
+
renameSync(tmp, path)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function readJson(path: string): unknown {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(readFileSync(path, 'utf8'))
|
|
147
|
+
} catch {
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function emptySyncState(
|
|
153
|
+
talker: string,
|
|
154
|
+
scope = talker,
|
|
155
|
+
source = SYNC_SOURCE,
|
|
156
|
+
overlapSeconds = DEFAULT_OVERLAP_SECONDS,
|
|
157
|
+
): SyncState {
|
|
158
|
+
return {
|
|
159
|
+
schema: SYNC_SCHEMA,
|
|
160
|
+
schemaVersion: 1,
|
|
161
|
+
source,
|
|
162
|
+
scope,
|
|
163
|
+
talker,
|
|
164
|
+
lastSuccessfulRun: null,
|
|
165
|
+
lastAttempt: null,
|
|
166
|
+
coveredFrom: null,
|
|
167
|
+
coveredTo: null,
|
|
168
|
+
checkpoint: { newestCreateTime: null, overlapSeconds },
|
|
169
|
+
recordsRead: 0,
|
|
170
|
+
recordsDeduplicated: 0,
|
|
171
|
+
recordsReturned: 0,
|
|
172
|
+
shardsRead: 0,
|
|
173
|
+
shardsFailed: 0,
|
|
174
|
+
shards: { scanned: 0, opened: 0, failed: 0, items: [] },
|
|
175
|
+
coverage: 'unverified',
|
|
176
|
+
mayHaveMore: false,
|
|
177
|
+
warnings: [],
|
|
178
|
+
updatedAt: null,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export class SyncStateStore {
|
|
183
|
+
constructor(
|
|
184
|
+
private readonly syncDir: string = SYNC_DIR,
|
|
185
|
+
private readonly jobsDir: string = JOBS_DIR,
|
|
186
|
+
) {}
|
|
187
|
+
|
|
188
|
+
statePath(source: string, talker: string): string {
|
|
189
|
+
return join(this.syncDir, `${source}-${talkerSlug(talker)}.json`)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** 读取检查点。无记录返回 null; schema 不认识则抛错, 不猜测。 */
|
|
193
|
+
read(source: string, talker: string): SyncState | null {
|
|
194
|
+
const path = this.statePath(source, talker)
|
|
195
|
+
const raw = readJson(path)
|
|
196
|
+
if (raw === null) return null
|
|
197
|
+
const state = raw as SyncState
|
|
198
|
+
if (state.schema !== SYNC_SCHEMA) throw new UnknownSchemaError(path, state.schema)
|
|
199
|
+
return state
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
write(state: SyncState): void {
|
|
203
|
+
const path = this.statePath(state.source, state.talker)
|
|
204
|
+
if (!existsSync(this.syncDir)) mkdirSync(this.syncDir, { recursive: true })
|
|
205
|
+
writeAtomic(path, sanitizeForState({ ...state, schema: SYNC_SCHEMA }))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 所有会话的检查点, 最近尝试在前。目录缺失或单个文件损坏都不影响其余。 */
|
|
209
|
+
list(): SyncState[] {
|
|
210
|
+
try {
|
|
211
|
+
if (!existsSync(this.syncDir)) return []
|
|
212
|
+
const states: SyncState[] = []
|
|
213
|
+
for (const name of readdirSync(this.syncDir)) {
|
|
214
|
+
if (!name.endsWith('.json')) continue
|
|
215
|
+
const raw = readJson(join(this.syncDir, name))
|
|
216
|
+
if (!raw || (raw as SyncState).schema !== SYNC_SCHEMA) continue
|
|
217
|
+
states.push(raw as SyncState)
|
|
218
|
+
}
|
|
219
|
+
return states.sort((a, b) => String(b.lastAttempt ?? '').localeCompare(String(a.lastAttempt ?? '')))
|
|
220
|
+
} catch {
|
|
221
|
+
return []
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
jobPath(jobId: string): string {
|
|
226
|
+
return join(this.jobsDir, `${jobId}.json`)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
writeJob(job: JobRecord): void {
|
|
230
|
+
if (!existsSync(this.jobsDir)) mkdirSync(this.jobsDir, { recursive: true })
|
|
231
|
+
writeAtomic(this.jobPath(job.jobId), sanitizeForState({ ...job, schema: JOB_SCHEMA }))
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
readJob(jobId: string): JobRecord | null {
|
|
235
|
+
const path = this.jobPath(jobId)
|
|
236
|
+
const raw = readJson(path)
|
|
237
|
+
if (raw === null) return null
|
|
238
|
+
const job = raw as JobRecord
|
|
239
|
+
if (job.schema !== JOB_SCHEMA) throw new UnknownSchemaError(path, job.schema)
|
|
240
|
+
return job
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 最近的 job 记录, 最新在前。 */
|
|
244
|
+
recentJobs(limit = 10): JobRecord[] {
|
|
245
|
+
try {
|
|
246
|
+
if (!existsSync(this.jobsDir)) return []
|
|
247
|
+
const ids = readdirSync(this.jobsDir)
|
|
248
|
+
.filter(name => name.endsWith('.json'))
|
|
249
|
+
.map(name => name.slice(0, -'.json'.length))
|
|
250
|
+
.sort()
|
|
251
|
+
.reverse()
|
|
252
|
+
.slice(0, limit)
|
|
253
|
+
const jobs: JobRecord[] = []
|
|
254
|
+
for (const id of ids) {
|
|
255
|
+
const job = this.readJob(id)
|
|
256
|
+
if (job) jobs.push(job)
|
|
257
|
+
}
|
|
258
|
+
return jobs
|
|
259
|
+
} catch {
|
|
260
|
+
return []
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export const syncStateStore = new SyncStateStore()
|
|
266
|
+
|
|
267
|
+
/** jobId 形如 job_20260919_0001。同日按时间戳后四位递增, 保证可排序。 */
|
|
268
|
+
export function makeJobId(now = new Date()): string {
|
|
269
|
+
const stamp = [
|
|
270
|
+
now.getFullYear(),
|
|
271
|
+
String(now.getMonth() + 1).padStart(2, '0'),
|
|
272
|
+
String(now.getDate()).padStart(2, '0'),
|
|
273
|
+
].join('')
|
|
274
|
+
return `job_${stamp}_${String(now.getTime() % 10000).padStart(4, '0')}`
|
|
275
|
+
}
|
|
@@ -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
|
+
}
|