@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,224 @@
1
+ /**
2
+ * 模块职责:`AdapterHost` 实现 —— 内核给每个账号的宿主能力面
3
+ * 依赖方向:依赖类型包、plugin/hooks 的 `ServerSink`、util/*;**不认识任何适配器实现**
4
+ * 生命周期:一个账号一个实例;随账号断开而回收(`DisposalRegistry` 逆序)
5
+ * 注意事项:适配器插件只拿到这个对象,拿不到内核内部。它解决三件事:
6
+ *
7
+ * **事件投递的闸门。** `BotDriver` 的契约写明「内核会在卸载时调 `disconnect()`,并断言
8
+ * 其后不再有事件投递进来」。这条断言由此处兑现:`signal` 一经 abort,`submit()` 立即开始
9
+ * 丢弃并计数 —— 不能指望适配器一定彻底停掉自己的 socket 回调,否则热重载后会收到重复回复。
10
+ *
11
+ * **服务器晚绑定。** HTTP 服务器装配得比适配器插件晚,故 `server` 是 getter、`ServerSink`
12
+ * 是取值函数:适配器在 `createBot()` 里存下 host,真要挂 webhook 时读到的是当时的实际状态。
13
+ * 服务器没开时 `info.enabled` 为 false,适配器据此告知使用者「该模式需要内置服务器」。
14
+ *
15
+ * **资源回收。** `route` / `websocket` / `createCache` / `onDispose` 全部登记进账号自己的
16
+ * `DisposalRegistry`,账号一断开即全部回收,适配器不必自行记录。
17
+ *
18
+ * 工厂分两层是刻意的:内核装配时绑定一次全局依赖,账号管理器每次连接只补「该账号」专属
19
+ * 的几项 —— 于是账号管理器不必认识分发器与服务器,`adapter` 层也不会反向依赖 `pipeline`。
20
+ */
21
+ import type {
22
+ AccountRecord,
23
+ AccountStatus,
24
+ AdapterHost,
25
+ BotApi,
26
+ ContactCache,
27
+ ContactCacheOptions,
28
+ Disposer,
29
+ IncomingEvent,
30
+ KvNamespace,
31
+ Logger,
32
+ PolicyView,
33
+ RouteHandler,
34
+ RouteOptions,
35
+ ServerInfo,
36
+ WebSocketHandler,
37
+ WebSocketOptions
38
+ } from "@yunzai-ng/types"
39
+ import type { ServerSink } from "../plugin/hooks.js"
40
+ import type { DisposalRegistry } from "../util/dispose.js"
41
+ import { createCache } from "../util/lru.js"
42
+
43
+ /** 状态上报的附加信息 */
44
+ export interface StatusDetail {
45
+ /** 错误消息 */
46
+ error?: string
47
+ /** 账号昵称 */
48
+ nickname?: string
49
+ }
50
+
51
+ /** 宿主工厂的全局依赖(内核装配时绑定一次) */
52
+ export interface AdapterHostDeps {
53
+ /**
54
+ * 基础日志器
55
+ *
56
+ * 工厂会自己派生带 `adapter`/`account` 字段的 child —— `AdapterHost.logger`
57
+ * 的契约是"已带这两个字段",由这里兑现而不是靠调用方记得。
58
+ */
59
+ readonly logger: Logger
60
+ /**
61
+ * 适配器数据的 KV 根命名空间
62
+ *
63
+ * 每个账号取得的是 `<root>/<adapterId>/<accountId>`:两个账号即使运行同一个
64
+ * 适配器亦不会相互覆写键,而同一账号重连后仍可读回其此前写入的数据
65
+ *(NapCat 的 `echo` 序号与登录态缓存均依赖该机制)。
66
+ */
67
+ readonly kv: KvNamespace
68
+ /** 内核策略视图 */
69
+ readonly policy: PolicyView
70
+ /**
71
+ * 取当前的服务器登记面
72
+ *
73
+ * 取函数而非直接传值:服务器子系统装好之前 `hooks.server` 是"不可用"占位,
74
+ * 之后会被整体替换掉。见文件头第 2 点。
75
+ */
76
+ readonly server: () => ServerSink
77
+ /**
78
+ * 把事件送进管线
79
+ *
80
+ * 由内核注入(最终指向 `EventDispatcher.submit`)。宿主不认识分发器,
81
+ * 否则 adapter 层就反过来依赖 pipeline 层了。
82
+ * @param event 通用事件
83
+ * @param bot 收到该事件的 Bot
84
+ */
85
+ readonly dispatch: (event: IncomingEvent, bot: BotApi) => void
86
+ }
87
+
88
+ /** 单次连接的账号侧参数 */
89
+ export interface AdapterHostParams {
90
+ /** 账号记录 */
91
+ readonly record: AccountRecord
92
+ /** 本次连接的取消信号源 */
93
+ readonly controller: AbortController
94
+ /** 本次连接的回收登记簿 */
95
+ readonly registry: DisposalRegistry
96
+ /**
97
+ * 上报账号状态
98
+ * @param status 新状态
99
+ * @param detail 附加信息
100
+ */
101
+ readonly setStatus: (status: AccountStatus, detail?: StatusDetail) => void
102
+ }
103
+
104
+ /** 宿主句柄:`host` 交给适配器,其余留给账号管理器 */
105
+ export interface AdapterHostHandle {
106
+ /** 交给 `createBot()` 的宿主对象 */
107
+ readonly host: AdapterHost
108
+ /**
109
+ * 绑定事件归属的 Bot
110
+ *
111
+ * 必须在 `createBot()` 返回、门面建好之后调用。宿主先于驱动存在
112
+ * (驱动是靠宿主创建出来的),所以这个槽位只能后填。
113
+ * @param bot Bot 门面
114
+ */
115
+ attach(bot: BotApi): void
116
+ /** 解绑:此后事件一律丢弃 */
117
+ detach(): void
118
+ /** 被丢弃的事件数(诊断用:非 0 说明适配器停止不彻底) */
119
+ readonly dropped: number
120
+ }
121
+
122
+ /** 宿主工厂 */
123
+ export type AdapterHostFactory = (params: AdapterHostParams) => AdapterHostHandle
124
+
125
+ /**
126
+ * 绑定全局依赖,得到宿主工厂
127
+ * @param deps 全局依赖
128
+ * @returns 宿主工厂
129
+ */
130
+ export function createAdapterHostFactory(deps: AdapterHostDeps): AdapterHostFactory {
131
+ return params => createAdapterHost(deps, params)
132
+ }
133
+
134
+ /**
135
+ * 创建一个账号宿主
136
+ * @param deps 全局依赖
137
+ * @param params 账号侧参数
138
+ * @returns 宿主句柄
139
+ */
140
+ export function createAdapterHost(deps: AdapterHostDeps, params: AdapterHostParams): AdapterHostHandle {
141
+ const { record, registry, setStatus } = params
142
+ const adapterId = record.adapterId
143
+ const accountId = record.id
144
+ const signal = params.controller.signal
145
+ const logger = deps.logger.child({ adapter: adapterId, account: accountId })
146
+ /** 路由挂载前缀 */
147
+ const scope = `/adapter/${adapterId}`
148
+
149
+ /** 事件归属的 Bot;`createBot()` 之后才有 */
150
+ let bot: BotApi | undefined
151
+ /** 丢弃的事件数 */
152
+ let dropped = 0
153
+ /** 是否已就"投递到无主宿主"提醒过 */
154
+ let warnedNoBot = false
155
+
156
+ const host: AdapterHost = {
157
+ logger,
158
+ signal,
159
+ policy: deps.policy,
160
+ kv: deps.kv.sub(adapterId).sub(accountId),
161
+ // 快照 + 复制 config:适配器往 config 上随手写字段是常事,
162
+ // 若给的是原对象,那些临时字段会被账号管理器一并持久化下去,
163
+ // 下次启动时又被当成用户配置读回来
164
+ account: { ...record, config: { ...record.config } },
165
+
166
+ // getter:服务器可能在适配器加载之后才启动,见文件头第 2 点
167
+ get server(): ServerInfo {
168
+ return deps.server().info
169
+ },
170
+
171
+ submit: (event: IncomingEvent): void => {
172
+ // 闸门一:账号已停用 / 插件已卸载。这是 BotDriver 契约里
173
+ // "断开后不再有事件投递进来"的兑现处
174
+ if (signal.aborted) {
175
+ dropped++
176
+ return
177
+ }
178
+ // 闸门二:尚未绑定 Bot。适配器在 connect() 完成前即收到事件,
179
+ // 此时不存在可用于回复的对象,继续处理只会在管线中抛错
180
+ if (bot === undefined) {
181
+ dropped++
182
+ if (!warnedNoBot) {
183
+ warnedNoBot = true
184
+ logger.warn("在 Bot 就绪前收到事件,已丢弃:请在 connect() 解析之后再开始投递事件")
185
+ }
186
+ return
187
+ }
188
+ deps.dispatch(event, bot)
189
+ },
190
+
191
+ route: (method: "GET" | "POST", path: string, handler: RouteHandler, routeOpts?: RouteOptions): Disposer =>
192
+ // 登记进账号的回收簿:账号一断开,webhook 端点必须跟着消失,
193
+ // 否则平台还在往一个没人接的地址推事件
194
+ registry.add(deps.server().route(scope, method, path, handler, routeOpts), `route:${method} ${scope}${path}`),
195
+
196
+ websocket: (path: string, handler: WebSocketHandler, wsOpts?: WebSocketOptions): Disposer =>
197
+ registry.add(deps.server().websocket(scope, path, handler, wsOpts), `ws:${scope}${path}`),
198
+
199
+ createCache: <V>(cacheOpts: ContactCacheOptions): ContactCache<V> => {
200
+ const cache = createCache<V>(cacheOpts)
201
+ // clear 而不是置 undefined:适配器可能还握着这个缓存对象的引用,
202
+ // 清空能立刻把内存还回去,之后即使被误用也只是全部 miss
203
+ registry.add(() => cache.clear(), "cache")
204
+ return cache
205
+ },
206
+
207
+ onDispose: (fn: Disposer): void => void registry.add(fn, "adapter:onDispose"),
208
+
209
+ setStatus: (status: AccountStatus, detail?: StatusDetail): void => setStatus(status, detail)
210
+ }
211
+
212
+ return {
213
+ host,
214
+ attach: (next: BotApi): void => {
215
+ bot = next
216
+ },
217
+ detach: (): void => {
218
+ bot = undefined
219
+ },
220
+ get dropped(): number {
221
+ return dropped
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,481 @@
1
+ /**
2
+ * 模块职责:交互式登录会话(实现 `LoginSession`)与登录会话管理器
3
+ * 依赖方向:依赖类型包与 util/*;通过注入的回调创建账号,**不认识账号管理器**
4
+ * 生命周期:一次登录一个会话;成功/失败/取消后短暂留存供 WebUI 展示结果,然后自毁
5
+ * 注意事项:内核不知道扫码、短信验证码、填 token 有什么区别,只提供两个原语:**推一步给用户看**
6
+ * (`push`)与**等用户答一次**(`ask`)。扫码是 push(qrcode) + push(progress),短信是
7
+ * ask(手机号) + ask(验证码) —— 故前端不必为每个新平台改一行代码。三处必须做对:
8
+ *
9
+ * **`start()` 同步返回。** 登录要等用户操作、动辄几十秒,HTTP 请求不能挂在那儿,故
10
+ * `login()` 在后台跑,前端拿会话 id 去轮询或订阅。
11
+ *
12
+ * **`ask` 带序号。** 用户连点两次提交或网络重发时,迟到的那次答复不能拿去满足**下一个**
13
+ * 问题(把手机号填进验证码框),故 `answer()` 必须带 `seq`,对不上就拒绝。
14
+ *
15
+ * **一切都有上限**:步骤数、每次提问的超时、整个会话的 TTL,结束即自毁 —— 一个人点开
16
+ * 登录页就走开,不该留下一个永远挂着的 promise 和一张常驻内存的二维码。
17
+ */
18
+ import type {
19
+ AccountRecord,
20
+ AdapterRegistryView,
21
+ LoginPrompt,
22
+ LoginSession,
23
+ LoginStep,
24
+ Logger
25
+ } from "@yunzai-ng/types"
26
+ import { parseDuration } from "../util/duration.js"
27
+ import { createEventId } from "../util/id.js"
28
+
29
+ /** 单次提问的缺省超时(毫秒),与 `LoginPrompt.timeout` 文档一致 */
30
+ export const DEFAULT_LOGIN_PROMPT_TIMEOUT = 180_000
31
+
32
+ /** 整个登录会话的硬上限(毫秒):扫码最多两三分钟,十分钟足够宽裕 */
33
+ export const DEFAULT_LOGIN_TTL = 600_000
34
+
35
+ /** 已结束的会话保留多久供前端读取结果(毫秒) */
36
+ const RESULT_TTL = 120_000
37
+
38
+ /** 步骤数组上限:`progress` 刷屏或反复刷新二维码时不能无限增长 */
39
+ const MAX_STEPS = 40
40
+
41
+ /** 登录会话状态 */
42
+ export type LoginStatus = "running" | "done" | "failed" | "cancelled"
43
+
44
+ /** 正在等待用户回答的提问 */
45
+ export interface PendingPrompt {
46
+ /** 提问序号,回答时必须带回 */
47
+ readonly seq: number
48
+ /** 提问内容 */
49
+ readonly prompt: LoginPrompt
50
+ /** 超时时刻(毫秒时间戳),前端据此显示倒计时 */
51
+ readonly deadline: number
52
+ }
53
+
54
+ /** 登录会话快照,WebUI 的唯一数据来源 */
55
+ export interface LoginSnapshot {
56
+ /** 会话 id */
57
+ readonly id: string
58
+ /** 适配器 id */
59
+ readonly adapterId: string
60
+ /** 登录方式 id */
61
+ readonly mode: string
62
+ /** 当前状态 */
63
+ readonly status: LoginStatus
64
+ /** 已推送的步骤,按时间顺序 */
65
+ readonly steps: readonly LoginStep[]
66
+ /** 正在等待的提问;没有则 undefined */
67
+ readonly pending: PendingPrompt | undefined
68
+ /** 失败原因 */
69
+ readonly error: string | undefined
70
+ /** 成功时创建出的账号记录 id */
71
+ readonly accountId: string | undefined
72
+ /** 开始时刻(毫秒时间戳) */
73
+ readonly startedAt: number
74
+ /** 最后变化时刻(毫秒时间戳),前端据此判断是否需要重绘 */
75
+ readonly updatedAt: number
76
+ }
77
+
78
+ /** 登录管理器构造参数 */
79
+ export interface LoginManagerOptions {
80
+ /** 日志器 */
81
+ readonly logger: Logger
82
+ /** 适配器注册表视图 */
83
+ readonly adapters: AdapterRegistryView
84
+ /**
85
+ * 登录成功后落账号
86
+ *
87
+ * 注入而非直接依赖账号管理器:否则 login → accounts → login 就绕成环了。
88
+ * @param adapterId 适配器 id
89
+ * @param config 登录产出的账号配置
90
+ * @param label 账号备注名
91
+ * @returns 新建的账号记录
92
+ */
93
+ readonly createAccount: (adapterId: string, config: Record<string, unknown>, label?: string) => Promise<AccountRecord>
94
+ /** 会话硬上限,缺省 `DEFAULT_LOGIN_TTL` */
95
+ readonly ttl?: number
96
+ }
97
+
98
+ /** 会话内部状态 */
99
+ interface SessionState {
100
+ /** 会话 id */
101
+ readonly id: string
102
+ /** 适配器 id */
103
+ readonly adapterId: string
104
+ /** 登录方式 */
105
+ readonly mode: string
106
+ /** 账号备注名 */
107
+ readonly label: string | undefined
108
+ /** 取消信号源 */
109
+ readonly controller: AbortController
110
+ /** 已推送步骤 */
111
+ readonly steps: LoginStep[]
112
+ /** 当前状态 */
113
+ status: LoginStatus
114
+ /** 正在等待的提问 */
115
+ pending: PendingPrompt | undefined
116
+ /** 交付答复 */
117
+ resolveAsk: ((value: unknown) => void) | undefined
118
+ /** 终止等待 */
119
+ rejectAsk: ((err: Error) => void) | undefined
120
+ /** 会话 TTL 定时器 */
121
+ ttlTimer: NodeJS.Timeout | undefined
122
+ /** 结束后自毁定时器 */
123
+ reapTimer: NodeJS.Timeout | undefined
124
+ /** 提问序号发号器 */
125
+ seq: number
126
+ /** 失败原因 */
127
+ error: string | undefined
128
+ /** 成功创建的账号 id */
129
+ accountId: string | undefined
130
+ /** 开始时刻 */
131
+ readonly startedAt: number
132
+ /** 最后变化时刻 */
133
+ updatedAt: number
134
+ }
135
+
136
+ /**
137
+ * 取错误的可读描述
138
+ * @param err 任意抛出物
139
+ * @returns 描述文本
140
+ */
141
+ function errText(err: unknown): string {
142
+ return err instanceof Error ? err.message : String(err)
143
+ }
144
+
145
+ /**
146
+ * 交互式登录会话管理器
147
+ *
148
+ * WebUI 侧的用法:`start()` 拿到 id → 轮询/订阅 `snapshot(id)` → 遇到
149
+ * `pending` 就渲染输入框,用户提交后 `answer(id, seq, value)` → 直到
150
+ * `status` 不再是 `running`。
151
+ */
152
+ export class LoginManager {
153
+ /** 日志器 */
154
+ readonly #logger: Logger
155
+ /** 适配器注册表 */
156
+ readonly #adapters: AdapterRegistryView
157
+ /** 账号创建回调 */
158
+ readonly #createAccount: LoginManagerOptions["createAccount"]
159
+ /** 会话硬上限 */
160
+ readonly #ttl: number
161
+ /** 会话 id → 状态 */
162
+ readonly #sessions = new Map<string, SessionState>()
163
+
164
+ /**
165
+ * @param opts 构造参数
166
+ */
167
+ constructor(opts: LoginManagerOptions) {
168
+ this.#logger = opts.logger.child({ scope: "login" })
169
+ this.#adapters = opts.adapters
170
+ this.#createAccount = opts.createAccount
171
+ this.#ttl = opts.ttl ?? DEFAULT_LOGIN_TTL
172
+ }
173
+
174
+ /** 进行中的会话数 */
175
+ get running(): number {
176
+ let n = 0
177
+ for (const s of this.#sessions.values()) if (s.status === "running") n++
178
+ return n
179
+ }
180
+
181
+ /**
182
+ * 开始一次交互式登录
183
+ *
184
+ * **同步返回**,登录流程在后台跑,见文件头第 1 点。
185
+ * @param adapterId 适配器 id
186
+ * @param mode 登录方式 id
187
+ * @param label 账号备注名
188
+ * @returns 初始快照
189
+ * @throws 适配器不存在、不支持交互式登录、或方式 id 无效时
190
+ */
191
+ start(adapterId: string, mode: string, label?: string): LoginSnapshot {
192
+ const provider = this.#adapters.get(adapterId)
193
+ if (provider === undefined) throw new Error(`适配器 ${adapterId} 未注册:请先安装并启用对应的适配器插件`)
194
+
195
+ const login = provider.login
196
+ if (login === undefined) {
197
+ throw new Error(`适配器 ${provider.name} 不支持交互式登录,请在"添加账号"里直接填写配置`)
198
+ }
199
+
200
+ const modes = provider.loginModes ?? []
201
+ if (modes.length > 0 && !modes.some(m => m.id === mode)) {
202
+ const names = modes.map(m => `${m.id}(${m.name})`).join("、")
203
+ throw new Error(`适配器 ${provider.name} 没有名为 ${mode} 的登录方式,可用:${names}`)
204
+ }
205
+
206
+ const id = createEventId("login")
207
+ const now = Date.now()
208
+ const state: SessionState = {
209
+ id,
210
+ adapterId,
211
+ mode,
212
+ label,
213
+ controller: new AbortController(),
214
+ steps: [],
215
+ status: "running",
216
+ pending: undefined,
217
+ resolveAsk: undefined,
218
+ rejectAsk: undefined,
219
+ ttlTimer: undefined,
220
+ reapTimer: undefined,
221
+ seq: 0,
222
+ error: undefined,
223
+ accountId: undefined,
224
+ startedAt: now,
225
+ updatedAt: now
226
+ }
227
+ this.#sessions.set(id, state)
228
+
229
+ state.ttlTimer = setTimeout(() => {
230
+ this.#abort(state, `登录超时(超过 ${Math.round(this.#ttl / 1000)} 秒未完成)`, "failed")
231
+ }, this.#ttl)
232
+ state.ttlTimer.unref?.()
233
+
234
+ const session = this.#createSession(state)
235
+ this.#logger.info(`开始登录:适配器 ${adapterId},方式 ${mode},会话 ${id}`)
236
+
237
+ // 后台跑:不 await,也不让它的 rejection 冒成 unhandledRejection
238
+ void login(session, mode).then(
239
+ config => void this.#succeed(state, config),
240
+ (err: unknown) => this.#fail(state, err)
241
+ )
242
+
243
+ return this.snapshotOf(state)
244
+ }
245
+
246
+ /**
247
+ * 读一个会话的快照
248
+ * @param id 会话 id
249
+ * @returns 快照;会话不存在(或已自毁)时 undefined
250
+ */
251
+ snapshot(id: string): LoginSnapshot | undefined {
252
+ const state = this.#sessions.get(id)
253
+ return state === undefined ? undefined : this.snapshotOf(state)
254
+ }
255
+
256
+ /**
257
+ * 列出全部会话
258
+ * @returns 快照数组,按开始时间
259
+ */
260
+ list(): LoginSnapshot[] {
261
+ return [...this.#sessions.values()].map(s => this.snapshotOf(s))
262
+ }
263
+
264
+ /**
265
+ * 提交用户的答复
266
+ * @param id 会话 id
267
+ * @param seq 提问序号,必须与 `pending.seq` 一致
268
+ * @param value 用户输入
269
+ * @returns 是否被接受
270
+ */
271
+ answer(id: string, seq: number, value: unknown): boolean {
272
+ const state = this.#sessions.get(id)
273
+ if (state === undefined || state.status !== "running") return false
274
+ const pending = state.pending
275
+ if (pending === undefined) return false
276
+ // 序号不符即丢弃:见文件头第 2 点
277
+ if (pending.seq !== seq) {
278
+ this.#logger.debug(`会话 ${id} 收到过期答复(seq ${seq},当前 ${pending.seq}),已忽略`)
279
+ return false
280
+ }
281
+ state.resolveAsk?.(value)
282
+ return true
283
+ }
284
+
285
+ /**
286
+ * 取消一个会话
287
+ * @param id 会话 id
288
+ * @returns 是否取消成功(会话不存在或已结束时 false)
289
+ */
290
+ cancel(id: string): boolean {
291
+ const state = this.#sessions.get(id)
292
+ if (state === undefined || state.status !== "running") return false
293
+ this.#abort(state, "用户取消了登录", "cancelled")
294
+ return true
295
+ }
296
+
297
+ /** 取消全部会话并清理(停机时调用) */
298
+ stop(): void {
299
+ for (const state of [...this.#sessions.values()]) {
300
+ if (state.status === "running") this.#abort(state, "内核正在停止", "cancelled")
301
+ if (state.ttlTimer !== undefined) clearTimeout(state.ttlTimer)
302
+ if (state.reapTimer !== undefined) clearTimeout(state.reapTimer)
303
+ }
304
+ this.#sessions.clear()
305
+ }
306
+
307
+ /**
308
+ * 生成快照
309
+ * @param state 会话状态
310
+ * @returns 快照
311
+ */
312
+ snapshotOf(state: SessionState): LoginSnapshot {
313
+ return {
314
+ id: state.id,
315
+ adapterId: state.adapterId,
316
+ mode: state.mode,
317
+ status: state.status,
318
+ // 复制数组:前端持有的快照不该跟着后续 push 变化,否则"这一帧看到了什么"
319
+ // 就说不清了。步骤有上限,复制成本可控。
320
+ steps: [...state.steps],
321
+ pending: state.pending,
322
+ error: state.error,
323
+ accountId: state.accountId,
324
+ startedAt: state.startedAt,
325
+ updatedAt: state.updatedAt
326
+ }
327
+ }
328
+
329
+ /**
330
+ * 造出交给适配器的 `LoginSession`
331
+ * @param state 会话状态
332
+ * @returns 登录会话
333
+ */
334
+ #createSession(state: SessionState): LoginSession {
335
+ const logger = this.#logger.child({ session: state.id })
336
+ return {
337
+ id: state.id,
338
+ logger,
339
+ signal: state.controller.signal,
340
+
341
+ push: (step: LoginStep): void => {
342
+ if (state.status !== "running") return
343
+ const steps = state.steps
344
+ const last = steps[steps.length - 1]
345
+ // 进度覆盖上一条进度:否则一个每秒 push 一次的适配器将累积出数百条
346
+ // 只有最后一条有意义的记录
347
+ if (step.type === "progress" && last?.type === "progress") steps[steps.length - 1] = step
348
+ else steps.push(step)
349
+ // 超出上限丢最旧的:二维码可能是几十 KB 的 base64,不能无限累积
350
+ while (steps.length > MAX_STEPS) steps.shift()
351
+ state.updatedAt = Date.now()
352
+ },
353
+
354
+ ask: <T = string>(prompt: LoginPrompt): Promise<T> => this.#ask<T>(state, prompt)
355
+ }
356
+ }
357
+
358
+ /**
359
+ * 等待一次用户输入
360
+ *
361
+ * 与 `pipeline/prompt.ts` 同构:超时、会话取消、用户答复三个终止条件
362
+ * 都汇到一个只许生效一次的 `settle` 上。
363
+ * @param state 会话状态
364
+ * @param prompt 提问内容
365
+ * @returns 用户输入
366
+ * @throws 超时、被取消、或适配器并发提问时
367
+ */
368
+ #ask<T>(state: SessionState, prompt: LoginPrompt): Promise<T> {
369
+ if (state.status !== "running") return Promise.reject(new Error("登录会话已结束,不能再提问"))
370
+ if (state.pending !== undefined) {
371
+ // 前端一次只渲染一个输入框,同时问两个问题必然有一个永远收不到答复
372
+ return Promise.reject(new Error("上一次提问还没得到答复:登录流程必须串行提问"))
373
+ }
374
+
375
+ const ms = parseDuration(prompt.timeout, DEFAULT_LOGIN_PROMPT_TIMEOUT)
376
+ const seq = ++state.seq
377
+
378
+ return new Promise<T>((resolve, reject) => {
379
+ let done = false
380
+ let timer: NodeJS.Timeout | undefined
381
+
382
+ /** 统一收尾 */
383
+ const settle = (fn: () => void): void => {
384
+ if (done) return
385
+ done = true
386
+ if (timer !== undefined) clearTimeout(timer)
387
+ state.pending = undefined
388
+ state.resolveAsk = undefined
389
+ state.rejectAsk = undefined
390
+ state.updatedAt = Date.now()
391
+ fn()
392
+ }
393
+
394
+ state.pending = { seq, prompt, deadline: Date.now() + ms }
395
+ state.resolveAsk = (value: unknown): void => settle(() => resolve(value as T))
396
+ state.rejectAsk = (err: Error): void => settle(() => reject(err))
397
+ state.updatedAt = Date.now()
398
+
399
+ if (ms > 0) {
400
+ timer = setTimeout(() => {
401
+ settle(() => reject(new Error(`等待"${prompt.label}"超时`)))
402
+ }, ms)
403
+ timer.unref?.()
404
+ }
405
+ })
406
+ }
407
+
408
+ /**
409
+ * 中止一个会话
410
+ * @param state 会话状态
411
+ * @param reason 原因
412
+ * @param status 终态
413
+ */
414
+ #abort(state: SessionState, reason: string, status: LoginStatus): void {
415
+ if (state.status !== "running") return
416
+ // 先落终态再 abort:适配器的 catch 里可能回读 snapshot,看到的应该是终态
417
+ state.status = status
418
+ state.error = reason
419
+ state.updatedAt = Date.now()
420
+ state.rejectAsk?.(new Error(reason))
421
+ state.controller.abort()
422
+ this.#finish(state)
423
+ this.#logger.info(`登录会话 ${state.id} 结束:${reason}`)
424
+ }
425
+
426
+ /**
427
+ * 登录成功:落账号
428
+ * @param state 会话状态
429
+ * @param config 登录产出的账号配置
430
+ */
431
+ async #succeed(state: SessionState, config: Record<string, unknown>): Promise<void> {
432
+ if (state.status !== "running") return
433
+ try {
434
+ const record = await this.#createAccount(state.adapterId, config, state.label)
435
+ state.accountId = record.id
436
+ state.status = "done"
437
+ state.updatedAt = Date.now()
438
+ this.#finish(state)
439
+ this.#logger.info(`登录成功:会话 ${state.id} → 账号 ${record.id}`)
440
+ } catch (err) {
441
+ // 登录本身成功了但存不下来(磁盘满、配置校验不过),这仍然是失败:
442
+ // 让用户看到原因,而不是一个"成功了但列表里没有"的账号
443
+ this.#fail(state, err)
444
+ }
445
+ }
446
+
447
+ /**
448
+ * 登录失败
449
+ * @param state 会话状态
450
+ * @param err 错误
451
+ */
452
+ #fail(state: SessionState, err: unknown): void {
453
+ // 已被取消/超时的会话不要把状态改成 failed —— 用户点了取消,
454
+ // 适配器随后抛出的 AbortError 只是取消的回声
455
+ if (state.status !== "running") return
456
+ state.status = "failed"
457
+ state.error = errText(err)
458
+ state.updatedAt = Date.now()
459
+ state.controller.abort()
460
+ this.#finish(state)
461
+ this.#logger.warn(`登录失败:会话 ${state.id}:${state.error}`)
462
+ }
463
+
464
+ /**
465
+ * 结束善后:停 TTL、安排自毁
466
+ * @param state 会话状态
467
+ */
468
+ #finish(state: SessionState): void {
469
+ if (state.ttlTimer !== undefined) {
470
+ clearTimeout(state.ttlTimer)
471
+ state.ttlTimer = undefined
472
+ }
473
+ if (state.reapTimer !== undefined) return
474
+ // 保留一段时间供前端读取结果,之后自毁 —— 否则一个长期运行的实例将累积
475
+ // 无数条历史登录记录(每条都可能挂着一张二维码)
476
+ state.reapTimer = setTimeout(() => {
477
+ this.#sessions.delete(state.id)
478
+ }, RESULT_TTL)
479
+ state.reapTimer.unref?.()
480
+ }
481
+ }