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,132 @@
1
+ #!/usr/bin/env python3
2
+ """Render WeChat face codes like [害羞] for HTML export.
3
+
4
+ WeChat stores faces in message text as bracketed names. Two renderers:
5
+
6
+ * Faces we ship the original artwork for (resources/emoji/*.png, MIT, from the
7
+ `wechat-emojis` package) become a <span> whose CSS class carries the image.
8
+ The image is base64-embedded once per page, not once per occurrence.
9
+ * Everything else falls back to the closest standard emoji. WeChat's faces are
10
+ its own artwork, so these are approximations - only used where we have no
11
+ original.
12
+
13
+ Unrecognised codes (including the placeholders this project generates itself,
14
+ such as [图片]) are left untouched.
15
+ """
16
+ import base64
17
+ import hashlib
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+
22
+ _HERE = Path(__file__).resolve().parent
23
+ _DATA_FILE = _HERE / 'data' / 'wechat_emoji.json'
24
+ EMOJI_DIR = _HERE.parent / 'resources' / 'wechat-emoji'
25
+
26
+ # Keys starting with "_" are documentation, not faces.
27
+ _FACES = {}
28
+ try:
29
+ with open(_DATA_FILE, encoding='utf-8') as fh:
30
+ _FACES = {k: v for k, v in json.load(fh).items() if not k.startswith('_')}
31
+ except (OSError, ValueError):
32
+ _FACES = {}
33
+
34
+
35
+ def _load_image_faces():
36
+ """{face name: png path} for every face we have original artwork for."""
37
+ if not EMOJI_DIR.is_dir():
38
+ return {}
39
+ return {p.stem: p for p in EMOJI_DIR.glob('*.png')}
40
+
41
+
42
+ IMAGE_FACES = _load_image_faces()
43
+ ALL_NAMES = set(_FACES) | set(IMAGE_FACES)
44
+
45
+ # Longest name first so [左哼哼] can never be shadowed by a shorter prefix.
46
+ _ALL_RE = None
47
+ if ALL_NAMES:
48
+ _alternation = '|'.join(re.escape(n) for n in sorted(ALL_NAMES, key=len, reverse=True))
49
+ _ALL_RE = re.compile(r'\[(' + _alternation + r')\]')
50
+
51
+
52
+ def face_class(name):
53
+ """Stable CSS class for a face with original artwork."""
54
+ return 'wxf-' + hashlib.md5(name.encode()).hexdigest()[:10]
55
+
56
+
57
+ def convert_faces(text):
58
+ """Replace known [表情] codes with emoji. Unknown codes pass through."""
59
+ if not text or _ALL_RE is None:
60
+ return text
61
+ return _ALL_RE.sub(lambda m: _FACES.get(m.group(1), m.group(0)), text)
62
+
63
+
64
+ def render_faces(text):
65
+ """Like convert_faces, but faces with original artwork become a <span>.
66
+
67
+ The returned string is HTML and must NOT be escaped again.
68
+ """
69
+ if not text or _ALL_RE is None:
70
+ return text
71
+
72
+ def repl(m):
73
+ name = m.group(1)
74
+ if name in IMAGE_FACES:
75
+ return f'<span class="wxface {face_class(name)}"></span>'
76
+ return _FACES.get(name, m.group(0))
77
+
78
+ return _ALL_RE.sub(repl, text)
79
+
80
+
81
+ def find_face(text):
82
+ """First WeChat face code in `text` as "[名字]", or None.
83
+
84
+ A single regex scan; callers used to loop the whole table doing
85
+ `label in text`, which got expensive once the table grew to ~120.
86
+ """
87
+ if not text or _ALL_RE is None:
88
+ return None
89
+ m = _ALL_RE.search(text)
90
+ return f"[{m.group(1)}]" if m else None
91
+
92
+
93
+ def has_face(text):
94
+ """True if `text` contains any known face code."""
95
+ if not text or _ALL_RE is None:
96
+ return False
97
+ return _ALL_RE.search(text) is not None
98
+
99
+
100
+ def faces_used_in(html):
101
+ """CSS classes referenced by an HTML fragment."""
102
+ return set(re.findall(r'wxface (wxf-[0-9a-f]{10})', html))
103
+
104
+
105
+ def face_css(html, indent=' '):
106
+ """CSS defining just the faces this page actually uses.
107
+
108
+ Embedding only what a page needs keeps paginated exports small - a full
109
+ sprite would add ~1MB to every page.
110
+ """
111
+ used = faces_used_in(html)
112
+ if not used:
113
+ return ''
114
+ rules = []
115
+ for name, path in sorted(IMAGE_FACES.items()):
116
+ cls = face_class(name)
117
+ if cls not in used:
118
+ continue
119
+ try:
120
+ b64 = base64.b64encode(path.read_bytes()).decode()
121
+ except OSError:
122
+ continue
123
+ rules.append(f'.{cls} {{ background-image: url(data:image/png;base64,{b64}); }}')
124
+ if not rules:
125
+ return ''
126
+ return indent + ('\n' + indent).join(rules)
127
+
128
+
129
+ if __name__ == '__main__':
130
+ import sys
131
+ for line in sys.stdin:
132
+ sys.stdout.write(convert_faces(line))
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env python3
2
+ """Decrypt WeChat custom stickers (表情包) from the local cache.
3
+
4
+ WeChat 4.x keeps stickers in `cache/YYYY-MM/Emoticon/<md5[:2]>/<md5>` and
5
+ `business/emoticon/{Persist,Thumb,...}`. Each file is AES-128-CBC with the key
6
+ also used as the IV, where
7
+
8
+ key = md5(f"{seed}{wxid}EMOTICON")[:16]
9
+
10
+ `seed` is a per-account constant held in WeChat's process memory (see
11
+ find_seed). Once known it is stable, so it only has to be scanned for once.
12
+
13
+ Most cached stickers are WeChat's own `wxgf` container - a small header
14
+ followed by a raw H.265 stream - which needs ffmpeg to turn into an image.
15
+ Decoded results are cached on disk because that is the expensive step.
16
+
17
+ Reference: CN-Grace/Wechat-Emoticon-Parser (v4.0-plus branch).
18
+ """
19
+ import glob
20
+ import hashlib
21
+ import os
22
+ import subprocess
23
+ import tempfile
24
+
25
+ try:
26
+ from cryptography.hazmat.primitives import padding
27
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
28
+ _HAVE_CRYPTO = True
29
+ except ImportError:
30
+ _HAVE_CRYPTO = False
31
+
32
+ try:
33
+ import zstandard
34
+ _HAVE_ZSTD = True
35
+ except ImportError:
36
+ _HAVE_ZSTD = False
37
+
38
+ IMAGE_MAGICS = (
39
+ (b'\x89PNG', 'image/png', '.png'),
40
+ (b'\xff\xd8\xff', 'image/jpeg', '.jpg'),
41
+ (b'GIF8', 'image/gif', '.gif'),
42
+ (b'RIFF', 'image/webp', '.webp'),
43
+ (b'wxgf', 'image/hevc', '.hevc'),
44
+ )
45
+
46
+
47
+ def derive_key(seed, wxid):
48
+ """Cache key for an account. Returns 16 bytes."""
49
+ return hashlib.md5(f'{seed}{wxid}EMOTICON'.encode()).digest()[:16]
50
+
51
+
52
+ def account_wxid(account_dir_name):
53
+ """`wxid_mgnjdl8034eh22_4c8e` -> `wxid_mgnjdl8034eh22`."""
54
+ parts = account_dir_name.rsplit('_', 1)
55
+ return parts[0] if len(parts) == 2 and len(parts[1]) == 4 else account_dir_name
56
+
57
+
58
+ def decrypt(raw, key):
59
+ """AES-128-CBC(key=IV) with PKCS7. Returns plaintext (best effort)."""
60
+ if not _HAVE_CRYPTO or not raw or len(raw) < 32:
61
+ return b''
62
+ body = raw[:len(raw) // 16 * 16]
63
+ dec = Cipher(algorithms.AES(key), modes.CBC(key)).decryptor()
64
+ out = dec.update(body) + dec.finalize()
65
+ try:
66
+ unp = padding.PKCS7(128).unpadder()
67
+ return unp.update(out) + unp.finalize()
68
+ except Exception:
69
+ return out
70
+
71
+
72
+ def sniff(data):
73
+ """(mime, extension) for decrypted bytes, or (None, None)."""
74
+ for magic, mime, ext in IMAGE_MAGICS:
75
+ if data.startswith(magic):
76
+ return mime, ext
77
+ return None, None
78
+
79
+
80
+ def find_seed_candidates():
81
+ """Decimal strings scraped from WeChat's process memory (seed candidates)."""
82
+ try:
83
+ import ctypes
84
+ from ctypes import (byref, c_size_t, c_void_p, create_string_buffer,
85
+ sizeof, wintypes)
86
+ import pymem
87
+ import re as _re
88
+ except ImportError:
89
+ return []
90
+
91
+ class MBI(ctypes.Structure):
92
+ _fields_ = [
93
+ ('BaseAddress', ctypes.c_void_p), ('AllocationBase', ctypes.c_void_p),
94
+ ('AllocationProtect', wintypes.DWORD), ('PartitionId', wintypes.WORD),
95
+ ('RegionSize', ctypes.c_size_t), ('State', wintypes.DWORD),
96
+ ('Protect', wintypes.DWORD), ('Type', wintypes.DWORD),
97
+ ]
98
+
99
+ k32 = ctypes.windll.kernel32
100
+ rpm = k32.ReadProcessMemory
101
+ rpm.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, wintypes.LPVOID,
102
+ ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
103
+ vqe = k32.VirtualQueryEx
104
+ vqe.argtypes = [wintypes.HANDLE, wintypes.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
105
+ vqe.restype = ctypes.c_size_t
106
+
107
+ pid = None
108
+ for p in pymem.process.list_processes():
109
+ try:
110
+ name = p.szExeFile
111
+ if isinstance(name, bytes):
112
+ name = name.decode('utf-8', 'ignore')
113
+ if name.lower() == 'weixin.exe':
114
+ pid = p.th32ProcessID
115
+ break
116
+ except Exception:
117
+ pass
118
+ if not pid:
119
+ return []
120
+
121
+ h = k32.OpenProcess(0x0010 | 0x0400, False, pid)
122
+ if not h:
123
+ return []
124
+
125
+ pat = _re.compile(rb'\d{5,20}')
126
+ seen = set()
127
+ addr = 0x10000
128
+ try:
129
+ while addr < 0x7FFFFFFFFFFF:
130
+ mbi = MBI()
131
+ if vqe(h, c_void_p(addr), byref(mbi), sizeof(mbi)) == 0:
132
+ break
133
+ ba, rs = mbi.BaseAddress or 0, mbi.RegionSize or 0
134
+ if rs == 0:
135
+ break
136
+ if (mbi.State == 0x1000 and 256 < rs < 200 * 1024 * 1024
137
+ and mbi.Protect not in (0, 0x01, 0x100)):
138
+ pos, end = ba, ba + rs
139
+ while pos < end:
140
+ n = min(65536, end - pos)
141
+ buf = create_string_buffer(n)
142
+ br = c_size_t(0)
143
+ if rpm(h, c_void_p(pos), buf, n, byref(br)) and br.value:
144
+ for m in pat.finditer(buf.raw[:br.value]):
145
+ s = m.group(0).decode()
146
+ if s not in seen:
147
+ seen.add(s)
148
+ yield s
149
+ pos += 65536
150
+ addr = ba + rs
151
+ finally:
152
+ k32.CloseHandle(h)
153
+
154
+
155
+ def find_seed(wxid, sample_file, candidates=None):
156
+ """The seed that decrypts `sample_file`, or None.
157
+
158
+ Proven per candidate by decrypting the sample and looking for a known
159
+ image magic - so a false positive is essentially impossible.
160
+ """
161
+ raw = open(sample_file, 'rb').read(64) if sample_file else b''
162
+ if not raw:
163
+ return None
164
+ for seed in (candidates or find_seed_candidates()):
165
+ if sniff(decrypt(raw, derive_key(seed, wxid)))[0]:
166
+ return seed
167
+ return None
168
+
169
+
170
+ def _ffmpeg_exe():
171
+ try:
172
+ import imageio_ffmpeg
173
+ return imageio_ffmpeg.get_ffmpeg_exe()
174
+ except Exception:
175
+ return None
176
+
177
+
178
+ _FFMPEG = None
179
+ _FFMPEG_CHECKED = False
180
+
181
+
182
+ WXGF_MAX_FRAMES = 4
183
+
184
+ # Below this share of non-dominant pixels a frame is treated as blank rather
185
+ # than as artwork. Line drawings on white still score well above it; the blank
186
+ # transition frames and truncated payloads sit at essentially 0.
187
+ BLANK_FRAME_SCORE = 0.005
188
+
189
+ # Animation output. The page shows a sticker at ~240px, and uncapped frame
190
+ # rates on a 50-frame clip produce a multi-megabyte GIF.
191
+ GIF_MAX_SIDE = 240
192
+ GIF_FPS = 10
193
+
194
+
195
+ def _frame_score(path):
196
+ """How much artwork a decoded frame carries, in [0, 1].
197
+
198
+ The share of pixels that are not the frame's dominant colour. Sticker
199
+ streams routinely open with a blank transition frame, which scores ~0.01
200
+ while the drawn frame scores several times that.
201
+ """
202
+ try:
203
+ from PIL import Image
204
+ from collections import Counter
205
+ except ImportError:
206
+ return None
207
+ try:
208
+ with Image.open(path) as image:
209
+ pixels = list(image.convert('RGB').getdata())
210
+ if not pixels:
211
+ return 0.0
212
+ dominant = Counter(pixels).most_common(1)[0][1]
213
+ return 1.0 - dominant / len(pixels)
214
+ except Exception:
215
+ return None
216
+
217
+
218
+ def _encode_animated_gif(source, target):
219
+ """Animated GIF from an H.265 stream, scaled for a chat bubble.
220
+
221
+ The stream is a sticker animation, not a still. Frames are capped at
222
+ `GIF_FPS` and `GIF_MAX_SIDE`, and quantised through a generated palette -
223
+ without that a 50-frame clip runs to megabytes instead of a few hundred KB.
224
+ """
225
+ try:
226
+ subprocess.run(
227
+ [_FFMPEG, '-y', '-loglevel', 'error', '-f', 'hevc', '-i', source,
228
+ '-vf', (f'fps={GIF_FPS},scale={GIF_MAX_SIDE}:-1:flags=lanczos,'
229
+ 'split[a][b];[a]palettegen[p];[b][p]paletteuse'),
230
+ '-loop', '0', target],
231
+ capture_output=True, timeout=120,
232
+ )
233
+ return os.path.isfile(target) and os.path.getsize(target) > 0
234
+ except Exception:
235
+ return False
236
+
237
+
238
+ def decode_wxgf(data):
239
+ """A `wxgf` payload as image bytes (animated GIF or PNG), or b''.
240
+
241
+ The container is 'wxgf' + a small header, then a raw H.265 stream. Multi
242
+ frame streams are sticker animations and are re-encoded as an animated GIF;
243
+ taking a single frame from those - which is what this used to do - shows a
244
+ still of what the sender saw moving.
245
+
246
+ Single frame streams are handled by picking the frame with the most
247
+ artwork rather than frame 1: many begin with a blank transition frame, so
248
+ frame 1 yields a white square.
249
+ """
250
+ global _FFMPEG, _FFMPEG_CHECKED
251
+ if not _FFMPEG_CHECKED:
252
+ _FFMPEG = _ffmpeg_exe()
253
+ _FFMPEG_CHECKED = True
254
+ if not _FFMPEG:
255
+ return b''
256
+ tmp_in = None
257
+ tmp_dir = None
258
+ try:
259
+ with tempfile.NamedTemporaryFile(suffix='.hevc', delete=False) as fh:
260
+ fh.write(data[4:])
261
+ tmp_in = fh.name
262
+ tmp_dir = tempfile.mkdtemp(prefix='weflow-wxgf-')
263
+ # Probe the frame count cheaply: all frames, no scaling.
264
+ subprocess.run(
265
+ [_FFMPEG, '-y', '-loglevel', 'error', '-f', 'hevc', '-i', tmp_in,
266
+ '-vsync', '0', os.path.join(tmp_dir, 'f%04d.png')],
267
+ capture_output=True, timeout=120,
268
+ )
269
+ frames = sorted(glob.glob(os.path.join(tmp_dir, 'f*.png')))
270
+ if not frames:
271
+ return b''
272
+ if len(frames) > 1:
273
+ gif_path = os.path.join(tmp_dir, 'anim.gif')
274
+ if _encode_animated_gif(tmp_in, gif_path):
275
+ with open(gif_path, 'rb') as fh:
276
+ return fh.read()
277
+ # Fall through to a still frame if the animation could not be built.
278
+ best = frames[0]
279
+ best_score = None
280
+ for frame in frames[:WXGF_MAX_FRAMES]:
281
+ score = _frame_score(frame)
282
+ if score is None:
283
+ break
284
+ if best_score is None or score > best_score:
285
+ best, best_score = frame, score
286
+ # Every frame blank means the cached payload is not decodable (a
287
+ # truncated download, typically). Report failure so the caller tries
288
+ # another source rather than embedding a white square.
289
+ if best_score is not None and best_score < BLANK_FRAME_SCORE:
290
+ return b''
291
+ with open(best, 'rb') as fh:
292
+ return fh.read()
293
+ except Exception:
294
+ return b''
295
+ finally:
296
+ if tmp_in:
297
+ try:
298
+ os.remove(tmp_in)
299
+ except OSError:
300
+ pass
301
+ if tmp_dir:
302
+ import shutil
303
+ shutil.rmtree(tmp_dir, ignore_errors=True)
304
+
305
+
306
+ def load_sticker(cache_dirs, md5_hex, key, decode_cache_dir=''):
307
+ """(image bytes, mime) for a sticker by md5, or (b'', '').
308
+
309
+ Decoding wxgf needs ffmpeg and is slow, so results are cached under
310
+ `decode_cache_dir` keyed by md5.
311
+ """
312
+ if not md5_hex or not key:
313
+ return b'', ''
314
+ if decode_cache_dir:
315
+ cached = os.path.join(decode_cache_dir, md5_hex)
316
+ if os.path.isfile(cached):
317
+ try:
318
+ with open(cached, 'rb') as fh:
319
+ data = fh.read()
320
+ mime, _ = sniff(data)
321
+ if mime:
322
+ return data, mime
323
+ except OSError:
324
+ pass
325
+
326
+ src = ''
327
+ for base in cache_dirs:
328
+ candidate = os.path.join(base, md5_hex[:2], md5_hex)
329
+ if os.path.isfile(candidate):
330
+ src = candidate
331
+ break
332
+ if not src:
333
+ return b'', ''
334
+
335
+ try:
336
+ with open(src, 'rb') as fh:
337
+ raw = fh.read()
338
+ except OSError:
339
+ return b'', ''
340
+ data = decrypt(raw, key)
341
+ mime, _ = sniff(data)
342
+ if mime == 'image/hevc':
343
+ # Re-sniff rather than assuming PNG: an animated stream decodes to a
344
+ # GIF, and labelling that `image/png` sent it down the JPEG path
345
+ # downstream, flattening the animation this was meant to preserve.
346
+ data = decode_wxgf(data)
347
+ mime = sniff(data)[0] or ''
348
+ if not mime:
349
+ return b'', ''
350
+
351
+ # Sticker art is often 512-1024px; the reader renders it at ~240px, so
352
+ # embedding it untouched is pure weight.
353
+ try:
354
+ from wechat_image import shrink
355
+ data, mime = shrink(data, mime, max_side=360)
356
+ except Exception:
357
+ pass
358
+
359
+ if decode_cache_dir and data:
360
+ try:
361
+ os.makedirs(decode_cache_dir, exist_ok=True)
362
+ with open(os.path.join(decode_cache_dir, md5_hex), 'wb') as fh:
363
+ fh.write(data)
364
+ except OSError:
365
+ pass
366
+ return data, mime
367
+
368
+
369
+ def sticker_cache_dirs(account_root):
370
+ """Where WeChat stores cached stickers for this account."""
371
+ dirs = []
372
+ cache_root = os.path.join(account_root, 'cache')
373
+ if os.path.isdir(cache_root):
374
+ for month in sorted(os.listdir(cache_root)):
375
+ d = os.path.join(cache_root, month, 'Emoticon')
376
+ if os.path.isdir(d):
377
+ dirs.append(d)
378
+ for sub in ('Persist', 'Thumb', 'Temp'):
379
+ d = os.path.join(account_root, 'business', 'emoticon', sub)
380
+ if os.path.isdir(d):
381
+ dirs.append(d)
382
+ return dirs
383
+
384
+
385
+ def any_sticker_file(dirs):
386
+ """Some cached sticker, used as the verification sample."""
387
+ for base in dirs:
388
+ for sub in sorted(os.listdir(base)) if os.path.isdir(base) else []:
389
+ path = os.path.join(base, sub)
390
+ if os.path.isdir(path):
391
+ for name in sorted(os.listdir(path)):
392
+ f = os.path.join(path, name)
393
+ if os.path.isfile(f) and os.path.getsize(f) > 32:
394
+ return f
395
+ return ''
396
+
397
+
398
+ def _main():
399
+ import argparse
400
+ ap = argparse.ArgumentParser(description='WeChat sticker cache helper')
401
+ ap.add_argument('--find-seed', metavar='ACCOUNT_ROOT',
402
+ help='Scan WeChat memory for the sticker seed of this account')
403
+ ap.add_argument('--json', action='store_true')
404
+ args = ap.parse_args()
405
+
406
+ if not args.find_seed:
407
+ ap.print_help()
408
+ return
409
+
410
+ account_root = args.find_seed
411
+ wxid = account_wxid(os.path.basename(os.path.normpath(account_root)))
412
+ dirs = sticker_cache_dirs(account_root)
413
+ sample = any_sticker_file(dirs)
414
+ if not sample:
415
+ print('未找到可用的表情缓存文件,请先在微信里查看几个表情包')
416
+ return
417
+
418
+ seed = find_seed(wxid, sample)
419
+ if not seed:
420
+ print('未找到 seed。请确认微信正在运行,且曾在微信中查看过表情包。')
421
+ return
422
+
423
+ import json as _json
424
+ if args.json:
425
+ print(_json.dumps({'success': True, 'seed': seed, 'wxid': wxid, 'sample': sample}))
426
+ else:
427
+ print(f'✓ seed = {seed}')
428
+ print(f' 账号: {wxid}')
429
+ print(f' 样本: {sample}')
430
+
431
+
432
+ if __name__ == '__main__':
433
+ _main()