weflow-cli 1.17.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 +30 -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 +2 -1
- 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_task.ps1 +213 -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/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 +19 -15
- 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,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
|
+
}
|