@lf_maple/koishi-plugin-phi-plugin-full 0.1.80

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 (629) hide show
  1. package/LICENSE +22 -0
  2. package/core/.github/ISSUE_TEMPLATE/bug.yaml +133 -0
  3. package/core/.github/ISSUE_TEMPLATE/config.yml +1 -0
  4. package/core/.github/ISSUE_TEMPLATE/enhancement.yml +44 -0
  5. package/core/.vscode/c_cpp_properties.json +18 -0
  6. package/core/.vscode/launch.json +24 -0
  7. package/core/.vscode/settings.json +59 -0
  8. package/core/CHANGELOG.md +184 -0
  9. package/core/LICENSE +674 -0
  10. package/core/README.md +330 -0
  11. package/core/README_en.md +285 -0
  12. package/core/apps/Dan.js +129 -0
  13. package/core/apps/RankList.js +322 -0
  14. package/core/apps/aliasProposal.js +223 -0
  15. package/core/apps/apiSetting.js +592 -0
  16. package/core/apps/b19.js +1329 -0
  17. package/core/apps/botClient.js +64 -0
  18. package/core/apps/chart.js +692 -0
  19. package/core/apps/guessGame/guessIll.js +821 -0
  20. package/core/apps/guessGame/guessLetter.js +831 -0
  21. package/core/apps/guessGame/guessTips.js +254 -0
  22. package/core/apps/guessGame/letterGameUtils.js +74 -0
  23. package/core/apps/guessGame.js +214 -0
  24. package/core/apps/help.js +117 -0
  25. package/core/apps/manage.js +288 -0
  26. package/core/apps/market.js +243 -0
  27. package/core/apps/money.js +1064 -0
  28. package/core/apps/phisong.js +1418 -0
  29. package/core/apps/session.js +664 -0
  30. package/core/apps/setting.js +468 -0
  31. package/core/apps/update.js +541 -0
  32. package/core/apps/user.js +968 -0
  33. package/core/components/Config.js +249 -0
  34. package/core/components/Data.js +304 -0
  35. package/core/components/FileWatcherRegistry.js +102 -0
  36. package/core/components/Logger.js +11 -0
  37. package/core/components/ProcessCleanup.js +113 -0
  38. package/core/components/Version.js +56 -0
  39. package/core/components/YamlReader.js +157 -0
  40. package/core/components/baseClass.js +287 -0
  41. package/core/components/common.js +47 -0
  42. package/core/components/index.js +13 -0
  43. package/core/components/platform/index.js +79 -0
  44. package/core/components/platform/koishi.js +800 -0
  45. package/core/components/platform/koishiDatabaseRedis.js +391 -0
  46. package/core/components/platform/memoryRedis.js +209 -0
  47. package/core/components/platform/types.d.ts +272 -0
  48. package/core/components/platform/yunzai.js +604 -0
  49. package/core/components/segment.js +4 -0
  50. package/core/config/config//350/277/231/351/207/214/346/230/257/351/205/215/347/275/256/346/226/207/344/273/266/347/233/256/345/275/225.txt +0 -0
  51. package/core/config/default_config/banGroup.yaml +14 -0
  52. package/core/config/default_config/config.yaml +159 -0
  53. package/core/config/default_config/nickconfig.yaml +6 -0
  54. package/core/config/default_config/otherinfo.yaml +51 -0
  55. package/core/guoba.support.js +597 -0
  56. package/core/index.js +85 -0
  57. package/core/lib/AES.js +40 -0
  58. package/core/lib/Base64.js +22 -0
  59. package/core/lib/ByteReader.js +168 -0
  60. package/core/lib/GameProgress.js +37 -0
  61. package/core/lib/GameRecord.js +47 -0
  62. package/core/lib/GameSettings.js +24 -0
  63. package/core/lib/GameUser.js +19 -0
  64. package/core/lib/Level.js +6 -0
  65. package/core/lib/LevelRecord.js +9 -0
  66. package/core/lib/PhigrosUser.js +135 -0
  67. package/core/lib/SaveManager.js +117 -0
  68. package/core/lib/Summary.js +47 -0
  69. package/core/lib/TapTap/CompleteQRCodeData.js +27 -0
  70. package/core/lib/TapTap/LCHelper.js +81 -0
  71. package/core/lib/TapTap/TapTapHelper.js +170 -0
  72. package/core/lib/TapTap/getQRcode.js +61 -0
  73. package/core/lib/Util.js +30 -0
  74. package/core/lib/getQRcode.js +73 -0
  75. package/core/lib/http.js +105 -0
  76. package/core/model/README.md +3 -0
  77. package/core/model/api/aliasProposalService.js +151 -0
  78. package/core/model/api/apiVersion.js +67 -0
  79. package/core/model/api/autoSeekApi.js +165 -0
  80. package/core/model/api/botApiAuth.js +208 -0
  81. package/core/model/api/botSyncService.js +78 -0
  82. package/core/model/api/gameRecordPayload.js +24 -0
  83. package/core/model/api/makeRequest.js +1004 -0
  84. package/core/model/api/makeRequestFnc.js +17 -0
  85. package/core/model/api/phiApiClient.js +53 -0
  86. package/core/model/api/phiApiErrors.js +121 -0
  87. package/core/model/filesystem/getFile.js +256 -0
  88. package/core/model/filesystem/path.js +84 -0
  89. package/core/model/game/Chart.js +57 -0
  90. package/core/model/game/LevelRecordInfo.js +109 -0
  91. package/core/model/game/SongsInfo.js +46 -0
  92. package/core/model/game/b30Analysis.js +93 -0
  93. package/core/model/game/chartParser/Chart.js +23 -0
  94. package/core/model/game/chartParser/JudgeLine.js +38 -0
  95. package/core/model/game/chartParser/base/JudgeLineEvent.js +99 -0
  96. package/core/model/game/chartParser/base/Note.js +44 -0
  97. package/core/model/game/chartParser/base/SpeedEvent.js +25 -0
  98. package/core/model/game/chartParser/base/baseType.js +2 -0
  99. package/core/model/game/constNum.js +215 -0
  100. package/core/model/game/fCompute.js +711 -0
  101. package/core/model/game/getChartTag.js +140 -0
  102. package/core/model/game/getComment.js +132 -0
  103. package/core/model/game/getInfo.js +1069 -0
  104. package/core/model/game/getRksRank.js +77 -0
  105. package/core/model/game/markdown.js +77 -0
  106. package/core/model/integrations/Vika.js +103 -0
  107. package/core/model/integrations/getInfoFromTap.js +80 -0
  108. package/core/model/render/getPic.js +79 -0
  109. package/core/model/render/picmodle.js +496 -0
  110. package/core/model/render/progress-bar.js +44 -0
  111. package/core/model/render/puppeteer.js +431 -0
  112. package/core/model/render/renderPressureHistory.js +71 -0
  113. package/core/model/render/send.js +94 -0
  114. package/core/model/save/Save.js +953 -0
  115. package/core/model/save/analyzeSaveHistory.js +413 -0
  116. package/core/model/save/getBackup.js +328 -0
  117. package/core/model/save/getSave.js +90 -0
  118. package/core/model/save/getSaveFromApi.js +46 -0
  119. package/core/model/save/saveHistory.js +586 -0
  120. package/core/model/save/scoreHistory.js +101 -0
  121. package/core/model/theme/catalog.js +192 -0
  122. package/core/model/theme/installGuard.js +275 -0
  123. package/core/model/theme/installLock.js +174 -0
  124. package/core/model/theme/installer.js +398 -0
  125. package/core/model/theme/manager.js +408 -0
  126. package/core/model/theme/marketClient.js +294 -0
  127. package/core/model/theme/paths.js +42 -0
  128. package/core/model/theme/policy.js +54 -0
  129. package/core/model/theme/recovery.js +167 -0
  130. package/core/model/theme/useService.js +187 -0
  131. package/core/model/type/aliasProposal.d.ts +45 -0
  132. package/core/model/type/type.js +420 -0
  133. package/core/model/user/apiPermission.js +62 -0
  134. package/core/model/user/getBanGroup.js +120 -0
  135. package/core/model/user/getNotes.js +48 -0
  136. package/core/model/user/pluginData.js +101 -0
  137. package/core/model/user/userCredentialStore.js +154 -0
  138. package/core/model/user/userCredentials.js +860 -0
  139. package/core/package.json +60 -0
  140. package/core/resources/0608badSave/save.json +1 -0
  141. package/core/resources/LICENSE +201 -0
  142. package/core/resources/html/analyzeSaveHistory/analyzeSaveHistory.art +220 -0
  143. package/core/resources/html/analyzeSaveHistory/analyzeSaveHistory.css +345 -0
  144. package/core/resources/html/analyzeSaveHistory/analyzeSaveHistory.html +434 -0
  145. package/core/resources/html/arcgrosB19/arcgros.png +0 -0
  146. package/core/resources/html/arcgrosB19/arcgrosB19-orz.html +276 -0
  147. package/core/resources/html/arcgrosB19/arcgrosB19.art +168 -0
  148. package/core/resources/html/arcgrosB19/arcgrosB19.css +637 -0
  149. package/core/resources/html/atlas/atlas-orz.html +313 -0
  150. package/core/resources/html/atlas/atlas.art +194 -0
  151. package/core/resources/html/atlas/atlas.css +695 -0
  152. package/core/resources/html/avatar/-SURREALISM-.png +0 -0
  153. package/core/resources/html/avatar/Alkaid.png +0 -0
  154. package/core/resources/html/avatar/Aphasia.png +0 -0
  155. package/core/resources/html/avatar/Artificial Existence.png +0 -0
  156. package/core/resources/html/avatar/Bonus Time.png +0 -0
  157. package/core/resources/html/avatar/Burn.png +0 -0
  158. package/core/resources/html/avatar/Cereris1.png +0 -0
  159. package/core/resources/html/avatar/Cereris2.png +0 -0
  160. package/core/resources/html/avatar/Cereris3.png +0 -0
  161. package/core/resources/html/avatar/Christmas -2019.png +0 -0
  162. package/core/resources/html/avatar/Cipher1.png +0 -0
  163. package/core/resources/html/avatar/Class Memories.png +0 -0
  164. package/core/resources/html/avatar/Credits.png +0 -0
  165. package/core/resources/html/avatar/Dash.png +0 -0
  166. package/core/resources/html/avatar/Dlyrotz.png +0 -0
  167. package/core/resources/html/avatar/Doppelganger1.png +0 -0
  168. package/core/resources/html/avatar/Doppelganger2.png +0 -0
  169. package/core/resources/html/avatar/Drop it.png +0 -0
  170. package/core/resources/html/avatar/Energy Synergy Matrix1.png +0 -0
  171. package/core/resources/html/avatar/Energy Synergy Matrix2.png +0 -0
  172. package/core/resources/html/avatar/Energy Synergy Matrix3.png +0 -0
  173. package/core/resources/html/avatar/Engine x Start!! (melody mix).png +0 -0
  174. package/core/resources/html/avatar/Engine x Start!! extra 1 -by cycats.png +0 -0
  175. package/core/resources/html/avatar/Eradication Catastrophe.png +0 -0
  176. package/core/resources/html/avatar/FULi AUTO SHOOTER extra 1 -by cycats.png +0 -0
  177. package/core/resources/html/avatar/FULi AUTO SHOOTER.png +0 -0
  178. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/264-Special1.png +0 -0
  179. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/264-Special2.png +0 -0
  180. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2641.png +0 -0
  181. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2642.png +0 -0
  182. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2643.png +0 -0
  183. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2644.png +0 -0
  184. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2645.png +0 -0
  185. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2646.png +0 -0
  186. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2647.png +0 -0
  187. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2648.png +0 -0
  188. package/core/resources/html/avatar/Feast/350/277/234/344/270/234/344/271/213/345/256/2649.png +0 -0
  189. package/core/resources/html/avatar/Find_Me.png +0 -0
  190. package/core/resources/html/avatar/Geopelia 1 -by cycats.png +0 -0
  191. package/core/resources/html/avatar/Geopelia 2 -by cycats.png +0 -0
  192. package/core/resources/html/avatar/Geopelia 3 -by /344/275/225/345/221/265.png +0 -0
  193. package/core/resources/html/avatar/Geopelia in WEPLAY -by /346/265/267/344/272/247.png +0 -0
  194. package/core/resources/html/avatar/Gino-AprilFool.png +0 -0
  195. package/core/resources/html/avatar/Glaciaxion.png +0 -0
  196. package/core/resources/html/avatar/HumaN.png +0 -0
  197. package/core/resources/html/avatar/Indelible Scar.png +0 -0
  198. package/core/resources/html/avatar/Introduction.png +0 -0
  199. package/core/resources/html/avatar/Khronostasis Katharsis.png +0 -0
  200. package/core/resources/html/avatar/MARENOL.png +0 -0
  201. package/core/resources/html/avatar/Marenol extra 1 -by /345/226/265n/350/221/265.png +0 -0
  202. package/core/resources/html/avatar/Non-Melodic Ragez (MUG Edit).png +0 -0
  203. package/core/resources/html/avatar/Non-Melodic Ragez extra 1 -by cycats.png +0 -0
  204. package/core/resources/html/avatar/Oblivion +0 -0
  205. package/core/resources/html/avatar/OblivionPHIN.png +0 -0
  206. package/core/resources/html/avatar/Para.png +0 -0
  207. package/core/resources/html/avatar/Pixel Rebelz1.png +0 -0
  208. package/core/resources/html/avatar/Pixel Rebelz2.png +0 -0
  209. package/core/resources/html/avatar/Pixel Rebelz3.png +0 -0
  210. package/core/resources/html/avatar/RIPPER extra 1 -by cycats.png +0 -0
  211. package/core/resources/html/avatar/RIPPER extra 2 -by /344/275/225/345/221/265.png +0 -0
  212. package/core/resources/html/avatar/Sein.png +0 -0
  213. package/core/resources/html/avatar/ShineAfter-/346/260/264/351/235/222-Special.png +0 -0
  214. package/core/resources/html/avatar/ShineAfter-/346/260/264/351/235/222-Special2.png +0 -0
  215. package/core/resources/html/avatar/ShineAfter-/346/260/264/351/235/222-/346/231/256/351/200/232.png +0 -0
  216. package/core/resources/html/avatar/ShineAfter-/346/260/264/351/235/222-/346/233/262/347/273/230Ver.png +0 -0
  217. package/core/resources/html/avatar/ShineAfter-/351/270/240-Special.png +0 -0
  218. package/core/resources/html/avatar/ShineAfter-/351/270/240-/346/231/256/351/200/232.png +0 -0
  219. package/core/resources/html/avatar/ShineAfter-/351/270/240-/346/233/262/347/273/230Ver.png +0 -0
  220. package/core/resources/html/avatar/ShineAfter-/351/270/240/345/222/214/346/260/264/351/235/222-Special.png +0 -0
  221. package/core/resources/html/avatar/Snow Desert.png +0 -0
  222. package/core/resources/html/avatar/Sparkle New Life.png +0 -0
  223. package/core/resources/html/avatar/Sultan Rage.png +0 -0
  224. package/core/resources/html/avatar/Winter /342/206/221cube/342/206/223.png +0 -0
  225. package/core/resources/html/avatar/Xenolith.png +0 -0
  226. package/core/resources/html/avatar/[PRAW].png +0 -0
  227. package/core/resources/html/avatar/dB doll.png +0 -0
  228. package/core/resources/html/avatar/ripper.png +0 -0
  229. package/core/resources/html/avatar//343/202/202/343/201/272/343/202/202/343/201/2721.png +0 -0
  230. package/core/resources/html/avatar//343/202/202/343/201/272/343/202/202/343/201/2722.png +0 -0
  231. package/core/resources/html/avatar//344/270/207/345/220/250/345/214/277/345/220/215/344/277/241.png +0 -0
  232. package/core/resources/html/avatar//344/272/221/345/245/263/345/255/251 extra 1 -by /345/276/241/345/235/202/346/236/234/345/255/220.png +0 -0
  233. package/core/resources/html/avatar//344/272/221/345/245/263/345/255/251.png +0 -0
  234. package/core/resources/html/avatar//345/205/211.png +0 -0
  235. package/core/resources/html/avatar//345/274/200/345/277/203/347/227/2051.png +0 -0
  236. package/core/resources/html/avatar//345/274/200/345/277/203/347/227/2052.png +0 -0
  237. package/core/resources/html/avatar//346/260/264/351/235/222-/344/270/200/345/221/250/345/271/264/347/233/270/350/201/232.png +0 -0
  238. package/core/resources/html/avatar//346/267/267/344/271/261-Confusion.png +0 -0
  239. package/core/resources/html/avatar//347/213/202/345/226/234/350/230/255/350/210/2361.png +0 -0
  240. package/core/resources/html/avatar//347/213/202/345/226/234/350/230/255/350/210/2362.png +0 -0
  241. package/core/resources/html/avatar//347/223/267/345/262/201-Special.png +0 -0
  242. package/core/resources/html/avatar//347/223/267/345/262/2011.png +0 -0
  243. package/core/resources/html/avatar//347/223/267/345/262/2012.png +0 -0
  244. package/core/resources/html/avatar//347/223/267/345/262/2013.png +0 -0
  245. package/core/resources/html/avatar//347/223/267/345/262/2014.png +0 -0
  246. package/core/resources/html/avatar//347/223/267/345/262/2015.png +0 -0
  247. package/core/resources/html/avatar//347/223/267/345/262/2016.png +0 -0
  248. package/core/resources/html/avatar//347/223/267/345/262/2017.png +0 -0
  249. package/core/resources/html/avatar//347/233/217/350/214/227-Special.png +0 -0
  250. package/core/resources/html/avatar//347/233/217/350/214/2271.png +0 -0
  251. package/core/resources/html/avatar//347/233/217/350/214/2272.png +0 -0
  252. package/core/resources/html/avatar//347/233/217/350/214/2273.png +0 -0
  253. package/core/resources/html/avatar//350/217/257/347/201/257/347/210/2611.png +0 -0
  254. package/core/resources/html/avatar//350/217/257/347/201/257/347/210/2612.png +0 -0
  255. package/core/resources/html/avatar//350/220/244/347/201/253/350/231/253/343/201/256/346/200/2501.png +0 -0
  256. package/core/resources/html/avatar//350/220/244/347/201/253/350/231/253/343/201/256/346/200/2502.png +0 -0
  257. package/core/resources/html/avatar//351/235/222/350/212/275-Special.png +0 -0
  258. package/core/resources/html/avatar//351/235/222/350/212/275.png +0 -0
  259. package/core/resources/html/avatar//351/243/216/345/261/277.png +0 -0
  260. package/core/resources/html/avatar//351/270/240-AprilFool.png +0 -0
  261. package/core/resources/html/avatar//351/270/240-/344/270/200/345/221/250/345/271/264/347/233/270/350/201/232.png +0 -0
  262. package/core/resources/html/b19/b19.art +490 -0
  263. package/core/resources/html/b19/b19.css +1261 -0
  264. package/core/resources/html/b19/b19666.art +350 -0
  265. package/core/resources/html/b19/b19666.css +1409 -0
  266. package/core/resources/html/b19/b19666.html +481 -0
  267. package/core/resources/html/b19/b19title.css +120 -0
  268. package/core/resources/html/b19/best19.html +853 -0
  269. package/core/resources/html/b19/dss2-orz.html +428 -0
  270. package/core/resources/html/b19/dss2.art +298 -0
  271. package/core/resources/html/b19/dss2.css +776 -0
  272. package/core/resources/html/b19/res/666.jpeg +0 -0
  273. package/core/resources/html/chap/chap-orz.html +93 -0
  274. package/core/resources/html/chap/chap.art +87 -0
  275. package/core/resources/html/chap/chap.css +365 -0
  276. package/core/resources/html/chartImg/chartImg.art +169 -0
  277. package/core/resources/html/chartImg/chartImg.css +439 -0
  278. package/core/resources/html/chartImg/echarts-wordcloud.min.js +3 -0
  279. package/core/resources/html/chartImg/echarts.js +85796 -0
  280. package/core/resources/html/chartImg/img/Drag.png +0 -0
  281. package/core/resources/html/chartImg/img/DragHL.png +0 -0
  282. package/core/resources/html/chartImg/img/Flick.png +0 -0
  283. package/core/resources/html/chartImg/img/FlickHL.png +0 -0
  284. package/core/resources/html/chartImg/img/Hold.png +0 -0
  285. package/core/resources/html/chartImg/img/HoldHL.png +0 -0
  286. package/core/resources/html/chartImg/img/Hold_End.png +0 -0
  287. package/core/resources/html/chartImg/img/Hold_Head.png +0 -0
  288. package/core/resources/html/chartImg/img/Hold_HeadHL.png +0 -0
  289. package/core/resources/html/chartImg/img/Tap.png +0 -0
  290. package/core/resources/html/chartImg/img/TapHL.png +0 -0
  291. package/core/resources/html/chartInfo/chartInfo-orz.html +250 -0
  292. package/core/resources/html/chartInfo/chartInfo.art +222 -0
  293. package/core/resources/html/chartInfo/chartInfo.css +265 -0
  294. package/core/resources/html/chartInfo/echarts-wordcloud.min.js +3 -0
  295. package/core/resources/html/chartInfo/echarts.js +85796 -0
  296. package/core/resources/html/clg/clg-orz.html +218 -0
  297. package/core/resources/html/clg/clg.art +88 -0
  298. package/core/resources/html/clg/clg.css +230 -0
  299. package/core/resources/html/clg/demo.jpg +0 -0
  300. package/core/resources/html/common/common.css +319 -0
  301. package/core/resources/html/common/css/playerInfo.css +136 -0
  302. package/core/resources/html/common/font/Aldrich-Regular.ttf +0 -0
  303. package/core/resources/html/common/font/HIMALAYA.TTF +0 -0
  304. package/core/resources/html/common/font/NotoColorEmoji-Regular.ttf +0 -0
  305. package/core/resources/html/common/font/NotoSans-Regular.ttf +0 -0
  306. package/core/resources/html/common/font/NotoSansArabic.ttf +0 -0
  307. package/core/resources/html/common/font/NotoSansCanadianAboriginal.ttf +0 -0
  308. package/core/resources/html/common/font/NotoSansJP.ttf +0 -0
  309. package/core/resources/html/common/font/NotoSansKannada.ttf +0 -0
  310. package/core/resources/html/common/font/NotoSansMath-Regular.ttf +0 -0
  311. package/core/resources/html/common/font/NotoSansSymbols2.ttf +0 -0
  312. package/core/resources/html/common/font/phi.ttf +0 -0
  313. package/core/resources/html/common/font//345/220/236/345/274/245/346/201/260/344/277/212.ttf +0 -0
  314. package/core/resources/html/common/layout/default.art +324 -0
  315. package/core/resources/html/common/layout/elem.art +21 -0
  316. package/core/resources/html/common/theme/snow/snow.css +17 -0
  317. package/core/resources/html/common/theme/snow/snow.js +19 -0
  318. package/core/resources/html/common/theme/star/star.js +201 -0
  319. package/core/resources/html/common/theme/topText/topText.css +15 -0
  320. package/core/resources/html/common/theme/topText/topText.js +30 -0
  321. package/core/resources/html/difficultyHistory/difficultyHistory-orz.html +361 -0
  322. package/core/resources/html/difficultyHistory/difficultyHistory.art +102 -0
  323. package/core/resources/html/difficultyHistory/difficultyHistory.css +205 -0
  324. package/core/resources/html/guess/guess-orz.html +50 -0
  325. package/core/resources/html/guess/guess.art +51 -0
  326. package/core/resources/html/guess/guess.css +33 -0
  327. package/core/resources/html/help/help-orz.html +73 -0
  328. package/core/resources/html/help/help.art +84 -0
  329. package/core/resources/html/help/help.css +266 -0
  330. package/core/resources/html/historyB30/historyB30-orz.html +168 -0
  331. package/core/resources/html/historyB30/historyB30.art +143 -0
  332. package/core/resources/html/historyB30/historyB30.css +270 -0
  333. package/core/resources/html/ill/ill-orz.html +21 -0
  334. package/core/resources/html/ill/ill.art +23 -0
  335. package/core/resources/html/ill/ill.css +79 -0
  336. package/core/resources/html/jrrp/ShineAfter.removebg.png +0 -0
  337. package/core/resources/html/jrrp/jrrp-orz.html +68 -0
  338. package/core/resources/html/jrrp/jrrp.art +58 -0
  339. package/core/resources/html/jrrp/jrrp.css +251 -0
  340. package/core/resources/html/list/list-orz.html +35 -0
  341. package/core/resources/html/list/list.art +67 -0
  342. package/core/resources/html/list/list.css +259 -0
  343. package/core/resources/html/lvsco/demo.jpg +0 -0
  344. package/core/resources/html/lvsco/lvsco-orz.html +334 -0
  345. package/core/resources/html/lvsco/lvsco.art +314 -0
  346. package/core/resources/html/lvsco/lvsco.css +706 -0
  347. package/core/resources/html/market/detail.art +44 -0
  348. package/core/resources/html/market/market.art +86 -0
  349. package/core/resources/html/market/market.css +64 -0
  350. package/core/resources/html/newSong/newSong.art +34 -0
  351. package/core/resources/html/newSong/newSong.css +48 -0
  352. package/core/resources/html/newSong/newSong.html +23 -0
  353. package/core/resources/html/newnotice/newnotice.art +47 -0
  354. package/core/resources/html/newnotice/newnotice.css +83 -0
  355. package/core/resources/html/otherimg/0.png +0 -0
  356. package/core/resources/html/otherimg/1.png +0 -0
  357. package/core/resources/html/otherimg/2.png +0 -0
  358. package/core/resources/html/otherimg/3.png +0 -0
  359. package/core/resources/html/otherimg/4.png +0 -0
  360. package/core/resources/html/otherimg/5.png +0 -0
  361. package/core/resources/html/otherimg/A.png +0 -0
  362. package/core/resources/html/otherimg/B.png +0 -0
  363. package/core/resources/html/otherimg/C.png +0 -0
  364. package/core/resources/html/otherimg/F.png +0 -0
  365. package/core/resources/html/otherimg/FC.png +0 -0
  366. package/core/resources/html/otherimg/NEW.png +0 -0
  367. package/core/resources/html/otherimg/Phigros_Icon_3.0.0.png +0 -0
  368. package/core/resources/html/otherimg/S.png +0 -0
  369. package/core/resources/html/otherimg/Star1.png +0 -0
  370. package/core/resources/html/otherimg/Star2.png +0 -0
  371. package/core/resources/html/otherimg/V.png +0 -0
  372. package/core/resources/html/otherimg/callback.png +0 -0
  373. package/core/resources/html/otherimg/chapHelp.png +0 -0
  374. package/core/resources/html/otherimg/dan_code.png +0 -0
  375. package/core/resources/html/otherimg/data.png +0 -0
  376. package/core/resources/html/otherimg/helpDoc.png +0 -0
  377. package/core/resources/html/otherimg/nickSuggest.png +0 -0
  378. package/core/resources/html/otherimg/phi.png +0 -0
  379. package/core/resources/html/otherimg/phigros.png +0 -0
  380. package/core/resources/html/otherimg/snow1.png +0 -0
  381. package/core/resources/html/otherimg/snow2.png +0 -0
  382. package/core/resources/html/otherimg/snow3.png +0 -0
  383. package/core/resources/html/otherimg/snow4.png +0 -0
  384. package/core/resources/html/otherimg/title.png +0 -0
  385. package/core/resources/html/rand/rand-orz.html +62 -0
  386. package/core/resources/html/rand/rand.art +59 -0
  387. package/core/resources/html/rand/rand.css +257 -0
  388. package/core/resources/html/rankingList/rankingList-orz.html +456 -0
  389. package/core/resources/html/rankingList/rankingList.art +171 -0
  390. package/core/resources/html/rankingList/rankingList.css +570 -0
  391. package/core/resources/html/rankingList-old/rankingList-orz.html +300 -0
  392. package/core/resources/html/rankingList-old/rankingList.art +89 -0
  393. package/core/resources/html/rankingList-old/rankingList.css +265 -0
  394. package/core/resources/html/score/score-orz.html +625 -0
  395. package/core/resources/html/score/score.art +249 -0
  396. package/core/resources/html/score/score.css +435 -0
  397. package/core/resources/html/score/scoreOld-orz.html +128 -0
  398. package/core/resources/html/score/scoreOld.art +230 -0
  399. package/core/resources/html/score/scoreOld.css +475 -0
  400. package/core/resources/html/score/scoreRankList-orz.html +613 -0
  401. package/core/resources/html/score/scoreRankList.art +231 -0
  402. package/core/resources/html/score/scoreRankList.css +197 -0
  403. package/core/resources/html/setting/setting-orz.html +123 -0
  404. package/core/resources/html/setting/setting.art +72 -0
  405. package/core/resources/html/setting/setting.css +170 -0
  406. package/core/resources/html/setting/userSetting-orz.html +132 -0
  407. package/core/resources/html/setting/userSetting.art +48 -0
  408. package/core/resources/html/setting/userSetting.css +160 -0
  409. package/core/resources/html/sign/sign-orz.html +262 -0
  410. package/core/resources/html/sign/sign.art +163 -0
  411. package/core/resources/html/sign/sign.css +706 -0
  412. package/core/resources/html/suggest/suggest.art +271 -0
  413. package/core/resources/html/suggest/suggest.css +300 -0
  414. package/core/resources/html/table/table-orz.html +228 -0
  415. package/core/resources/html/table/table.art +163 -0
  416. package/core/resources/html/table/table.css +401 -0
  417. package/core/resources/html/tasks/tasks-orz.html +83 -0
  418. package/core/resources/html/tasks/tasks.art +118 -0
  419. package/core/resources/html/tasks/tasks.css +334 -0
  420. package/core/resources/html/update/update-orz.html +735 -0
  421. package/core/resources/html/update/update.art +259 -0
  422. package/core/resources/html/update/update.css +676 -0
  423. package/core/resources/html/userinfo/userinfo-old-orz.html +408 -0
  424. package/core/resources/html/userinfo/userinfo-old.art +233 -0
  425. package/core/resources/html/userinfo/userinfo-old.css +466 -0
  426. package/core/resources/html/userinfo/userinfo-orz.html +543 -0
  427. package/core/resources/html/userinfo/userinfo.art +288 -0
  428. package/core/resources/html/userinfo/userinfo.css +757 -0
  429. package/core/resources/info/DLC/arc.json +412 -0
  430. package/core/resources/info/DLC/chunithm.json +1172 -0
  431. package/core/resources/info/DLC/dynamix.json +455 -0
  432. package/core/resources/info/DLC/fanshi.json +132 -0
  433. package/core/resources/info/DLC/lanota.json +412 -0
  434. package/core/resources/info/DLC/maimai.json +1110 -0
  435. package/core/resources/info/DLC/orz.json +158 -0
  436. package/core/resources/info/DLC/rizline.json +40 -0
  437. package/core/resources/info/DLC/rotaeno.json +159 -0
  438. package/core/resources/info/DLC/ryceam.json +53 -0
  439. package/core/resources/info/avatar.txt +109 -0
  440. package/core/resources/info/chaplist.yaml +102 -0
  441. package/core/resources/info/help/api.json +83 -0
  442. package/core/resources/info/help.json +436 -0
  443. package/core/resources/info/info.csv +315 -0
  444. package/core/resources/info/infolist.json +1758 -0
  445. package/core/resources/info/jrrp.json +92 -0
  446. package/core/resources/info/nicklist.yaml +2373 -0
  447. package/core/resources/info/notesInfo.json +1 -0
  448. package/core/resources/info/notice.json +11 -0
  449. package/core/resources/info/oldInfo/100/change.csv +218 -0
  450. package/core/resources/info/oldInfo/100/info.json +7 -0
  451. package/core/resources/info/oldInfo/101/change.csv +218 -0
  452. package/core/resources/info/oldInfo/101/info.json +7 -0
  453. package/core/resources/info/oldInfo/102/change.csv +223 -0
  454. package/core/resources/info/oldInfo/102/info.json +7 -0
  455. package/core/resources/info/oldInfo/103/change.csv +224 -0
  456. package/core/resources/info/oldInfo/103/info.json +7 -0
  457. package/core/resources/info/oldInfo/104/change.csv +225 -0
  458. package/core/resources/info/oldInfo/104/info.json +7 -0
  459. package/core/resources/info/oldInfo/105/change.csv +228 -0
  460. package/core/resources/info/oldInfo/105/info.json +7 -0
  461. package/core/resources/info/oldInfo/106/change.csv +229 -0
  462. package/core/resources/info/oldInfo/106/info.json +7 -0
  463. package/core/resources/info/oldInfo/108/change.csv +233 -0
  464. package/core/resources/info/oldInfo/108/info.json +7 -0
  465. package/core/resources/info/oldInfo/109/change.csv +235 -0
  466. package/core/resources/info/oldInfo/109/info.json +7 -0
  467. package/core/resources/info/oldInfo/110/change.csv +235 -0
  468. package/core/resources/info/oldInfo/110/info.json +7 -0
  469. package/core/resources/info/oldInfo/111/change.csv +242 -0
  470. package/core/resources/info/oldInfo/111/info.json +7 -0
  471. package/core/resources/info/oldInfo/112/change.csv +242 -0
  472. package/core/resources/info/oldInfo/112/info.json +7 -0
  473. package/core/resources/info/oldInfo/113/change.csv +244 -0
  474. package/core/resources/info/oldInfo/113/info.json +7 -0
  475. package/core/resources/info/oldInfo/115/change.csv +247 -0
  476. package/core/resources/info/oldInfo/115/info.json +7 -0
  477. package/core/resources/info/oldInfo/116/change.csv +249 -0
  478. package/core/resources/info/oldInfo/116/info.json +7 -0
  479. package/core/resources/info/oldInfo/118/change.csv +252 -0
  480. package/core/resources/info/oldInfo/118/info.json +7 -0
  481. package/core/resources/info/oldInfo/119/change.csv +255 -0
  482. package/core/resources/info/oldInfo/119/info.json +7 -0
  483. package/core/resources/info/oldInfo/120/change.csv +255 -0
  484. package/core/resources/info/oldInfo/120/info.json +7 -0
  485. package/core/resources/info/oldInfo/121/change.csv +257 -0
  486. package/core/resources/info/oldInfo/121/info.json +7 -0
  487. package/core/resources/info/oldInfo/122/change.csv +260 -0
  488. package/core/resources/info/oldInfo/122/info.json +7 -0
  489. package/core/resources/info/oldInfo/123/change.csv +260 -0
  490. package/core/resources/info/oldInfo/123/info.json +7 -0
  491. package/core/resources/info/oldInfo/124/change.csv +262 -0
  492. package/core/resources/info/oldInfo/124/info.json +7 -0
  493. package/core/resources/info/oldInfo/125/change.csv +266 -0
  494. package/core/resources/info/oldInfo/125/info.json +7 -0
  495. package/core/resources/info/oldInfo/126/change.csv +269 -0
  496. package/core/resources/info/oldInfo/126/info.json +7 -0
  497. package/core/resources/info/oldInfo/127/change.csv +270 -0
  498. package/core/resources/info/oldInfo/127/info.json +7 -0
  499. package/core/resources/info/oldInfo/128/change.csv +273 -0
  500. package/core/resources/info/oldInfo/128/info.json +7 -0
  501. package/core/resources/info/oldInfo/129/change.csv +274 -0
  502. package/core/resources/info/oldInfo/129/info.json +7 -0
  503. package/core/resources/info/oldInfo/130/change.csv +275 -0
  504. package/core/resources/info/oldInfo/130/info.json +7 -0
  505. package/core/resources/info/oldInfo/131/change.csv +278 -0
  506. package/core/resources/info/oldInfo/131/info.json +7 -0
  507. package/core/resources/info/oldInfo/132/change.csv +280 -0
  508. package/core/resources/info/oldInfo/132/info.json +7 -0
  509. package/core/resources/info/oldInfo/133/change.csv +284 -0
  510. package/core/resources/info/oldInfo/133/info.json +7 -0
  511. package/core/resources/info/oldInfo/134/change.csv +286 -0
  512. package/core/resources/info/oldInfo/134/info.json +7 -0
  513. package/core/resources/info/oldInfo/135/change.csv +290 -0
  514. package/core/resources/info/oldInfo/135/info.json +7 -0
  515. package/core/resources/info/oldInfo/136/change.csv +290 -0
  516. package/core/resources/info/oldInfo/136/info.json +7 -0
  517. package/core/resources/info/oldInfo/137/change.csv +293 -0
  518. package/core/resources/info/oldInfo/137/info.json +7 -0
  519. package/core/resources/info/oldInfo/138/change.csv +295 -0
  520. package/core/resources/info/oldInfo/138/info.json +7 -0
  521. package/core/resources/info/oldInfo/139/change.csv +295 -0
  522. package/core/resources/info/oldInfo/139/info.json +7 -0
  523. package/core/resources/info/oldInfo/140/change.csv +298 -0
  524. package/core/resources/info/oldInfo/140/info.json +7 -0
  525. package/core/resources/info/oldInfo/141/change.csv +300 -0
  526. package/core/resources/info/oldInfo/141/info.json +7 -0
  527. package/core/resources/info/oldInfo/142/change.csv +302 -0
  528. package/core/resources/info/oldInfo/142/info.json +7 -0
  529. package/core/resources/info/oldInfo/143/change.csv +303 -0
  530. package/core/resources/info/oldInfo/143/info.json +7 -0
  531. package/core/resources/info/oldInfo/144/change.csv +303 -0
  532. package/core/resources/info/oldInfo/144/info.json +7 -0
  533. package/core/resources/info/oldInfo/145/change.csv +305 -0
  534. package/core/resources/info/oldInfo/145/info.json +7 -0
  535. package/core/resources/info/oldInfo/146/change.csv +305 -0
  536. package/core/resources/info/oldInfo/146/info.json +7 -0
  537. package/core/resources/info/oldInfo/148/change.csv +307 -0
  538. package/core/resources/info/oldInfo/148/info.json +7 -0
  539. package/core/resources/info/oldInfo/149/change.csv +307 -0
  540. package/core/resources/info/oldInfo/149/info.json +7 -0
  541. package/core/resources/info/oldInfo/151/change.csv +309 -0
  542. package/core/resources/info/oldInfo/151/info.json +7 -0
  543. package/core/resources/info/oldInfo/152/change.csv +312 -0
  544. package/core/resources/info/oldInfo/152/info.json +7 -0
  545. package/core/resources/info/oldInfo/153/change.csv +313 -0
  546. package/core/resources/info/oldInfo/153/info.json +7 -0
  547. package/core/resources/info/oldInfo/154/change.csv +315 -0
  548. package/core/resources/info/oldInfo/154/info.json +7 -0
  549. package/core/resources/info/oldInfo/92/change.csv +204 -0
  550. package/core/resources/info/oldInfo/92/info.json +7 -0
  551. package/core/resources/info/oldInfo/93/change.csv +206 -0
  552. package/core/resources/info/oldInfo/93/info.json +7 -0
  553. package/core/resources/info/oldInfo/94/change.csv +209 -0
  554. package/core/resources/info/oldInfo/94/info.json +7 -0
  555. package/core/resources/info/oldInfo/95/change.csv +211 -0
  556. package/core/resources/info/oldInfo/95/info.json +7 -0
  557. package/core/resources/info/oldInfo/96/change.csv +216 -0
  558. package/core/resources/info/oldInfo/96/info.json +7 -0
  559. package/core/resources/info/oldInfo/98/change.csv +216 -0
  560. package/core/resources/info/oldInfo/98/info.json +7 -0
  561. package/core/resources/info/oldInfo/99/change.csv +217 -0
  562. package/core/resources/info/oldInfo/99/info.json +7 -0
  563. package/core/resources/info/oldNotesInfo.json +1 -0
  564. package/core/resources/info/sentences.json +1 -0
  565. package/core/resources/info/spinfo.json +362 -0
  566. package/core/resources/info/tips.txt +278 -0
  567. package/core/resources/otherill//351/233/252/351/231/215SP.png +0 -0
  568. package/core/resources/song-select.html +535 -0
  569. package/core/resources/themes/README.md +24 -0
  570. package/core/resources/themes/milthm/A.png +0 -0
  571. package/core/resources/themes/milthm/B.png +0 -0
  572. package/core/resources/themes/milthm/C.png +0 -0
  573. package/core/resources/themes/milthm/F.png +0 -0
  574. package/core/resources/themes/milthm/FC.png +0 -0
  575. package/core/resources/themes/milthm/S.png +0 -0
  576. package/core/resources/themes/milthm/V.png +0 -0
  577. package/core/resources/themes/milthm/arcgrosB19.css +36 -0
  578. package/core/resources/themes/milthm/b19.art +464 -0
  579. package/core/resources/themes/milthm/b19.css +1314 -0
  580. package/core/resources/themes/milthm/bg.png +0 -0
  581. package/core/resources/themes/milthm/clg.css +37 -0
  582. package/core/resources/themes/milthm/difficultyHistory.css +42 -0
  583. package/core/resources/themes/milthm/font.ttf +0 -0
  584. package/core/resources/themes/milthm/help.css +36 -0
  585. package/core/resources/themes/milthm/historyB30.css +48 -0
  586. package/core/resources/themes/milthm/info.yaml +45 -0
  587. package/core/resources/themes/milthm/list.css +39 -0
  588. package/core/resources/themes/milthm/phi.png +0 -0
  589. package/core/resources/themes/milthm/setting.css +37 -0
  590. package/core/resources/themes/milthm/sign.css +81 -0
  591. package/core/resources/themes/milthm/suggest.css +40 -0
  592. package/core/resources/themes/milthm/table.css +54 -0
  593. package/core/resources/themes/milthm/update.css +68 -0
  594. package/core/tests/aliasProposalService.test.js +82 -0
  595. package/core/tests/apiAccountDeletion.test.js +125 -0
  596. package/core/tests/apiPermission.test.js +44 -0
  597. package/core/tests/apiVersion.test.js +55 -0
  598. package/core/tests/b30Analysis.test.js +38 -0
  599. package/core/tests/backgroundIllustration.test.js +21 -0
  600. package/core/tests/botApiAuthRecovery.test.js +148 -0
  601. package/core/tests/botSyncService.test.js +42 -0
  602. package/core/tests/fileWatcherRegistry.test.js +44 -0
  603. package/core/tests/gameRecordPayload.test.js +34 -0
  604. package/core/tests/guessLetter.test.js +79 -0
  605. package/core/tests/makeRequestHandling.test.js +118 -0
  606. package/core/tests/marketOverflow.test.js +31 -0
  607. package/core/tests/platformTemplateCache.test.js +92 -0
  608. package/core/tests/pluginDataThemePreference.test.js +36 -0
  609. package/core/tests/processCleanup.test.js +58 -0
  610. package/core/tests/puppeteerShutdown.test.js +17 -0
  611. package/core/tests/renderPressureHistory.test.js +59 -0
  612. package/core/tests/saveStats.test.js +26 -0
  613. package/core/tests/themeInstallGuard.test.js +289 -0
  614. package/core/tests/themeManager.test.js +625 -0
  615. package/core/tests/themeManagerInit.test.js +11 -0
  616. package/core/tests/themeMarket.test.js +855 -0
  617. package/core/tests/themePageRouting.test.js +431 -0
  618. package/core/tests/themePolicy.test.js +28 -0
  619. package/core/tests/themeStorageRecovery.test.js +192 -0
  620. package/core/tests/userCredentialModuleBoundaries.test.js +41 -0
  621. package/core/tests/userCredentials.test.js +285 -0
  622. package/core/tsconfig.json +32 -0
  623. package/core/types/vendor.d.ts +14 -0
  624. package/lib/index.cjs +358 -0
  625. package/lib/index.d.cts +182 -0
  626. package/lib/index.d.ts +182 -0
  627. package/lib/index.js +320 -0
  628. package/package.json +74 -0
  629. package/readme.md +3 -0
@@ -0,0 +1,855 @@
1
+ import assert from 'node:assert/strict'
2
+ import crypto from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import test from 'node:test'
7
+ import art from 'art-template'
8
+ import JSZip from 'jszip'
9
+ import Config from '../components/Config.js'
10
+ import { phiMarket } from '../apps/market.js'
11
+ import { buildMarketQuickMarkdown, sendMarketQuickCommands } from '../model/game/markdown.js'
12
+ import getBanGroup from '../model/user/getBanGroup.js'
13
+ import getNotes from '../model/user/getNotes.js'
14
+ import send from '../model/render/send.js'
15
+ import picmodle from '../model/render/picmodle.js'
16
+ import themeManager from '../model/theme/manager.js'
17
+ import themePolicy from '../model/theme/policy.js'
18
+ import { themesDir } from '../model/theme/paths.js'
19
+ import themeUseService, { ThemeUseService } from '../model/theme/useService.js'
20
+ import makeRequest from '../model/api/makeRequest.js'
21
+ import {
22
+ downloadThemeArchive,
23
+ getAvailableMarketTheme,
24
+ getAvailableMarketThemes,
25
+ ThemeMarketClientError,
26
+ } from '../model/theme/marketClient.js'
27
+ import {
28
+ fetchThemeCatalog,
29
+ fetchThemeDetail,
30
+ getLocalThemeCatalog,
31
+ getLocalThemeDetail,
32
+ normalizeMarketTheme,
33
+ THEME_MARKET_PAGE_SIZE,
34
+ } from '../model/theme/catalog.js'
35
+ import {
36
+ installMarketArchive,
37
+ isMarketThemeCached,
38
+ marketWorkPath,
39
+ recoverMarketInstall,
40
+ withMarketInstallLock,
41
+ } from '../model/theme/installer.js'
42
+ import { marketInstallLockPath } from '../model/theme/installLock.js'
43
+
44
+ const sha256 = 'b'.repeat(64)
45
+
46
+ /** @param {string} themeId @param {{topLevel?:boolean, unsafePath?:boolean}} [options] */
47
+ async function makeArchive(themeId, options = {}) {
48
+ await recoverMarketInstall(themeId)
49
+ const zip = new JSZip()
50
+ const prefix = options.topLevel ? `${themeId}/` : ''
51
+ zip.file(`${prefix}info.yaml`, `id: ${themeId}\nname: Market ${themeId}\ndescription: test\n`)
52
+ zip.file(`${prefix}b19.css`, 'body { color: #fff; }\n')
53
+ if (options.unsafePath) zip.file('../outside.txt', 'unsafe')
54
+ const data = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' })
55
+ const archivePath = marketWorkPath(`test-${themeId}-${crypto.randomUUID()}.zip`)
56
+ await fs.promises.writeFile(archivePath, data, { mode: 0o600 })
57
+ return archivePath
58
+ }
59
+
60
+ test('verified downloader sends no credentials and enforces size and SHA-256', async () => {
61
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'phi-market-download-'))
62
+ const target = path.join(tempDir, 'theme.zip')
63
+ const bytes = Buffer.from('verified zip bytes')
64
+ const download = {
65
+ downloadId: 'download-test',
66
+ themeId: 'download-test',
67
+ version: '1.0.0',
68
+ fileName: 'download-test.zip',
69
+ contentType: 'application/zip',
70
+ size: bytes.length,
71
+ sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
72
+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
73
+ downloadUrl: 'https://lyh.org.cn:18473/api/integrations/phi-plugin/download/opaque',
74
+ }
75
+ /** @type {typeof fetch} */
76
+ const fetchImpl = async (_url, init) => {
77
+ assert.deepEqual(init?.headers, { Accept: 'application/zip' })
78
+ assert.equal('Authorization' in /** @type {any} */ (init?.headers), false)
79
+ return new Response(bytes, {
80
+ status: 200,
81
+ headers: { 'Content-Type': 'application/zip', 'Content-Length': String(bytes.length) },
82
+ })
83
+ }
84
+ try {
85
+ await downloadThemeArchive(download, target, { fetchImpl })
86
+ assert.deepEqual(await fs.promises.readFile(target), bytes)
87
+
88
+ await assert.rejects(
89
+ downloadThemeArchive({ ...download, sha256: '0'.repeat(64) }, target, { fetchImpl }),
90
+ error => error instanceof ThemeMarketClientError && error.code === 'theme_package_integrity_failed',
91
+ )
92
+ assert.equal(fs.existsSync(target), false)
93
+
94
+ let networkAttempts = 0
95
+ await assert.rejects(
96
+ downloadThemeArchive(download, target, {
97
+ fetchImpl: async () => {
98
+ networkAttempts++
99
+ throw Object.assign(new Error('socket hang up'), { code: 'ECONNRESET' })
100
+ },
101
+ }),
102
+ error => /** @type {any} */ (error)?.code === 'api_network_error',
103
+ )
104
+ assert.equal(networkAttempts, 2)
105
+ } finally {
106
+ await fs.promises.rm(tempDir, { recursive: true, force: true })
107
+ }
108
+ })
109
+
110
+ test('market installer visibility follows the Bot blacklist and whitelist policy', async () => {
111
+ const themeId = `markettest${Date.now()}`
112
+ const target = path.join(themesDir, themeId)
113
+ const previousPolicy = themePolicy.snapshot()
114
+ try {
115
+ const archive = await makeArchive(themeId, { topLevel: true })
116
+ const receipt = await withMarketInstallLock(() => installMarketArchive(themeId, {
117
+ version: '1.2.3', sha256,
118
+ }, archive))
119
+ assert.equal(receipt.slug, themeId)
120
+ assert.equal(await isMarketThemeCached(themeId, { version: '1.2.3', sha256 }), true)
121
+ themeManager.scan()
122
+ assert.equal(themeManager.getTheme(themeId)?.marketInstalled, true)
123
+ themePolicy.apply({ mode: 'blacklist', entries: [] }, false)
124
+ assert.equal(themeManager.getThemeList().some(theme => theme.id === themeId), true)
125
+ assert.equal(Boolean(themeManager.getThemeOptions()[themeId]), true)
126
+ themePolicy.apply({ mode: 'blacklist', entries: [themeId] }, false)
127
+ assert.equal(themeManager.getThemeList().some(theme => theme.id === themeId), false)
128
+ assert.equal(Boolean(themeManager.getThemeOptions(themeId)[themeId]), false)
129
+ const blockedLocalTheme = getLocalThemeCatalog(themeId).themes[0]
130
+ assert.equal(blockedLocalTheme?.slug, themeId)
131
+ assert.equal(blockedLocalTheme?.botDownloadAllowed, false)
132
+ await assert.rejects(
133
+ new ThemeUseService({ marketEnabled: () => true }).use(themeId),
134
+ error => /** @type {any} */(error)?.code === 'theme_not_allowed_by_bot',
135
+ )
136
+ themePolicy.apply({ mode: 'whitelist', entries: [themeId] }, false)
137
+ assert.equal(themeManager.getThemeList().some(theme => theme.id === themeId), true)
138
+ /** @type {string[]} */ const onlineCalls = []
139
+ const updated = await new ThemeUseService({
140
+ marketEnabled: () => true,
141
+ getTheme: async slug => {
142
+ onlineCalls.push(`detail:${slug}`)
143
+ return /** @type {any} */ ({
144
+ slug, name: 'Updated theme', downloadPolicy: 'public', botDownloadAllowed: true,
145
+ })
146
+ },
147
+ install: async slug => {
148
+ onlineCalls.push(`install:${slug}`)
149
+ return /** @type {any} */ ({
150
+ cached: false, version: '2.0.0', theme: themeManager.getTheme(slug),
151
+ })
152
+ },
153
+ }).use(themeId)
154
+ assert.deepEqual(onlineCalls, [`detail:${themeId}`, `install:${themeId}`])
155
+ assert.equal(updated.cached, false)
156
+ assert.equal(updated.version, '2.0.0')
157
+
158
+ let installCalled = false
159
+ const offline = await new ThemeUseService({
160
+ marketEnabled: () => true,
161
+ getTheme: async () => { throw Object.assign(new Error('offline'), { code: 'api_offline' }) },
162
+ install: async () => { installCalled = true; return /** @type {any} */ ({}) },
163
+ }).use(themeId)
164
+ assert.equal(offline.cached, true)
165
+ assert.equal(offline.local, false)
166
+ assert.equal(offline.offline, true)
167
+ assert.equal(offline.theme.id, themeId)
168
+ assert.equal(installCalled, false)
169
+
170
+ const authorizationOffline = await new ThemeUseService({
171
+ marketEnabled: () => true,
172
+ getTheme: async slug => /** @type {any} */ ({
173
+ slug, name: 'Theme', downloadPolicy: 'public', botDownloadAllowed: true,
174
+ }),
175
+ install: async () => { throw Object.assign(new Error('timeout'), { code: 'api_timeout' }) },
176
+ }).use(themeId)
177
+ assert.equal(authorizationOffline.cached, true)
178
+ assert.equal(authorizationOffline.detail?.slug, themeId)
179
+
180
+ await assert.rejects(
181
+ new ThemeUseService({
182
+ marketEnabled: () => true,
183
+ getTheme: async slug => /** @type {any} */ ({
184
+ slug, name: 'Theme', downloadPolicy: 'public', botDownloadAllowed: true,
185
+ }),
186
+ install: async () => { throw new ThemeMarketClientError('theme_store_bot_not_whitelisted', 403) },
187
+ }).use(themeId),
188
+ error => /** @type {any} */(error)?.code === 'theme_store_bot_not_whitelisted',
189
+ )
190
+ } finally {
191
+ themePolicy.apply(previousPolicy, false)
192
+ await fs.promises.rm(target, { recursive: true, force: true })
193
+ themeManager.scan()
194
+ }
195
+ })
196
+
197
+ test('market installer rejects traversal and preserves a same-name local theme', async () => {
198
+ const unsafeId = `unsafe${Date.now()}`
199
+ const localId = `local${Date.now()}`
200
+ const localTarget = path.join(themesDir, localId)
201
+ let unsafeArchive = ''
202
+ let localArchive = ''
203
+ try {
204
+ unsafeArchive = await makeArchive(unsafeId, { unsafePath: true })
205
+ await assert.rejects(
206
+ withMarketInstallLock(() => installMarketArchive(unsafeId, { version: '1.0.0', sha256 }, unsafeArchive)),
207
+ error => error instanceof ThemeMarketClientError && error.code === 'theme_package_unsafe_path',
208
+ )
209
+ assert.equal(fs.existsSync(path.resolve(themesDir, '..', 'outside.txt')), false)
210
+
211
+ await fs.promises.mkdir(localTarget, { recursive: true })
212
+ await fs.promises.writeFile(path.join(localTarget, 'info.yaml'), `id: ${localId}\nname: Local\n`)
213
+ localArchive = await makeArchive(localId)
214
+ await assert.rejects(
215
+ withMarketInstallLock(() => installMarketArchive(localId, { version: '1.0.0', sha256 }, localArchive)),
216
+ error => error instanceof ThemeMarketClientError && error.code === 'theme_conflicts_with_local_theme',
217
+ )
218
+ assert.match(await fs.promises.readFile(path.join(localTarget, 'info.yaml'), 'utf8'), /name: Local/)
219
+ } finally {
220
+ await fs.promises.rm(path.join(themesDir, unsafeId), { recursive: true, force: true })
221
+ await fs.promises.rm(localTarget, { recursive: true, force: true })
222
+ if (unsafeArchive) await fs.promises.rm(unsafeArchive, { force: true })
223
+ if (localArchive) await fs.promises.rm(localArchive, { force: true })
224
+ themeManager.scan()
225
+ }
226
+ })
227
+
228
+ test('market install lock serializes concurrent operations', async () => {
229
+ /** @type {string[]} */
230
+ const order = []
231
+ await Promise.all([
232
+ withMarketInstallLock(async () => {
233
+ order.push('first-start')
234
+ await new Promise(resolve => setTimeout(resolve, 50))
235
+ order.push('first-end')
236
+ }),
237
+ withMarketInstallLock(async () => {
238
+ order.push('second-start')
239
+ order.push('second-end')
240
+ }),
241
+ ])
242
+ assert.deepEqual(order, ['first-start', 'first-end', 'second-start', 'second-end'])
243
+ })
244
+
245
+ test('a stale lock whose pid was reused by another process is still reaped', async () => {
246
+ // 进程启动标识依赖 /proc/<pid>/stat,非 Linux 平台退化为仅按 pid 判断,跳过该场景。
247
+ if (!fs.existsSync('/proc/self/stat')) return
248
+ fs.mkdirSync(themesDir, { recursive: true })
249
+ const backdated = new Date(Date.now() - 10 * 60_000)
250
+ await withMarketInstallLock(async () => {
251
+ fs.writeFileSync(marketInstallLockPath, `${JSON.stringify({
252
+ pid: process.pid,
253
+ // 存活进程的 starttime 不可能为 0:模拟锁持有者崩溃后 pid 被复用的情形。
254
+ identity: '0',
255
+ token: crypto.randomUUID(),
256
+ createdAt: new Date(backdated).toISOString(),
257
+ })}\n`, { mode: 0o600 })
258
+ fs.utimesSync(marketInstallLockPath, backdated, backdated)
259
+ })
260
+ let entered = false
261
+ try {
262
+ await withMarketInstallLock(async () => { entered = true })
263
+ assert.equal(entered, true)
264
+ assert.equal(fs.existsSync(marketInstallLockPath), false)
265
+ } finally {
266
+ await fs.promises.rm(marketInstallLockPath, { force: true })
267
+ }
268
+ })
269
+
270
+ test('theme use validates Bot access before installing on demand', async () => {
271
+ /** @type {string[]} */
272
+ const calls = []
273
+ const service = new ThemeUseService({
274
+ getTheme: async slug => {
275
+ calls.push(`validate:${slug}`)
276
+ return /** @type {any} */ ({ slug, name: 'Ocean Salt', botDownloadAllowed: true })
277
+ },
278
+ install: async slug => {
279
+ calls.push(`install:${slug}`)
280
+ return /** @type {any} */ ({ cached: false, version: '1.0.0', theme: { id: slug, name: 'Ocean Salt' } })
281
+ },
282
+ })
283
+
284
+ const result = await service.use('ocean-salt')
285
+ assert.deepEqual(calls, ['validate:ocean-salt', 'install:ocean-salt'])
286
+ assert.equal(result.cached, false)
287
+ assert.equal(result.theme.id, 'ocean-salt')
288
+ assert.equal(result.detail.slug, 'ocean-salt')
289
+ })
290
+
291
+ test('theme use rejects invalid slugs before contacting the market', async () => {
292
+ let called = false
293
+ const service = new ThemeUseService({
294
+ getTheme: async () => { called = true; return /** @type {any} */ ({}) },
295
+ install: async () => /** @type {any} */({}),
296
+ })
297
+ await assert.rejects(service.use('../unsafe'), error => /** @type {any} */(error)?.code === 'theme_slug_invalid')
298
+ assert.equal(called, false)
299
+ })
300
+
301
+ test('theme use applies the Bot policy to slugs that are not installed yet', async () => {
302
+ const previousPolicy = themePolicy.snapshot()
303
+ /** @type {string[]} */
304
+ const calls = []
305
+ const service = new ThemeUseService({
306
+ getTheme: async slug => { calls.push(`validate:${slug}`); return /** @type {any} */ ({}) },
307
+ install: async slug => { calls.push(`install:${slug}`); return /** @type {any} */ ({}) },
308
+ })
309
+ try {
310
+ themePolicy.apply({ mode: 'blacklist', entries: ['blocked-slug'] }, false)
311
+ await assert.rejects(
312
+ service.use('blocked-slug'),
313
+ error => /** @type {any} */(error)?.code === 'theme_not_allowed_by_bot',
314
+ )
315
+ assert.deepEqual(calls, [])
316
+ themePolicy.apply({ mode: 'whitelist', entries: ['other-theme'] }, false)
317
+ await assert.rejects(
318
+ service.use('blocked-slug'),
319
+ error => /** @type {any} */(error)?.code === 'theme_not_allowed_by_bot',
320
+ )
321
+ assert.deepEqual(calls, [])
322
+ } finally {
323
+ themePolicy.apply(previousPolicy, false)
324
+ }
325
+ })
326
+
327
+ test('market detail capability accepts both API layouts and rejects ambiguity', async () => {
328
+ const originals = {
329
+ list: makeRequest.getThemeMarketList,
330
+ detail: makeRequest.getThemeMarketDetail,
331
+ }
332
+ const baseTheme = {
333
+ slug: 'contract-theme',
334
+ name: 'Contract Theme',
335
+ downloadPolicy: 'public',
336
+ }
337
+ try {
338
+ makeRequest.getThemeMarketDetail = async () => ({
339
+ ok: true,
340
+ theme: baseTheme,
341
+ botDownloadAllowed: true,
342
+ })
343
+ assert.equal((await getAvailableMarketTheme('contract-theme')).botDownloadAllowed, true)
344
+
345
+ makeRequest.getThemeMarketDetail = async () => ({
346
+ ok: true,
347
+ theme: { ...baseTheme, botDownloadAllowed: true },
348
+ })
349
+ assert.equal((await getAvailableMarketTheme('contract-theme')).botDownloadAllowed, true)
350
+
351
+ makeRequest.getThemeMarketList = async () => ({
352
+ ok: true,
353
+ botDownloadAllowed: true,
354
+ themes: [baseTheme],
355
+ })
356
+ assert.equal((await getAvailableMarketThemes())[0]?.botDownloadAllowed, true)
357
+
358
+ makeRequest.getThemeMarketDetail = async () => ({
359
+ ok: true,
360
+ botDownloadAllowed: true,
361
+ theme: { ...baseTheme, botDownloadAllowed: false },
362
+ })
363
+ await assert.rejects(
364
+ getAvailableMarketTheme('contract-theme'),
365
+ error => /** @type {any} */(error)?.code === 'theme_store_invalid_response',
366
+ )
367
+
368
+ makeRequest.getThemeMarketDetail = async () => ({
369
+ ok: true,
370
+ botDownloadAllowed: false,
371
+ theme: baseTheme,
372
+ })
373
+ await assert.rejects(
374
+ getAvailableMarketTheme('contract-theme'),
375
+ error => /** @type {any} */(error)?.code === 'theme_not_allowed_by_bot',
376
+ )
377
+ } finally {
378
+ makeRequest.getThemeMarketList = originals.list
379
+ makeRequest.getThemeMarketDetail = originals.detail
380
+ }
381
+ })
382
+
383
+ test('installed custom themes can be listed, inspected, and used without the API', async () => {
384
+ const catalog = getLocalThemeCatalog('milthm')
385
+ assert.equal(catalog.localOnly, true)
386
+ assert.equal(catalog.total, 1)
387
+ assert.equal(catalog.themes[0]?.slug, 'milthm')
388
+ assert.equal(catalog.themes[0]?.local, true)
389
+ assert.equal(catalog.themes[0]?.botDownloadAllowed, true)
390
+
391
+ const detail = getLocalThemeDetail('milthm')
392
+ assert.equal(detail?.name, 'Milthm')
393
+ assert.equal(detail?.compatibility, '本地已安装')
394
+
395
+ let onlineCalls = 0
396
+ const service = new ThemeUseService({
397
+ getTheme: async () => { onlineCalls++; throw new Error('must stay offline') },
398
+ install: async () => { onlineCalls++; throw new Error('must stay offline') },
399
+ })
400
+ const result = await service.use('milthm')
401
+ assert.equal(result.cached, true)
402
+ assert.equal(result.theme.id, 'milthm')
403
+ assert.equal(onlineCalls, 0)
404
+ })
405
+
406
+ test('market slug command lets a regular user download and select the theme', async () => {
407
+ const originals = {
408
+ getUserCfg: Config.getUserCfg,
409
+ getBan: getBanGroup.get,
410
+ getNotesData: getNotes.getNotesData,
411
+ putNotesData: getNotes.putNotesData,
412
+ send: send.send_with_At,
413
+ use: themeUseService.use,
414
+ }
415
+ const pluginData = { theme: 'default' }
416
+ /** @type {string[]} */ const messages = []
417
+ /** @type {string[]} */ const used = []
418
+ /** @type {any[]} */ const useOptions = []
419
+ Config.getUserCfg = /** @type {any} */ ((_name = '', key = '') => key === 'cmdhead' ? 'phi' : ['openPhiPluginApi', 'enableCustomThemeApi'].includes(key))
420
+ getBanGroup.get = async () => false
421
+ getNotes.getNotesData = async () => /** @type {any} */(pluginData)
422
+ getNotes.putNotesData = (_userId, data) => data === pluginData
423
+ send.send_with_At = async (_event, message) => { messages.push(String(message)) }
424
+ themeUseService.use = async (slug, options) => {
425
+ used.push(slug)
426
+ useOptions.push(options)
427
+ return /** @type {any} */ ({ cached: false, version: '1.0.0', theme: { id: slug, name: 'Ocean Salt' } })
428
+ }
429
+ try {
430
+ const command = new phiMarket()
431
+ const handled = await command.market(/** @type {any} */({
432
+ msg: '/phi market ocean-salt', user_id: 'regular-user', isMaster: false,
433
+ }))
434
+ assert.equal(handled, true)
435
+ assert.deepEqual(used, ['ocean-salt'])
436
+ assert.match(useOptions[0]?.requesterId || '', /regular-user$/)
437
+ assert.equal(pluginData.theme, 'ocean-salt')
438
+ assert.match(messages.at(-1) || '', /主题已启用/)
439
+ } finally {
440
+ Config.getUserCfg = originals.getUserCfg
441
+ getBanGroup.get = originals.getBan
442
+ getNotes.getNotesData = originals.getNotesData
443
+ getNotes.putNotesData = originals.putNotesData
444
+ send.send_with_At = originals.send
445
+ themeUseService.use = originals.use
446
+ }
447
+ })
448
+
449
+ test('market command is scoped to the configured command head and myset has no custom-theme bypass', () => {
450
+ const command = fs.readFileSync(new URL('../apps/market.js', import.meta.url), 'utf8')
451
+ const settings = fs.readFileSync(new URL('../apps/setting.js', import.meta.url), 'utf8')
452
+ assert.equal(command.includes("reg: `^[#/](${Config.getUserCfg('config', 'cmdhead')})(\\\\s*)market"), true)
453
+ assert.doesNotMatch(command, /escapedCommandHead/)
454
+ assert.doesNotMatch(command, /\^\[#\/\]market/)
455
+ assert.doesNotMatch(command, /if \(!e\.isMaster\)/)
456
+ assert.match(command, /themeUseService\.use\(themeId, \{ requesterId:/)
457
+ assert.match(command, /pluginData\.theme = themeId/)
458
+ assert.match(settings, /getThemeOptions\(pluginData\.theme\)/)
459
+ assert.match(settings, /themeUseService\.use\(canonicalValue, \{ requesterId:/)
460
+ assert.doesNotMatch(settings, /const custom = themeManager\.getTheme/)
461
+ })
462
+
463
+ test('market UI preserves Bot download capability and uses the phi-plugin-api proxy', async () => {
464
+ assert.equal(normalizeMarketTheme({ slug: 'restricted-theme', name: 'Restricted', botDownloadAllowed: false }).botDownloadAllowed, false)
465
+ assert.equal(normalizeMarketTheme({ slug: 'public-theme', name: 'Public', botDownloadAllowed: true }).botDownloadAllowed, true)
466
+ assert.equal(normalizeMarketTheme({ slug: 'anonymous-theme', name: 'Anonymous' }).botDownloadAllowed, null)
467
+ const marketTemplate = fs.readFileSync(new URL('../resources/html/market/market.art', import.meta.url), 'utf8')
468
+ assert.match(marketTemplate, /botDownloadAllowed === true/)
469
+ assert.match(marketTemplate, />可用</)
470
+ assert.match(marketTemplate, />不可用</)
471
+
472
+ const layoutData = {
473
+ defaultLayout: path.resolve('resources/html/common/layout/default.art'),
474
+ _res_path: '/resources/',
475
+ theme: 'default',
476
+ themeInfo: null,
477
+ sys: { scale: '' },
478
+ bodyClass: '',
479
+ }
480
+ const localTheme = getLocalThemeDetail('milthm')
481
+ const localCatalogHtml = art.render(marketTemplate, {
482
+ ...layoutData,
483
+ ...getLocalThemeCatalog(),
484
+ currentTheme: 'milthm',
485
+ commandHead: 'phi',
486
+ })
487
+ assert.match(localCatalogHtml, /本地离线模式/)
488
+ assert.match(localCatalogHtml, /policy-local/)
489
+ const detailTemplate = fs.readFileSync(new URL('../resources/html/market/detail.art', import.meta.url), 'utf8')
490
+ const localDetailHtml = art.render(detailTemplate, { ...layoutData, detail: localTheme })
491
+ assert.match(localDetailHtml, /已安装,可离线使用/)
492
+
493
+ const originals = {
494
+ list: makeRequest.getThemeMarketList,
495
+ detail: makeRequest.getThemeMarketDetail,
496
+ }
497
+ makeRequest.getThemeMarketList = async () => ({
498
+ ok: true,
499
+ themes: [
500
+ { slug: 'proxy-theme', name: 'Proxy Theme', botDownloadAllowed: false },
501
+ { slug: 'milthm', name: 'Remote Milthm', botDownloadAllowed: false },
502
+ ],
503
+ })
504
+ makeRequest.getThemeMarketDetail = async themeId => ({
505
+ ok: true,
506
+ theme: { slug: themeId, name: 'Proxy Theme', downloadPolicy: 'bot_only' },
507
+ botDownloadAllowed: false,
508
+ releaseNotes: 'proxy response',
509
+ })
510
+ try {
511
+ const catalog = await fetchThemeCatalog()
512
+ assert.equal(catalog.themes[0].slug, 'milthm')
513
+ assert.equal(catalog.themes[0].local, true)
514
+ assert.equal(catalog.themes.filter(theme => theme.slug === 'milthm').length, 1)
515
+ assert.equal(catalog.themes.find(theme => theme.slug === 'proxy-theme')?.botDownloadAllowed, false)
516
+ const detail = await fetchThemeDetail('proxy-theme')
517
+ assert.equal(detail.botDownloadAllowed, false)
518
+ assert.equal(detail.releaseNotes, 'proxy response')
519
+ } finally {
520
+ makeRequest.getThemeMarketList = originals.list
521
+ makeRequest.getThemeMarketDetail = originals.detail
522
+ }
523
+ })
524
+
525
+ test('market command falls back to local custom themes when API is disabled or unavailable', async () => {
526
+ const originals = {
527
+ getUserCfg: Config.getUserCfg,
528
+ getBan: getBanGroup.get,
529
+ getNotesData: getNotes.getNotesData,
530
+ list: makeRequest.getThemeMarketList,
531
+ market: picmodle.market,
532
+ marketDetail: picmodle.marketDetail,
533
+ sendWithAt: send.send_with_At,
534
+ reply: send.reply,
535
+ }
536
+ let apiEnabled = false
537
+ let onlineCalls = 0
538
+ /** @type {any[]} */ const catalogs = []
539
+ /** @type {any[]} */ const details = []
540
+ Config.getUserCfg = /** @type {any} */ ((_name = '', key = '') => {
541
+ if (key === 'cmdhead') return 'phi'
542
+ if (key === 'openPhiPluginApi') return apiEnabled
543
+ if (key === 'enableCustomThemeApi') return true
544
+ if (key === 'LetterMarkdown') return false
545
+ return undefined
546
+ })
547
+ getBanGroup.get = async () => false
548
+ getNotes.getNotesData = async () => /** @type {any} */({ theme: 'default' })
549
+ makeRequest.getThemeMarketList = async () => {
550
+ onlineCalls++
551
+ throw Object.assign(new Error('offline'), { code: 'api_offline' })
552
+ }
553
+ picmodle.market = /** @type {any} */ (async (/** @type {any} */ _event, /** @type {any} */ data) => { catalogs.push(data); return 'local-market' })
554
+ picmodle.marketDetail = /** @type {any} */ (async (/** @type {any} */ _event, /** @type {any} */ data) => { details.push(data); return 'local-detail' })
555
+ send.send_with_At = async () => undefined
556
+ send.reply = async () => undefined
557
+
558
+ try {
559
+ const command = new phiMarket()
560
+ const event = (/** @type {string} */ msg) => /** @type {any} */({ msg, user_id: 'offline-market-user' })
561
+ assert.equal(await command.market(event('/phi market')), true)
562
+ assert.equal(onlineCalls, 0)
563
+ assert.equal(catalogs[0]?.localOnly, true)
564
+ assert.equal(catalogs[0]?.themes.some((/** @type {any} */ theme) => theme.slug === 'milthm'), true)
565
+
566
+ apiEnabled = true
567
+ assert.equal(await command.market(event('/phi market')), true)
568
+ assert.equal(onlineCalls, 1)
569
+ assert.equal(catalogs[1]?.localOnly, true)
570
+
571
+ makeRequest.getThemeMarketList = /** @type {any} */ (async () => {
572
+ onlineCalls++
573
+ return { ok: false, themes: [] }
574
+ })
575
+ assert.equal(await command.market(event('/phi market')), true)
576
+ assert.equal(onlineCalls, 2)
577
+ assert.equal(catalogs[2]?.localOnly, true)
578
+
579
+ apiEnabled = false
580
+ assert.equal(await command.market(event('/phi market detail milthm')), true)
581
+ assert.equal(details[0]?.detail.local, true)
582
+ assert.equal(details[0]?.detail.slug, 'milthm')
583
+ } finally {
584
+ Config.getUserCfg = originals.getUserCfg
585
+ getBanGroup.get = originals.getBan
586
+ getNotes.getNotesData = originals.getNotesData
587
+ makeRequest.getThemeMarketList = originals.list
588
+ picmodle.market = originals.market
589
+ picmodle.marketDetail = originals.marketDetail
590
+ send.send_with_At = originals.sendWithAt
591
+ send.reply = originals.reply
592
+ }
593
+ })
594
+
595
+ test('market-installed detail is online-first and only connection errors use local detail', async () => {
596
+ const themeId = `detailtest${Date.now()}`
597
+ const target = path.join(themesDir, themeId)
598
+ const originals = {
599
+ getUserCfg: Config.getUserCfg,
600
+ getBan: getBanGroup.get,
601
+ getNotesData: getNotes.getNotesData,
602
+ detail: makeRequest.getThemeMarketDetail,
603
+ marketDetail: picmodle.marketDetail,
604
+ sendWithAt: send.send_with_At,
605
+ }
606
+ /** @type {any[]} */ const rendered = []
607
+ /** @type {any[]} */ const replies = []
608
+ try {
609
+ const archive = await makeArchive(themeId)
610
+ await withMarketInstallLock(() => installMarketArchive(themeId, {
611
+ version: '1.0.0', sha256,
612
+ }, archive))
613
+ themeManager.scan()
614
+
615
+ Config.getUserCfg = /** @type {any} */ ((_name = '', key = '') => {
616
+ if (key === 'cmdhead') return 'phi'
617
+ if (key === 'openPhiPluginApi') return true
618
+ if (key === 'enableCustomThemeApi') return true
619
+ return false
620
+ })
621
+ getBanGroup.get = async () => false
622
+ getNotes.getNotesData = async () => /** @type {any} */ ({ theme: 'default' })
623
+ makeRequest.getThemeMarketDetail = async () => ({
624
+ ok: true,
625
+ botDownloadAllowed: true,
626
+ theme: {
627
+ slug: themeId,
628
+ name: 'Remote Detail',
629
+ version: '2.0.0',
630
+ downloadPolicy: 'public',
631
+ },
632
+ })
633
+ picmodle.marketDetail = /** @type {any} */ (async (/** @type {any} */ _event, /** @type {any} */ data) => {
634
+ rendered.push(data.detail)
635
+ return 'market-detail'
636
+ })
637
+ send.send_with_At = async (_event, message) => { replies.push(message); return undefined }
638
+
639
+ const command = new phiMarket()
640
+ const event = /** @type {any} */ ({ msg: `/phi market detail ${themeId}`, user_id: 'detail-user' })
641
+ assert.equal(await command.market(event), true)
642
+ assert.equal(rendered[0]?.name, 'Remote Detail')
643
+ assert.equal(rendered[0]?.version, '2.0.0')
644
+ assert.equal(rendered[0]?.local, false)
645
+
646
+ makeRequest.getThemeMarketDetail = async () => {
647
+ throw Object.assign(new Error('offline'), { code: 'api_offline' })
648
+ }
649
+ assert.equal(await command.market(event), true)
650
+ assert.equal(rendered[1]?.slug, themeId)
651
+ assert.equal(rendered[1]?.version, '1.0.0')
652
+ assert.equal(rendered[1]?.local, true)
653
+
654
+ makeRequest.getThemeMarketDetail = async () => {
655
+ throw new ThemeMarketClientError('theme_store_invalid_response', 502)
656
+ }
657
+ assert.equal(await command.market(event), true)
658
+ assert.equal(rendered.length, 2)
659
+ assert.equal(replies.at(-1), '未找到该主题,或主题市场暂时不可用。')
660
+ } finally {
661
+ Config.getUserCfg = originals.getUserCfg
662
+ getBanGroup.get = originals.getBan
663
+ getNotes.getNotesData = originals.getNotesData
664
+ makeRequest.getThemeMarketDetail = originals.detail
665
+ picmodle.marketDetail = originals.marketDetail
666
+ send.send_with_At = originals.sendWithAt
667
+ await fs.promises.rm(target, { recursive: true, force: true })
668
+ themeManager.scan()
669
+ }
670
+ })
671
+
672
+ test('market page sends one safe Markdown action row for each displayed theme', async () => {
673
+ const themes = [
674
+ { slug: 'ocean-salt', name: 'Ocean "Salt"', botDownloadAllowed: true },
675
+ { slug: 'restricted-theme', name: 'Restricted | Theme', botDownloadAllowed: false },
676
+ ]
677
+ const originalGetUserCfg = Config.getUserCfg
678
+ const originalReply = send.reply
679
+ /** @type {any[]} */ const replies = []
680
+ Config.getUserCfg = /** @type {any} */ ((_name = '', key = '') => key === 'cmdhead' ? 'custom' : key === 'LetterMarkdown')
681
+ send.reply = async (_event, message) => { replies.push(message); return {} }
682
+ try {
683
+ const markdown = buildMarketQuickMarkdown(/** @type {any} */(themes), { page: 2, pageCount: 3 })
684
+ assert.match(markdown, /Ocean "Salt" \| <qqbot-cmd-input text="\/custom market detail ocean-salt" show="查看详情"/)
685
+ assert.match(markdown, /text="\/custom market ocean-salt" show="使用主题"/)
686
+ assert.match(markdown, /Restricted \\| Theme \| <qqbot-cmd-input text="\/custom market detail restricted-theme" show="查看详情"/)
687
+ assert.match(markdown, /text="\/custom market restricted-theme" show="使用主题"/)
688
+ assert.match(markdown, /text="\/custompr" show="上一页"/)
689
+ assert.match(markdown, /text="\/customnx" show="下一页"/)
690
+ assert.equal((markdown.match(/^\|/gm) || []).length, themes.length + 4)
691
+
692
+ await sendMarketQuickCommands(/** @type {any} */({}), /** @type {any} */(themes))
693
+ assert.equal(replies.length, 1)
694
+ assert.equal(replies[0]?.type, 'markdown')
695
+ assert.match(replies[0]?.text || '', /\/custom market ocean-salt/)
696
+ } finally {
697
+ Config.getUserCfg = originalGetUserCfg
698
+ send.reply = originalReply
699
+ }
700
+ })
701
+
702
+ test('market page sends no quick-command text when Markdown is disabled or fails', async () => {
703
+ const themes = /** @type {any} */ ([{ slug: 'ocean-salt', name: 'Ocean Salt', botDownloadAllowed: true }])
704
+ const originalGetUserCfg = Config.getUserCfg
705
+ const originalReply = send.reply
706
+ let calls = 0
707
+ try {
708
+ Config.getUserCfg = /** @type {any} */ (() => false)
709
+ send.reply = async () => { calls++; return {} }
710
+ await sendMarketQuickCommands(/** @type {any} */({}), themes)
711
+ assert.equal(calls, 0)
712
+
713
+ Config.getUserCfg = /** @type {any} */ ((_name = '', key = '') => key === 'LetterMarkdown')
714
+ send.reply = async () => { calls++; throw new Error('markdown unavailable') }
715
+ await sendMarketQuickCommands(/** @type {any} */({}), themes)
716
+ assert.equal(calls, 1)
717
+
718
+ send.reply = async () => { calls++; return { error: [new Error('markdown rejected')] } }
719
+ await sendMarketQuickCommands(/** @type {any} */({}), themes)
720
+ assert.equal(calls, 2)
721
+
722
+ const guoba = fs.readFileSync(new URL('../guoba.support.js', import.meta.url), 'utf8')
723
+ assert.match(guoba, /field: 'LetterMarkdown'/)
724
+ assert.doesNotMatch(guoba, /field: 'letterMarkdown'/)
725
+ } finally {
726
+ Config.getUserCfg = originalGetUserCfg
727
+ send.reply = originalReply
728
+ }
729
+ })
730
+
731
+ test('market shorthand navigation preserves the current query and page state', async () => {
732
+ const originals = {
733
+ getUserCfg: Config.getUserCfg,
734
+ getBan: getBanGroup.get,
735
+ getNotesData: getNotes.getNotesData,
736
+ list: makeRequest.getThemeMarketList,
737
+ market: picmodle.market,
738
+ sendWithAt: send.send_with_At,
739
+ reply: send.reply,
740
+ }
741
+ /** @type {{query:string,page:number,pageCount:number,commandHead:string}[]} */ const rendered = []
742
+ Config.getUserCfg = /** @type {any} */ ((_name = '', key = '') => {
743
+ if (key === 'cmdhead') return 'custom'
744
+ if (key === 'openPhiPluginApi') return true
745
+ if (key === 'LetterMarkdown') return false
746
+ return undefined
747
+ })
748
+ getBanGroup.get = async () => false
749
+ getNotes.getNotesData = async () => /** @type {any} */({ theme: 'default' })
750
+ makeRequest.getThemeMarketList = async () => ({
751
+ ok: true,
752
+ themes: Array.from({ length: THEME_MARKET_PAGE_SIZE + 2 }, (_, index) => ({
753
+ slug: `ocean-theme-${index}`,
754
+ name: `Ocean Theme ${index}`,
755
+ summary: 'ocean results',
756
+ botDownloadAllowed: true,
757
+ })),
758
+ })
759
+ picmodle.market = /** @type {any} */ (async (/** @type {any} */ _event, /** @type {any} */ data) => {
760
+ rendered.push({ query: data.query, page: data.page, pageCount: data.pageCount, commandHead: data.commandHead })
761
+ return `market-page-${data.page}`
762
+ })
763
+ send.send_with_At = async () => undefined
764
+ send.reply = async () => undefined
765
+
766
+ try {
767
+ const command = new phiMarket()
768
+ const event = (/** @type {string} */ msg) => /** @type {any} */({
769
+ msg, user_id: 'market-nav-user', group_id: 'market-nav-group', platform: 'test', isGroup: true,
770
+ })
771
+ assert.equal(await command.market(event('/custom market list ocean 1')), true)
772
+ assert.equal(await command.marketPage(event('/customnx')), true)
773
+ assert.equal(await command.marketPage(event('/custom pr')), true)
774
+ assert.deepEqual(rendered, [
775
+ { query: 'ocean', page: 1, pageCount: 2, commandHead: 'custom' },
776
+ { query: 'ocean', page: 2, pageCount: 2, commandHead: 'custom' },
777
+ { query: 'ocean', page: 1, pageCount: 2, commandHead: 'custom' },
778
+ ])
779
+ assert.match(String(command.rule?.[1]?.reg || ''), /nx\|pr\|上一页\|下一页/)
780
+ } finally {
781
+ Config.getUserCfg = originals.getUserCfg
782
+ getBanGroup.get = originals.getBan
783
+ getNotes.getNotesData = originals.getNotesData
784
+ makeRequest.getThemeMarketList = originals.list
785
+ picmodle.market = originals.market
786
+ send.send_with_At = originals.sendWithAt
787
+ send.reply = originals.reply
788
+ }
789
+ })
790
+
791
+ test('market catalog filters before paginating and list-only requests do not alter user settings', async () => {
792
+ const originalList = makeRequest.getThemeMarketList
793
+ const themes = Array.from({ length: THEME_MARKET_PAGE_SIZE + 2 }, (_, index) => ({
794
+ slug: `page-theme-${index}`,
795
+ name: `Page Theme ${index}`,
796
+ summary: 'pagination',
797
+ updatedAt: `2026-08-${String(19 - Math.min(index, 18)).padStart(2, '0')}`,
798
+ botDownloadAllowed: true,
799
+ }))
800
+ makeRequest.getThemeMarketList = async () => ({ ok: true, themes })
801
+ try {
802
+ const first = await fetchThemeCatalog('pagination', 1)
803
+ assert.equal(first.page, 1)
804
+ assert.equal(first.pageCount, 2)
805
+ assert.equal(first.total, THEME_MARKET_PAGE_SIZE + 2)
806
+ assert.equal(first.themes.length, THEME_MARKET_PAGE_SIZE)
807
+ const second = await fetchThemeCatalog('pagination', 2)
808
+ assert.equal(second.page, 2)
809
+ assert.equal(second.themes.length, 2)
810
+ assert.notEqual(first.themes[0].slug, second.themes[0].slug)
811
+ } finally {
812
+ makeRequest.getThemeMarketList = originalList
813
+ }
814
+
815
+ const command = fs.readFileSync(new URL('../apps/market.js', import.meta.url), 'utf8')
816
+ assert.match(command, /fetchThemeCatalog/)
817
+ assert.match(command, /marketDetail/)
818
+ const listBranch = command.slice(command.indexOf('// 无参数'), command.indexOf('// detail/info'))
819
+ assert.doesNotMatch(listBranch, /putNotesData\(/)
820
+ })
821
+
822
+ test('market catalog rejects malformed responses and deduplicates valid slugs', async () => {
823
+ const originalList = makeRequest.getThemeMarketList
824
+ const validTheme = {
825
+ slug: 'duplicate-theme',
826
+ name: 'Duplicate Theme',
827
+ botDownloadAllowed: true,
828
+ }
829
+ try {
830
+ for (const response of [
831
+ { ok: false, themes: [] },
832
+ { ok: true, themes: null },
833
+ { ok: true, themes: Array.from({ length: 501 }, () => validTheme) },
834
+ { ok: true, themes: [{ name: 'Missing slug', botDownloadAllowed: true }] },
835
+ { ok: true, themes: [{ slug: 'missing-capability', name: 'Missing capability' }] },
836
+ ]) {
837
+ makeRequest.getThemeMarketList = /** @type {any} */ (async () => response)
838
+ await assert.rejects(
839
+ fetchThemeCatalog(),
840
+ error => /** @type {any} */(error)?.code === 'theme_store_invalid_response',
841
+ )
842
+ }
843
+
844
+ makeRequest.getThemeMarketList = async () => ({
845
+ ok: true,
846
+ themes: [validTheme, { ...validTheme, name: 'Duplicate Theme Again' }],
847
+ })
848
+ const catalog = await fetchThemeCatalog('duplicate')
849
+ assert.equal(catalog.total, 1)
850
+ assert.equal(catalog.themes[0]?.slug, 'duplicate-theme')
851
+ assert.equal(catalog.themes[0]?.name, 'Duplicate Theme')
852
+ } finally {
853
+ makeRequest.getThemeMarketList = originalList
854
+ }
855
+ })