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.
Files changed (73) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +3 -0
  3. package/bin/weflow-cli.ts +167 -0
  4. package/dist/bin/weflow-cli.js +199 -0
  5. package/dist/bin/weflow-cli.js.map +1 -1
  6. package/dist/src/services/diagnosticReport.d.ts +6 -0
  7. package/dist/src/services/diagnosticReport.d.ts.map +1 -1
  8. package/dist/src/services/diagnosticReport.js +16 -0
  9. package/dist/src/services/diagnosticReport.js.map +1 -1
  10. package/dist/src/services/wechatCompat.d.ts +29 -0
  11. package/dist/src/services/wechatCompat.d.ts.map +1 -0
  12. package/dist/src/services/wechatCompat.js +65 -0
  13. package/dist/src/services/wechatCompat.js.map +1 -0
  14. package/docs/DATA_CONTRACT.md +25 -25
  15. package/docs/DECISIONS.md +26 -0
  16. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +365 -365
  17. package/docs/HEALTH-CHECK.md +104 -104
  18. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -310
  19. package/docs/NEXT_ACTIONS.md +236 -236
  20. package/docs/NPM-PUBLISH.md +130 -130
  21. package/docs/NT_DATABASES.md +47 -0
  22. package/docs/POSITIONING.md +68 -68
  23. package/docs/PROJECT_STATE.md +3 -2
  24. package/docs/RELEASING.md +4 -0
  25. package/package.json +1 -1
  26. package/requirements-3x.txt +4 -4
  27. package/requirements-voice.txt +15 -15
  28. package/resources/js/graph3d/NOTICE.txt +35 -35
  29. package/resources/js/graph3d/d3-binarytree.min.js +2 -2
  30. package/resources/js/graph3d/d3-dispatch.min.js +2 -2
  31. package/resources/js/graph3d/d3-force-3d.min.js +2 -2
  32. package/resources/js/graph3d/d3-octree.min.js +2 -2
  33. package/resources/js/graph3d/d3-quadtree.min.js +2 -2
  34. package/resources/js/graph3d/d3-timer.min.js +2 -2
  35. package/resources/js/graph3d/three.min.js +6 -6
  36. package/resources/panel/quick-menu.cjs +197 -197
  37. package/scripts/_utils.py +1338 -1338
  38. package/scripts/annual_report.py +3 -1
  39. package/scripts/article_notes.py +423 -423
  40. package/scripts/contact_schema.py +339 -339
  41. package/scripts/create_reading_notes.py +11 -11
  42. package/scripts/daily_mail.py +51 -5
  43. package/scripts/daily_task.ps1 +216 -213
  44. package/scripts/extract_todos.py +3 -1
  45. package/scripts/fav_notes.py +321 -321
  46. package/scripts/health_check.py +473 -473
  47. package/scripts/imported_chats.py +68 -68
  48. package/scripts/install_tasks.ps1 +105 -105
  49. package/scripts/key_cache.py +232 -0
  50. package/scripts/leads.py +179 -179
  51. package/scripts/mcp_bridge.py +3 -1
  52. package/scripts/media_index.py +150 -0
  53. package/scripts/network.py +206 -206
  54. package/scripts/nt_common.py +32 -0
  55. package/scripts/nt_decrypt.py +2023 -1954
  56. package/scripts/opinion_timeline.py +138 -138
  57. package/scripts/replay.py +803 -803
  58. package/scripts/run_hidden.vbs +24 -24
  59. package/scripts/semantic_search.py +5 -1
  60. package/scripts/user_notes.py +315 -315
  61. package/scripts/voice_transcribe.py +139 -139
  62. package/scripts/watch_issues.py +212 -212
  63. package/scripts/watch_mail.py +131 -131
  64. package/scripts/wechat_avatar.py +185 -0
  65. package/scripts/wechat_ledger.py +190 -0
  66. package/src/services/cursorProbe.ts +222 -222
  67. package/src/services/diagnosticReport.ts +22 -0
  68. package/src/services/qchatImport.ts +327 -327
  69. package/src/services/setupVenv.ts +238 -238
  70. package/src/services/syncService.ts +363 -363
  71. package/src/services/syncState.ts +275 -275
  72. package/src/services/wechatCompat.ts +67 -0
  73. 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
+ }