@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,758 @@
1
+ /**
2
+ * 模块职责:账号生命周期管理(持久化、连接、重连、状态)+ 实现 `AccountsView`
3
+ * 依赖方向:依赖类型包、KV、适配器注册表、Bot 注册表、宿主工厂;**不认识任何具体协议**
4
+ * 生命周期:随内核创建;每个账号一份 `AccountRuntime`,随账号删除或内核停止回收
5
+ * 注意事项:账号是**数据**(KV 里的 `AccountRecord`),协议是**插件**(`AdapterProvider`),
6
+ * 两者在运行时撮合 —— 故增删账号不必重启进程。四个必须做对的点:
7
+ *
8
+ * **重连要有退避、要能被取消。** 用 `backoffDelay`,日志按次数递减(首次 warn、
9
+ * 之后每 10 次一条);账号一被禁用或删除,pending 的重连定时器立刻清掉。
10
+ *
11
+ * **`connect()` 必须防重入。** 双击「连接」、重连定时器与手动连接撞在一起,都会让两个
12
+ * driver 连同一个账号 —— 表现为消息收两遍、回两遍。用 `#connecting` 做单飞。
13
+ *
14
+ * **每个账号一份 `DisposalRegistry` + 一个 `AbortController`,且顺序要对。** 断开时先
15
+ * abort(让宿主立刻开始丢事件),再回收登记,最后才 await driver 的 `disconnect()`;
16
+ * 反了就会出现「已断开的账号还在往管线里灌事件」。`disconnect()` 同样先 abort 再 await
17
+ * 正在进行的 `connect()` —— 被动接入的适配器要等对端连入才返回,不先 abort 会卡住停机。
18
+ *
19
+ * **适配器插件卸载时账号要同步下线。** `onUnregister` 是同步回调(见 registry.ts),
20
+ * 故此处同步改状态并 abort,异步的 socket 关闭交给 `#closing` 由 `stop()` 兜底 await。
21
+ */
22
+ import type {
23
+ AccountRecord,
24
+ AccountState,
25
+ AccountStatus,
26
+ AccountsView,
27
+ AdapterProvider,
28
+ BotDriver,
29
+ Disposer,
30
+ KvNamespace,
31
+ Logger
32
+ } from "@yunzai-ng/types"
33
+ import type { CoreEventBus } from "../plugin/events.js"
34
+ import { backoffDelay } from "../util/defer.js"
35
+ import { DisposalRegistry } from "../util/dispose.js"
36
+ import { uuid } from "../util/id.js"
37
+ import type { AdapterEntry, AdapterRegistry } from "./registry.js"
38
+ import type { BotFacade, BotRegistry, SendPolicyView } from "./bots.js"
39
+ import { createBotFacade } from "./bots.js"
40
+ import type { AdapterHostFactory, AdapterHostHandle } from "./host.js"
41
+
42
+ /** KV 里存账号记录的键前缀 */
43
+ const RECORD_PREFIX = "account:"
44
+
45
+ /** 重连退避参数:最长每 1 分钟一次,既可较快恢复又不至于对对端造成过载 */
46
+ const RECONNECT_BACKOFF = { baseDelay: 2_000, maxDelay: 60_000, factor: 2, jitter: 0.25 } as const
47
+
48
+ /** 每多少次失败落一条 warn(其余落 debug),避免日志刷屏 */
49
+ const WARN_EVERY = 10
50
+
51
+ /** 单个账号的运行时状态 */
52
+ interface AccountRuntime {
53
+ /** 持久化记录 */
54
+ record: AccountRecord
55
+ /** 当前状态 */
56
+ status: AccountStatus
57
+ /** 最近错误 */
58
+ error: string | undefined
59
+ /** 进入当前状态的时刻 */
60
+ since: number
61
+ /** 已重连次数 */
62
+ retries: number
63
+ /** 本次连接的取消信号源;未连接时 undefined */
64
+ controller: AbortController | undefined
65
+ /** 本次连接期间的回收簿;未连接时 undefined */
66
+ registry: DisposalRegistry | undefined
67
+ /** 驱动实例 */
68
+ driver: BotDriver | undefined
69
+ /** 对外门面 */
70
+ facade: BotFacade | undefined
71
+ /** 重连定时器 */
72
+ timer: NodeJS.Timeout | undefined
73
+ /** 正在进行的连接(单飞用) */
74
+ connecting: Promise<void> | undefined
75
+ /** 正在进行的断开(停机时 await 它) */
76
+ closing: Promise<void> | undefined
77
+ /** 是否已被删除 */
78
+ removed: boolean
79
+ }
80
+
81
+ /** 账号管理器构造参数 */
82
+ export interface AccountManagerOptions {
83
+ /** 日志器 */
84
+ readonly logger: Logger
85
+ /** 存账号记录的 KV 命名空间 */
86
+ readonly kv: KvNamespace
87
+ /** 适配器注册表 */
88
+ readonly adapters: AdapterRegistry
89
+ /** Bot 注册表 */
90
+ readonly bots: BotRegistry
91
+ /** 宿主工厂 */
92
+ readonly createHost: AdapterHostFactory
93
+ /** 内核事件总线 */
94
+ readonly events: CoreEventBus
95
+ /** 发送策略视图,透传给每个 Bot 门面 */
96
+ readonly sendPolicy: SendPolicyView
97
+ }
98
+
99
+ /**
100
+ * 取错误的可读描述
101
+ * @param err 任意抛出物
102
+ * @returns 描述文本
103
+ */
104
+ function errText(err: unknown): string {
105
+ return err instanceof Error ? err.message : String(err)
106
+ }
107
+
108
+ /**
109
+ * 账号管理器
110
+ *
111
+ * 既是 WebUI 的增删改查入口,也是 `AccountsView` 的实现(插件通过
112
+ * `ctx.app.accounts` 只读观察)。
113
+ */
114
+ export class AccountManager implements AccountsView {
115
+ /** 日志器 */
116
+ readonly #logger: Logger
117
+ /** KV 命名空间 */
118
+ readonly #kv: KvNamespace
119
+ /** 适配器注册表 */
120
+ readonly #adapters: AdapterRegistry
121
+ /** Bot 注册表 */
122
+ readonly #bots: BotRegistry
123
+ /** 宿主工厂 */
124
+ readonly #createHost: AdapterHostFactory
125
+ /** 事件总线 */
126
+ readonly #events: CoreEventBus
127
+ /** 发送策略视图 */
128
+ readonly #sendPolicy: SendPolicyView
129
+ /** 账号 id → 运行时 */
130
+ readonly #runtimes = new Map<string, AccountRuntime>()
131
+ /** 适配器注册表的监听句柄,停机时摘除 */
132
+ readonly #unhook: Disposer[]
133
+ /** 是否已停机 */
134
+ #stopped = false
135
+
136
+ /**
137
+ * @param opts 构造参数
138
+ */
139
+ constructor(opts: AccountManagerOptions) {
140
+ this.#logger = opts.logger.child({ scope: "account" })
141
+ this.#kv = opts.kv
142
+ this.#adapters = opts.adapters
143
+ this.#bots = opts.bots
144
+ this.#createHost = opts.createHost
145
+ this.#events = opts.events
146
+ this.#sendPolicy = opts.sendPolicy
147
+
148
+ // 适配器热插拔(文件头第 4 点):卸载即下线,重新注册即连回来。
149
+ // 在构造函数中自行装配,而不交由 kernel/runtime.ts 装配:遗漏的后果是
150
+ // "适配器插件更新之后账号不再自动恢复",这种缺失不报错、不留痕,
151
+ // 只会使人认为热重载不可用 —— 可以消除的装配步骤不应留给调用方。
152
+ this.#unhook = [
153
+ opts.adapters.onUnregister((id, entry) => this.detachAdapter(id, entry)),
154
+ opts.adapters.onRegister(id => this.onAdapterRegistered(id))
155
+ ]
156
+ }
157
+
158
+ /** 账号总数 */
159
+ get size(): number {
160
+ return this.#runtimes.size
161
+ }
162
+
163
+ /**
164
+ * 从 KV 载入全部账号记录(不连接)
165
+ *
166
+ * 与 `startAll()` 分开:内核 `load` 阶段就该知道有哪些账号(WebUI 要能显示
167
+ * 列表),但连接要等适配器插件全部注册完 —— 否则先加载的账号会因为
168
+ * "适配器未注册"白失败一次。
169
+ * @returns 载入的账号数
170
+ */
171
+ async load(): Promise<number> {
172
+ for await (const [key, value] of this.#kv.entries<AccountRecord>(RECORD_PREFIX)) {
173
+ const record = value as AccountRecord | undefined
174
+ // 手工编辑过 KV、或旧版本残留的坏记录:跳过而不是让整个内核起不来
175
+ if (record === undefined || typeof record.id !== "string" || typeof record.adapterId !== "string") {
176
+ this.#logger.warn(`跳过无法解析的账号记录 ${key}`)
177
+ continue
178
+ }
179
+ this.#runtimes.set(record.id, {
180
+ record,
181
+ status: record.enabled ? "offline" : "disabled",
182
+ error: undefined,
183
+ since: Date.now(),
184
+ retries: 0,
185
+ controller: undefined,
186
+ registry: undefined,
187
+ driver: undefined,
188
+ facade: undefined,
189
+ timer: undefined,
190
+ connecting: undefined,
191
+ closing: undefined,
192
+ removed: false
193
+ })
194
+ }
195
+ this.#logger.info(`载入 ${this.#runtimes.size} 个账号`)
196
+ return this.#runtimes.size
197
+ }
198
+
199
+ /**
200
+ * 连接全部已启用的账号
201
+ *
202
+ * 并发连接:一个账号的对端没起来不该让后面的账号排队等它退避完。
203
+ * 单个账号失败只落日志(`connect` 内部已转成 error 状态 + 重连),
204
+ * 所以这里 `allSettled` 之后不需要检查结果。
205
+ */
206
+ async startAll(): Promise<void> {
207
+ const targets = [...this.#runtimes.values()].filter(rt => rt.record.enabled)
208
+ if (targets.length === 0) {
209
+ this.#logger.info("没有已启用的账号,等待在 WebUI 中添加")
210
+ return
211
+ }
212
+ await Promise.allSettled(targets.map(rt => this.connect(rt.record.id)))
213
+ }
214
+
215
+ /* ─────────────────────────── AccountsView ─────────────────────────── */
216
+
217
+ /**
218
+ * 列出全部账号及其状态
219
+ * @returns 状态快照数组
220
+ */
221
+ list(): AccountState[] {
222
+ return [...this.#runtimes.values()].map(rt => this.#snapshot(rt))
223
+ }
224
+
225
+ /**
226
+ * 按记录 id 取账号状态
227
+ * @param id 账号记录 id
228
+ * @returns 状态快照;不存在时 undefined
229
+ */
230
+ get(id: string): AccountState | undefined {
231
+ const rt = this.#runtimes.get(id)
232
+ return rt === undefined ? undefined : this.#snapshot(rt)
233
+ }
234
+
235
+ /* ─────────────────────────── 增删改 ─────────────────────────── */
236
+
237
+ /**
238
+ * 新建账号
239
+ *
240
+ * 配置先过 `validateAccount()` 再落盘:非法配置一旦存入,只会在下次启动时
241
+ * 表现为一条无从判断原因的连接失败。
242
+ * @param adapterId 适配器 id
243
+ * @param config 账号配置(WebUI 提交的原始对象)
244
+ * @param label 备注名
245
+ * @param enabled 是否立即启用并连接,缺省 true
246
+ * @returns 新建的记录
247
+ * @throws 适配器未注册或配置校验失败时
248
+ */
249
+ async create(adapterId: string, config: unknown, label?: string, enabled = true): Promise<AccountRecord> {
250
+ const provider = this.#requireAdapter(adapterId)
251
+ const normalized = this.#validate(provider, config)
252
+
253
+ const now = Date.now()
254
+ const record: AccountRecord = {
255
+ id: uuid(),
256
+ adapterId,
257
+ enabled,
258
+ config: normalized,
259
+ createdAt: now,
260
+ updatedAt: now
261
+ }
262
+ if (label !== undefined && label !== "") record.label = label
263
+
264
+ await this.#persist(record)
265
+ this.#runtimes.set(record.id, {
266
+ record,
267
+ status: enabled ? "offline" : "disabled",
268
+ error: undefined,
269
+ since: now,
270
+ retries: 0,
271
+ controller: undefined,
272
+ registry: undefined,
273
+ driver: undefined,
274
+ facade: undefined,
275
+ timer: undefined,
276
+ connecting: undefined,
277
+ closing: undefined,
278
+ removed: false
279
+ })
280
+ this.#logger.info(`新增账号 ${this.#describe(record)}`)
281
+ if (enabled) await this.connect(record.id)
282
+ return record
283
+ }
284
+
285
+ /**
286
+ * 修改账号配置
287
+ *
288
+ * 改完必须重连:连接参数(地址、token)变了而 socket 还是旧的,
289
+ * 用户会以为"改了没生效"。
290
+ * @param id 账号记录 id
291
+ * @param patch 要改的字段
292
+ * @returns 更新后的记录
293
+ * @throws 账号不存在或配置校验失败时
294
+ */
295
+ async update(id: string, patch: { config?: unknown; label?: string; enabled?: boolean }): Promise<AccountRecord> {
296
+ const rt = this.#require(id)
297
+ const provider = this.#requireAdapter(rt.record.adapterId)
298
+
299
+ const next: AccountRecord = { ...rt.record, updatedAt: Date.now() }
300
+ if (patch.config !== undefined) next.config = this.#validate(provider, patch.config)
301
+ if (patch.label !== undefined) next.label = patch.label
302
+ if (patch.enabled !== undefined) next.enabled = patch.enabled
303
+
304
+ await this.#persist(next)
305
+ rt.record = next
306
+
307
+ await this.disconnect(id, "配置已修改")
308
+ if (next.enabled) await this.connect(id)
309
+ else this.#setStatus(rt, "disabled", undefined)
310
+ return next
311
+ }
312
+
313
+ /**
314
+ * 启用 / 禁用账号
315
+ * @param id 账号记录 id
316
+ * @param enabled 是否启用
317
+ * @returns 更新后的记录
318
+ * @throws 账号不存在时
319
+ */
320
+ async setEnabled(id: string, enabled: boolean): Promise<AccountRecord> {
321
+ return this.update(id, { enabled })
322
+ }
323
+
324
+ /**
325
+ * 删除账号
326
+ * @param id 账号记录 id
327
+ * @returns 是否删掉了(不存在时 false)
328
+ */
329
+ async remove(id: string): Promise<boolean> {
330
+ const rt = this.#runtimes.get(id)
331
+ if (rt === undefined) return false
332
+ // 先标记再断开:断开过程里到达的重连调度要能看到"这个账号已经没了"
333
+ rt.removed = true
334
+ await this.disconnect(id, "账号已删除")
335
+ this.#runtimes.delete(id)
336
+ await this.#kv.del(`${RECORD_PREFIX}${id}`)
337
+ this.#logger.info(`删除账号 ${this.#describe(rt.record)}`)
338
+ return true
339
+ }
340
+
341
+ /* ─────────────────────────── 连接 / 断开 ─────────────────────────── */
342
+
343
+ /**
344
+ * 连接一个账号
345
+ *
346
+ * 幂等且防重入:已在线直接返回,正在连接则复用同一个 promise(文件头第 2 点)。
347
+ * 失败**不抛出异常** —— 账号无法连接属于常态(对端未启动),转为 `error` 状态并
348
+ * 安排重连即可;抛出异常只会使 WebUI 的批量操作在第一个账号处中断。
349
+ * @param id 账号记录 id
350
+ * @throws 账号不存在时
351
+ */
352
+ async connect(id: string): Promise<void> {
353
+ const rt = this.#require(id)
354
+ if (this.#stopped) return
355
+ if (rt.status === "online" && rt.driver !== undefined) return
356
+ if (rt.connecting !== undefined) return rt.connecting
357
+
358
+ this.#clearTimer(rt)
359
+ const task = this.#doConnect(rt).finally(() => {
360
+ rt.connecting = undefined
361
+ })
362
+ rt.connecting = task
363
+ return task
364
+ }
365
+
366
+ /**
367
+ * 断开一个账号
368
+ * @param id 账号记录 id
369
+ * @param reason 断开原因(落日志与 `bot/offline`)
370
+ */
371
+ async disconnect(id: string, reason = "手动断开"): Promise<void> {
372
+ const rt = this.#runtimes.get(id)
373
+ if (rt === undefined) return
374
+ this.#clearTimer(rt)
375
+ // **先 abort 再等待**。被动接入的适配器(反向 WS、HTTP 上报)的 `connect()`
376
+ // 需一直等到对端连入才返回,可能长达十余秒。先 await 即等于令停机被一个
377
+ // "尚未发生的连接"阻塞;abort 之后驱动会立即从等待中退出,该 await
378
+ // 仅用于收尾。顺序颠倒会使 `stop()` 阻塞至该等待超时为止。
379
+ rt.controller?.abort()
380
+ if (rt.connecting !== undefined) await rt.connecting.catch(() => undefined)
381
+ await this.#teardown(rt, reason)
382
+ if (!rt.removed) this.#setStatus(rt, rt.record.enabled ? "offline" : "disabled", undefined)
383
+ }
384
+
385
+ /**
386
+ * 重连一个账号
387
+ * @param id 账号记录 id
388
+ */
389
+ async reconnect(id: string): Promise<void> {
390
+ await this.disconnect(id, "手动重连")
391
+ await this.connect(id)
392
+ }
393
+
394
+ /**
395
+ * 适配器插件被卸载:同步让它名下的账号下线
396
+ *
397
+ * **同步部分**必须在本函数返回前完成(改状态 + abort + 从 Bot 注册表摘除),
398
+ * 因为调用方是同步的 `Disposer`。异步的 socket 关闭挂在 `rt.closing` 上,
399
+ * 由 `stop()` 兜底 await。见文件头第 4 点。
400
+ * @param adapterId 适配器 id
401
+ * @param entry 摘除的注册条目(仅用于日志)
402
+ */
403
+ detachAdapter(adapterId: string, entry?: AdapterEntry): void {
404
+ const owner = entry?.owner
405
+ const reason = owner === undefined ? `适配器 ${adapterId} 已卸载` : `适配器插件 ${owner} 已卸载`
406
+ for (const rt of this.#runtimes.values()) {
407
+ if (rt.record.adapterId !== adapterId) continue
408
+ this.#clearTimer(rt)
409
+ // 同步切断事件投递:宿主的 signal 一 abort,submit() 立刻开始丢事件
410
+ rt.controller?.abort()
411
+ const facade = rt.facade
412
+ if (facade !== undefined) {
413
+ // 同步让门面失效并摘出注册表:插件的 pickBot() 立刻拿不到它,
414
+ // 已经拿在手上的那个也会在下一次调用时明确报错
415
+ facade.close()
416
+ this.#bots.remove(rt.record.id)
417
+ if (rt.status === "online") this.#events.emitDetached("bot/offline", facade, reason)
418
+ }
419
+ this.#setStatus(rt, rt.record.enabled ? "offline" : "disabled", reason)
420
+ // 异步收尾(关 socket、回收登记):不能 await,但要让 stop() 有机会等到
421
+ rt.closing = this.#teardown(rt, reason)
422
+ void rt.closing.catch(() => undefined)
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 适配器插件重新注册:把它名下已启用的账号连回来
428
+ *
429
+ * 热重载的另一半。不 await:注册通知也发生在同步路径上。
430
+ * @param adapterId 适配器 id
431
+ */
432
+ onAdapterRegistered(adapterId: string): void {
433
+ if (this.#stopped) return
434
+ for (const rt of this.#runtimes.values()) {
435
+ if (rt.record.adapterId !== adapterId) continue
436
+ if (!rt.record.enabled || rt.status === "online" || rt.status === "connecting") continue
437
+ this.#logger.info(`适配器 ${adapterId} 已重新注册,正在重连账号 ${this.#describe(rt.record)}`)
438
+ void this.connect(rt.record.id).catch(() => undefined)
439
+ }
440
+ }
441
+
442
+ /**
443
+ * 停机:断开全部账号
444
+ *
445
+ * 并发断开并等到全部收尾,包括 `detachAdapter` 留下的异步尾巴 ——
446
+ * 否则进程退出时会留下没关的 socket,NapCat 侧要等到 TCP 超时才发现。
447
+ */
448
+ async stop(): Promise<void> {
449
+ this.#stopped = true
450
+ // 先摘监听:停机过程中若有插件正好在卸载,detachAdapter 再来一遍
451
+ // 只会和下面的 disconnect 抢同一个运行时
452
+ for (const off of this.#unhook) off()
453
+ this.#unhook.length = 0
454
+
455
+ const tasks: Promise<unknown>[] = []
456
+ for (const rt of this.#runtimes.values()) {
457
+ this.#clearTimer(rt)
458
+ if (rt.closing !== undefined) tasks.push(rt.closing.catch(() => undefined))
459
+ tasks.push(this.disconnect(rt.record.id, "内核正在停止").catch(() => undefined))
460
+ }
461
+ await Promise.allSettled(tasks)
462
+ }
463
+
464
+ /* ─────────────────────────── 内部 ─────────────────────────── */
465
+
466
+ /**
467
+ * 真正的连接过程
468
+ * @param rt 账号运行时
469
+ */
470
+ async #doConnect(rt: AccountRuntime): Promise<void> {
471
+ const record = rt.record
472
+ const provider = this.#adapters.get(record.adapterId)
473
+ if (provider === undefined) {
474
+ // 不算失败到要重连:适配器插件没装,重试一万次也是一样的结果。
475
+ // 等 onAdapterRegistered 把它叫回来。
476
+ this.#setStatus(rt, "error", `适配器 ${record.adapterId} 未注册`)
477
+ this.#logger.warn(`账号 ${this.#describe(record)} 无法连接:适配器 ${record.adapterId} 未注册`)
478
+ return
479
+ }
480
+
481
+ this.#setStatus(rt, "connecting", undefined)
482
+ const controller = new AbortController()
483
+ const registry = new DisposalRegistry(`account:${record.id}`)
484
+ rt.controller = controller
485
+ rt.registry = registry
486
+
487
+ let handle: AdapterHostHandle | undefined
488
+ // 提到 try 外面:`rt.driver` 要等连上之后才赋值,而 `connect()` 抛错恰恰是
489
+ // 最常见的失败路径,那时只有这个引用能把半成品驱动交给 #teardown 关掉
490
+ let driver: BotDriver | undefined
491
+ try {
492
+ handle = this.#createHost({
493
+ record,
494
+ controller,
495
+ registry,
496
+ setStatus: (status, detail) => {
497
+ // 适配器自报状态(如"正在扫码")。它报 offline 时走完整的下线流程
498
+ // 而不是仅修改一个字段:socket 已不存在,Bot 注册表中的对应条目也必须摘除。
499
+ if (status === "offline" && rt.status === "online") {
500
+ void this.#onDriverOffline(rt, detail?.error ?? "适配器报告已下线")
501
+ return
502
+ }
503
+ this.#setStatus(rt, status, detail?.error)
504
+ }
505
+ })
506
+
507
+ driver = await provider.createBot(provider.validateAccount(record.config), handle.host)
508
+ if (controller.signal.aborted) {
509
+ // 连接期间账号被禁用/删除了:把刚建好的 driver 立刻收掉,
510
+ // 否则它会成为一个没人引用、还在收消息的幽灵
511
+ await driver.disconnect().catch(() => undefined)
512
+ return
513
+ }
514
+
515
+ await driver.connect()
516
+ if (controller.signal.aborted) {
517
+ await driver.disconnect().catch(() => undefined)
518
+ return
519
+ }
520
+
521
+ const facade = createBotFacade({
522
+ driver,
523
+ accountId: record.id,
524
+ logger: this.#logger.child({ account: record.id }),
525
+ policy: this.#sendPolicy,
526
+ // 门面自己不认识总线(见 adapter/bots.ts 文件头),由此处代为转发。
527
+ // emitDetached:统计插件绝不该拖慢发送主链路
528
+ onSent: info => this.#events.emitDetached("message/sent", info)
529
+ })
530
+ rt.driver = driver
531
+ rt.facade = facade
532
+ this.#bots.add(facade)
533
+ // 绑在最后一步:宿主此前收到的事件一律丢弃(宿主文件头闸门二),
534
+ // 因为在门面就绪之前 e.reply() 无处可发
535
+ handle.attach(facade)
536
+
537
+ // 回填 selfId:WebUI 要显示"这个账号是哪个 QQ",而它只有连上才知道
538
+ if (driver.selfId !== "" && record.selfId !== driver.selfId) {
539
+ record.selfId = driver.selfId
540
+ record.updatedAt = Date.now()
541
+ await this.#persist(record).catch((err: unknown) => {
542
+ this.#logger.warn(`回填账号 ${record.id} 的 selfId 失败:${errText(err)}`)
543
+ })
544
+ }
545
+
546
+ rt.retries = 0
547
+ this.#setStatus(rt, "online", undefined)
548
+ this.#logger.info(`账号上线:${this.#describe(record)}(${driver.nickname || driver.selfId})`)
549
+ this.#events.emitDetached("bot/online", facade)
550
+ } catch (err) {
551
+ handle?.detach()
552
+ // 补登记再收拾:失败点在 `rt.driver = driver` 之前时(`connect()` 抛错就是),
553
+ // #teardown 看不到这个驱动,它带着的 socket 与定时器就没人关了 —— 而
554
+ // #scheduleReconnect 每次重试都新建一个驱动,漏一个就是永久漏一个
555
+ rt.driver ??= driver
556
+ // **必须在 #teardown 之前读**:它自己会 abort 这个 controller,读晚了
557
+ // 就永远是 true,于是真正的连接失败也不再上报 error、也不再重连
558
+ const deliberate = controller.signal.aborted
559
+ await this.#teardown(rt, "连接失败")
560
+ // 被 abort 打断的不算"连接失败",那是 disconnect/stop 主动要求停下来。
561
+ // 照常报 error 并排重连的话,`disconnect()` 里的 #clearTimer 早已跑过,
562
+ // 这个新排的定时器没人清,几秒后会把一个刚被停掉的账号重新连起来
563
+ if (deliberate) return
564
+ this.#setStatus(rt, "error", errText(err))
565
+ this.#scheduleReconnect(rt, err)
566
+ }
567
+ }
568
+
569
+ /**
570
+ * 驱动侧断线:清理并安排重连
571
+ * @param rt 账号运行时
572
+ * @param reason 原因
573
+ */
574
+ async #onDriverOffline(rt: AccountRuntime, reason: string): Promise<void> {
575
+ if (rt.status !== "online" && rt.status !== "connecting") return
576
+ await this.#teardown(rt, reason)
577
+ if (rt.removed || this.#stopped) return
578
+ this.#setStatus(rt, rt.record.enabled ? "offline" : "disabled", reason)
579
+ if (rt.record.enabled) this.#scheduleReconnect(rt, new Error(reason))
580
+ }
581
+
582
+ /**
583
+ * 收拾一次连接留下的一切
584
+ *
585
+ * 顺序是刻意的:abort(停止投递)→ 摘 Bot 注册表(`pickBot` 拿不到它)→
586
+ * 回收登记(路由、缓存、定时器)→ 关 socket。反过来会有"已断开却还在
587
+ * 处理事件"的窗口。
588
+ * @param rt 账号运行时
589
+ * @param reason 原因
590
+ */
591
+ async #teardown(rt: AccountRuntime, reason: string): Promise<void> {
592
+ const driver = rt.driver
593
+ const facade = rt.facade
594
+ const wasOnline = rt.status === "online"
595
+
596
+ rt.controller?.abort()
597
+ rt.controller = undefined
598
+ rt.driver = undefined
599
+ rt.facade = undefined
600
+
601
+ if (facade !== undefined) {
602
+ // 幂等:detachAdapter 可能已经 close 过了
603
+ facade.close()
604
+ this.#bots.remove(rt.record.id)
605
+ }
606
+
607
+ const registry = rt.registry
608
+ rt.registry = undefined
609
+ if (registry !== undefined) {
610
+ const failures = registry.dispose()
611
+ for (const f of failures) {
612
+ this.#logger.warn(`回收账号 ${rt.record.id} 的 ${f.label} 时出错:${errText(f.error)}`)
613
+ }
614
+ }
615
+
616
+ if (driver !== undefined) {
617
+ try {
618
+ await driver.disconnect()
619
+ } catch (err) {
620
+ // 断开失败没有补救手段,落日志即可 —— 进程退出时 socket 总会关掉
621
+ this.#logger.warn(`断开账号 ${rt.record.id} 时出错:${errText(err)}`)
622
+ }
623
+ }
624
+
625
+ if (wasOnline) {
626
+ this.#logger.info(`账号下线:${this.#describe(rt.record)}(${reason})`)
627
+ if (facade !== undefined) this.#events.emitDetached("bot/offline", facade, reason)
628
+ }
629
+ }
630
+
631
+ /**
632
+ * 安排一次重连
633
+ * @param rt 账号运行时
634
+ * @param err 触发重连的错误
635
+ */
636
+ #scheduleReconnect(rt: AccountRuntime, err: unknown): void {
637
+ if (this.#stopped || rt.removed || !rt.record.enabled) return
638
+ this.#clearTimer(rt)
639
+
640
+ rt.retries++
641
+ const delay = backoffDelay(rt.retries, RECONNECT_BACKOFF)
642
+ const text = `账号 ${this.#describe(rt.record)} 连接失败(第 ${rt.retries} 次):${errText(err)};${Math.round(delay / 1000)} 秒后重试`
643
+ // 对端长期不在线时不刷屏:首次与每 10 次一条 warn,其余降级到 debug
644
+ if (rt.retries === 1 || rt.retries % WARN_EVERY === 0) this.#logger.warn(text)
645
+ else this.#logger.debug(text)
646
+
647
+ rt.timer = setTimeout(() => {
648
+ rt.timer = undefined
649
+ void this.connect(rt.record.id).catch(() => undefined)
650
+ }, delay)
651
+ rt.timer.unref?.()
652
+ }
653
+
654
+ /**
655
+ * 清掉待触发的重连
656
+ * @param rt 账号运行时
657
+ */
658
+ #clearTimer(rt: AccountRuntime): void {
659
+ if (rt.timer === undefined) return
660
+ clearTimeout(rt.timer)
661
+ rt.timer = undefined
662
+ }
663
+
664
+ /**
665
+ * 落一次状态变化
666
+ * @param rt 账号运行时
667
+ * @param status 新状态
668
+ * @param error 错误信息
669
+ */
670
+ #setStatus(rt: AccountRuntime, status: AccountStatus, error: string | undefined): void {
671
+ if (rt.status === status && rt.error === error) return
672
+ rt.status = status
673
+ rt.error = error
674
+ rt.since = Date.now()
675
+ }
676
+
677
+ /**
678
+ * 生成状态快照
679
+ * @param rt 账号运行时
680
+ * @returns 快照
681
+ */
682
+ #snapshot(rt: AccountRuntime): AccountState {
683
+ // 复制记录:WebUI 拿到的对象不该是内部状态的别名,改一下就悄悄改了内核
684
+ const state: AccountState = {
685
+ record: { ...rt.record, config: { ...rt.record.config } },
686
+ status: rt.status,
687
+ since: rt.since,
688
+ retries: rt.retries
689
+ }
690
+ if (rt.error !== undefined) state.error = rt.error
691
+ const nickname = rt.facade?.nickname
692
+ if (nickname !== undefined && nickname !== "") state.nickname = nickname
693
+ return state
694
+ }
695
+
696
+ /**
697
+ * 写一条记录进 KV
698
+ * @param record 账号记录
699
+ */
700
+ async #persist(record: AccountRecord): Promise<void> {
701
+ await this.#kv.set(`${RECORD_PREFIX}${record.id}`, record)
702
+ }
703
+
704
+ /**
705
+ * 取运行时,不存在即抛
706
+ * @param id 账号记录 id
707
+ * @returns 运行时
708
+ * @throws 账号不存在时
709
+ */
710
+ #require(id: string): AccountRuntime {
711
+ const rt = this.#runtimes.get(id)
712
+ if (rt === undefined) throw new Error(`账号 ${id} 不存在`)
713
+ return rt
714
+ }
715
+
716
+ /**
717
+ * 取适配器,未注册即抛
718
+ * @param adapterId 适配器 id
719
+ * @returns 适配器
720
+ * @throws 未注册时
721
+ */
722
+ #requireAdapter(adapterId: string): AdapterProvider {
723
+ const provider = this.#adapters.get(adapterId)
724
+ if (provider === undefined) {
725
+ const available = this.#adapters
726
+ .list()
727
+ .map(a => a.id)
728
+ .join("、")
729
+ throw new Error(`适配器 ${adapterId} 未注册${available === "" ? "" : `,可用:${available}`}`)
730
+ }
731
+ return provider
732
+ }
733
+
734
+ /**
735
+ * 走一遍适配器的配置校验
736
+ * @param provider 适配器
737
+ * @param config 原始配置
738
+ * @returns 规范化后的配置
739
+ * @throws 校验不通过时(错误信息会展示给用户)
740
+ */
741
+ #validate(provider: AdapterProvider, config: unknown): Record<string, unknown> {
742
+ try {
743
+ return provider.validateAccount(config) as Record<string, unknown>
744
+ } catch (err) {
745
+ throw new Error(`账号配置不符合适配器 ${provider.name} 的要求:${errText(err)}`)
746
+ }
747
+ }
748
+
749
+ /**
750
+ * 账号的可读描述
751
+ * @param record 账号记录
752
+ * @returns 描述文本
753
+ */
754
+ #describe(record: AccountRecord): string {
755
+ const who = record.label ?? record.selfId ?? record.id.slice(0, 8)
756
+ return `${who}@${record.adapterId}`
757
+ }
758
+ }