@yunzai-ng/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +51 -0
  3. package/dist/adapter/accounts.d.ts +174 -0
  4. package/dist/adapter/accounts.d.ts.map +1 -0
  5. package/dist/adapter/accounts.js +653 -0
  6. package/dist/adapter/accounts.js.map +1 -0
  7. package/dist/adapter/bots.d.ts +142 -0
  8. package/dist/adapter/bots.d.ts.map +1 -0
  9. package/dist/adapter/bots.js +454 -0
  10. package/dist/adapter/bots.js.map +1 -0
  11. package/dist/adapter/host.d.ts +114 -0
  12. package/dist/adapter/host.d.ts.map +1 -0
  13. package/dist/adapter/host.js +90 -0
  14. package/dist/adapter/host.js.map +1 -0
  15. package/dist/adapter/login.d.ts +174 -0
  16. package/dist/adapter/login.d.ts.map +1 -0
  17. package/dist/adapter/login.js +345 -0
  18. package/dist/adapter/login.js.map +1 -0
  19. package/dist/adapter/registry.d.ts +102 -0
  20. package/dist/adapter/registry.d.ts.map +1 -0
  21. package/dist/adapter/registry.js +150 -0
  22. package/dist/adapter/registry.js.map +1 -0
  23. package/dist/config/core-config.d.ts +151 -0
  24. package/dist/config/core-config.d.ts.map +1 -0
  25. package/dist/config/core-config.js +338 -0
  26. package/dist/config/core-config.js.map +1 -0
  27. package/dist/config/schema.d.ts +449 -0
  28. package/dist/config/schema.d.ts.map +1 -0
  29. package/dist/config/schema.js +871 -0
  30. package/dist/config/schema.js.map +1 -0
  31. package/dist/config/store.d.ts +184 -0
  32. package/dist/config/store.d.ts.map +1 -0
  33. package/dist/config/store.js +428 -0
  34. package/dist/config/store.js.map +1 -0
  35. package/dist/config/yaml.d.ts +23 -0
  36. package/dist/config/yaml.d.ts.map +1 -0
  37. package/dist/config/yaml.js +108 -0
  38. package/dist/config/yaml.js.map +1 -0
  39. package/dist/http/client.d.ts +82 -0
  40. package/dist/http/client.d.ts.map +1 -0
  41. package/dist/http/client.js +658 -0
  42. package/dist/http/client.js.map +1 -0
  43. package/dist/index.d.ts +77 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +94 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/kernel/app.d.ts +244 -0
  48. package/dist/kernel/app.d.ts.map +1 -0
  49. package/dist/kernel/app.js +654 -0
  50. package/dist/kernel/app.js.map +1 -0
  51. package/dist/kernel/kv-sink.d.ts +34 -0
  52. package/dist/kernel/kv-sink.d.ts.map +1 -0
  53. package/dist/kernel/kv-sink.js +25 -0
  54. package/dist/kernel/kv-sink.js.map +1 -0
  55. package/dist/kernel/policy.d.ts +77 -0
  56. package/dist/kernel/policy.d.ts.map +1 -0
  57. package/dist/kernel/policy.js +99 -0
  58. package/dist/kernel/policy.js.map +1 -0
  59. package/dist/kernel/runtime.d.ts +117 -0
  60. package/dist/kernel/runtime.d.ts.map +1 -0
  61. package/dist/kernel/runtime.js +197 -0
  62. package/dist/kernel/runtime.js.map +1 -0
  63. package/dist/kernel/sql-sink.d.ts +36 -0
  64. package/dist/kernel/sql-sink.d.ts.map +1 -0
  65. package/dist/kernel/sql-sink.js +114 -0
  66. package/dist/kernel/sql-sink.js.map +1 -0
  67. package/dist/logger/format.d.ts +67 -0
  68. package/dist/logger/format.d.ts.map +1 -0
  69. package/dist/logger/format.js +218 -0
  70. package/dist/logger/format.js.map +1 -0
  71. package/dist/logger/index.d.ts +97 -0
  72. package/dist/logger/index.d.ts.map +1 -0
  73. package/dist/logger/index.js +363 -0
  74. package/dist/logger/index.js.map +1 -0
  75. package/dist/logger/rotate.d.ts +47 -0
  76. package/dist/logger/rotate.d.ts.map +1 -0
  77. package/dist/logger/rotate.js +242 -0
  78. package/dist/logger/rotate.js.map +1 -0
  79. package/dist/message/segment.d.ts +255 -0
  80. package/dist/message/segment.d.ts.map +1 -0
  81. package/dist/message/segment.js +510 -0
  82. package/dist/message/segment.js.map +1 -0
  83. package/dist/message/split.d.ts +34 -0
  84. package/dist/message/split.d.ts.map +1 -0
  85. package/dist/message/split.js +79 -0
  86. package/dist/message/split.js.map +1 -0
  87. package/dist/message/target.d.ts +29 -0
  88. package/dist/message/target.d.ts.map +1 -0
  89. package/dist/message/target.js +34 -0
  90. package/dist/message/target.js.map +1 -0
  91. package/dist/pipeline/cooldown.d.ts +84 -0
  92. package/dist/pipeline/cooldown.d.ts.map +1 -0
  93. package/dist/pipeline/cooldown.js +94 -0
  94. package/dist/pipeline/cooldown.js.map +1 -0
  95. package/dist/pipeline/dispatch.d.ts +98 -0
  96. package/dist/pipeline/dispatch.d.ts.map +1 -0
  97. package/dist/pipeline/dispatch.js +323 -0
  98. package/dist/pipeline/dispatch.js.map +1 -0
  99. package/dist/pipeline/event.d.ts +130 -0
  100. package/dist/pipeline/event.d.ts.map +1 -0
  101. package/dist/pipeline/event.js +538 -0
  102. package/dist/pipeline/event.js.map +1 -0
  103. package/dist/pipeline/middleware.d.ts +63 -0
  104. package/dist/pipeline/middleware.d.ts.map +1 -0
  105. package/dist/pipeline/middleware.js +174 -0
  106. package/dist/pipeline/middleware.js.map +1 -0
  107. package/dist/pipeline/prompt.d.ts +80 -0
  108. package/dist/pipeline/prompt.d.ts.map +1 -0
  109. package/dist/pipeline/prompt.js +155 -0
  110. package/dist/pipeline/prompt.js.map +1 -0
  111. package/dist/pipeline/router.d.ts +98 -0
  112. package/dist/pipeline/router.d.ts.map +1 -0
  113. package/dist/pipeline/router.js +489 -0
  114. package/dist/pipeline/router.js.map +1 -0
  115. package/dist/platform/detect.d.ts +24 -0
  116. package/dist/platform/detect.d.ts.map +1 -0
  117. package/dist/platform/detect.js +178 -0
  118. package/dist/platform/detect.js.map +1 -0
  119. package/dist/platform/paths.d.ts +38 -0
  120. package/dist/platform/paths.d.ts.map +1 -0
  121. package/dist/platform/paths.js +129 -0
  122. package/dist/platform/paths.js.map +1 -0
  123. package/dist/platform/system.d.ts +71 -0
  124. package/dist/platform/system.d.ts.map +1 -0
  125. package/dist/platform/system.js +242 -0
  126. package/dist/platform/system.js.map +1 -0
  127. package/dist/plugin/context.d.ts +92 -0
  128. package/dist/plugin/context.d.ts.map +1 -0
  129. package/dist/plugin/context.js +573 -0
  130. package/dist/plugin/context.js.map +1 -0
  131. package/dist/plugin/define.d.ts +92 -0
  132. package/dist/plugin/define.d.ts.map +1 -0
  133. package/dist/plugin/define.js +82 -0
  134. package/dist/plugin/define.js.map +1 -0
  135. package/dist/plugin/discover.d.ts +106 -0
  136. package/dist/plugin/discover.d.ts.map +1 -0
  137. package/dist/plugin/discover.js +254 -0
  138. package/dist/plugin/discover.js.map +1 -0
  139. package/dist/plugin/events.d.ts +109 -0
  140. package/dist/plugin/events.d.ts.map +1 -0
  141. package/dist/plugin/events.js +209 -0
  142. package/dist/plugin/events.js.map +1 -0
  143. package/dist/plugin/hooks.d.ts +266 -0
  144. package/dist/plugin/hooks.d.ts.map +1 -0
  145. package/dist/plugin/hooks.js +84 -0
  146. package/dist/plugin/hooks.js.map +1 -0
  147. package/dist/plugin/host.d.ts +160 -0
  148. package/dist/plugin/host.d.ts.map +1 -0
  149. package/dist/plugin/host.js +546 -0
  150. package/dist/plugin/host.js.map +1 -0
  151. package/dist/plugin/market.d.ts +264 -0
  152. package/dist/plugin/market.d.ts.map +1 -0
  153. package/dist/plugin/market.js +700 -0
  154. package/dist/plugin/market.js.map +1 -0
  155. package/dist/plugin/services.d.ts +116 -0
  156. package/dist/plugin/services.d.ts.map +1 -0
  157. package/dist/plugin/services.js +213 -0
  158. package/dist/plugin/services.js.map +1 -0
  159. package/dist/plugin/tar.d.ts +65 -0
  160. package/dist/plugin/tar.d.ts.map +1 -0
  161. package/dist/plugin/tar.js +277 -0
  162. package/dist/plugin/tar.js.map +1 -0
  163. package/dist/render/registry.d.ts +127 -0
  164. package/dist/render/registry.d.ts.map +1 -0
  165. package/dist/render/registry.js +306 -0
  166. package/dist/render/registry.js.map +1 -0
  167. package/dist/scheduler/index.d.ts +67 -0
  168. package/dist/scheduler/index.d.ts.map +1 -0
  169. package/dist/scheduler/index.js +279 -0
  170. package/dist/scheduler/index.js.map +1 -0
  171. package/dist/server/api.d.ts +179 -0
  172. package/dist/server/api.d.ts.map +1 -0
  173. package/dist/server/api.js +602 -0
  174. package/dist/server/api.js.map +1 -0
  175. package/dist/server/auth.d.ts +83 -0
  176. package/dist/server/auth.d.ts.map +1 -0
  177. package/dist/server/auth.js +178 -0
  178. package/dist/server/auth.js.map +1 -0
  179. package/dist/server/browse.d.ts +60 -0
  180. package/dist/server/browse.d.ts.map +1 -0
  181. package/dist/server/browse.js +187 -0
  182. package/dist/server/browse.js.map +1 -0
  183. package/dist/server/files.d.ts +27 -0
  184. package/dist/server/files.d.ts.map +1 -0
  185. package/dist/server/files.js +76 -0
  186. package/dist/server/files.js.map +1 -0
  187. package/dist/server/index.d.ts +189 -0
  188. package/dist/server/index.d.ts.map +1 -0
  189. package/dist/server/index.js +1123 -0
  190. package/dist/server/index.js.map +1 -0
  191. package/dist/server/table.d.ts +105 -0
  192. package/dist/server/table.d.ts.map +1 -0
  193. package/dist/server/table.js +289 -0
  194. package/dist/server/table.js.map +1 -0
  195. package/dist/store/index.d.ts +50 -0
  196. package/dist/store/index.d.ts.map +1 -0
  197. package/dist/store/index.js +120 -0
  198. package/dist/store/index.js.map +1 -0
  199. package/dist/store/json.d.ts +73 -0
  200. package/dist/store/json.d.ts.map +1 -0
  201. package/dist/store/json.js +174 -0
  202. package/dist/store/json.js.map +1 -0
  203. package/dist/store/kv.d.ts +126 -0
  204. package/dist/store/kv.d.ts.map +1 -0
  205. package/dist/store/kv.js +232 -0
  206. package/dist/store/kv.js.map +1 -0
  207. package/dist/store/level.d.ts +103 -0
  208. package/dist/store/level.d.ts.map +1 -0
  209. package/dist/store/level.js +119 -0
  210. package/dist/store/level.js.map +1 -0
  211. package/dist/store/memory.d.ts +61 -0
  212. package/dist/store/memory.d.ts.map +1 -0
  213. package/dist/store/memory.js +94 -0
  214. package/dist/store/memory.js.map +1 -0
  215. package/dist/store/sql.d.ts +142 -0
  216. package/dist/store/sql.d.ts.map +1 -0
  217. package/dist/store/sql.js +246 -0
  218. package/dist/store/sql.js.map +1 -0
  219. package/dist/testing/fake.d.ts +109 -0
  220. package/dist/testing/fake.d.ts.map +1 -0
  221. package/dist/testing/fake.js +196 -0
  222. package/dist/testing/fake.js.map +1 -0
  223. package/dist/testing/index.d.ts +16 -0
  224. package/dist/testing/index.d.ts.map +1 -0
  225. package/dist/testing/index.js +16 -0
  226. package/dist/testing/index.js.map +1 -0
  227. package/dist/testing/mock-adapter.d.ts +205 -0
  228. package/dist/testing/mock-adapter.d.ts.map +1 -0
  229. package/dist/testing/mock-adapter.js +309 -0
  230. package/dist/testing/mock-adapter.js.map +1 -0
  231. package/dist/util/deep.d.ts +108 -0
  232. package/dist/util/deep.d.ts.map +1 -0
  233. package/dist/util/deep.js +275 -0
  234. package/dist/util/deep.js.map +1 -0
  235. package/dist/util/defer.d.ts +107 -0
  236. package/dist/util/defer.d.ts.map +1 -0
  237. package/dist/util/defer.js +163 -0
  238. package/dist/util/defer.js.map +1 -0
  239. package/dist/util/dispose.d.ts +88 -0
  240. package/dist/util/dispose.d.ts.map +1 -0
  241. package/dist/util/dispose.js +143 -0
  242. package/dist/util/dispose.js.map +1 -0
  243. package/dist/util/duration.d.ts +28 -0
  244. package/dist/util/duration.d.ts.map +1 -0
  245. package/dist/util/duration.js +77 -0
  246. package/dist/util/duration.js.map +1 -0
  247. package/dist/util/fs.d.ts +110 -0
  248. package/dist/util/fs.d.ts.map +1 -0
  249. package/dist/util/fs.js +269 -0
  250. package/dist/util/fs.js.map +1 -0
  251. package/dist/util/id.d.ts +49 -0
  252. package/dist/util/id.d.ts.map +1 -0
  253. package/dist/util/id.js +87 -0
  254. package/dist/util/id.js.map +1 -0
  255. package/dist/util/lru.d.ts +74 -0
  256. package/dist/util/lru.d.ts.map +1 -0
  257. package/dist/util/lru.js +156 -0
  258. package/dist/util/lru.js.map +1 -0
  259. package/dist/util/queue.d.ts +64 -0
  260. package/dist/util/queue.d.ts.map +1 -0
  261. package/dist/util/queue.js +147 -0
  262. package/dist/util/queue.js.map +1 -0
  263. package/dist/util/text.d.ts +86 -0
  264. package/dist/util/text.d.ts.map +1 -0
  265. package/dist/util/text.js +182 -0
  266. package/dist/util/text.js.map +1 -0
  267. package/package.json +44 -0
  268. package/src/adapter/accounts.ts +758 -0
  269. package/src/adapter/bots.ts +582 -0
  270. package/src/adapter/host.ts +224 -0
  271. package/src/adapter/login.ts +481 -0
  272. package/src/adapter/registry.ts +202 -0
  273. package/src/config/core-config.test.ts +60 -0
  274. package/src/config/core-config.ts +401 -0
  275. package/src/config/schema.test.ts +179 -0
  276. package/src/config/schema.ts +1036 -0
  277. package/src/config/store.test.ts +245 -0
  278. package/src/config/store.ts +506 -0
  279. package/src/config/yaml.ts +119 -0
  280. package/src/http/client.test.ts +568 -0
  281. package/src/http/client.ts +777 -0
  282. package/src/index.ts +106 -0
  283. package/src/kernel/app.test.ts +267 -0
  284. package/src/kernel/app.ts +843 -0
  285. package/src/kernel/kv-sink.ts +58 -0
  286. package/src/kernel/policy.ts +120 -0
  287. package/src/kernel/runtime.test.ts +542 -0
  288. package/src/kernel/runtime.ts +307 -0
  289. package/src/kernel/sql-sink.ts +154 -0
  290. package/src/logger/format.ts +259 -0
  291. package/src/logger/index.ts +427 -0
  292. package/src/logger/rotate.ts +265 -0
  293. package/src/message/segment.test.ts +335 -0
  294. package/src/message/segment.ts +569 -0
  295. package/src/message/split.ts +97 -0
  296. package/src/message/target.ts +48 -0
  297. package/src/pipeline/cooldown.ts +136 -0
  298. package/src/pipeline/dispatch.ts +419 -0
  299. package/src/pipeline/event.ts +735 -0
  300. package/src/pipeline/middleware.test.ts +426 -0
  301. package/src/pipeline/middleware.ts +199 -0
  302. package/src/pipeline/prompt.ts +212 -0
  303. package/src/pipeline/router.test.ts +432 -0
  304. package/src/pipeline/router.ts +575 -0
  305. package/src/platform/detect.ts +178 -0
  306. package/src/platform/paths.ts +147 -0
  307. package/src/platform/system.test.ts +102 -0
  308. package/src/platform/system.ts +289 -0
  309. package/src/plugin/context.test.ts +459 -0
  310. package/src/plugin/context.ts +757 -0
  311. package/src/plugin/define.test.ts +120 -0
  312. package/src/plugin/define.ts +142 -0
  313. package/src/plugin/discover.test.ts +253 -0
  314. package/src/plugin/discover.ts +353 -0
  315. package/src/plugin/events.test.ts +162 -0
  316. package/src/plugin/events.ts +266 -0
  317. package/src/plugin/hooks.ts +373 -0
  318. package/src/plugin/host.test.ts +696 -0
  319. package/src/plugin/host.ts +738 -0
  320. package/src/plugin/market.test.ts +781 -0
  321. package/src/plugin/market.ts +870 -0
  322. package/src/plugin/services.test.ts +135 -0
  323. package/src/plugin/services.ts +261 -0
  324. package/src/plugin/tar.test.ts +158 -0
  325. package/src/plugin/tar.ts +309 -0
  326. package/src/render/registry.test.ts +127 -0
  327. package/src/render/registry.ts +439 -0
  328. package/src/scheduler/index.ts +357 -0
  329. package/src/server/api.test.ts +802 -0
  330. package/src/server/api.ts +869 -0
  331. package/src/server/auth.ts +201 -0
  332. package/src/server/browse.test.ts +193 -0
  333. package/src/server/browse.ts +240 -0
  334. package/src/server/files.ts +97 -0
  335. package/src/server/index.test.ts +566 -0
  336. package/src/server/index.ts +1386 -0
  337. package/src/server/table.ts +351 -0
  338. package/src/store/index.ts +156 -0
  339. package/src/store/json.ts +198 -0
  340. package/src/store/kv.test.ts +307 -0
  341. package/src/store/kv.ts +256 -0
  342. package/src/store/level.ts +167 -0
  343. package/src/store/memory.ts +109 -0
  344. package/src/store/sql.test.ts +204 -0
  345. package/src/store/sql.ts +324 -0
  346. package/src/testing/fake.ts +297 -0
  347. package/src/testing/index.ts +15 -0
  348. package/src/testing/mock-adapter.ts +588 -0
  349. package/src/util/deep.ts +272 -0
  350. package/src/util/defer.ts +211 -0
  351. package/src/util/dispose.ts +169 -0
  352. package/src/util/duration.ts +82 -0
  353. package/src/util/fs.ts +279 -0
  354. package/src/util/id.ts +94 -0
  355. package/src/util/lru.ts +185 -0
  356. package/src/util/queue.ts +167 -0
  357. package/src/util/text.ts +189 -0
@@ -0,0 +1,575 @@
1
+ /**
2
+ * 模块职责:命令注册表与匹配(实现 `CommandSink`)
3
+ * 依赖方向:依赖类型包、plugin/hooks 的登记结构、util/text;不依赖事件实现与适配器
4
+ * 生命周期:随内核创建,随内核销毁;每条注册返回 Disposer,插件卸载即摘除
5
+ * 注意事项:**按触发词首字符分桶,而不是每条消息线性匹配全部正则。** 线性匹配下,
6
+ * 装 20 个插件就意味着每条消息数百次正则测试,且插件越多越慢。
7
+ *
8
+ * 分桶后:`"#体力"` 进入 `#` 桶,一条 `#体力` 仅
9
+ * 尝试 `#` 桶内的候选。桶内按 `priority` 预排序(注册时排序一次,
10
+ * 匹配时不排序),因此匹配路径上不存在 sort 与正则编译。
11
+ *
12
+ * 正则模式尽力提取字面前缀(`/^#体力$/` → `#`)亦进入桶;无法提取的
13
+ * (`/^\d+/`、带 `m` 或 `i` 标志的字母开头)落入 `#unbucketed`,
14
+ * 每条消息均需尝试 —— 这是必须付出的代价,因此插件作者在可使用字符串时
15
+ * 不应改用正则。
16
+ */
17
+ import type {
18
+ CommandInfo,
19
+ CommandMatch,
20
+ CommandOptions,
21
+ CommandPattern,
22
+ Disposer,
23
+ Logger,
24
+ MessageEvent,
25
+ MessageScene
26
+ } from "@yunzai-ng/types"
27
+ import type { CommandRegistration, CommandSink } from "../plugin/hooks.js"
28
+
29
+ /** 缺省优先级 */
30
+ const DEFAULT_PRIORITY = 100
31
+
32
+ /** 正则里不能当字面前缀的字符 */
33
+ const REGEX_META = new Set([".", "*", "+", "?", "(", ")", "[", "]", "{", "}", "|", "$", "^"])
34
+
35
+ /** 会让前一个字符变成"可选"的量词,出现即无法据此分桶 */
36
+ const OPTIONAL_QUANTIFIER = new Set(["?", "*"])
37
+
38
+ /**
39
+ * 一次模式命中的原始结果
40
+ *
41
+ * 与 `CommandMatch` 的区别是不含 `name`/`plugin` —— 那两个来自登记而非模式。
42
+ */
43
+ interface PatternHit {
44
+ /** 命中的模式可读形式 */
45
+ pattern: string
46
+ /** 命中的触发词 */
47
+ trigger: string
48
+ /** 触发词之后剩余的文本,已 trim */
49
+ rest: string
50
+ /** 命名捕获组 */
51
+ groups: Readonly<Record<string, string>>
52
+ /** 数字捕获,下标 0 为整体匹配 */
53
+ captures: readonly string[]
54
+ }
55
+
56
+ /** 编译后的单个模式 */
57
+ interface CompiledPattern {
58
+ /** 可读形式;字符串模式即其本身,正则取 `source` */
59
+ readonly label: string
60
+ /** 分桶键(触发词首字符);`undefined` 表示无法分桶 */
61
+ readonly key: string | undefined
62
+ /**
63
+ * 尝试匹配
64
+ * @param text 消息纯文本
65
+ * @returns 命中结果;未命中返回 undefined
66
+ */
67
+ match(text: string): PatternHit | undefined
68
+ }
69
+
70
+ /** 注册表内部条目 */
71
+ interface Entry {
72
+ /** 原始登记(可变,插件可能随后追加别名) */
73
+ readonly reg: CommandRegistration
74
+ /** 注册序号,用于同优先级时的稳定排序 */
75
+ readonly seq: number
76
+ /** 编译后的模式 */
77
+ compiled: CompiledPattern[]
78
+ }
79
+
80
+ /** 一条命中的候选 */
81
+ export interface RouterCandidate {
82
+ /** 命中的登记 */
83
+ readonly reg: CommandRegistration
84
+ /** 填给 `e.command` 的匹配结果 */
85
+ readonly match: CommandMatch
86
+ }
87
+
88
+ /** 命令路由器构造参数 */
89
+ export interface CommandRouterOptions {
90
+ /** 日志器 */
91
+ readonly logger: Logger
92
+ /**
93
+ * 判断某命令是否被用户禁用
94
+ *
95
+ * 由配置层注入而非由路由器自行读取配置:路由器不应知晓配置文件的结构,
96
+ * 而 WebUI 的"停用某命令"开关(阶段四)只需替换该函数即可接入。
97
+ * @param reg 登记内容
98
+ * @returns 是否禁用
99
+ */
100
+ readonly isDisabled?: (reg: CommandRegistration) => boolean
101
+ }
102
+
103
+ /**
104
+ * 取字符串模式的分桶键
105
+ * @param pattern 字符串模式
106
+ * @returns 首字符
107
+ */
108
+ function keyOfString(pattern: string): string {
109
+ return [...pattern][0] ?? ""
110
+ }
111
+
112
+ /**
113
+ * 尝试从正则里提取可分桶的字面首字符
114
+ *
115
+ * 只在**能证明**"命中必然以该字符开头"时才返回,宁可放弃优化也不能漏匹配。
116
+ * 放弃的情形:
117
+ * - 没有 `^` 锚定 —— 可以从任意位置命中
118
+ * - `m` 标志 —— `^` 也匹配行首,消息第二行开头也算命中,首字符判断不成立
119
+ * - `i` 标志且首字符是 ASCII 字母 —— 大小写两个桶,不值当
120
+ * - 首字符是元字符、字符类(`\d`)、或后面跟着 `?`/`*` 等可选量词
121
+ * @param re 正则
122
+ * @returns 分桶键;无法提取时 undefined
123
+ */
124
+ function keyOfRegExp(re: RegExp): string | undefined {
125
+ if (re.flags.includes("m")) return undefined
126
+
127
+ const src = re.source
128
+ if (!src.startsWith("^")) return undefined
129
+
130
+ let literal: string
131
+ let after: number
132
+
133
+ const first = src[1]
134
+ if (first === undefined) return undefined
135
+
136
+ if (first === "\\") {
137
+ const escaped = src[2]
138
+ if (escaped === undefined) return undefined
139
+ // `\d` / `\w` / `\s` / `\b` 这类是字符类或断言,不是字面量
140
+ if (/[a-zA-Z0-9]/.test(escaped)) return undefined
141
+ literal = escaped
142
+ after = 3
143
+ } else if (REGEX_META.has(first)) {
144
+ return undefined
145
+ } else {
146
+ literal = first
147
+ after = 2
148
+ }
149
+
150
+ const next = src[after]
151
+ if (next !== undefined && OPTIONAL_QUANTIFIER.has(next)) return undefined
152
+ // `{0,3}` 也让首字符可选;`{2}` 不会,但区分成本高于收益,一律放弃
153
+ if (next === "{") return undefined
154
+
155
+ if (re.flags.includes("i") && /[a-zA-Z]/.test(literal)) return undefined
156
+
157
+ return literal
158
+ }
159
+
160
+ /**
161
+ * 去掉正则的有状态标志
162
+ *
163
+ * `g` 与 `y` 会让 `exec` 推进 `lastIndex`:同一个正则对象在第二条消息上
164
+ * 会从上次结束的位置开始找,表现为"命令只灵一次"。这类 bug 极难排查,
165
+ * 因此在注册时就消灭掉,并告知插件作者。
166
+ * @param re 原始正则
167
+ * @param label 日志用的命令标识
168
+ * @param logger 日志器
169
+ * @returns 无状态标志的正则
170
+ */
171
+ function stripStatefulFlags(re: RegExp, label: string, logger: Logger): RegExp {
172
+ if (!re.flags.includes("g") && !re.flags.includes("y")) return re
173
+ const flags = re.flags.replace(/[gy]/g, "")
174
+ logger.warn(`命令 ${label} 的正则带 g/y 标志,已自动去除:这两个标志会让匹配位置在消息之间残留`)
175
+ return new RegExp(re.source, flags)
176
+ }
177
+
178
+ /**
179
+ * 编译一个模式
180
+ * @param pattern 原始模式
181
+ * @param opts 命令选项(`anywhere` 影响字符串匹配方式)
182
+ * @param label 日志用的命令标识
183
+ * @param logger 日志器
184
+ * @returns 编译结果
185
+ * @throws 字符串模式为空时
186
+ */
187
+ function compile(pattern: CommandPattern, opts: CommandOptions, label: string, logger: Logger): CompiledPattern {
188
+ if (typeof pattern === "string") {
189
+ if (pattern === "") throw new Error(`命令 ${label} 的字符串模式不能为空:空串会匹配所有消息`)
190
+
191
+ if (opts.anywhere === true) {
192
+ return {
193
+ label: pattern,
194
+ // anywhere 的触发词可以出现在任何位置,首字符判断不成立
195
+ key: undefined,
196
+ match: text => {
197
+ const at = text.indexOf(pattern)
198
+ if (at < 0) return undefined
199
+ return {
200
+ pattern,
201
+ trigger: pattern,
202
+ rest: text.slice(at + pattern.length).trim(),
203
+ groups: {},
204
+ captures: [pattern]
205
+ }
206
+ }
207
+ }
208
+ }
209
+
210
+ return {
211
+ label: pattern,
212
+ key: keyOfString(pattern),
213
+ match: text => {
214
+ if (!text.startsWith(pattern)) return undefined
215
+ return {
216
+ pattern,
217
+ trigger: pattern,
218
+ rest: text.slice(pattern.length).trim(),
219
+ groups: {},
220
+ captures: [pattern]
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ const re = stripStatefulFlags(pattern, label, logger)
227
+ return {
228
+ label: re.source,
229
+ key: keyOfRegExp(re),
230
+ match: text => {
231
+ const m = re.exec(text)
232
+ if (m === null) return undefined
233
+ const whole = m[0]
234
+ return {
235
+ pattern: re.source,
236
+ trigger: whole,
237
+ rest: text.slice(m.index + whole.length).trim(),
238
+ groups: m.groups ?? {},
239
+ // 捕获组可能不参与匹配(`(a)|(b)`),undefined 统一成空串,
240
+ // 免得插件里到处写 `captures[1] ?? ""`
241
+ captures: Array.from(m, v => v ?? "")
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ /**
248
+ * 比较两个条目的匹配顺序
249
+ * @param a 条目 a
250
+ * @param b 条目 b
251
+ * @returns 排序结果
252
+ */
253
+ function byPriority(a: Entry, b: Entry): number {
254
+ const pa = a.reg.options.priority ?? DEFAULT_PRIORITY
255
+ const pb = b.reg.options.priority ?? DEFAULT_PRIORITY
256
+ return pa !== pb ? pa - pb : a.seq - b.seq
257
+ }
258
+
259
+ /**
260
+ * 把一项按序插入已排序数组
261
+ *
262
+ * 注册是低频操作、匹配是高频操作,因此把排序成本挪到注册时,
263
+ * 让匹配路径上一次 sort 都不做。
264
+ * @param list 已排序数组(就地修改)
265
+ * @param entry 待插入条目
266
+ */
267
+ function insertSorted(list: Entry[], entry: Entry): void {
268
+ let lo = 0
269
+ let hi = list.length
270
+ while (lo < hi) {
271
+ const mid = (lo + hi) >>> 1
272
+ // 断言安全:mid < hi <= length
273
+ if (byPriority(list[mid]!, entry) <= 0) lo = mid + 1
274
+ else hi = mid
275
+ }
276
+ list.splice(lo, 0, entry)
277
+ }
278
+
279
+ /**
280
+ * 判断事件场景是否落在命令声明的范围内
281
+ * @param opts 命令选项
282
+ * @param scene 事件场景
283
+ * @returns 是否允许
284
+ */
285
+ function sceneAllowed(opts: CommandOptions, scene: MessageScene): boolean {
286
+ const want = opts.scene
287
+ if (want === undefined) return true
288
+ return Array.isArray(want) ? want.includes(scene) : want === scene
289
+ }
290
+
291
+ /**
292
+ * 命令路由器
293
+ *
294
+ * 既是 `ctx.command()` 的落点(`CommandSink`),也是消息管线的查询入口
295
+ * (`match()`),还是 WebUI 的数据来源(`list()`)。
296
+ */
297
+ export class CommandRouter implements CommandSink {
298
+ /** 日志器 */
299
+ readonly #logger: Logger
300
+ /** 命令禁用判定 */
301
+ readonly #isDisabled: (reg: CommandRegistration) => boolean
302
+ /** 登记 → 条目 */
303
+ readonly #entries = new Map<CommandRegistration, Entry>()
304
+ /** 首字符 → 已排序的条目数组 */
305
+ readonly #buckets = new Map<string, Entry[]>()
306
+ /** 无法分桶、每条消息都要试的条目(已排序) */
307
+ #unbucketed: Entry[] = []
308
+ /** 注册序号发号器 */
309
+ #seq = 0
310
+
311
+ /**
312
+ * @param opts 构造参数
313
+ */
314
+ constructor(opts: CommandRouterOptions) {
315
+ this.#logger = opts.logger
316
+ this.#isDisabled = opts.isDisabled ?? (() => false)
317
+ }
318
+
319
+ /** 已注册的命令条数 */
320
+ get size(): number {
321
+ return this.#entries.size
322
+ }
323
+
324
+ /**
325
+ * 登记一条命令
326
+ *
327
+ * 不会抛错:单个模式编译失败(如空串)只落一条 error 日志并跳过该模式,
328
+ * 其余别名照旧可用 —— 一条命令的一个别名写错不该让插件装不上。
329
+ * @param reg 登记内容
330
+ * @returns 注销句柄
331
+ */
332
+ register(reg: CommandRegistration): Disposer {
333
+ if (this.#entries.has(reg)) return () => this.#remove(reg)
334
+
335
+ const entry: Entry = { reg, seq: this.#seq++, compiled: [] }
336
+ entry.compiled = this.#compileAll(reg)
337
+ this.#entries.set(reg, entry)
338
+ this.#index(entry)
339
+
340
+ return () => this.#remove(reg)
341
+ }
342
+
343
+ /**
344
+ * 模式变化后重建索引
345
+ *
346
+ * 链式构造器的 `.alias()` 会往 `reg.patterns` 里塞新模式。整条重建而不是
347
+ * 增量追加:别名极少,而增量逻辑要处理"旧键还在不在别的模式里用"这种
348
+ * 引用计数问题,不值得。
349
+ * @param reg 登记内容
350
+ */
351
+ reindex(reg: CommandRegistration): void {
352
+ const entry = this.#entries.get(reg)
353
+ if (entry === undefined) return
354
+ this.#unindex(entry)
355
+ entry.compiled = this.#compileAll(reg)
356
+ this.#index(entry)
357
+ }
358
+
359
+ /**
360
+ * 找出该消息命中的全部命令,按匹配顺序排列
361
+ *
362
+ * 只做**模式匹配**与**零成本的静态过滤**(禁用、场景、@我、权限)。
363
+ * 冷却是异步的、要写 KV,且命中后才该计费,因此留给 dispatch。
364
+ * @param e 消息事件
365
+ * @returns 候选数组;无命中时为空数组
366
+ */
367
+ match(e: MessageEvent): RouterCandidate[] {
368
+ const candidates = this.#candidates(e.text)
369
+ if (candidates.length === 0) return []
370
+
371
+ const out: RouterCandidate[] = []
372
+ for (const entry of candidates) {
373
+ const { reg } = entry
374
+ if (reg.handler === undefined) continue
375
+ if (this.#isDisabled(reg)) continue
376
+ if (!sceneAllowed(reg.options, e.scene)) continue
377
+ // 机器人自己发的消息:全局 bot.ignoreSelf 为 false 时事件才会走到这里,
378
+ // 但单条命令仍然默认不响应自己 —— 否则一个"复读"类命令会自我触发成死循环
379
+ if (reg.options.ignoreSelf !== false && e.sender.uid === e.selfId) continue
380
+ // 群聊要求 @ 机器人;私聊天然算"对我说话"
381
+ if (reg.options.atMe === true && e.isGroup && !e.atMe) continue
382
+ if (reg.options.master === true && !e.isMaster) continue
383
+ // 主人不受群管限制:否则主人在自己不是管理员的群里连管理命令都用不了
384
+ if (reg.options.admin === true && !e.isGroupAdmin && !e.isMaster) continue
385
+
386
+ for (const pattern of entry.compiled) {
387
+ const hit = pattern.match(e.text)
388
+ if (hit === undefined) continue
389
+ out.push({
390
+ reg,
391
+ match: {
392
+ name: this.#nameOf(entry),
393
+ pattern: hit.pattern,
394
+ trigger: hit.trigger,
395
+ rest: hit.rest,
396
+ groups: hit.groups,
397
+ captures: hit.captures,
398
+ plugin: reg.plugin
399
+ }
400
+ })
401
+ // 同一条命令的多个别名只算一次命中
402
+ break
403
+ }
404
+ }
405
+ return out
406
+ }
407
+
408
+ /**
409
+ * 导出全部命令描述
410
+ * @returns 命令描述数组,按匹配顺序
411
+ */
412
+ list(): CommandInfo[] {
413
+ const all = [...this.#entries.values()].sort(byPriority)
414
+ return all.map(entry => {
415
+ const { options: o, plugin } = entry.reg
416
+ const info: CommandInfo = {
417
+ name: this.#nameOf(entry),
418
+ patterns: entry.compiled.map(p => p.label),
419
+ plugin,
420
+ master: o.master === true,
421
+ admin: o.admin === true,
422
+ hidden: o.hidden === true,
423
+ disabled: this.#isDisabled(entry.reg)
424
+ }
425
+ if (o.desc !== undefined) info.desc = o.desc
426
+ if (o.usage !== undefined) info.usage = o.usage
427
+ if (o.group !== undefined) info.group = o.group
428
+ return info
429
+ })
430
+ }
431
+
432
+ /**
433
+ * 摘除某插件的全部命令
434
+ *
435
+ * 插件卸载时上下文会逐条调用 Disposer,此处为兜底:若存在未经 ctx 的登记
436
+ *(例如插件 setup 中途抛错),亦不会留下悬空命令。
437
+ * @param plugin 插件名
438
+ * @returns 摘除的条数
439
+ */
440
+ removePlugin(plugin: string): number {
441
+ let n = 0
442
+ for (const reg of [...this.#entries.keys()]) {
443
+ if (reg.plugin === plugin) {
444
+ this.#remove(reg)
445
+ n++
446
+ }
447
+ }
448
+ return n
449
+ }
450
+
451
+ /** 清空全部登记 */
452
+ clear(): void {
453
+ this.#entries.clear()
454
+ this.#buckets.clear()
455
+ this.#unbucketed = []
456
+ }
457
+
458
+ /**
459
+ * 编译一条登记的全部模式
460
+ * @param reg 登记内容
461
+ * @returns 编译结果数组
462
+ */
463
+ #compileAll(reg: CommandRegistration): CompiledPattern[] {
464
+ const label = `${reg.plugin}:${String(reg.patterns[0] ?? "?")}`
465
+ const out: CompiledPattern[] = []
466
+ for (const p of reg.patterns) {
467
+ try {
468
+ out.push(compile(p, reg.options, label, this.#logger))
469
+ } catch (err) {
470
+ // 一个别名编译失败不该让整条命令消失,其余模式仍然可用
471
+ this.#logger.error(`命令 ${label} 的模式 ${String(p)} 无效:${err instanceof Error ? err.message : String(err)}`)
472
+ }
473
+ }
474
+ if (out.length === 0) this.#logger.error(`命令 ${label} 没有任何有效模式,将不会被触发`)
475
+ return out
476
+ }
477
+
478
+ /**
479
+ * 取命令展示名
480
+ * @param entry 条目 t
481
+ * @returns 首个模式的可读形式
482
+ */
483
+ #nameOf(entry: Entry): string {
484
+ return entry.compiled[0]?.label ?? String(entry.reg.patterns[0] ?? "?")
485
+ }
486
+
487
+ /**
488
+ * 把条目挂进索引
489
+ * @param entry 条目
490
+ */
491
+ #index(entry: Entry): void {
492
+ // 同一条命令的多个模式可能落在同一个桶(`#体力`/`#树脂` 都在 `#`),
493
+ // 用 Set 去重,避免一条命令在桶里出现两次导致重复命中
494
+ const keys = new Set<string>()
495
+ let needsUnbucketed = false
496
+ for (const p of entry.compiled) {
497
+ if (p.key === undefined || p.key === "") needsUnbucketed = true
498
+ else keys.add(p.key)
499
+ }
500
+
501
+ for (const key of keys) {
502
+ let bucket = this.#buckets.get(key)
503
+ if (bucket === undefined) {
504
+ bucket = []
505
+ this.#buckets.set(key, bucket)
506
+ }
507
+ insertSorted(bucket, entry)
508
+ }
509
+ if (needsUnbucketed) insertSorted(this.#unbucketed, entry)
510
+ }
511
+
512
+ /**
513
+ * 将条目从索引中摘除
514
+ * @param entry 条目
515
+ */
516
+ #unindex(entry: Entry): void {
517
+ for (const [key, bucket] of this.#buckets) {
518
+ const at = bucket.indexOf(entry)
519
+ if (at >= 0) bucket.splice(at, 1)
520
+ if (bucket.length === 0) this.#buckets.delete(key)
521
+ }
522
+ const at = this.#unbucketed.indexOf(entry)
523
+ if (at >= 0) this.#unbucketed.splice(at, 1)
524
+ }
525
+
526
+ /**
527
+ * 注销一条登记
528
+ * @param reg 登记内容
529
+ */
530
+ #remove(reg: CommandRegistration): void {
531
+ const entry = this.#entries.get(reg)
532
+ if (entry === undefined) return
533
+ this.#unindex(entry)
534
+ this.#entries.delete(reg)
535
+ }
536
+
537
+ /**
538
+ * 取该文本需要试的候选条目(已按匹配顺序排好)
539
+ * @param text 消息纯文本
540
+ * @returns 候选条目数组
541
+ */
542
+ #candidates(text: string): readonly Entry[] {
543
+ const first = [...text][0]
544
+ const bucket = first === undefined ? undefined : this.#buckets.get(first)
545
+ const loose = this.#unbucketed
546
+
547
+ // 绝大多数消息走这两条零分配的捷径
548
+ if (bucket === undefined) return loose
549
+ if (loose.length === 0) return bucket
550
+
551
+ // 两边都已排序,线性归并即可,不必 concat + sort
552
+ const out: Entry[] = []
553
+ let i = 0
554
+ let j = 0
555
+ while (i < bucket.length && j < loose.length) {
556
+ const a = bucket[i]!
557
+ const b = loose[j]!
558
+ if (a === b) {
559
+ // 一条命令可能同时有可分桶与不可分桶的模式,只保留一次
560
+ out.push(a)
561
+ i++
562
+ j++
563
+ } else if (byPriority(a, b) <= 0) {
564
+ out.push(a)
565
+ i++
566
+ } else {
567
+ out.push(b)
568
+ j++
569
+ }
570
+ }
571
+ while (i < bucket.length) out.push(bucket[i++]!)
572
+ while (j < loose.length) out.push(loose[j++]!)
573
+ return out
574
+ }
575
+ }