weflow-cli 1.5.0 → 1.6.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 (332) hide show
  1. package/ARCHITECTURE.md +105 -0
  2. package/CHANGELOG.md +121 -0
  3. package/CONTRIBUTING.md +45 -0
  4. package/LICENSE +21 -21
  5. package/OPERATIONS.md +272 -0
  6. package/README.en.md +281 -0
  7. package/README.md +334 -149
  8. package/SECURITY.md +47 -0
  9. package/bin/weflow-cli.ts +4050 -581
  10. package/dist/bin/weflow-cli.js +4310 -588
  11. package/dist/bin/weflow-cli.js.map +1 -1
  12. package/dist/mcp-server/index.js +178 -236
  13. package/dist/mcp-server/index.js.map +1 -1
  14. package/dist/src/core/dbPathService.d.ts +15 -1
  15. package/dist/src/core/dbPathService.d.ts.map +1 -1
  16. package/dist/src/core/dbPathService.js +177 -19
  17. package/dist/src/core/dbPathService.js.map +1 -1
  18. package/dist/src/core/keyService.d.ts +8 -0
  19. package/dist/src/core/keyService.d.ts.map +1 -1
  20. package/dist/src/core/keyService.js +127 -22
  21. package/dist/src/core/keyService.js.map +1 -1
  22. package/dist/src/core/ntCore.d.ts +59 -1
  23. package/dist/src/core/ntCore.d.ts.map +1 -1
  24. package/dist/src/core/ntCore.js +201 -65
  25. package/dist/src/core/ntCore.js.map +1 -1
  26. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  27. package/dist/src/core/sqlcipherCore.js +18 -1
  28. package/dist/src/core/sqlcipherCore.js.map +1 -1
  29. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  30. package/dist/src/core/wcdbCore.js +3 -6
  31. package/dist/src/core/wcdbCore.js.map +1 -1
  32. package/dist/src/services/assistantDaemon.d.ts +19 -0
  33. package/dist/src/services/assistantDaemon.d.ts.map +1 -0
  34. package/dist/src/services/assistantDaemon.js +117 -0
  35. package/dist/src/services/assistantDaemon.js.map +1 -0
  36. package/dist/src/services/assistantMemory.d.ts +36 -0
  37. package/dist/src/services/assistantMemory.d.ts.map +1 -0
  38. package/dist/src/services/assistantMemory.js +182 -0
  39. package/dist/src/services/assistantMemory.js.map +1 -0
  40. package/dist/src/services/assistantPrivacy.d.ts +25 -0
  41. package/dist/src/services/assistantPrivacy.d.ts.map +1 -0
  42. package/dist/src/services/assistantPrivacy.js +77 -0
  43. package/dist/src/services/assistantPrivacy.js.map +1 -0
  44. package/dist/src/services/assistantRouting.d.ts +18 -0
  45. package/dist/src/services/assistantRouting.d.ts.map +1 -0
  46. package/dist/src/services/assistantRouting.js +68 -0
  47. package/dist/src/services/assistantRouting.js.map +1 -0
  48. package/dist/src/services/assistantService.d.ts +37 -0
  49. package/dist/src/services/assistantService.d.ts.map +1 -0
  50. package/dist/src/services/assistantService.js +284 -0
  51. package/dist/src/services/assistantService.js.map +1 -0
  52. package/dist/src/services/assistantTools.d.ts +24 -0
  53. package/dist/src/services/assistantTools.d.ts.map +1 -0
  54. package/dist/src/services/assistantTools.js +582 -0
  55. package/dist/src/services/assistantTools.js.map +1 -0
  56. package/dist/src/services/chatService.d.ts +44 -0
  57. package/dist/src/services/chatService.d.ts.map +1 -1
  58. package/dist/src/services/chatService.js +105 -4
  59. package/dist/src/services/chatService.js.map +1 -1
  60. package/dist/src/services/configService.d.ts +55 -0
  61. package/dist/src/services/configService.d.ts.map +1 -1
  62. package/dist/src/services/configService.js +147 -6
  63. package/dist/src/services/configService.js.map +1 -1
  64. package/dist/src/services/evidenceService.d.ts +30 -0
  65. package/dist/src/services/evidenceService.d.ts.map +1 -0
  66. package/dist/src/services/evidenceService.js +98 -0
  67. package/dist/src/services/evidenceService.js.map +1 -0
  68. package/dist/src/services/exportService.d.ts +13 -20
  69. package/dist/src/services/exportService.d.ts.map +1 -1
  70. package/dist/src/services/exportService.js +113 -47
  71. package/dist/src/services/exportService.js.map +1 -1
  72. package/dist/src/services/initKeyService.d.ts +19 -0
  73. package/dist/src/services/initKeyService.d.ts.map +1 -0
  74. package/dist/src/services/initKeyService.js +80 -0
  75. package/dist/src/services/initKeyService.js.map +1 -0
  76. package/dist/src/services/messageContract.d.ts +23 -0
  77. package/dist/src/services/messageContract.d.ts.map +1 -0
  78. package/dist/src/services/messageContract.js +49 -0
  79. package/dist/src/services/messageContract.js.map +1 -0
  80. package/dist/src/services/messageQuery.d.ts +7 -0
  81. package/dist/src/services/messageQuery.d.ts.map +1 -0
  82. package/dist/src/services/messageQuery.js +30 -0
  83. package/dist/src/services/messageQuery.js.map +1 -0
  84. package/dist/src/services/wechat-formatter.js +14 -14
  85. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  86. package/dist/src/services/wechatMessageService.js +31 -3
  87. package/dist/src/services/wechatMessageService.js.map +1 -1
  88. package/dist/src/services/whitelistService.d.ts +52 -4
  89. package/dist/src/services/whitelistService.d.ts.map +1 -1
  90. package/dist/src/services/whitelistService.js +183 -17
  91. package/dist/src/services/whitelistService.js.map +1 -1
  92. package/dist/src/types.d.ts +20 -0
  93. package/dist/src/types.d.ts.map +1 -1
  94. package/dist/src/utils/dateRange.d.ts +15 -0
  95. package/dist/src/utils/dateRange.d.ts.map +1 -0
  96. package/dist/src/utils/dateRange.js +48 -0
  97. package/dist/src/utils/dateRange.js.map +1 -0
  98. package/dist/src/utils/mcpSecurity.d.ts +5 -0
  99. package/dist/src/utils/mcpSecurity.d.ts.map +1 -0
  100. package/dist/src/utils/mcpSecurity.js +39 -0
  101. package/dist/src/utils/mcpSecurity.js.map +1 -0
  102. package/dist/src/utils/packageRoot.d.ts +2 -0
  103. package/dist/src/utils/packageRoot.d.ts.map +1 -0
  104. package/dist/src/utils/packageRoot.js +16 -0
  105. package/dist/src/utils/packageRoot.js.map +1 -0
  106. package/dist/src/utils/python.d.ts +2 -0
  107. package/dist/src/utils/python.d.ts.map +1 -0
  108. package/dist/src/utils/python.js +72 -0
  109. package/dist/src/utils/python.js.map +1 -0
  110. package/dist/src/utils/pythonProcessEnv.d.ts +3 -0
  111. package/dist/src/utils/pythonProcessEnv.d.ts.map +1 -0
  112. package/dist/src/utils/pythonProcessEnv.js +46 -0
  113. package/dist/src/utils/pythonProcessEnv.js.map +1 -0
  114. package/dist/src/utils/talkerUtils.d.ts +4 -1
  115. package/dist/src/utils/talkerUtils.d.ts.map +1 -1
  116. package/dist/src/utils/talkerUtils.js +9 -9
  117. package/dist/src/utils/talkerUtils.js.map +1 -1
  118. package/dist/src/utils/wechatEmoji.d.ts +5 -0
  119. package/dist/src/utils/wechatEmoji.d.ts.map +1 -0
  120. package/dist/src/utils/wechatEmoji.js +35 -0
  121. package/dist/src/utils/wechatEmoji.js.map +1 -0
  122. package/docs/AI_INTERFACE.md +189 -0
  123. package/docs/BRANCHES.md +87 -0
  124. package/docs/DATA_CONTRACT.md +74 -0
  125. package/docs/DECISIONS.md +270 -0
  126. package/docs/DEEPSEEK_V4_FLASH/346/226/275/345/267/245/346/226/207/346/241/243.md +234 -0
  127. package/docs/EVIDENCE_GUIDE.md +96 -0
  128. package/docs/MCP.md +95 -0
  129. package/docs/PARTNERS.md +41 -0
  130. package/docs/PROJECT_STATE.md +96 -0
  131. package/docs/RELEASING.md +124 -0
  132. package/docs/ROADMAP.md +191 -0
  133. package/docs/SETUP.md +103 -0
  134. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  135. package/docs/images/weflow-architecture.png +0 -0
  136. package/docs/images/weflow-architecture.svg +65 -0
  137. package/mcp-server/index.ts +169 -269
  138. package/package.json +81 -72
  139. package/requirements-3x.txt +4 -0
  140. package/requirements-voice.txt +15 -0
  141. package/requirements.txt +9 -0
  142. package/resources/js/marked.min.js +69 -0
  143. package/resources/wechat-emoji/666.png +0 -0
  144. package/resources/wechat-emoji/Awesome.png +0 -0
  145. package/resources/wechat-emoji/Concerned.png +0 -0
  146. package/resources/wechat-emoji/Cry.png +0 -0
  147. package/resources/wechat-emoji/Emm.png +0 -0
  148. package/resources/wechat-emoji/Facepalm.png +0 -0
  149. package/resources/wechat-emoji/Grin.png +0 -0
  150. package/resources/wechat-emoji/OK.png +0 -0
  151. package/resources/wechat-emoji/Respect.png +0 -0
  152. package/resources/wechat-emoji/Sick.png +0 -0
  153. package/resources/wechat-emoji/Sleep.png +0 -0
  154. package/resources/wechat-emoji/Smile.png +0 -0
  155. package/resources/wechat-emoji//344/272/262/344/272/262.png +0 -0
  156. package/resources/wechat-emoji//344/276/277/344/276/277.png +0 -0
  157. package/resources/wechat-emoji//345/201/267/347/254/221.png +0 -0
  158. package/resources/wechat-emoji//345/202/262/346/205/242.png +0 -0
  159. package/resources/wechat-emoji//345/206/215/350/247/201.png +0 -0
  160. package/resources/wechat-emoji//345/207/213/350/260/242.png +0 -0
  161. package/resources/wechat-emoji//345/212/240/346/262/271.png +0 -0
  162. package/resources/wechat-emoji//345/213/276/345/274/225.png +0 -0
  163. package/resources/wechat-emoji//345/217/221/345/221/206.png +0 -0
  164. package/resources/wechat-emoji//345/217/221/346/200/222.png +0 -0
  165. package/resources/wechat-emoji//345/217/221/346/212/226.png +0 -0
  166. package/resources/wechat-emoji//345/217/257/346/200/234.png +0 -0
  167. package/resources/wechat-emoji//345/217/263/345/223/274/345/223/274.png +0 -0
  168. package/resources/wechat-emoji//345/217/271/346/260/224.png +0 -0
  169. package/resources/wechat-emoji//345/220/203/347/223/234.png +0 -0
  170. package/resources/wechat-emoji//345/220/210/345/215/201.png +0 -0
  171. package/resources/wechat-emoji//345/220/220.png +0 -0
  172. package/resources/wechat-emoji//345/221/262/347/211/231.png +0 -0
  173. package/resources/wechat-emoji//345/222/222/351/252/202.png +0 -0
  174. package/resources/wechat-emoji//345/222/226/345/225/241.png +0 -0
  175. package/resources/wechat-emoji//345/223/207.png +0 -0
  176. package/resources/wechat-emoji//345/225/244/351/205/222.png +0 -0
  177. package/resources/wechat-emoji//345/230/230.png +0 -0
  178. package/resources/wechat-emoji//345/230/264/345/224/207.png +0 -0
  179. package/resources/wechat-emoji//345/230/277/345/223/210.png +0 -0
  180. package/resources/wechat-emoji//345/233/247.png +0 -0
  181. package/resources/wechat-emoji//345/233/260.png +0 -0
  182. package/resources/wechat-emoji//345/235/217/347/254/221.png +0 -0
  183. package/resources/wechat-emoji//345/244/247/345/223/255.png +0 -0
  184. package/resources/wechat-emoji//345/244/251/345/225/212.png +0 -0
  185. package/resources/wechat-emoji//345/244/252/351/230/263.png +0 -0
  186. package/resources/wechat-emoji//345/244/261/346/234/233.png +0 -0
  187. package/resources/wechat-emoji//345/245/270/347/254/221.png +0 -0
  188. package/resources/wechat-emoji//345/245/275/347/232/204.png +0 -0
  189. package/resources/wechat-emoji//345/247/224/345/261/210.png +0 -0
  190. package/resources/wechat-emoji//345/256/263/347/276/236.png +0 -0
  191. package/resources/wechat-emoji//345/260/264/345/260/254.png +0 -0
  192. package/resources/wechat-emoji//345/272/206/347/245/235.png +0 -0
  193. package/resources/wechat-emoji//345/274/261.png +0 -0
  194. package/resources/wechat-emoji//345/274/272.png +0 -0
  195. package/resources/wechat-emoji//345/276/227/346/204/217.png +0 -0
  196. package/resources/wechat-emoji//345/276/256/347/254/221.png +0 -0
  197. package/resources/wechat-emoji//345/277/203/347/242/216.png +0 -0
  198. package/resources/wechat-emoji//345/277/253/345/223/255/344/272/206.png +0 -0
  199. package/resources/wechat-emoji//346/201/220/346/203/247.png +0 -0
  200. package/resources/wechat-emoji//346/202/240/351/227/262.png +0 -0
  201. package/resources/wechat-emoji//346/203/212/346/201/220.png +0 -0
  202. package/resources/wechat-emoji//346/203/212/350/256/266.png +0 -0
  203. package/resources/wechat-emoji//346/204/211/345/277/253.png +0 -0
  204. package/resources/wechat-emoji//346/206/250/347/254/221.png +0 -0
  205. package/resources/wechat-emoji//346/211/223/350/204/270.png +0 -0
  206. package/resources/wechat-emoji//346/212/223/347/213/202.png +0 -0
  207. package/resources/wechat-emoji//346/212/240/351/274/273.png +0 -0
  208. package/resources/wechat-emoji//346/212/261/346/213/263.png +0 -0
  209. package/resources/wechat-emoji//346/213/245/346/212/261.png +0 -0
  210. package/resources/wechat-emoji//346/213/263/345/244/264.png +0 -0
  211. package/resources/wechat-emoji//346/215/202/350/204/270.png +0 -0
  212. package/resources/wechat-emoji//346/217/241/346/211/213.png +0 -0
  213. package/resources/wechat-emoji//346/222/207/345/230/264.png +0 -0
  214. package/resources/wechat-emoji//346/223/246/346/261/227.png +0 -0
  215. package/resources/wechat-emoji//346/225/262/346/211/223.png +0 -0
  216. package/resources/wechat-emoji//346/227/240/350/257/255.png +0 -0
  217. package/resources/wechat-emoji//346/227/272/346/237/264.png +0 -0
  218. package/resources/wechat-emoji//346/231/225.png +0 -0
  219. package/resources/wechat-emoji//346/234/210/344/272/256.png +0 -0
  220. package/resources/wechat-emoji//346/234/272/346/231/272.png +0 -0
  221. package/resources/wechat-emoji//346/261/227.png +0 -0
  222. package/resources/wechat-emoji//346/265/201/346/263/252.png +0 -0
  223. package/resources/wechat-emoji//347/202/270/345/274/271.png +0 -0
  224. package/resources/wechat-emoji//347/203/237/350/212/261.png +0 -0
  225. package/resources/wechat-emoji//347/210/206/347/253/271.png +0 -0
  226. package/resources/wechat-emoji//347/210/261/345/277/203.png +0 -0
  227. package/resources/wechat-emoji//347/214/252/345/244/264.png +0 -0
  228. package/resources/wechat-emoji//347/216/253/347/221/260.png +0 -0
  229. package/resources/wechat-emoji//347/224/237/347/227/205.png +0 -0
  230. package/resources/wechat-emoji//347/226/221/351/227/256.png +0 -0
  231. package/resources/wechat-emoji//347/231/274.png +0 -0
  232. package/resources/wechat-emoji//347/231/275/347/234/274.png +0 -0
  233. package/resources/wechat-emoji//347/232/261/347/234/211.png +0 -0
  234. package/resources/wechat-emoji//347/235/241.png +0 -0
  235. package/resources/wechat-emoji//347/240/264/346/266/225/344/270/272/347/254/221.png +0 -0
  236. package/resources/wechat-emoji//347/244/274/347/211/251.png +0 -0
  237. package/resources/wechat-emoji//347/244/276/344/274/232/347/244/276/344/274/232.png +0 -0
  238. package/resources/wechat-emoji//347/246/217.png +0 -0
  239. package/resources/wechat-emoji//347/254/221/350/204/270.png +0 -0
  240. package/resources/wechat-emoji//347/272/242/345/214/205.png +0 -0
  241. package/resources/wechat-emoji//347/277/273/347/231/275/347/234/274.png +0 -0
  242. package/resources/wechat-emoji//350/200/266.png +0 -0
  243. package/resources/wechat-emoji//350/203/234/345/210/251.png +0 -0
  244. package/resources/wechat-emoji//350/204/270/347/272/242.png +0 -0
  245. package/resources/wechat-emoji//350/211/262.png +0 -0
  246. package/resources/wechat-emoji//350/213/246/346/266/251.png +0 -0
  247. package/resources/wechat-emoji//350/217/234/345/210/200.png +0 -0
  248. package/resources/wechat-emoji//350/233/213/347/263/225.png +0 -0
  249. package/resources/wechat-emoji//350/241/260.png +0 -0
  250. package/resources/wechat-emoji//350/243/202/345/274/200.png +0 -0
  251. package/resources/wechat-emoji//350/256/251/346/210/221/347/234/213/347/234/213.png +0 -0
  252. package/resources/wechat-emoji//350/260/203/347/232/256.png +0 -0
  253. package/resources/wechat-emoji//350/267/263/350/267/263.png +0 -0
  254. package/resources/wechat-emoji//350/275/254/345/234/210.png +0 -0
  255. package/resources/wechat-emoji//351/204/231/350/247/206.png +0 -0
  256. package/resources/wechat-emoji//351/227/255/345/230/264.png +0 -0
  257. package/resources/wechat-emoji//351/230/264/351/231/251.png +0 -0
  258. package/resources/wechat-emoji//351/232/276/350/277/207.png +0 -0
  259. package/resources/wechat-emoji//351/252/267/351/253/205.png +0 -0
  260. package/resources/wechat-emoji//351/274/223/346/216/214.png +0 -0
  261. package/scripts/_batch_link_sources.py +73 -73
  262. package/scripts/_utils.py +29 -0
  263. package/scripts/annual_report.py +796 -796
  264. package/scripts/auto_tag.py +128 -128
  265. package/scripts/biz_daily.py +210 -53
  266. package/scripts/chat_report.py +10 -0
  267. package/scripts/chat_stats.py +702 -702
  268. package/scripts/classify_daily.py +17 -11
  269. package/scripts/create_reading_notes.py +370 -370
  270. package/scripts/daily_stats.py +132 -0
  271. package/scripts/enrich_backlinks.py +163 -163
  272. package/scripts/export_chat_html.py +1704 -107
  273. package/scripts/extract_todos.py +445 -404
  274. package/scripts/fav_server.py +119 -12
  275. package/scripts/fix_topics.py +111 -111
  276. package/scripts/generate_ai_report.py +1 -1
  277. package/scripts/generate_html.py +597 -277
  278. package/scripts/inspection_records.py +354 -0
  279. package/scripts/mcp_bridge.py +398 -398
  280. package/scripts/nt_decrypt.py +702 -48
  281. package/scripts/pipeline.py +27 -19
  282. package/scripts/promote_all.py +312 -310
  283. package/scripts/promote_ideas.py +299 -297
  284. package/scripts/rag_chat.py +194 -191
  285. package/scripts/semantic_search.py +435 -431
  286. package/scripts/sync_fav.py +201 -146
  287. package/scripts/sync_weread.py +192 -192
  288. package/scripts/vault_rag.py +140 -139
  289. package/scripts/vault_search.py +141 -141
  290. package/scripts/wechat_emoji.py +132 -0
  291. package/scripts/wechat_emoticon.py +433 -0
  292. package/scripts/wechat_image.py +258 -0
  293. package/scripts/wechat_voice.py +327 -0
  294. package/src/core/dbPathService.ts +163 -18
  295. package/src/core/keyService.ts +137 -25
  296. package/src/core/ntCore.ts +215 -62
  297. package/src/core/sqlcipherCore.ts +17 -1
  298. package/src/core/wcdbCore.ts +3 -7
  299. package/src/services/assistantDaemon.ts +111 -0
  300. package/src/services/assistantMemory.ts +193 -0
  301. package/src/services/assistantPrivacy.ts +87 -0
  302. package/src/services/assistantRouting.ts +92 -0
  303. package/src/services/assistantService.ts +295 -0
  304. package/src/services/assistantTools.ts +549 -0
  305. package/src/services/chatService.ts +133 -4
  306. package/src/services/configService.ts +194 -6
  307. package/src/services/evidenceService.ts +129 -0
  308. package/src/services/exportService.ts +113 -48
  309. package/src/services/initKeyService.ts +83 -0
  310. package/src/services/messageContract.ts +77 -0
  311. package/src/services/messageQuery.ts +38 -0
  312. package/src/services/wechat-formatter.ts +348 -348
  313. package/src/services/wechatMessageService.ts +25 -3
  314. package/src/services/wereadService.ts +308 -308
  315. package/src/services/whitelistService.ts +209 -22
  316. package/src/types.ts +20 -0
  317. package/src/utils/dateRange.ts +58 -0
  318. package/src/utils/mcpSecurity.ts +39 -0
  319. package/src/utils/packageRoot.ts +14 -0
  320. package/src/utils/python.ts +73 -0
  321. package/src/utils/pythonProcessEnv.ts +45 -0
  322. package/src/utils/talkerUtils.ts +14 -2
  323. package/dist/src/utils/errors.d.ts +0 -25
  324. package/dist/src/utils/errors.d.ts.map +0 -1
  325. package/dist/src/utils/errors.js +0 -43
  326. package/dist/src/utils/errors.js.map +0 -1
  327. package/dist/src/utils/pythonRunner.d.ts +0 -27
  328. package/dist/src/utils/pythonRunner.d.ts.map +0 -1
  329. package/dist/src/utils/pythonRunner.js +0 -62
  330. package/dist/src/utils/pythonRunner.js.map +0 -1
  331. package/src/utils/errors.ts +0 -42
  332. package/src/utils/pythonRunner.ts +0 -81
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+ """Decrypt WeChat 4.x chat images from the local `.dat` store.
3
+
4
+ Why this exists: WeChat's thumbnail cache (`cache/YYYY-MM/Message/<会话>/Thumb`)
5
+ holds mainly *article covers* and only ~2 months of history, so ordinary chat
6
+ photos from older conversations render as an empty `[图片]`. The originals are
7
+ on disk the whole time, at
8
+
9
+ msg/attach/<md5(会话)>/<YYYY-MM>/Img/<file_id>.dat (原图)
10
+ msg/attach/<md5(会话)>/<YYYY-MM>/Img/<file_id>_t.dat (缩略图)
11
+
12
+ `file_id` is not an md5 of the image and does not appear in the message XML -
13
+ it comes from `message_resource.db`, see `load_file_ids`.
14
+
15
+ Container (V2, the only variant seen on 4.x):
16
+
17
+ [6B 07 08 56 32 08 07][4B aes_size LE][4B xor_size LE][1B pad]
18
+ + AES-128-ECB(ciphertext) | plaintext | XOR tail
19
+
20
+ Key derivation reuses the account seed:
21
+
22
+ key = md5(f"{seed}{wxid}").hexdigest()[:16] # 16 ASCII bytes
23
+
24
+ Note the asymmetry with stickers, which use the *first 16 bytes* of
25
+ md5(f"{seed}{wxid}EMOTICON") - a different string and a different slicing.
26
+
27
+ Reference: ZedeX/weixin-decrypte-script.
28
+ """
29
+ import hashlib
30
+ import os
31
+ import struct
32
+
33
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
34
+
35
+ DAT_SIG = b'\x07\x08\x56\x32\x08\x07'
36
+
37
+ MAGICS = (
38
+ (b'\xff\xd8\xff', 'image/jpeg'),
39
+ (b'\x89PNG', 'image/png'),
40
+ (b'GIF8', 'image/gif'),
41
+ (b'RIFF', 'image/webp'),
42
+ (b'wxgf', 'image/hevc'),
43
+ )
44
+
45
+
46
+ def derive_key(seed, wxid):
47
+ """16 ASCII bytes from md5(f"{seed}{wxid}")."""
48
+ return hashlib.md5(f'{seed}{wxid}'.encode()).hexdigest()[:16].encode()
49
+
50
+
51
+ def decrypt(raw, key, seed):
52
+ """Decrypt a V2 `.dat`, or b'' if it is another variant."""
53
+ if len(raw) < 16 or raw[:6] != DAT_SIG:
54
+ return b''
55
+ try:
56
+ aes_size, xor_size = struct.unpack('<II', raw[6:14])
57
+ except struct.error:
58
+ return b''
59
+ if aes_size + xor_size > len(raw) or aes_size % 16:
60
+ return b''
61
+ body = raw[15:15 + aes_size]
62
+ dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor()
63
+ out = dec.update(body) + dec.finalize()
64
+ end = len(raw) - xor_size if xor_size else len(raw)
65
+ out += raw[15 + aes_size:end]
66
+ if xor_size:
67
+ xk = int(seed) & 0xFF
68
+ out += bytes(b ^ xk for b in raw[end:])
69
+ return out
70
+
71
+
72
+ def sniff(data):
73
+ for magic, mime in MAGICS:
74
+ if data.startswith(magic):
75
+ return mime
76
+ return ''
77
+
78
+
79
+ def load_file_ids(resource_db, master_key, talker):
80
+ """{(message_local_id, create_time): file_id} for one conversation.
81
+
82
+ `packed_info` is a small protobuf carrying the on-disk file id as a
83
+ 32-character hex string; that string is not derivable from the message, so
84
+ the mapping has to come from this table.
85
+ """
86
+ import re
87
+ try:
88
+ from sqlcipher3 import dbapi2 as sqlcipher
89
+ from nt_keys import derive_db_key
90
+ except ImportError:
91
+ return {}
92
+
93
+ if not resource_db or not os.path.isfile(resource_db) or not master_key:
94
+ return {}
95
+ try:
96
+ salt = open(resource_db, 'rb').read(16).hex()
97
+ key = derive_db_key(master_key, resource_db)
98
+ conn = sqlcipher.connect(resource_db)
99
+ cur = conn.cursor()
100
+ cur.execute(f'PRAGMA key = "x\'{key}{salt}\'";')
101
+ chat_id = None
102
+ for rowid, user_name in cur.execute('SELECT rowid, user_name FROM ChatName2Id'):
103
+ if user_name == talker:
104
+ chat_id = rowid
105
+ break
106
+ if chat_id is None:
107
+ conn.close()
108
+ return {}
109
+ out = {}
110
+ cur.execute('''SELECT message_local_id, message_create_time, packed_info
111
+ FROM MessageResourceInfo WHERE chat_id = ?''', (chat_id,))
112
+ for lid, ts, packed in cur.fetchall():
113
+ if isinstance(packed, str):
114
+ packed = packed.encode('latin-1', 'ignore')
115
+ m = re.search(rb'[0-9a-f]{32}', packed or b'')
116
+ if m:
117
+ out[(lid, ts)] = m.group(0).decode()
118
+ conn.close()
119
+ return out
120
+ except Exception:
121
+ return {}
122
+
123
+
124
+ def shrink(data, mime, max_side=720, quality=82):
125
+ """Downscale to at most `max_side` on the long edge, as JPEG.
126
+
127
+ The exported page shows images at ~240px, but a 2-3x display still needs
128
+ more than the 120-210px thumbnails WeChat caches - those upscale into the
129
+ blocky mess users notice. Full images run ~370KB on average though, so
130
+ embedding them untouched would balloon the export; ~720px is the point
131
+ where they look clean at any realistic zoom without the weight.
132
+ """
133
+ # An animated GIF must not go through a JPEG re-encode: PIL decodes the
134
+ # first frame only, so the export showed a still of what the sender saw
135
+ # moving. Re-encoding also tends to grow small GIFs, so they pass through
136
+ # untouched unless they are genuinely oversized.
137
+ if mime == 'image/gif':
138
+ try:
139
+ import io as _io
140
+ from PIL import Image
141
+ with Image.open(_io.BytesIO(data)) as probe:
142
+ if getattr(probe, 'is_animated', False):
143
+ return data, mime
144
+ except Exception:
145
+ return data, mime
146
+ try:
147
+ import io as _io
148
+ from PIL import Image
149
+ except ImportError:
150
+ return data, mime
151
+ try:
152
+ im = Image.open(_io.BytesIO(data))
153
+ # Chat photos are routinely 4000x3000 or larger. draft() lets the JPEG
154
+ # decoder work at a reduced scale directly instead of decoding the full
155
+ # image and then throwing most of it away - measured ~3x faster, which
156
+ # matters because an export shrinks several hundred of these.
157
+ try:
158
+ im.draft('RGB', (max_side, max_side))
159
+ except Exception:
160
+ pass
161
+ im.load()
162
+ w, h = im.size
163
+ if max(w, h) > max_side:
164
+ scale = max_side / max(w, h)
165
+ im = im.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.BILINEAR)
166
+ elif mime == 'image/jpeg':
167
+ return data, mime # already small enough and already compact
168
+ # wxgf decodes to PNG, and PNG at ~700px runs several times the size of
169
+ # the same photo as JPEG - so re-encode unless it would grow the file.
170
+ if im.mode in ('RGBA', 'LA', 'P'):
171
+ # Stickers carry transparency; flatten onto white rather than
172
+ # letting the conversion produce a black background.
173
+ im = im.convert('RGBA')
174
+ bg = Image.new('RGB', im.size, (255, 255, 255))
175
+ bg.paste(im, mask=im.split()[-1])
176
+ im = bg
177
+ else:
178
+ im = im.convert('RGB')
179
+ buf = _io.BytesIO()
180
+ im.save(buf, 'JPEG', quality=quality, optimize=True)
181
+ out = buf.getvalue()
182
+ return (out, 'image/jpeg') if 0 < len(out) < len(data) else (data, mime)
183
+ except Exception:
184
+ return data, mime
185
+
186
+
187
+ def load_image(account_root, talker, file_id, key, seed, create_time, decode_cache_dir=''):
188
+ """(image bytes, mime) for a chat image, or (b'', '').
189
+
190
+ Prefers the full image over the thumbnail: WeChat only caches 120-210px
191
+ thumbnails, and those look blocky at the size the reader renders. The
192
+ result is downscaled to keep the export a sane size.
193
+ """
194
+ if not file_id or not key:
195
+ return b'', ''
196
+ if decode_cache_dir:
197
+ cached = os.path.join(decode_cache_dir, file_id)
198
+ if os.path.isfile(cached):
199
+ try:
200
+ with open(cached, 'rb') as fh:
201
+ data = fh.read()
202
+ mime = sniff(data)
203
+ if mime:
204
+ return data, mime
205
+ except OSError:
206
+ pass
207
+
208
+ month = ''
209
+ if create_time:
210
+ import datetime
211
+ month = datetime.datetime.fromtimestamp(create_time).strftime('%Y-%m')
212
+ conv_dir = os.path.join(account_root, 'msg', 'attach',
213
+ hashlib.md5(talker.encode()).hexdigest())
214
+
215
+ # The message's own month first, then the rest newest-first: the month a
216
+ # file lives in is derived from its create_time, but older data sometimes
217
+ # sits in a neighbouring bucket.
218
+ search_dirs = []
219
+ if os.path.isdir(conv_dir):
220
+ if month:
221
+ search_dirs.append(os.path.join(conv_dir, month, 'Img'))
222
+ for m in sorted(os.listdir(conv_dir), reverse=True):
223
+ d = os.path.join(conv_dir, m, 'Img')
224
+ if d not in search_dirs:
225
+ search_dirs.append(d)
226
+
227
+ # Full image first - the thumbnail is only 120-210px and looks blocky.
228
+ candidates = []
229
+ for d in search_dirs:
230
+ candidates.append(os.path.join(d, f'{file_id}.dat'))
231
+ candidates.append(os.path.join(d, f'{file_id}_t.dat'))
232
+
233
+ for path in candidates:
234
+ if not os.path.isfile(path):
235
+ continue
236
+ try:
237
+ with open(path, 'rb') as fh:
238
+ raw = fh.read()
239
+ except OSError:
240
+ continue
241
+ data = decrypt(raw, key, seed)
242
+ mime = sniff(data)
243
+ if mime == 'image/hevc':
244
+ from wechat_emoticon import decode_wxgf
245
+ data = decode_wxgf(data)
246
+ mime = 'image/png' if data else ''
247
+ if not mime:
248
+ continue
249
+ data, mime = shrink(data, mime)
250
+ if decode_cache_dir:
251
+ try:
252
+ os.makedirs(decode_cache_dir, exist_ok=True)
253
+ with open(os.path.join(decode_cache_dir, file_id), 'wb') as fh:
254
+ fh.write(data)
255
+ except OSError:
256
+ pass
257
+ return data, mime
258
+ return b'', ''
@@ -0,0 +1,327 @@
1
+ #!/usr/bin/env python3
2
+ """Decode WeChat voice messages and transcribe them locally.
3
+
4
+ WeChat 4.x keeps voice payloads in `media_*.db` under `VoiceInfo.voice_data`,
5
+ encoded as SILK v3 (a one-byte length prefix, then `#!SILK_V3`). Browsers
6
+ cannot play SILK and ffmpeg has no SILK decoder, so a chat export has nothing
7
+ to show for a voice message; the audio is decoded with `pilk` and transcribed
8
+ with a local `faster-whisper` model instead.
9
+
10
+ Recognition is expensive next to everything else an export does, so it is
11
+ deliberately kept out of the export path: this module fills a
12
+ content-addressed cache, and the exporter only reads it. That makes the pass
13
+ resumable, re-runnable, and free to repeat once it has been paid for once.
14
+
15
+ The default model is a Cantonese fine-tune, chosen because stock Whisper
16
+ answers Cantonese with fluent-looking Mandarin nonsense - worse than nothing,
17
+ since it reads as a real sentence while saying something that was never said.
18
+ See OPERATIONS.md for how to fetch it.
19
+
20
+ Nothing here touches the network: the model is loaded from the local Hugging
21
+ Face cache and no audio or text leaves the machine.
22
+ """
23
+ import hashlib
24
+ import json
25
+ import os
26
+ import sys
27
+
28
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
+
30
+ # Appended to the transcript cache for a clip the recogniser returned nothing
31
+ # for, so a re-run does not pay for it twice.
32
+ EMPTY_MARKER = '\x00empty'
33
+
34
+ DEFAULT_MODEL = 'small'
35
+
36
+ # A Cantonese-tuned model, fetched separately (see the README note in
37
+ # OPERATIONS.md). Worth preferring automatically when present: stock Whisper
38
+ # answers Cantonese speech with fluent-looking Mandarin nonsense - text that
39
+ # reads as a real sentence but says nothing that was said. A Cantonese
40
+ # fine-tune transcribes the same audio into actual Cantonese.
41
+ CANTONESE_MODEL_DIR = os.path.join(
42
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
43
+ 'models', 'whisper-small-cantonese', 'cts')
44
+
45
+ # Detected rather than forced. Forcing `yue` on the Cantonese model returned
46
+ # empty output for every clip tried; letting it detect produced Cantonese.
47
+ DEFAULT_LANGUAGE = None
48
+
49
+
50
+ def voice_shards(db_path):
51
+ """`media_*.db` files that sit beside the configured message database."""
52
+ db = os.path.abspath(db_path)
53
+ folder = os.path.dirname(db)
54
+ if not os.path.isdir(folder):
55
+ return []
56
+ return sorted(
57
+ os.path.join(folder, name)
58
+ for name in os.listdir(folder)
59
+ if name.lower().startswith('media_') and name.lower().endswith('.db')
60
+ )
61
+
62
+
63
+ def _open(path, key_hex, salt_hex, passphrase=''):
64
+ """Connect to a shard, deriving its key the same way message shards do."""
65
+ from export_chat_html import derive_database_key, connect
66
+ shard_key, shard_salt = derive_database_key(path, key_hex, salt_hex, passphrase)
67
+ conn, cursor = connect(path, shard_key, shard_salt)
68
+ return conn, cursor
69
+
70
+
71
+ def load_voice_map(db_path, key_hex, salt_hex, passphrase='', talker=''):
72
+ """{(talker, local_id): voice bytes} for one conversation.
73
+
74
+ Scoped to a talker because a whole account is only ever needed one
75
+ conversation at a time, and the payloads are held in memory.
76
+ """
77
+ voices = {}
78
+ if not talker:
79
+ return voices
80
+ for shard in voice_shards(db_path):
81
+ conn = None
82
+ try:
83
+ conn, cursor = _open(shard, key_hex, salt_hex, passphrase)
84
+ cursor.execute('SELECT rowid, user_name FROM Name2Id')
85
+ names = {row[0]: row[1] for row in cursor.fetchall()}
86
+ cursor.execute(
87
+ 'SELECT chat_name_id, local_id, voice_data FROM VoiceInfo '
88
+ 'WHERE voice_data IS NOT NULL'
89
+ )
90
+ for chat_id, local_id, blob in cursor.fetchall():
91
+ if names.get(chat_id) != talker or not blob:
92
+ continue
93
+ voices[local_id] = bytes(blob)
94
+ except Exception:
95
+ continue
96
+ finally:
97
+ if conn is not None:
98
+ conn.close()
99
+ return voices
100
+
101
+
102
+ def voice_key(blob):
103
+ """Cache key for a voice payload: its content digest."""
104
+ return hashlib.md5(blob).hexdigest()
105
+
106
+
107
+ def decode_silk(blob, wav_path):
108
+ """Write `blob` to `wav_path` as 16kHz mono WAV. True on success.
109
+
110
+ pilk takes paths rather than bytes, and the 16kHz rate is what Whisper
111
+ wants, so no resampling step is needed afterwards.
112
+ """
113
+ try:
114
+ import pilk
115
+ except ImportError:
116
+ return False
117
+ silk_path = wav_path + '.silk'
118
+ try:
119
+ with open(silk_path, 'wb') as handle:
120
+ handle.write(blob)
121
+ pilk.silk_to_wav(silk_path, wav_path, rate=16000)
122
+ return os.path.isfile(wav_path) and os.path.getsize(wav_path) > 44
123
+ except Exception:
124
+ return False
125
+ finally:
126
+ try:
127
+ os.remove(silk_path)
128
+ except OSError:
129
+ pass
130
+
131
+
132
+ def resolve_model(model_name=''):
133
+ """Model to load: the caller's choice, else a local Cantonese fine-tune,
134
+ else the stock model name."""
135
+ if model_name:
136
+ return model_name
137
+ if os.path.isdir(CANTONESE_MODEL_DIR):
138
+ return CANTONESE_MODEL_DIR
139
+ return DEFAULT_MODEL
140
+
141
+
142
+ def _prepare_cuda_dlls():
143
+ """Put the pip-provided CUDA libraries on PATH.
144
+
145
+ ctranslate2 resolves cuBLAS/cuDNN through PATH on Windows and ignores
146
+ `os.add_dll_directory`, so the `nvidia-*` wheel directories have to be
147
+ prepended before it is imported. Without this, a `cuda` request fails with
148
+ "Library cublas64_12.dll is not found" even when the wheels are installed.
149
+ """
150
+ try:
151
+ import glob
152
+ import sysconfig
153
+ except ImportError:
154
+ return
155
+ if os.name != 'nt':
156
+ return
157
+ site = sysconfig.get_paths().get('purelib', '')
158
+ if not site:
159
+ return
160
+ dirs = glob.glob(os.path.join(site, 'nvidia', '*', 'bin'))
161
+ if dirs:
162
+ os.environ['PATH'] = os.pathsep.join(dirs) + os.pathsep + os.environ.get('PATH', '')
163
+
164
+
165
+ def load_model(model_name='', device='auto'):
166
+ """A Whisper model, or None when faster-whisper is unavailable.
167
+
168
+ `auto` prefers the GPU: on the tested hardware a clip goes from 2.0s to
169
+ 0.07s, which is the difference between an overnight pass and a coffee
170
+ break. Falls back to CPU whenever CUDA is not usable, so a machine without
171
+ the GPU libraries still works, just slower.
172
+ """
173
+ try:
174
+ from faster_whisper import WhisperModel
175
+ except ImportError:
176
+ return None
177
+ order = ('cuda', 'cpu') if device in ('auto', 'cuda') else ('cpu',)
178
+ for candidate in order:
179
+ if candidate == 'cuda':
180
+ _prepare_cuda_dlls()
181
+ try:
182
+ compute = 'float16' if candidate == 'cuda' else 'int8'
183
+ return WhisperModel(resolve_model(model_name), device=candidate, compute_type=compute)
184
+ except Exception:
185
+ continue
186
+ return None
187
+
188
+
189
+ class TranscriptCache:
190
+ """Content-addressed transcripts on disk.
191
+
192
+ Keyed by the voice payload's digest rather than by message id: the same
193
+ clip forwarded between chats is recognised once, and a re-export after the
194
+ message ids shift still hits.
195
+ """
196
+
197
+ def __init__(self, cache_dir):
198
+ self.dir = cache_dir or ''
199
+
200
+ def path(self, key):
201
+ return os.path.join(self.dir, key) if self.dir else ''
202
+
203
+ def get(self, key):
204
+ """Transcript, or None when this clip has not been processed yet."""
205
+ path = self.path(key)
206
+ if not path or not os.path.isfile(path):
207
+ return None
208
+ try:
209
+ with open(path, 'r', encoding='utf-8') as handle:
210
+ text = handle.read()
211
+ except OSError:
212
+ return None
213
+ return '' if text == EMPTY_MARKER else text
214
+
215
+ def put(self, key, text):
216
+ path = self.path(key)
217
+ if not path:
218
+ return
219
+ try:
220
+ os.makedirs(self.dir, exist_ok=True)
221
+ with open(path, 'w', encoding='utf-8') as handle:
222
+ handle.write(text if text else EMPTY_MARKER)
223
+ except OSError:
224
+ pass
225
+
226
+
227
+ def transcribe_wav(wav_path, model, beam_size=1, language=DEFAULT_LANGUAGE):
228
+ """Transcript for a WAV file, or '' when nothing was recognised."""
229
+ try:
230
+ segments, _ = model.transcribe(wav_path, language=language, beam_size=beam_size)
231
+ return ''.join(segment.text for segment in segments).strip()
232
+ except Exception:
233
+ return ''
234
+
235
+
236
+ def ensure_transcripts(voices, cache_dir, model_name='',
237
+ beam_size=1, limit=0, language=DEFAULT_LANGUAGE,
238
+ device='auto', log=print):
239
+ """Fill the transcript cache for any clip that is missing one.
240
+
241
+ Returns (processed, cached, failed). Pending work is measured by cache
242
+ misses, so an interrupted run resumes where it stopped instead of starting
243
+ over - which matters, because a long conversation is hours of audio.
244
+ """
245
+ cache = TranscriptCache(cache_dir)
246
+ pending = []
247
+ for local_id, blob in voices.items():
248
+ if not blob:
249
+ continue
250
+ key = voice_key(blob)
251
+ if cache.get(key) is None:
252
+ pending.append((key, blob))
253
+
254
+ # Count the cache hits before --limit trims the queue, otherwise a limited
255
+ # run reports everything it did not process as already done.
256
+ already = len(voices) - len(pending)
257
+ if limit and limit > 0:
258
+ pending = pending[:limit]
259
+ if not pending:
260
+ log(f'所有 {len(voices)} 条语音都已有转写(缓存命中)')
261
+ return 0, already, 0
262
+
263
+ resolved = resolve_model(model_name)
264
+ log(f'待转写 {len(pending)} 条(已有 {already} 条)')
265
+ log(f'模型: {resolved}')
266
+ model = load_model(model_name, device=device)
267
+ if model is None:
268
+ log('无法加载 faster-whisper 模型,转写跳过')
269
+ return 0, already, len(pending)
270
+
271
+ import tempfile
272
+ processed = failed = 0
273
+ scratch = tempfile.mkdtemp(prefix='weflow-voice-')
274
+ try:
275
+ for index, (key, blob) in enumerate(pending, 1):
276
+ wav_path = os.path.join(scratch, f'{index}.wav')
277
+ if not decode_silk(blob, wav_path):
278
+ cache.put(key, '')
279
+ failed += 1
280
+ else:
281
+ text = transcribe_wav(wav_path, model, beam_size=beam_size, language=language)
282
+ cache.put(key, text)
283
+ processed += 1
284
+ try:
285
+ os.remove(wav_path)
286
+ except OSError:
287
+ pass
288
+ if index % 10 == 0 or index == len(pending):
289
+ log(f' 转写 {index}/{len(pending)}(新增 {processed},失败 {failed})')
290
+ finally:
291
+ try:
292
+ import shutil
293
+ shutil.rmtree(scratch, ignore_errors=True)
294
+ except Exception:
295
+ pass
296
+ return processed, already, failed
297
+
298
+
299
+ def _main():
300
+ import argparse
301
+ parser = argparse.ArgumentParser(description='微信语音转写(本地 Whisper)')
302
+ parser.add_argument('--db', required=True, help='message_0.db 路径,用于定位同目录的 media_*.db')
303
+ parser.add_argument('--key', required=True, help='NT key hex')
304
+ parser.add_argument('--salt', required=True, help='NT salt hex')
305
+ parser.add_argument('--passphrase', default='', help='分片密钥口令')
306
+ parser.add_argument('--talker', required=True, help='会话 id')
307
+ parser.add_argument('--cache-dir', required=True, help='转写缓存目录')
308
+ parser.add_argument('--model', default='', help='模型名或本地目录(默认优先用本地粤语模型)')
309
+ parser.add_argument('--language', default='', help='语言代码;留空为自动检测(粤语模型必须留空)')
310
+ parser.add_argument('--device', default='auto', choices=['auto', 'cuda', 'cpu'], help='推理设备')
311
+ parser.add_argument('--beam-size', type=int, default=1)
312
+ parser.add_argument('--limit', type=int, default=0, help='本次最多转写多少条(0 为不限)')
313
+ args = parser.parse_args()
314
+
315
+ voices = load_voice_map(args.db, args.key, args.salt, args.passphrase, args.talker)
316
+ print(f'会话 {args.talker}: 找到 {len(voices)} 条语音', flush=True)
317
+ processed, already, failed = ensure_transcripts(
318
+ voices, args.cache_dir, model_name=args.model,
319
+ beam_size=args.beam_size, limit=args.limit,
320
+ language=args.language or None, device=args.device)
321
+ print(json.dumps({'success': True, 'voices': len(voices),
322
+ 'transcribed': processed, 'cached': already, 'failed': failed},
323
+ ensure_ascii=False))
324
+
325
+
326
+ if __name__ == '__main__':
327
+ _main()