weflow-cli 1.9.0 → 1.10.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.
Files changed (128) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +948 -0
  3. package/OPERATIONS.md +65 -11
  4. package/README.en.md +14 -5
  5. package/README.md +21 -6
  6. package/bin/weflow-cli.ts +348 -39
  7. package/dist/bin/weflow-cli.js +363 -68
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/messageQuery.d.ts +18 -0
  32. package/dist/src/services/messageQuery.d.ts.map +1 -1
  33. package/dist/src/services/messageQuery.js +29 -0
  34. package/dist/src/services/messageQuery.js.map +1 -1
  35. package/dist/src/services/wechatClient.d.ts +125 -0
  36. package/dist/src/services/wechatClient.d.ts.map +1 -0
  37. package/dist/src/services/wechatClient.js +344 -0
  38. package/dist/src/services/wechatClient.js.map +1 -0
  39. package/dist/src/services/wechatMedia.d.ts +44 -0
  40. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  41. package/dist/src/services/wechatMedia.js +155 -0
  42. package/dist/src/services/wechatMedia.js.map +1 -0
  43. package/dist/src/services/wechatMessageService.d.ts +11 -0
  44. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  45. package/dist/src/services/wechatMessageService.js +55 -11
  46. package/dist/src/services/wechatMessageService.js.map +1 -1
  47. package/dist/src/types.d.ts +4 -0
  48. package/dist/src/types.d.ts.map +1 -1
  49. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  50. package/docs/DECISIONS.md +1274 -0
  51. package/docs/EXTENDING.md +69 -7
  52. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
  53. package/docs/HEALTH-CHECK.md +12 -7
  54. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  55. package/docs/MCP.md +2 -2
  56. package/docs/PROJECT_STATE.md +62 -5
  57. package/docs/RELEASING.md +115 -1
  58. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  59. package/logo.png +0 -0
  60. package/mcp-server/index.ts +18 -3
  61. package/package.json +5 -3
  62. package/resources/js/graph3d/NOTICE.txt +35 -0
  63. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  64. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  65. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  66. package/resources/js/graph3d/d3-octree.min.js +2 -0
  67. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  68. package/resources/js/graph3d/d3-timer.min.js +2 -0
  69. package/resources/js/graph3d/layout.mjs +63 -0
  70. package/resources/js/graph3d/three.min.js +6 -0
  71. package/resources/js/graph3d/viewer2d.js +448 -0
  72. package/resources/panel/index.html +12 -0
  73. package/resources/panel/mascot-blink-1.png +0 -0
  74. package/resources/panel/mascot-blink-2.png +0 -0
  75. package/resources/panel/mascot-lift-down-1.png +0 -0
  76. package/resources/panel/mascot-lift-down-2.png +0 -0
  77. package/resources/panel/mascot-lift-down-3.png +0 -0
  78. package/resources/panel/mascot-lift-down-4.png +0 -0
  79. package/resources/panel/mascot-lift-down-5.png +0 -0
  80. package/resources/panel/mascot-lift-down-6.png +0 -0
  81. package/resources/panel/mascot-lift-up-1.png +0 -0
  82. package/resources/panel/mascot-lift-up-2.png +0 -0
  83. package/resources/panel/mascot-lift-up-3.png +0 -0
  84. package/resources/panel/mascot-lift-up-4.png +0 -0
  85. package/resources/panel/mascot-lift-up-5.png +0 -0
  86. package/resources/panel/mascot-lift-up-6.png +0 -0
  87. package/resources/panel/mascot-tickle-1.png +0 -0
  88. package/resources/panel/mascot-tickle-2.png +0 -0
  89. package/resources/panel/mascot-tickle-3.png +0 -0
  90. package/resources/panel/mascot-tickle-4.png +0 -0
  91. package/resources/panel/panel.css +111 -0
  92. package/resources/panel/renderer.js +480 -8
  93. package/scripts/_utils.py +1203 -977
  94. package/scripts/biz_daily.py +25 -10
  95. package/scripts/chat_notes.py +15 -5
  96. package/scripts/check_readme_commands.py +426 -0
  97. package/scripts/compile_wiki.py +163 -57
  98. package/scripts/contact_schema.py +339 -0
  99. package/scripts/data/wechat_emoji.json +225 -0
  100. package/scripts/fav_notes.py +4 -1
  101. package/scripts/graph_2d.py +151 -0
  102. package/scripts/graph_3d.py +646 -0
  103. package/scripts/health_check.py +163 -26
  104. package/scripts/nt_decrypt.py +3 -3
  105. package/scripts/panel_frames.py +683 -0
  106. package/scripts/pipeline.py +21 -10
  107. package/scripts/quality_eval.py +5 -0
  108. package/scripts/rag_chat.py +33 -16
  109. package/scripts/reply_debt.py +26 -6
  110. package/scripts/semantic_search.py +130 -22
  111. package/scripts/vault_rag.py +42 -16
  112. package/scripts/vault_search.py +44 -8
  113. package/scripts/wechat_emoticon.py +6 -0
  114. package/scripts/wiki_lint.py +94 -17
  115. package/src/core/dbPathService.ts +22 -4
  116. package/src/panel/server.ts +33 -0
  117. package/src/services/assistantService.ts +26 -0
  118. package/src/services/assistantTools.ts +2452 -2319
  119. package/src/services/configService.ts +5 -2
  120. package/src/services/messageQuery.ts +29 -0
  121. package/src/services/wechatClient.ts +380 -0
  122. package/src/services/wechatMedia.ts +166 -0
  123. package/src/services/wechatMessageService.ts +62 -11
  124. package/src/types.ts +4 -0
  125. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  126. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  127. package/dist/src/utils/wechatEmoji.js +0 -35
  128. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -28,6 +28,8 @@ interface CliConfig {
28
28
  wechatOcSyncBuf: string
29
29
  /** 持久化的 context_token: wxid -> token (加密 JSON 字符串) */
30
30
  wechatOcContextTokens: string
31
+ /** 官方通道的**入站媒体**是否下载到本地(默认 'false'):'true' 才下载,落到 output/wechat-media/ */
32
+ wechatMediaDownload: string
31
33
  // 白名单
32
34
  whitelist: string[]
33
35
  // 黑名单 (绝对禁止收发)
@@ -109,7 +111,7 @@ const CONFIG_DIR = join(homedir(), '.weflow-cli')
109
111
  const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
110
112
 
111
113
  export class ConfigService {
112
- private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
114
+ private config: CliConfig = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
113
115
 
114
116
  /** 本进程修改过、待回写的字段 (多进程并发写保护) */
115
117
  private dirty = new Set<keyof CliConfig>()
@@ -149,6 +151,7 @@ export class ConfigService {
149
151
  wechatOcBaseUrl: data.wechatOcBaseUrl || '',
150
152
  wechatOcSyncBuf: data.wechatOcSyncBuf || '',
151
153
  wechatOcContextTokens: data.wechatOcContextTokens || '',
154
+ wechatMediaDownload: data.wechatMediaDownload === 'true' ? 'true' : 'false',
152
155
  whitelist,
153
156
  blacklist,
154
157
  whitelistEntries,
@@ -366,7 +369,7 @@ export class ConfigService {
366
369
  }
367
370
 
368
371
  clear(): void {
369
- this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
372
+ this.config = { dbPath: '', wxid: '', decryptKey: '', decryptKey3x: '', dataVersion: '', dbPath3x: '', ntDbPath: '', ntKey: '', ntSalt: '', contactDbPath: '', contactKey: '', contactSalt: '', wechatOcToken: '', wechatOcAccountId: '', wechatOcBaseUrl: '', wechatOcSyncBuf: '', wechatOcContextTokens: '', wechatMediaDownload: 'false', whitelist: [], blacklist: [], whitelistEntries: [], blacklistEntries: [], vaultRepo: '', aiEngine: 'deepseek', wereadApiKey: '', deepseekApiKey: '', typesafeApiKey: '', dashscopeApiKey: '', snsDbPath: '', snsKey: '', snsSalt: '', favDbPath: '', favKey: '', favPassphrase: '', emoticonSeed: '', assistantPrivacy: 'strict', localModel: '', aiBaseUrl: '', aiModel: '', assistantWhitelist: '', assistantGroupWhitelist: '', assistantGroupRequireMention: 'true', assistantFastRoute: 'off', assistantPanelUser: '', quickReplyContacts: '', skillDirs: '', skillDisabled: '', dailySources: '', dailySourceCategories: '', dailyExcludeTopics: '', dailyAiEnabled: 'true' }
370
373
  // clear 意图是全量重置: 所有字段标记为脏, 覆盖磁盘上的全部旧值
371
374
  this.dirty = new Set(Object.keys(this.config) as (keyof CliConfig)[])
372
375
  this.save()
@@ -5,6 +5,35 @@ export interface MessageTimeRange {
5
5
  to?: number
6
6
  }
7
7
 
8
+ /**
9
+ * 这三种形状的输入会被 `resolveTalker` **原样返回**(见 `src/utils/talkerUtils.ts`),
10
+ * 于是拼错的 ID 会一路走到「取不到消息」,和「这条会话在窗口内确实没有消息」长得一模一样。
11
+ * 只看形状,不碰 IO —— 判定所需的名单由调用方传进来。
12
+ */
13
+ export function looksLikeTalkerId(input: string): boolean {
14
+ const value = String(input || '')
15
+ return value.startsWith('wxid_') || value.includes('@chatroom') || value.includes('@openim')
16
+ }
17
+
18
+ /**
19
+ * 空结果时补的一句话:**只在"看起来是 ID、但会话与联系人都没有它"时**才给。
20
+ * 返回空串表示没什么可补的 —— 名字形状的输入本来就由 `resolveTalker` 报错,认识的 ID
21
+ * 取不到消息是正常结果。
22
+ *
23
+ * 这里**不改退出码、也不改 `success`**:既有契约里「没有消息」不是错误(脚本与 MCP 依赖它,
24
+ * 见 `docs/HEALTH-CHECK.md` 的那一行),所以只把歧义说出来,不动语义。
25
+ */
26
+ export function emptyMessagesNote(
27
+ input: string,
28
+ known: { sessionUsernames: Iterable<string>; contactUsernames: Iterable<string> },
29
+ ): string {
30
+ if (!looksLikeTalkerId(input)) return ''
31
+ for (const id of known.sessionUsernames) if (id === input) return ''
32
+ for (const id of known.contactUsernames) if (id === input) return ''
33
+ return `这个 ID 既不在会话里、也不在联系人里:${input}` +
34
+ ' —— 先确认有没有拼错(weflow-cli sessions / weflow-cli contacts)'
35
+ }
36
+
8
37
  export async function collectMessagesInRange(
9
38
  fetchPage: (limit: number, offset: number) => Promise<Message[]>,
10
39
  limit: number,
@@ -0,0 +1,380 @@
1
+ /**
2
+ * **这台机器上的微信客户端,运行时到底加载的是哪一份。**
3
+ *
4
+ * 为什么需要它:`check` / `health_check` 一直在报"数据版本 4.x"(那是**数据库格式**),
5
+ * 而"客户端是哪个版本"从来没人报过 —— 出问题时(数据读不出来、客户端起不来)这恰恰是
6
+ * 第一个要问的。
7
+ *
8
+ * **判据来自一份实测的启动器逆向分析**(报告在仓库外),它纠正了一个很自然的错法:
9
+ *
10
+ * - `Weixin.exe` 里**确实**硬编码着一个版本字面量,而且**恰好与本机的版本目录同名** ——
11
+ * 看起来"读它就能知道版本"。但控制流显示它只是**兜底**:主路径读
12
+ * `%APPDATA%\Tencent\xwechat\update\update.data` 里记的目录,只有那次失败才会用到它。
13
+ * ⇒ **读字面量判版本是错的**,哪怕它在这台机器上碰巧对。(报告里那条"2/2 实测"就是先
14
+ * 写错、后被控制流推翻的。)
15
+ * - 正确的判据是**活进程命令行里的 `--user-lib-dir`** —— 那是真的被加载的那个目录。
16
+ * - 而"磁盘上有个版本目录"**不代表它能用**:本机 `D:\WeiXin\4.1.15.11\` 是升级残留,
17
+ * 只剩 `crashpad_handler.exe` 一个文件(**本机实测** 1 个文件 / 2.1 MB;报告 §2.3 记了同一件事)。
18
+ *
19
+ * 所以这个模块只做两件事,且都**不猜**:从进程命令行取实际目录;把一个安装根下的版本目录
20
+ * 分成"能用/不能用"(判据是**有没有 `Weixin.dll`** —— 启动器加载的就是它)。
21
+ * 取不到就是 `null` + 一句 `note`,不拿别的来源顶替。
22
+ */
23
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs'
24
+ import { join, win32 } from 'node:path'
25
+ import { execFile } from 'node:child_process'
26
+ import { promisify } from 'node:util'
27
+
28
+ const execFileAsync = promisify(execFile)
29
+
30
+ /** 一个 `Weixin.exe` 进程的画像(只留判版本要用的两个字段)。 */
31
+ export interface WechatClientProcess {
32
+ pid: number
33
+ exe: string
34
+ /** 命令行里的 `--user-lib-dir`;进程没带就是 null(**逆向报告实测**:`--type=xplayer` 那个不带)。 */
35
+ loadedDir: string | null
36
+ /** 命令行里的 `--wechat-files-path`:客户端自己说数据目录在哪(小程序那组进程会带)。 */
37
+ dataDir: string | null
38
+ /** 命令行里的 `--client_version`:客户端自己的版本 token(十进制,如 `0xF2541F0D`)。 */
39
+ versionToken: string | null
40
+ }
41
+
42
+ /** 版本的判据与结论 —— 只跟"哪个进程加载了哪个目录"有关,读不到**别的来源**。 */
43
+ export interface WechatVersionFacts {
44
+ /** 本机有没有在跑的 `Weixin.exe`(没跑 ⇒ 版本未知,不是"没问题")。 */
45
+ running: boolean
46
+ /** 去重后的实际加载目录,按出现顺序。 */
47
+ loadedDirs: string[]
48
+ /** 版本号 = 加载目录的名字;**恰好一个**目录时才给,否则 null。 */
49
+ version: string | null
50
+ /** `version` 为 null 时说清为什么。 */
51
+ note: string
52
+ /** 安装根下**不能用的**版本目录(没有 `Weixin.dll`)。 */
53
+ unusableVersionDirs: Array<{ dir: string; files: number }>
54
+ }
55
+
56
+ export interface WechatClientFacts extends WechatVersionFacts {
57
+ /** 客户端自报的构建 token(`--client_version`,如 `0xF2541F0D`)—— 与目录名并列的**第二个来源**。 */
58
+ versionTokens: string[]
59
+ /** 进程命令行里的 `--wechat-files-path`:**客户端自己说**数据目录在哪。 */
60
+ dataDirs: string[]
61
+ /** `update\update.data` 在不在(在 ⇒ 加载路径可能不由命令行决定;见 `caveats`)。 */
62
+ updateData: { path: string; present: boolean }
63
+ /** 客户端崩溃记录:只报条数与最近时间,**不读内容**。 */
64
+ crashRecords: { dir: string; reports: number; records: number; newest: string | null }
65
+ /** 读这个版本时**必须一起说出去**的不确定(目前只有一条:`update.data` 在场)。 */
66
+ caveats: string[]
67
+ }
68
+
69
+ /** 版本目录名:`4.1.15.13` 这种。别的一律不当版本目录(不猜)。 */
70
+ const VERSION_DIR = /^\d+(\.\d+)+$/
71
+
72
+ /**
73
+ * PowerShell 一行取所有 `Weixin.exe` 的 pid / 可执行路径 / 命令行。
74
+ *
75
+ * 与 `keyService.isProcessRunning` 用同一种调法(`-NoProfile -NonInteractive`):这台机器上
76
+ * 那是仓库既有做法,不引第二种。`ConvertTo-Json` 在只有一个结果时会给对象而不是数组,
77
+ * 所以下面 `parseProcessRows` 两种都收。
78
+ */
79
+ // **两个镜像名都要问**:`--user-lib-dir` 在 `Weixin.exe` 那组上,而 `--wechat-files-path`
80
+ // 只在 `WeChatAppEx.exe`(小程序运行时那组)的命令行上(**本机实测**:拿它去筛 `Weixin.exe` 会一条都取不到,
81
+ // 于是"客户端自报数据目录"这一项**永远是空的**而看不出来)。版本那一支只认 `Weixin.exe`(见 `summarize`)。
82
+ const PS_LIST = "$p = Get-CimInstance Win32_Process -Filter (\"Name='Weixin.exe' or Name='WeChatAppEx.exe'\") | "
83
+ + 'Select-Object ProcessId, ExecutablePath, CommandLine; '
84
+ + 'if ($p) { $p | ConvertTo-Json -Compress }'
85
+
86
+ export function parseProcessRows(raw: string): WechatClientProcess[] {
87
+ const text = String(raw || '').trim()
88
+ if (!text) return []
89
+ let data: unknown
90
+ try {
91
+ data = JSON.parse(text)
92
+ } catch {
93
+ return []
94
+ }
95
+ const rows = Array.isArray(data) ? data : [data]
96
+ const out: WechatClientProcess[] = []
97
+ for (const row of rows) {
98
+ if (!row || typeof row !== 'object') continue
99
+ const r = row as Record<string, unknown>
100
+ const pid = Number(r.ProcessId)
101
+ if (!Number.isFinite(pid)) continue
102
+ const commandLine = String(r.CommandLine || '')
103
+ out.push({
104
+ pid,
105
+ exe: String(r.ExecutablePath || ''),
106
+ loadedDir: userLibDir(commandLine),
107
+ dataDir: wechatFilesPath(commandLine),
108
+ versionToken: clientVersionToken(commandLine),
109
+ })
110
+ }
111
+ return out
112
+ }
113
+
114
+ /**
115
+ * 从一条命令行里取 `--user-lib-dir` 的值。**两种写法都认**(`=` 与空格、带引号与不带),
116
+ * 取不到返回 null。
117
+ *
118
+ * 注意这个参数**不在 `Weixin.exe` 的字符串表里**(**逆向报告实测**:0 命中)—— 它是被加载的 DLL
119
+ * 自己解析的。也就是说:只有**进程**能告诉我们它,二进制里搜不到。
120
+ */
121
+ export function userLibDir(commandLine: string): string | null {
122
+ const m = /--user-lib-dir\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(String(commandLine || ''))
123
+ if (!m) return null
124
+ const value = (m[1] || m[2] || m[3] || '').trim().replace(/["']/g, '')
125
+ return value || null
126
+ }
127
+
128
+ /**
129
+ * 从命令行里取 `--wechat-files-path`:**客户端自己说数据目录在哪**。
130
+ *
131
+ * 用处在排查:它和配置里的数据目录不一致时,说明两边指向的不是同一份库(本机数据目录
132
+ * 还有 D 盘软链,同一份库两条路径)。只报出来,**不拿它去改配置**。
133
+ */
134
+ export function wechatFilesPath(commandLine: string): string | null {
135
+ const m = /--wechat-files-path\s*=\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(String(commandLine || ''))
136
+ if (!m) return null
137
+ const value = (m[1] || m[2] || m[3] || '').trim().replace(/["']/g, '')
138
+ return value || null
139
+ }
140
+
141
+ /**
142
+ * 命令行里的 `--client_version`:客户端**自己的版本 token**(十进制)。
143
+ *
144
+ * 为什么要报它:它是"哪个构建"的**另一个独立来源** —— 版本目录名(`4.1.15.13`)是装载位置,
145
+ * 而这个是客户端自报的构建标识,两者来自不同的地方。顺带:实测本机这个值 `4065599245`
146
+ * 正是 `0xF2541F0D`,也就是那份逆向报告从三处(反编译 / 插件 manifest / UA)定下来的版本号。
147
+ *
148
+ * ⚠️ **不给"版本号"**:token 与三段式版本号之间的编码规则**没人解出来**(报告只确认"旧版
149
+ * `0xF2541D0C` → 新版 `0xF2541F0D`,高 16 位两版都是 `F254`")。所以它只作为**并列的第二来源**
150
+ * 报出去,不拿它去推版本号。
151
+ */
152
+ export function clientVersionToken(commandLine: string): string | null {
153
+ const m = /--client_version\s*=\s*(\d+)/.exec(String(commandLine || ''))
154
+ if (!m) return null
155
+ const value = Number(m[1])
156
+ if (!Number.isFinite(value) || value <= 0) return null
157
+ return '0x' + value.toString(16).toUpperCase().padStart(8, '0')
158
+ }
159
+
160
+ /** 客户端崩溃记录:只数文件、只取最近时间,**不读任何内容**。 */
161
+ export function crashSummary(root: string): WechatClientFacts['crashRecords'] {
162
+ const reports = countFiles(join(root, 'reports'))
163
+ const records = countFiles(join(root, 'records')) + countFiles(join(root, 'metadata'))
164
+ let newest: string | null = null
165
+ try {
166
+ for (const sub of ['reports', 'records', 'metadata']) {
167
+ const dir = join(root, sub)
168
+ if (!existsSync(dir)) continue
169
+ for (const name of readdirSync(dir)) {
170
+ const at = statSync(join(dir, name)).mtime
171
+ if (!newest || at > new Date(newest)) newest = at.toISOString()
172
+ }
173
+ }
174
+ } catch {
175
+ // 读不到时间就给 null —— 不拿当前时间顶替(那会造出一个假的"最近崩溃")
176
+ }
177
+ return { dir: root, reports, records, newest }
178
+ }
179
+
180
+ function countFiles(dir: string): number {
181
+ try {
182
+ return readdirSync(dir).filter(name => {
183
+ try {
184
+ return statSync(join(dir, name)).isFile()
185
+ } catch {
186
+ return false
187
+ }
188
+ }).length
189
+ } catch {
190
+ return 0
191
+ }
192
+ }
193
+
194
+ /** 两条路径是不是同一个位置。**按真身比**:本机数据目录有 D 盘软链,同一份库有两条路径。 */
195
+ export function samePath(a: string, b: string): boolean {
196
+ try {
197
+ return realpathSync(a).toLowerCase() === realpathSync(b).toLowerCase()
198
+ } catch {
199
+ return false
200
+ }
201
+ }
202
+
203
+ /** `%APPDATA%\Tencent\xwechat` —— 崩溃记录与 `update.data` 都在这棵树下。 */
204
+ export function xwechatRoot(appData: string | undefined = process.env.APPDATA): string | null {
205
+ return appData ? join(appData, 'Tencent', 'xwechat') : null
206
+ }
207
+
208
+ /**
209
+ * `update.data` 在场时**必须一起说出去**的那句话。
210
+ *
211
+ * 报告把这条明确留成未解:"若该文件存在,exe 侧第一次 `LoadLibraryExW` 就会用它,
212
+ * 根本不会看命令行 —— 两条路谁优先【未知】"。所以这个函数的存在意义是:
213
+ * 别让新报出去的那个版本在那种机器上**悄悄变成错的**。
214
+ */
215
+ export function updateDataCaveats(present: boolean): string[] {
216
+ if (!present) return []
217
+ return ['update\\update.data 存在:加载路径**可能**由它决定,命令行给的版本未必是实际加载的那个'
218
+ + '(那份逆向没解开它的内部格式,也没判定两条路谁优先)']
219
+ }
220
+
221
+ /** 一个版本目录能不能用:**有没有 `Weixin.dll`**(启动器加载的就是它)。 */
222
+ export function versionDirFiles(dir: string): string[] | null {
223
+ try {
224
+ if (!statSync(dir).isDirectory()) return null
225
+ return readdirSync(dir)
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+
231
+ /** 把一堆目录名分成"能用的版本目录 / 不能用的 / 与版本无关的"。纯函数,便于测。 */
232
+ export function classifyVersionDirs(entries: Array<{ name: string; files: string[] }>): {
233
+ usable: string[]
234
+ unusable: Array<{ dir: string; files: number }>
235
+ } {
236
+ const usable: string[] = []
237
+ const unusable: Array<{ dir: string; files: number }> = []
238
+ for (const entry of entries) {
239
+ if (!VERSION_DIR.test(entry.name)) continue
240
+ if (entry.files.includes('Weixin.dll')) usable.push(entry.name)
241
+ else unusable.push({ dir: entry.name, files: entry.files.length })
242
+ }
243
+ return { usable, unusable }
244
+ }
245
+
246
+ /** 安装根(`D:\WeiXin`)→ 它的版本目录分类。读不出来就当空(不猜)。 */
247
+ export function versionDirsUnder(installRoot: string): ReturnType<typeof classifyVersionDirs> {
248
+ let names: string[]
249
+ try {
250
+ names = readdirSync(installRoot)
251
+ } catch {
252
+ return { usable: [], unusable: [] }
253
+ }
254
+ const entries = names.map(name => ({ name, files: versionDirFiles(join(installRoot, name)) || [] }))
255
+ return classifyVersionDirs(entries)
256
+ }
257
+
258
+ export function summarize(processes: WechatClientProcess[],
259
+ unusable: Array<{ dir: string; files: number }>): WechatVersionFacts {
260
+ // 去重按**规范形**(去掉尾部分隔符、大小写折叠 —— Windows 路径大小写不敏感):
261
+ // 同一个目录写成 `D:\WeiXin\4.1.15.13` 与 `…\4.1.15.13\` 是**一个**目录,
262
+ // 不折算的话会被当成"两个进程指向不同目录"从而拒绝给出版本号。
263
+ // **版本只跟 `Weixin.exe` 有关**:`WeChatAppEx.exe`(小程序那组)与它在同一张进程表里
264
+ // (后者的命令行上带着 `--wechat-files-path`),但它不加载 `Weixin.dll` —— 拿它的
265
+ // "没带 `--user-lib-dir`"去说事会把"客户端在跑、版本取得到"讲成"取不到"。
266
+ // 用 `win32` 而不是平台 `path`:这里拆的**永远是 Windows 路径**(进程表/命令行给的),
267
+ // 而平台 `basename` 在别的平台上会把 `D:\WeiXin\4.1.15.13\Weixin.exe` 当成**一个文件名**
268
+ // —— 于是过滤出 0 个启动器、版本退回"未知",在 Windows 上还看不出来(CI 的 ubuntu 上红了 4 条)。
269
+ // 在 Windows 上 `win32.*` 与平台版完全等价,所以目标机器的行为一点没变。
270
+ const launchers = processes.filter(p => win32.basename(p.exe).toLowerCase() === 'weixin.exe')
271
+ const seen = new Map<string, string>()
272
+ for (const p of launchers) {
273
+ if (!p.loadedDir) continue
274
+ const shown = p.loadedDir.replace(/[\\/]+$/, '')
275
+ if (!shown) continue
276
+ const key = shown.toLowerCase()
277
+ if (!seen.has(key)) seen.set(key, shown)
278
+ }
279
+ const loadedDirs = [...seen.values()]
280
+ let version: string | null = null
281
+ let note = ''
282
+ if (!launchers.length) {
283
+ note = '客户端没在运行 ⇒ 版本未知(`--user-lib-dir` 只有进程知道)'
284
+ } else if (!loadedDirs.length) {
285
+ note = `在跑 ${launchers.length} 个进程,但命令行里都没有 --user-lib-dir`
286
+ } else if (loadedDirs.length > 1) {
287
+ // **不猜**:多个目录各有各的来源(子进程可以指到不同的库),挑一个报出去就是编。
288
+ // 目录本身照旧列出来(那是实测到的),只是不给"版本号"这个单一结论。
289
+ note = `有 ${loadedDirs.length} 个不同的目录在使用,不挑一个报版本`
290
+ } else {
291
+ version = win32.basename(loadedDirs[0])
292
+ }
293
+ return {
294
+ running: processes.length > 0,
295
+ loadedDirs,
296
+ version,
297
+ note,
298
+ unusableVersionDirs: unusable,
299
+ }
300
+ }
301
+
302
+ /** 默认的执行器:`Get-CimInstance`。测试里换掉它,别真去问系统。 */
303
+ export type PowershellRunner = (command: string) => Promise<string>
304
+
305
+ /**
306
+ * **只要那一句话**:客户端自报的数据目录。
307
+ *
308
+ * 给 `init` 的数据目录发现用(`DbPathService.autoDetect({ hintedRoots })`)—— 它是唯一
309
+ * **来自客户端自己**的来源,别的都是按目录名猜的。找不到就返回空数组(调用方走老路),
310
+ * **不抛**:这只是个线索,不该让初始化失败。
311
+ */
312
+ export async function reportedDataDirs(run: PowershellRunner = defaultRunner): Promise<string[]> {
313
+ if (process.platform !== 'win32') return []
314
+ let raw = ''
315
+ try {
316
+ raw = await run(PS_LIST)
317
+ } catch {
318
+ return []
319
+ }
320
+ const dirs = parseProcessRows(raw).map(p => p.dataDir).filter((d): d is string => !!d)
321
+ return [...new Set(dirs.map(d => d.replace(/[\\/]+$/, '')).filter(Boolean))]
322
+ }
323
+
324
+ const defaultRunner: PowershellRunner = async (command) => {
325
+ const { stdout } = await execFileAsync('powershell.exe',
326
+ ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15_000 })
327
+ return String(stdout || '')
328
+ }
329
+
330
+ /**
331
+ * 汇总:进程画像 + 每个安装根下"不能用的版本目录"。
332
+ *
333
+ * 非 Windows 直接返回"平台不支持"(`Get-CimInstance` 是 Windows 的),**不是**返回"没问题"。
334
+ */
335
+ export async function detectWechatClient(run: PowershellRunner = defaultRunner): Promise<WechatClientFacts> {
336
+ const root = xwechatRoot()
337
+ const nothing: WechatClientFacts = {
338
+ running: false, loadedDirs: [], version: null, note: '', unusableVersionDirs: [],
339
+ versionTokens: [],
340
+ dataDirs: [], updateData: { path: root ? join(root, 'update', 'update.data') : '', present: false },
341
+ crashRecords: { dir: root ? join(root, 'crashinfo') : '', reports: 0, records: 0, newest: null },
342
+ caveats: [],
343
+ }
344
+ if (process.platform !== 'win32') {
345
+ return { ...nothing, note: `判版本只做了 Windows(当前平台 ${process.platform})` }
346
+ }
347
+ let raw = ''
348
+ try {
349
+ raw = await run(PS_LIST)
350
+ } catch {
351
+ raw = ''
352
+ }
353
+ const processes = parseProcessRows(raw)
354
+ // 安装根从**启动器**的可执行路径推(**别去猜**:注册表与常见路径都不可靠 ——
355
+ // 注册表那条空的是**我们自己**的旧结论,出处是 `docs/DECISIONS.md` 里那句"`WeChat Files`
356
+ // 与注册表 InstallPath 都为空"(讲的是 3.x);**别把它记成那份逆向报告的结论**,
357
+ // 报告里没有注册表这一段)。只取 `Weixin.exe` 的目录:小程序的目录是插件目录,
358
+ // 那里的版本号子目录不是客户端版本。
359
+ const roots = [...new Set(processes
360
+ .filter(p => win32.basename(p.exe).toLowerCase() === 'weixin.exe')
361
+ .map(p => win32.dirname(p.exe))
362
+ .filter(dir => dir && existsSync(dir)))]
363
+ const unusable = roots.flatMap(r => versionDirsUnder(r).unusable)
364
+ const version = summarize(processes, unusable)
365
+
366
+ // `update.data` 在场 ⇒ **它可能才是决定加载路径的那个**,命令行给的版本就未必是加载的那个。
367
+ // 报告把这条明确留成未解("两条路谁优先 —— 【未知】"),所以这里**不猜**:只说存在,
368
+ // 并把不确定性跟着版本一起带出去(否则新报的这个版本会在那种机器上悄悄变成错的)。
369
+ const updateFile = nothing.updateData.path
370
+ const present = !!updateFile && existsSync(updateFile)
371
+ return {
372
+ ...version,
373
+ // 客户端自报的构建 token(与"目录名"并列的第二来源)——**不拿它推版本号**,编码规则没人解出来
374
+ versionTokens: [...new Set(processes.map(p => p.versionToken).filter((t): t is string => !!t))],
375
+ dataDirs: [...new Set(processes.map(p => p.dataDir).filter((d): d is string => !!d))],
376
+ updateData: { path: updateFile, present },
377
+ crashRecords: crashSummary(nothing.crashRecords.dir),
378
+ caveats: updateDataCaveats(present),
379
+ }
380
+ }
@@ -0,0 +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
+ }