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
|
@@ -1,166 +1,166 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 官方通道(ilink)**入站媒体**的落地:把 CDN 上的密文拉下来、解密、写到本地磁盘。
|
|
3
|
-
*
|
|
4
|
-
* 为什么值得做:入站图片/语音/文件/视频此前只解析出"有这一项",`filePath` 是空串,于是助手拿到消息
|
|
5
|
-
* 也不知道图里是什么(那四项的落地当时是显式延后的)。下载与解密的本事本来就有
|
|
6
|
-
* (`WechatClient.downloadMedia`:`<cdn>/download?encrypted_query_param=…` + AES-128-ECB + 去 PKCS#7),
|
|
7
|
-
* 缺的只是把它接上入站这条路。
|
|
8
|
-
*
|
|
9
|
-
* 字段名与优先级照**厂商自己的实现**(`third-party/WeKnora/internal/im/wechat/longpoll.go`):
|
|
10
|
-
* 图片优先用 `image_item.aeskey`(十六进制字符串,**不走 base64**),退回 `media.aes_key`(base64);
|
|
11
|
-
* 语音/文件/视频只有 `media.aes_key`。两种编码都由 `WechatClient.parseMediaAesKey` 认。
|
|
12
|
-
*
|
|
13
|
-
* 三条边界写在这里,不指望调用方记得:
|
|
14
|
-
* 1. **它不是默认行为** —— 打开 `wechatMediaDownload` 才发生网络访问;
|
|
15
|
-
* 2. **文件名永远由本地决定** —— 服务端给的名字只取 basename 并过滤字符,`../` 之类必须在这里被吃掉;
|
|
16
|
-
* 3. **超过上限就不写**(`WECHAT_MEDIA_MAX_BYTES`),失败不留半个文件 —— 半个文件比没有更难查。
|
|
17
|
-
*/
|
|
18
|
-
import { basename, join } from 'path'
|
|
19
|
-
import { mkdir, writeFile } from 'fs/promises'
|
|
20
|
-
|
|
21
|
-
/** 单件上限。够大(手机拍的视频也能过),又不至于一次 CDN 事故把磁盘写满 */
|
|
22
|
-
export const WECHAT_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
|
23
|
-
|
|
24
|
-
/** 落地目录:默认仓库相对 `output/wechat-media/`(`output/` 已在 .gitignore 里),可用环境变量改(测试用临时目录) */
|
|
25
|
-
export function wechatMediaDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
26
|
-
const fromEnv = env.WEFLOW_WECHAT_MEDIA_DIR
|
|
27
|
-
return fromEnv && fromEnv.trim() ? fromEnv : join('output', 'wechat-media')
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export type InboundMediaType = 'image' | 'record' | 'file' | 'video'
|
|
31
|
-
|
|
32
|
-
/** item.type 的数字编码(与服务端 item_list 一致) */
|
|
33
|
-
export function mediaTypeOf(itemType: number): InboundMediaType | null {
|
|
34
|
-
if (itemType === 2) return 'image'
|
|
35
|
-
if (itemType === 3) return 'record'
|
|
36
|
-
if (itemType === 4) return 'file'
|
|
37
|
-
if (itemType === 5) return 'video'
|
|
38
|
-
return null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** 服务端给的名字只当作"一个字符串"用:取 basename、去掉控制字符与路径字符、去掉前导点、限长 */
|
|
42
|
-
export function sanitizeFileName(name: string): string {
|
|
43
|
-
const base = basename(String(name || '').replace(/\\/g, '/')).trim()
|
|
44
|
-
return base
|
|
45
|
-
.replace(/[\u0000-\u001f<>:"|?*]/g, '_')
|
|
46
|
-
.replace(/^\.+/, '')
|
|
47
|
-
.trim()
|
|
48
|
-
.slice(0, 80)
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** 按魔数认扩展名。认不出就返回空串(调用方退回按类型给的后缀),不猜 */
|
|
52
|
-
export function extFromMagic(buf: Buffer): string {
|
|
53
|
-
const b = buf
|
|
54
|
-
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return '.jpg'
|
|
55
|
-
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return '.png'
|
|
56
|
-
if (b.length >= 6 && b.subarray(0, 6).toString('latin1').startsWith('GIF8')) return '.gif'
|
|
57
|
-
if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
58
|
-
&& b.subarray(8, 12).toString('latin1') === 'WEBP') return '.webp'
|
|
59
|
-
if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return '.bmp'
|
|
60
|
-
if (b.length >= 12 && b.subarray(4, 8).toString('latin1') === 'ftyp') return '.mp4'
|
|
61
|
-
if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return '.webm'
|
|
62
|
-
if (b.length >= 5 && b.subarray(0, 5).toString('latin1') === '#!AMR') return '.amr'
|
|
63
|
-
if (b.length >= 4 && b.subarray(0, 4).toString('latin1') === 'OggS') return '.ogg'
|
|
64
|
-
if (b.length >= 2 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) return '.mp3'
|
|
65
|
-
if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
66
|
-
&& b.subarray(8, 12).toString('latin1') === 'WAVE') return '.wav'
|
|
67
|
-
return ''
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 该媒体类型在服务端字段树里的节点名 */
|
|
71
|
-
function mediaNode(item: any, mediaType: InboundMediaType): any {
|
|
72
|
-
if (mediaType === 'image') return item?.image_item
|
|
73
|
-
if (mediaType === 'record') return item?.voice_item
|
|
74
|
-
if (mediaType === 'file') return item?.file_item
|
|
75
|
-
return item?.video_item
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** 下载参数(`encrypt_query_param`)。取不到就没法下载——**这时候不猜、不留空文件** */
|
|
79
|
-
export function pickEncryptParam(item: any, mediaType: InboundMediaType): string {
|
|
80
|
-
const node = mediaNode(item, mediaType)
|
|
81
|
-
return String(node?.media?.encrypt_query_param || '')
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* AES key 的取值优先级。**图片这一支必须照厂商实现**:`image_item.aeskey` 是十六进制字符串,
|
|
86
|
-
* 比 `media.aes_key`(base64)更靠前;漏了这个优先级,图片会解出一堆乱码却不报错。
|
|
87
|
-
*/
|
|
88
|
-
export function pickAesKeyValue(item: any, mediaType: InboundMediaType): string {
|
|
89
|
-
const node = mediaNode(item, mediaType)
|
|
90
|
-
if (mediaType === 'image' && node?.aeskey) return String(node.aeskey)
|
|
91
|
-
return String(node?.media?.aes_key || '')
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export interface SaveMediaOptions {
|
|
95
|
-
item: any
|
|
96
|
-
mediaType: InboundMediaType
|
|
97
|
-
/** 同一条消息里的第几项(一条消息可以带多张图) */
|
|
98
|
-
index: number
|
|
99
|
-
/** 名字前缀:优先用服务端给的 client_id */
|
|
100
|
-
messageId: string
|
|
101
|
-
dir?: string
|
|
102
|
-
/** 注入的下载器(默认由调用方传 `client.downloadMedia`),测试里换成假的 */
|
|
103
|
-
download: (encryptedQueryParam: string, aesKeyValue: string) => Promise<Buffer>
|
|
104
|
-
/** 文件类消息里服务端给的文件名 */
|
|
105
|
-
serverFileName?: string
|
|
106
|
-
maxBytes?: number
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export interface SaveMediaResult {
|
|
110
|
-
/** 成功是磁盘路径;失败是空串(**绝不返回"看起来成功"的半成品**) */
|
|
111
|
-
path: string
|
|
112
|
-
/** 失败原因,进日志用 */
|
|
113
|
-
reason: string
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* 下载一件入站媒体并落盘。失败一律返回 `{ path: '', reason }` 且**不留下文件**:
|
|
118
|
-
* 助手与导出拿到空 path 时知道"没有这玩意",比拿到一个 0 字节文件强。
|
|
119
|
-
*/
|
|
120
|
-
export async function saveInboundMedia(opts: SaveMediaOptions): Promise<SaveMediaResult> {
|
|
121
|
-
const { item, mediaType, index, messageId, download } = opts
|
|
122
|
-
const cap = opts.maxBytes ?? WECHAT_MEDIA_MAX_BYTES
|
|
123
|
-
|
|
124
|
-
const encryptParam = pickEncryptParam(item, mediaType)
|
|
125
|
-
if (!encryptParam) return { path: '', reason: '服务端没给 encrypt_query_param' }
|
|
126
|
-
|
|
127
|
-
const aesKeyValue = pickAesKeyValue(item, mediaType)
|
|
128
|
-
if (!aesKeyValue) return { path: '', reason: '服务端没给 aes key' }
|
|
129
|
-
|
|
130
|
-
let bytes: Buffer
|
|
131
|
-
try {
|
|
132
|
-
bytes = await download(encryptParam, aesKeyValue)
|
|
133
|
-
} catch (error: any) {
|
|
134
|
-
return { path: '', reason: `下载或解密失败:${error?.message || error}` }
|
|
135
|
-
}
|
|
136
|
-
if (!bytes || bytes.length === 0) return { path: '', reason: '下载结果为空' }
|
|
137
|
-
if (bytes.length > cap) {
|
|
138
|
-
return { path: '', reason: `超过上限(${bytes.length} > ${cap} 字节)` }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const byMagic = extFromMagic(bytes)
|
|
142
|
-
const fallbackExt = mediaType === 'image' ? '.jpg' : ''
|
|
143
|
-
const prefix = sanitizeFileName(messageId) || 'msg'
|
|
144
|
-
let name: string
|
|
145
|
-
if (mediaType === 'file' && opts.serverFileName) {
|
|
146
|
-
const safe = sanitizeFileName(opts.serverFileName)
|
|
147
|
-
if (safe) {
|
|
148
|
-
const hasExt = /\.[A-Za-z0-9]{1,8}$/.test(safe)
|
|
149
|
-
name = `${prefix}-${index}-${hasExt || !byMagic ? safe : safe + byMagic}`
|
|
150
|
-
} else {
|
|
151
|
-
name = `${prefix}-${index}${byMagic}`
|
|
152
|
-
}
|
|
153
|
-
} else {
|
|
154
|
-
name = `${prefix}-${index}${byMagic || fallbackExt}`
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const dir = opts.dir || wechatMediaDir()
|
|
158
|
-
const full = join(dir, name)
|
|
159
|
-
try {
|
|
160
|
-
await mkdir(dir, { recursive: true })
|
|
161
|
-
await writeFile(full, bytes)
|
|
162
|
-
} catch (error: any) {
|
|
163
|
-
return { path: '', reason: `写盘失败:${error?.message || error}` }
|
|
164
|
-
}
|
|
165
|
-
return { path: full, reason: '' }
|
|
166
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* 官方通道(ilink)**入站媒体**的落地:把 CDN 上的密文拉下来、解密、写到本地磁盘。
|
|
3
|
+
*
|
|
4
|
+
* 为什么值得做:入站图片/语音/文件/视频此前只解析出"有这一项",`filePath` 是空串,于是助手拿到消息
|
|
5
|
+
* 也不知道图里是什么(那四项的落地当时是显式延后的)。下载与解密的本事本来就有
|
|
6
|
+
* (`WechatClient.downloadMedia`:`<cdn>/download?encrypted_query_param=…` + AES-128-ECB + 去 PKCS#7),
|
|
7
|
+
* 缺的只是把它接上入站这条路。
|
|
8
|
+
*
|
|
9
|
+
* 字段名与优先级照**厂商自己的实现**(`third-party/WeKnora/internal/im/wechat/longpoll.go`):
|
|
10
|
+
* 图片优先用 `image_item.aeskey`(十六进制字符串,**不走 base64**),退回 `media.aes_key`(base64);
|
|
11
|
+
* 语音/文件/视频只有 `media.aes_key`。两种编码都由 `WechatClient.parseMediaAesKey` 认。
|
|
12
|
+
*
|
|
13
|
+
* 三条边界写在这里,不指望调用方记得:
|
|
14
|
+
* 1. **它不是默认行为** —— 打开 `wechatMediaDownload` 才发生网络访问;
|
|
15
|
+
* 2. **文件名永远由本地决定** —— 服务端给的名字只取 basename 并过滤字符,`../` 之类必须在这里被吃掉;
|
|
16
|
+
* 3. **超过上限就不写**(`WECHAT_MEDIA_MAX_BYTES`),失败不留半个文件 —— 半个文件比没有更难查。
|
|
17
|
+
*/
|
|
18
|
+
import { basename, join } from 'path'
|
|
19
|
+
import { mkdir, writeFile } from 'fs/promises'
|
|
20
|
+
|
|
21
|
+
/** 单件上限。够大(手机拍的视频也能过),又不至于一次 CDN 事故把磁盘写满 */
|
|
22
|
+
export const WECHAT_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
/** 落地目录:默认仓库相对 `output/wechat-media/`(`output/` 已在 .gitignore 里),可用环境变量改(测试用临时目录) */
|
|
25
|
+
export function wechatMediaDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
26
|
+
const fromEnv = env.WEFLOW_WECHAT_MEDIA_DIR
|
|
27
|
+
return fromEnv && fromEnv.trim() ? fromEnv : join('output', 'wechat-media')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type InboundMediaType = 'image' | 'record' | 'file' | 'video'
|
|
31
|
+
|
|
32
|
+
/** item.type 的数字编码(与服务端 item_list 一致) */
|
|
33
|
+
export function mediaTypeOf(itemType: number): InboundMediaType | null {
|
|
34
|
+
if (itemType === 2) return 'image'
|
|
35
|
+
if (itemType === 3) return 'record'
|
|
36
|
+
if (itemType === 4) return 'file'
|
|
37
|
+
if (itemType === 5) return 'video'
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 服务端给的名字只当作"一个字符串"用:取 basename、去掉控制字符与路径字符、去掉前导点、限长 */
|
|
42
|
+
export function sanitizeFileName(name: string): string {
|
|
43
|
+
const base = basename(String(name || '').replace(/\\/g, '/')).trim()
|
|
44
|
+
return base
|
|
45
|
+
.replace(/[\u0000-\u001f<>:"|?*]/g, '_')
|
|
46
|
+
.replace(/^\.+/, '')
|
|
47
|
+
.trim()
|
|
48
|
+
.slice(0, 80)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 按魔数认扩展名。认不出就返回空串(调用方退回按类型给的后缀),不猜 */
|
|
52
|
+
export function extFromMagic(buf: Buffer): string {
|
|
53
|
+
const b = buf
|
|
54
|
+
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return '.jpg'
|
|
55
|
+
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return '.png'
|
|
56
|
+
if (b.length >= 6 && b.subarray(0, 6).toString('latin1').startsWith('GIF8')) return '.gif'
|
|
57
|
+
if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
58
|
+
&& b.subarray(8, 12).toString('latin1') === 'WEBP') return '.webp'
|
|
59
|
+
if (b.length >= 2 && b[0] === 0x42 && b[1] === 0x4d) return '.bmp'
|
|
60
|
+
if (b.length >= 12 && b.subarray(4, 8).toString('latin1') === 'ftyp') return '.mp4'
|
|
61
|
+
if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) return '.webm'
|
|
62
|
+
if (b.length >= 5 && b.subarray(0, 5).toString('latin1') === '#!AMR') return '.amr'
|
|
63
|
+
if (b.length >= 4 && b.subarray(0, 4).toString('latin1') === 'OggS') return '.ogg'
|
|
64
|
+
if (b.length >= 2 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) return '.mp3'
|
|
65
|
+
if (b.length >= 12 && b.subarray(0, 4).toString('latin1') === 'RIFF'
|
|
66
|
+
&& b.subarray(8, 12).toString('latin1') === 'WAVE') return '.wav'
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 该媒体类型在服务端字段树里的节点名 */
|
|
71
|
+
function mediaNode(item: any, mediaType: InboundMediaType): any {
|
|
72
|
+
if (mediaType === 'image') return item?.image_item
|
|
73
|
+
if (mediaType === 'record') return item?.voice_item
|
|
74
|
+
if (mediaType === 'file') return item?.file_item
|
|
75
|
+
return item?.video_item
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 下载参数(`encrypt_query_param`)。取不到就没法下载——**这时候不猜、不留空文件** */
|
|
79
|
+
export function pickEncryptParam(item: any, mediaType: InboundMediaType): string {
|
|
80
|
+
const node = mediaNode(item, mediaType)
|
|
81
|
+
return String(node?.media?.encrypt_query_param || '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* AES key 的取值优先级。**图片这一支必须照厂商实现**:`image_item.aeskey` 是十六进制字符串,
|
|
86
|
+
* 比 `media.aes_key`(base64)更靠前;漏了这个优先级,图片会解出一堆乱码却不报错。
|
|
87
|
+
*/
|
|
88
|
+
export function pickAesKeyValue(item: any, mediaType: InboundMediaType): string {
|
|
89
|
+
const node = mediaNode(item, mediaType)
|
|
90
|
+
if (mediaType === 'image' && node?.aeskey) return String(node.aeskey)
|
|
91
|
+
return String(node?.media?.aes_key || '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface SaveMediaOptions {
|
|
95
|
+
item: any
|
|
96
|
+
mediaType: InboundMediaType
|
|
97
|
+
/** 同一条消息里的第几项(一条消息可以带多张图) */
|
|
98
|
+
index: number
|
|
99
|
+
/** 名字前缀:优先用服务端给的 client_id */
|
|
100
|
+
messageId: string
|
|
101
|
+
dir?: string
|
|
102
|
+
/** 注入的下载器(默认由调用方传 `client.downloadMedia`),测试里换成假的 */
|
|
103
|
+
download: (encryptedQueryParam: string, aesKeyValue: string) => Promise<Buffer>
|
|
104
|
+
/** 文件类消息里服务端给的文件名 */
|
|
105
|
+
serverFileName?: string
|
|
106
|
+
maxBytes?: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface SaveMediaResult {
|
|
110
|
+
/** 成功是磁盘路径;失败是空串(**绝不返回"看起来成功"的半成品**) */
|
|
111
|
+
path: string
|
|
112
|
+
/** 失败原因,进日志用 */
|
|
113
|
+
reason: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 下载一件入站媒体并落盘。失败一律返回 `{ path: '', reason }` 且**不留下文件**:
|
|
118
|
+
* 助手与导出拿到空 path 时知道"没有这玩意",比拿到一个 0 字节文件强。
|
|
119
|
+
*/
|
|
120
|
+
export async function saveInboundMedia(opts: SaveMediaOptions): Promise<SaveMediaResult> {
|
|
121
|
+
const { item, mediaType, index, messageId, download } = opts
|
|
122
|
+
const cap = opts.maxBytes ?? WECHAT_MEDIA_MAX_BYTES
|
|
123
|
+
|
|
124
|
+
const encryptParam = pickEncryptParam(item, mediaType)
|
|
125
|
+
if (!encryptParam) return { path: '', reason: '服务端没给 encrypt_query_param' }
|
|
126
|
+
|
|
127
|
+
const aesKeyValue = pickAesKeyValue(item, mediaType)
|
|
128
|
+
if (!aesKeyValue) return { path: '', reason: '服务端没给 aes key' }
|
|
129
|
+
|
|
130
|
+
let bytes: Buffer
|
|
131
|
+
try {
|
|
132
|
+
bytes = await download(encryptParam, aesKeyValue)
|
|
133
|
+
} catch (error: any) {
|
|
134
|
+
return { path: '', reason: `下载或解密失败:${error?.message || error}` }
|
|
135
|
+
}
|
|
136
|
+
if (!bytes || bytes.length === 0) return { path: '', reason: '下载结果为空' }
|
|
137
|
+
if (bytes.length > cap) {
|
|
138
|
+
return { path: '', reason: `超过上限(${bytes.length} > ${cap} 字节)` }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const byMagic = extFromMagic(bytes)
|
|
142
|
+
const fallbackExt = mediaType === 'image' ? '.jpg' : ''
|
|
143
|
+
const prefix = sanitizeFileName(messageId) || 'msg'
|
|
144
|
+
let name: string
|
|
145
|
+
if (mediaType === 'file' && opts.serverFileName) {
|
|
146
|
+
const safe = sanitizeFileName(opts.serverFileName)
|
|
147
|
+
if (safe) {
|
|
148
|
+
const hasExt = /\.[A-Za-z0-9]{1,8}$/.test(safe)
|
|
149
|
+
name = `${prefix}-${index}-${hasExt || !byMagic ? safe : safe + byMagic}`
|
|
150
|
+
} else {
|
|
151
|
+
name = `${prefix}-${index}${byMagic}`
|
|
152
|
+
}
|
|
153
|
+
} else {
|
|
154
|
+
name = `${prefix}-${index}${byMagic || fallbackExt}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const dir = opts.dir || wechatMediaDir()
|
|
158
|
+
const full = join(dir, name)
|
|
159
|
+
try {
|
|
160
|
+
await mkdir(dir, { recursive: true })
|
|
161
|
+
await writeFile(full, bytes)
|
|
162
|
+
} catch (error: any) {
|
|
163
|
+
return { path: '', reason: `写盘失败:${error?.message || error}` }
|
|
164
|
+
}
|
|
165
|
+
return { path: full, reason: '' }
|
|
166
|
+
}
|
package/src/utils/python.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process'
|
|
2
2
|
import { delimiter, join } from 'path'
|
|
3
|
-
import { accessSync, constants } from 'fs'
|
|
3
|
+
import { accessSync, constants, existsSync } from 'fs'
|
|
4
|
+
import { homedir } from 'os'
|
|
4
5
|
|
|
5
6
|
let cached: string | null = null
|
|
6
7
|
|
|
@@ -17,6 +18,26 @@ function isExecutable(path: string): boolean {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/** `weflow-cli setup` 建的自管 venv 的解释器路径;没建过就是 null。 */
|
|
22
|
+
export function managedVenvPython(): string | null {
|
|
23
|
+
const exe = process.platform === 'win32'
|
|
24
|
+
? join(homedir(), '.weflow-cli', 'venv', 'Scripts', 'python.exe')
|
|
25
|
+
: join(homedir(), '.weflow-cli', 'venv', 'bin', 'python')
|
|
26
|
+
return existsSync(exe) ? exe : null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 候选序:自管 venv 永远排最前,其余按调用方给的序。纯函数,供测试钉住
|
|
31
|
+
* "建了 venv 就该优先用它"这件事——它坏了的表现是 setup 装好了包、
|
|
32
|
+
* 运行时却仍在用 PATH 上的解释器,两边各自"正常"。
|
|
33
|
+
*/
|
|
34
|
+
export function orderCandidates(
|
|
35
|
+
managed: string | null,
|
|
36
|
+
rest: string[],
|
|
37
|
+
): string[] {
|
|
38
|
+
return managed ? [managed, ...rest.filter(c => c !== managed)] : [...rest]
|
|
39
|
+
}
|
|
40
|
+
|
|
20
41
|
/** 按顺序返回 PATH 中所有可用的 Python 解释器(含命令名候选) */
|
|
21
42
|
function candidates(): string[] {
|
|
22
43
|
const found: string[] = []
|
|
@@ -55,13 +76,16 @@ function hasSqlcipher(cmd: string): boolean {
|
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
function detect(): string {
|
|
79
|
+
// 自管 venv(`weflow-cli setup`)优先:它就是为了"这台机器有一个能
|
|
80
|
+
// import sqlcipher3 的 Python"而存在的。venv 坏了(装完又被卸了包)会
|
|
81
|
+
// 自然落到下面的 PATH 候选——行为退回 setup 之前,而不是更糟。
|
|
82
|
+
const ordered = orderCandidates(managedVenvPython(), candidates())
|
|
58
83
|
// 优先选择能 import sqlcipher3 的解释器(NT 解密核心依赖),
|
|
59
84
|
// 避免命中 PATH 中仅有基础库的裸 Python 环境(如被 IDE 注入的 VM Python 抢占)。
|
|
60
|
-
const
|
|
61
|
-
for (const cmd of list) {
|
|
85
|
+
for (const cmd of ordered) {
|
|
62
86
|
if (canRun(cmd) && hasSqlcipher(cmd)) return cmd
|
|
63
87
|
}
|
|
64
|
-
for (const cmd of
|
|
88
|
+
for (const cmd of ordered) {
|
|
65
89
|
if (canRun(cmd)) return cmd
|
|
66
90
|
}
|
|
67
91
|
return 'python'
|
|
@@ -71,3 +95,8 @@ export function getPythonCommand(): string {
|
|
|
71
95
|
cached ??= detect()
|
|
72
96
|
return cached
|
|
73
97
|
}
|
|
98
|
+
|
|
99
|
+
/** setup 装完后清缓存,让同一进程里后续的 getPythonCommand 看见新 venv。 */
|
|
100
|
+
export function clearPythonCommandCache(): void {
|
|
101
|
+
cached = null
|
|
102
|
+
}
|