@xmanrui/dsh-im 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 (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/THIRD_PARTY_NOTICES.md +15 -0
  4. package/bin/dsh-im.mjs +99 -0
  5. package/cordis.patch.yml +3 -0
  6. package/lib/client.js +6211 -0
  7. package/lib/index.js +13339 -0
  8. package/package.json +73 -0
  9. package/plugin-src/client/build.mjs +38 -0
  10. package/plugin-src/client/channel-logos.js +107 -0
  11. package/plugin-src/client/channels/dingtalk/api.js +203 -0
  12. package/plugin-src/client/channels/dingtalk/index.js +709 -0
  13. package/plugin-src/client/channels/dingtalk/styles.js +140 -0
  14. package/plugin-src/client/channels/discord/api.js +11 -0
  15. package/plugin-src/client/channels/discord/index.js +25 -0
  16. package/plugin-src/client/channels/discord/styles.js +19 -0
  17. package/plugin-src/client/channels/feishu/api.js +325 -0
  18. package/plugin-src/client/channels/feishu/index.js +986 -0
  19. package/plugin-src/client/channels/feishu/styles.js +505 -0
  20. package/plugin-src/client/channels/qq/api.js +119 -0
  21. package/plugin-src/client/channels/qq/index.js +402 -0
  22. package/plugin-src/client/channels/qq/styles.js +19 -0
  23. package/plugin-src/client/channels/shared/token-api.js +88 -0
  24. package/plugin-src/client/channels/shared/token-channel.js +277 -0
  25. package/plugin-src/client/channels/telegram/api.js +11 -0
  26. package/plugin-src/client/channels/telegram/index.js +25 -0
  27. package/plugin-src/client/channels/telegram/styles.js +19 -0
  28. package/plugin-src/client/channels/wecom/api.js +119 -0
  29. package/plugin-src/client/channels/wecom/index.js +402 -0
  30. package/plugin-src/client/channels/wecom/styles.js +19 -0
  31. package/plugin-src/client/channels/weixin/api.js +156 -0
  32. package/plugin-src/client/channels/weixin/index.js +577 -0
  33. package/plugin-src/client/channels/weixin/styles.js +120 -0
  34. package/plugin-src/client/credential-binding.js +117 -0
  35. package/plugin-src/client/index.js +192 -0
  36. package/plugin-src/client/lifecycle.js +86 -0
  37. package/plugin-src/client/styles.js +192 -0
  38. package/plugin-src/host/build.mjs +24 -0
  39. package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
  40. package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
  41. package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
  42. package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
  43. package/plugin-src/host/channels/discord/index.mjs +30 -0
  44. package/plugin-src/host/channels/discord/production.mjs +17 -0
  45. package/plugin-src/host/channels/discord/rpc.mjs +21 -0
  46. package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
  47. package/plugin-src/host/channels/feishu/controller.mjs +172 -0
  48. package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
  49. package/plugin-src/host/channels/feishu/index.mjs +73 -0
  50. package/plugin-src/host/channels/feishu/production.mjs +138 -0
  51. package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
  52. package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
  53. package/plugin-src/host/channels/qq/index.mjs +30 -0
  54. package/plugin-src/host/channels/qq/production.mjs +109 -0
  55. package/plugin-src/host/channels/qq/rpc.mjs +146 -0
  56. package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
  57. package/plugin-src/host/channels/shared/production.mjs +104 -0
  58. package/plugin-src/host/channels/shared/rpc.mjs +119 -0
  59. package/plugin-src/host/channels/telegram/index.mjs +30 -0
  60. package/plugin-src/host/channels/telegram/production.mjs +17 -0
  61. package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
  62. package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
  63. package/plugin-src/host/channels/wecom/index.mjs +36 -0
  64. package/plugin-src/host/channels/wecom/production.mjs +113 -0
  65. package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
  66. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
  67. package/plugin-src/host/channels/weixin/index.mjs +39 -0
  68. package/plugin-src/host/channels/weixin/production.mjs +119 -0
  69. package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
  70. package/plugin-src/host/index.mjs +44 -0
  71. package/plugin-src/host/rpc-authority.mjs +11 -0
  72. package/scripts/verify-package.mjs +97 -0
  73. package/src/channels/dingtalk/config-store.mjs +282 -0
  74. package/src/channels/dingtalk/device-auth.mjs +237 -0
  75. package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
  76. package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
  77. package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
  78. package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
  79. package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
  80. package/src/channels/dingtalk/harness-client.mjs +299 -0
  81. package/src/channels/dingtalk/state-store.mjs +212 -0
  82. package/src/channels/discord/config-store.mjs +24 -0
  83. package/src/channels/discord/discord-api.mjs +153 -0
  84. package/src/channels/discord/discord-bridge.mjs +15 -0
  85. package/src/channels/discord/discord-controller.mjs +16 -0
  86. package/src/channels/discord/discord-runtime.mjs +460 -0
  87. package/src/channels/discord/harness-client.mjs +3 -0
  88. package/src/channels/discord/state-store.mjs +3 -0
  89. package/src/channels/feishu/bridge.mjs +216 -0
  90. package/src/channels/feishu/config.mjs +71 -0
  91. package/src/channels/feishu/feishu-app.mjs +51 -0
  92. package/src/channels/feishu/feishu-channel.mjs +153 -0
  93. package/src/channels/feishu/feishu-runtime.mjs +222 -0
  94. package/src/channels/feishu/harness-client.mjs +268 -0
  95. package/src/channels/feishu/message-utils.mjs +51 -0
  96. package/src/channels/feishu/multi-bot-controller.mjs +686 -0
  97. package/src/channels/feishu/plugin-config-store.mjs +204 -0
  98. package/src/channels/feishu/plugin-controller.mjs +248 -0
  99. package/src/channels/feishu/registration-manager.mjs +345 -0
  100. package/src/channels/feishu/state-store.mjs +71 -0
  101. package/src/channels/qq/config-store.mjs +171 -0
  102. package/src/channels/qq/harness-client.mjs +3 -0
  103. package/src/channels/qq/qq-bridge.mjs +177 -0
  104. package/src/channels/qq/qq-controller.mjs +452 -0
  105. package/src/channels/qq/qq-runtime.mjs +208 -0
  106. package/src/channels/qq/qr-auth.mjs +24 -0
  107. package/src/channels/qq/state-store.mjs +96 -0
  108. package/src/channels/shared/conversation-state-store.mjs +107 -0
  109. package/src/channels/shared/editable-message-stream.mjs +81 -0
  110. package/src/channels/shared/text-harness-bridge.mjs +180 -0
  111. package/src/channels/shared/token-bot-controller.mjs +298 -0
  112. package/src/channels/shared/token-config-store.mjs +180 -0
  113. package/src/channels/telegram/config-store.mjs +24 -0
  114. package/src/channels/telegram/harness-client.mjs +3 -0
  115. package/src/channels/telegram/state-store.mjs +3 -0
  116. package/src/channels/telegram/telegram-api.mjs +120 -0
  117. package/src/channels/telegram/telegram-bridge.mjs +15 -0
  118. package/src/channels/telegram/telegram-controller.mjs +16 -0
  119. package/src/channels/telegram/telegram-runtime.mjs +278 -0
  120. package/src/channels/wecom/config-store.mjs +170 -0
  121. package/src/channels/wecom/harness-client.mjs +3 -0
  122. package/src/channels/wecom/qr-auth.mjs +95 -0
  123. package/src/channels/wecom/state-store.mjs +90 -0
  124. package/src/channels/wecom/wecom-bridge.mjs +243 -0
  125. package/src/channels/wecom/wecom-controller.mjs +474 -0
  126. package/src/channels/wecom/wecom-runtime.mjs +209 -0
  127. package/src/channels/weixin/config-store.mjs +182 -0
  128. package/src/channels/weixin/harness-client.mjs +259 -0
  129. package/src/channels/weixin/state-store.mjs +112 -0
  130. package/src/channels/weixin/weixin-api.mjs +319 -0
  131. package/src/channels/weixin/weixin-bridge.mjs +173 -0
  132. package/src/channels/weixin/weixin-controller.mjs +545 -0
  133. package/src/channels/weixin/weixin-runtime.mjs +204 -0
@@ -0,0 +1,686 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { RegistrationManager } from './registration-manager.mjs';
3
+ import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
4
+
5
+ const ACTIVE_REGISTRATION_STATES = new Set([
6
+ 'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
7
+ ]);
8
+ const MUTABLE_REGISTRATION_STATES = new Set([...ACTIVE_REGISTRATION_STATES, 'saving']);
9
+ const ALL_VISIBLE_SENDERS = '*';
10
+
11
+ function idleConnection() {
12
+ return {
13
+ ready: false,
14
+ feishuLongConnectionState: 'idle',
15
+ harnessReachable: false,
16
+ };
17
+ }
18
+
19
+ function connectionStatus(runtime) {
20
+ return runtime ? runtime.status : idleConnection();
21
+ }
22
+
23
+ function isConnected(connection) {
24
+ return connection.ready === true
25
+ && connection.feishuLongConnectionState === 'connected'
26
+ && connection.harnessReachable === true;
27
+ }
28
+
29
+ function maskedAppId(appId) {
30
+ return appId.length > 12
31
+ ? `${appId.slice(0, 8)}••••${appId.slice(-4)}`
32
+ : 'cli_••••';
33
+ }
34
+
35
+ function publicBot(config) {
36
+ return {
37
+ name: config.botName,
38
+ appIdMasked: maskedAppId(config.appId),
39
+ activated: config.activated,
40
+ domain: config.domain,
41
+ };
42
+ }
43
+
44
+ function botPhase({ connected, error, connection }) {
45
+ if (connected) return 'connected';
46
+ if (error || connection.feishuLongConnectionState === 'failed') return 'error';
47
+ return 'disconnected';
48
+ }
49
+
50
+ function makeBotId() {
51
+ return `bot_${randomUUID().replaceAll('-', '')}`;
52
+ }
53
+
54
+ function makeRegistrationId() {
55
+ return `reg_${randomUUID().replaceAll('-', '')}`;
56
+ }
57
+
58
+ function secretRefFor(botId) {
59
+ return `DSH_FEISHU_APP_SECRET_${botId.slice(4).toUpperCase()}`;
60
+ }
61
+
62
+ /**
63
+ * Multi-account Feishu orchestration. Each bot owns its credential reference,
64
+ * runtime and session store. Config commits are serialized, while unrelated
65
+ * runtime lifecycles may proceed independently.
66
+ */
67
+ export class MultiBotDshFeishuController {
68
+ #registerApp;
69
+ #verifyApp;
70
+ #credentials;
71
+ #configStore;
72
+ #createRuntime;
73
+ #deleteState;
74
+ #createBotId;
75
+ #createRegistrationId;
76
+ #runtimes = new Map();
77
+ #botErrors = new Map();
78
+ #registrations = new Map();
79
+ #botOwnership = new Map();
80
+ #latestRegistrationId = null;
81
+ #configTransition = Promise.resolve();
82
+ #botTransitions = new Map();
83
+ #revision = 1;
84
+ #closed = false;
85
+
86
+ constructor({
87
+ registerApp,
88
+ verifyApp,
89
+ credentials,
90
+ configStore,
91
+ createRuntime,
92
+ deleteState = async () => {},
93
+ createBotId = makeBotId,
94
+ createRegistrationId = makeRegistrationId,
95
+ }) {
96
+ if (typeof registerApp !== 'function') throw new Error('registerApp is required');
97
+ if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
98
+ if (!credentials) throw new Error('credentials service is required');
99
+ if (!configStore || typeof configStore.list !== 'function') {
100
+ throw new Error('multi-bot config store is required');
101
+ }
102
+ if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
103
+ if (typeof deleteState !== 'function') throw new Error('deleteState must be a function');
104
+ this.#registerApp = registerApp;
105
+ this.#verifyApp = verifyApp;
106
+ this.#credentials = credentials;
107
+ this.#configStore = configStore;
108
+ this.#createRuntime = createRuntime;
109
+ this.#deleteState = deleteState;
110
+ this.#createBotId = createBotId;
111
+ this.#createRegistrationId = createRegistrationId;
112
+ }
113
+
114
+ async initialize() {
115
+ if (this.#closed) return this.status();
116
+ const bots = this.#configStore.list();
117
+ let attempted = false;
118
+ await Promise.allSettled(bots.map((config) => this.#withBotTransition(config.id, async () => {
119
+ const current = connectionStatus(this.#runtimes.get(config.id));
120
+ if (isConnected(current)
121
+ || current.feishuLongConnectionState === 'connecting'
122
+ || current.feishuLongConnectionState === 'reconnecting') {
123
+ return;
124
+ }
125
+ attempted = true;
126
+ if (config.deletionPending) {
127
+ this.#botErrors.set(config.id, {
128
+ code: 'deletion_pending',
129
+ message: '机器人正在等待完成本地删除,请重试移除。',
130
+ });
131
+ return;
132
+ }
133
+ let resolved;
134
+ try {
135
+ resolved = await this.#credentials.resolve(config.secretRef);
136
+ } catch {
137
+ this.#botErrors.set(config.id, {
138
+ code: 'missing_credentials',
139
+ message: '无法读取机器人凭据,请检查凭据存储。',
140
+ });
141
+ return;
142
+ }
143
+ if (!resolved?.value) {
144
+ this.#botErrors.set(config.id, {
145
+ code: 'missing_credentials',
146
+ message: '机器人凭据缺失,请删除后重新扫码接入。',
147
+ });
148
+ return;
149
+ }
150
+ try {
151
+ await this.#startRuntime(config, resolved.value);
152
+ this.#botErrors.delete(config.id);
153
+ } catch {
154
+ this.#botErrors.set(config.id, {
155
+ code: 'connection_failed',
156
+ message: '机器人暂时无法连接飞书,请重试。',
157
+ });
158
+ }
159
+ })));
160
+ if (attempted) this.#touch();
161
+ return this.status();
162
+ }
163
+
164
+ startRegistration() {
165
+ this.#assertOpen();
166
+ const id = this.#createRegistrationId();
167
+ if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id) || this.#registrations.has(id)) {
168
+ throw new Error('Registration id generator returned an invalid or duplicate id');
169
+ }
170
+ const record = { id, manager: null, botId: null, createdNew: false, cancelled: false };
171
+ record.manager = new RegistrationManager({
172
+ registerApp: this.#registerApp,
173
+ onCredentials: (result) => this.#serializeConfig(() => this.#acceptCredentials(record, result)),
174
+ });
175
+ this.#registrations.set(id, record);
176
+ this.#latestRegistrationId = id;
177
+ this.#trimRegistrations();
178
+ record.manager.start({
179
+ source: 'deepseek-harness',
180
+ createOnly: true,
181
+ appPreset: {
182
+ name: '{user} 的北汇星河 AI 助手',
183
+ desc: '连接飞书与 DeepSeek Harness,在聊天中使用企业 AI 助手。',
184
+ },
185
+ addons: {
186
+ preset: false,
187
+ scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
188
+ events: { items: { tenant: ['im.message.receive_v1'] } },
189
+ },
190
+ });
191
+ this.#touch();
192
+ return this.registrationStatus(id);
193
+ }
194
+
195
+ hasRegistration(attemptId) {
196
+ return this.#registrations.has(attemptId);
197
+ }
198
+
199
+ registrationStatus(attemptId) {
200
+ const record = this.#registrations.get(attemptId);
201
+ if (!record) return null;
202
+ return this.#status({ registration: record, selectedBotId: record.botId });
203
+ }
204
+
205
+ async cancelRegistration(attemptId = this.#latestRegistrationId) {
206
+ const record = this.#registrations.get(attemptId);
207
+ if (!record) return this.status();
208
+ if (!MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) {
209
+ return this.registrationStatus(attemptId);
210
+ }
211
+ record.cancelled = true;
212
+ record.manager.cancel();
213
+ await this.#serializeConfig(async () => {
214
+ if (record.createdNew && record.botId
215
+ && this.#botOwnership.get(record.botId) === record.id
216
+ && this.#configStore.getBot(record.botId)) {
217
+ await this.#withBotTransition(record.botId, () => this.#deleteBot(record.botId));
218
+ }
219
+ });
220
+ this.#touch();
221
+ return this.registrationStatus(attemptId) ?? this.status();
222
+ }
223
+
224
+ status(botId) {
225
+ return this.#status({
226
+ registration: this.#registrations.get(this.#latestRegistrationId) ?? null,
227
+ selectedBotId: botId,
228
+ });
229
+ }
230
+
231
+ async bindCredentials({ appId, appSecret, domain = 'feishu' } = {}) {
232
+ this.#assertOpen();
233
+ const normalizedAppId = typeof appId === 'string' ? appId.trim() : '';
234
+ const normalizedSecret = typeof appSecret === 'string' ? appSecret.trim() : '';
235
+ const normalizedDomain = domain === 'lark' ? 'lark' : 'feishu';
236
+ if (!normalizedAppId || !normalizedSecret) {
237
+ throw new TypeError('Feishu App ID and App Secret are required');
238
+ }
239
+
240
+ return this.#serializeConfig(async () => {
241
+ this.#assertOpen();
242
+ const bot = await this.#verifyApp({
243
+ appId: normalizedAppId,
244
+ appSecret: normalizedSecret,
245
+ domain: normalizedDomain,
246
+ });
247
+ this.#assertOpen();
248
+ const existing = this.#configStore.list().find(
249
+ (candidate) => candidate.appId === normalizedAppId,
250
+ );
251
+ const botId = existing?.id ?? this.#createBotId();
252
+ if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)
253
+ || (!existing && this.#configStore.getBot(botId))) {
254
+ throw new Error('Bot id generator returned an invalid or duplicate id');
255
+ }
256
+ const secretRef = existing?.secretRef ?? secretRefFor(botId);
257
+ const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined);
258
+ const config = {
259
+ ...existing,
260
+ id: botId,
261
+ appId: normalizedAppId,
262
+ secretRef,
263
+ ownerOpenIds: existing?.ownerOpenIds?.length
264
+ ? existing.ownerOpenIds
265
+ : [ALL_VISIBLE_SENDERS],
266
+ domain: normalizedDomain,
267
+ botName: bot.name,
268
+ botOpenId: bot.openId,
269
+ activated: bot.activated,
270
+ deletionPending: false,
271
+ connectedAt: new Date().toISOString(),
272
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
273
+ };
274
+
275
+ await this.#credentials.set(secretRef, normalizedSecret);
276
+ let saved;
277
+ try {
278
+ saved = await this.#configStore.saveBot(config);
279
+ } catch (error) {
280
+ await this.#restoreCredential(secretRef, previousSecret);
281
+ throw error;
282
+ }
283
+
284
+ await this.#withBotTransition(botId, async () => {
285
+ try {
286
+ await this.#startRuntime(saved, normalizedSecret);
287
+ this.#botErrors.delete(botId);
288
+ } catch {
289
+ this.#botErrors.set(botId, {
290
+ code: 'connection_failed',
291
+ message: '机器人已经绑定,但长连接未就绪,请点击重试。',
292
+ });
293
+ }
294
+ });
295
+ this.#touch();
296
+ return this.status(botId);
297
+ });
298
+ }
299
+
300
+ async reconnectBot(botId) {
301
+ this.#assertOpen();
302
+ return this.#withBotTransition(botId, async () => {
303
+ const config = this.#requireBot(botId);
304
+ if (config.deletionPending) {
305
+ this.#botErrors.set(botId, {
306
+ code: 'deletion_pending',
307
+ message: '机器人正在等待完成本地删除,请重试移除。',
308
+ });
309
+ return this.status(botId);
310
+ }
311
+ if (isConnected(connectionStatus(this.#runtimes.get(botId)))) {
312
+ return this.status(botId);
313
+ }
314
+ let resolved;
315
+ try {
316
+ resolved = await this.#credentials.resolve(config.secretRef);
317
+ } catch {
318
+ resolved = null;
319
+ }
320
+ if (!resolved?.value) {
321
+ this.#botErrors.set(botId, {
322
+ code: 'missing_credentials',
323
+ message: '机器人凭据缺失,请删除后重新扫码接入。',
324
+ });
325
+ this.#touch();
326
+ return this.status(botId);
327
+ }
328
+ try {
329
+ await this.#startRuntime(config, resolved.value);
330
+ this.#botErrors.delete(botId);
331
+ } catch {
332
+ this.#botErrors.set(botId, {
333
+ code: 'connection_failed',
334
+ message: '机器人暂时无法连接飞书,请重试。',
335
+ });
336
+ }
337
+ this.#touch();
338
+ return this.status(botId);
339
+ });
340
+ }
341
+
342
+ async disconnectBot(botId) {
343
+ this.#assertOpen();
344
+ // An operational pause only: credentials/config remain durable, so the
345
+ // bot reconnects on the next Host start unless it is explicitly deleted.
346
+ return this.#withBotTransition(botId, async () => {
347
+ this.#requireBot(botId);
348
+ await this.#stopRuntime(botId);
349
+ this.#botErrors.delete(botId);
350
+ this.#touch();
351
+ return this.status(botId);
352
+ });
353
+ }
354
+
355
+ async deleteBot(botId) {
356
+ this.#assertOpen();
357
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
358
+ this.#requireBot(botId);
359
+ await this.#deleteBot(botId);
360
+ this.#touch();
361
+ return this.status();
362
+ }));
363
+ }
364
+
365
+ // Compatibility methods for the original one-bot browser contract.
366
+ async reconnect() {
367
+ const bot = this.#configStore.list()[0];
368
+ return bot ? this.reconnectBot(bot.id) : this.status();
369
+ }
370
+
371
+ async disconnect() {
372
+ const bot = this.#configStore.list()[0];
373
+ return bot ? this.deleteBot(bot.id) : this.status();
374
+ }
375
+
376
+ async close() {
377
+ if (this.#closed) return;
378
+ this.#closed = true;
379
+ for (const record of this.#registrations.values()) {
380
+ if (MUTABLE_REGISTRATION_STATES.has(record.manager.status().state)) {
381
+ record.cancelled = true;
382
+ record.manager.cancel();
383
+ }
384
+ }
385
+ await this.#configTransition;
386
+ await Promise.allSettled([...this.#botTransitions.values()]);
387
+ await Promise.allSettled([...this.#runtimes.keys()].map((id) => this.#stopRuntime(id)));
388
+ }
389
+
390
+ #status({ registration, selectedBotId } = {}) {
391
+ const bots = this.#configStore.list().map((config) => {
392
+ const connection = connectionStatus(this.#runtimes.get(config.id));
393
+ const connected = isConnected(connection);
394
+ const error = this.#botErrors.get(config.id) ?? null;
395
+ return {
396
+ botId: config.id,
397
+ phase: botPhase({ connected, error, connection }),
398
+ connected,
399
+ configured: true,
400
+ bot: publicBot(config),
401
+ connection,
402
+ error,
403
+ };
404
+ });
405
+ const registrationSnapshot = registration ? this.#registrationSnapshot(registration) : {
406
+ state: 'idle', attempt: 0, updatedAt: Date.now(),
407
+ };
408
+ const registering = ACTIVE_REGISTRATION_STATES.has(registrationSnapshot.state);
409
+ const connecting = registrationSnapshot.state === 'saving';
410
+ const registrationOwnsProjection = Boolean(registration) && (registering || connecting);
411
+ const selected = bots.find((bot) => bot.botId === selectedBotId)
412
+ ?? (registrationOwnsProjection ? null : (bots[0] ?? null));
413
+ const aggregateConnected = bots.some((bot) => bot.connected);
414
+ let phase = selected?.phase ?? 'unconfigured';
415
+ if (registering) phase = 'registering';
416
+ else if (connecting) phase = 'connecting';
417
+ else if (registrationSnapshot.state === 'error' && !selected) phase = 'error';
418
+ return {
419
+ schemaVersion: 2,
420
+ revision: this.#revision,
421
+ phase,
422
+ connected: selected?.connected ?? false,
423
+ configured: bots.length > 0,
424
+ bot: selected?.bot ?? null,
425
+ connection: selected?.connection ?? idleConnection(),
426
+ error: selected?.error ?? registrationSnapshot.error ?? null,
427
+ registration: registrationSnapshot,
428
+ bots,
429
+ totals: {
430
+ configured: bots.length,
431
+ connected: bots.filter((bot) => bot.connected).length,
432
+ },
433
+ anyConnected: aggregateConnected,
434
+ };
435
+ }
436
+
437
+ #registrationSnapshot(record) {
438
+ const snapshot = record.manager.status();
439
+ return {
440
+ ...snapshot,
441
+ attempt: record.id,
442
+ ...(record.botId ? { botId: record.botId } : {}),
443
+ };
444
+ }
445
+
446
+ async #acceptCredentials(record, result) {
447
+ if (record.cancelled) throw new Error('Registration was cancelled');
448
+ const appId = result.client_id;
449
+ const appSecret = result.client_secret;
450
+ const ownerOpenId = result.user_info?.open_id;
451
+ const domain = result.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
452
+ if (!ownerOpenId) throw new Error('Feishu registration returned no owner open_id');
453
+
454
+ const bot = await this.#verifyApp({ appId, appSecret, domain });
455
+ if (record.cancelled) throw new Error('Registration was cancelled');
456
+ const existing = this.#configStore.list().find((candidate) => candidate.appId === appId);
457
+ const botId = existing?.id ?? this.#createBotId();
458
+ if (typeof botId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(botId)
459
+ || (!existing && this.#configStore.getBot(botId))) {
460
+ throw new Error('Bot id generator returned an invalid or duplicate id');
461
+ }
462
+ const secretRef = existing?.secretRef ?? secretRefFor(botId);
463
+ const previousOwnership = this.#botOwnership.get(botId);
464
+ const previousSecret = await this.#credentials.resolve(secretRef).catch(() => undefined);
465
+ await this.#credentials.set(secretRef, appSecret);
466
+ let config;
467
+ try {
468
+ config = await this.#configStore.saveBot({
469
+ ...existing,
470
+ id: botId,
471
+ appId,
472
+ secretRef,
473
+ ownerOpenIds: [...new Set([...(existing?.ownerOpenIds ?? []), ownerOpenId])],
474
+ domain,
475
+ botName: bot.name,
476
+ botOpenId: bot.openId,
477
+ activated: bot.activated,
478
+ deletionPending: false,
479
+ connectedAt: new Date().toISOString(),
480
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
481
+ });
482
+ record.botId = botId;
483
+ record.createdNew = !existing;
484
+ this.#botOwnership.set(botId, record.id);
485
+ } catch (error) {
486
+ try {
487
+ await this.#restoreCredential(secretRef, previousSecret);
488
+ } catch (restoreError) {
489
+ throw new Error('Unable to restore the Feishu credential after a config failure.', {
490
+ cause: restoreError,
491
+ });
492
+ }
493
+ throw error;
494
+ }
495
+
496
+ if (record.cancelled) {
497
+ if (record.createdNew) {
498
+ await this.#withBotTransition(botId, () => this.#deleteBot(botId));
499
+ } else {
500
+ await this.#configStore.saveBot(existing);
501
+ await this.#restoreCredential(secretRef, previousSecret);
502
+ if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
503
+ else this.#botOwnership.delete(botId);
504
+ }
505
+ throw new Error('Registration was cancelled');
506
+ }
507
+ let cancellationRolledBack = false;
508
+ try {
509
+ await this.#withBotTransition(botId, () => this.#startRuntime(config, appSecret));
510
+ if (record.cancelled) {
511
+ if (record.createdNew) {
512
+ await this.#withBotTransition(botId, () => this.#deleteBot(botId));
513
+ } else {
514
+ await this.#configStore.saveBot(existing);
515
+ await this.#restoreCredential(secretRef, previousSecret);
516
+ if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
517
+ else this.#botOwnership.delete(botId);
518
+ if (previousSecret?.value && !existing.deletionPending) {
519
+ await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value));
520
+ } else {
521
+ await this.#withBotTransition(botId, () => this.#stopRuntime(botId));
522
+ }
523
+ }
524
+ cancellationRolledBack = true;
525
+ throw new Error('Registration was cancelled');
526
+ }
527
+ this.#botErrors.delete(botId);
528
+ this.#touch();
529
+ } catch (error) {
530
+ if (record.cancelled) {
531
+ if (!cancellationRolledBack && record.createdNew && this.#configStore.getBot(botId)) {
532
+ await this.#withBotTransition(botId, () => this.#deleteBot(botId));
533
+ } else if (!cancellationRolledBack && existing) {
534
+ await this.#configStore.saveBot(existing);
535
+ await this.#restoreCredential(secretRef, previousSecret);
536
+ if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
537
+ else this.#botOwnership.delete(botId);
538
+ if (!this.#closed && previousSecret?.value && !existing.deletionPending) {
539
+ await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value));
540
+ } else {
541
+ await this.#withBotTransition(botId, () => this.#stopRuntime(botId));
542
+ }
543
+ }
544
+ this.#touch();
545
+ throw error;
546
+ }
547
+ if (existing && previousSecret?.value) {
548
+ try {
549
+ await this.#configStore.saveBot(existing);
550
+ await this.#restoreCredential(secretRef, previousSecret);
551
+ if (previousOwnership) this.#botOwnership.set(botId, previousOwnership);
552
+ else this.#botOwnership.delete(botId);
553
+ if (!existing.deletionPending) {
554
+ await this.#withBotTransition(botId, () => this.#startRuntime(existing, previousSecret.value));
555
+ this.#botErrors.delete(botId);
556
+ } else {
557
+ await this.#withBotTransition(botId, () => this.#stopRuntime(botId));
558
+ this.#botErrors.set(botId, {
559
+ code: 'deletion_pending',
560
+ message: '机器人正在等待完成本地删除,请重试移除。',
561
+ });
562
+ }
563
+ this.#touch();
564
+ throw error;
565
+ } catch (restoreError) {
566
+ if (restoreError === error) throw error;
567
+ this.#botErrors.set(botId, {
568
+ code: 'connection_failed',
569
+ message: '机器人连接更新失败,且原连接无法恢复,请重试。',
570
+ });
571
+ this.#touch();
572
+ throw new Error('Unable to restore the previous Feishu bot connection.', {
573
+ cause: restoreError,
574
+ });
575
+ }
576
+ }
577
+ this.#botErrors.set(botId, {
578
+ code: 'connection_failed',
579
+ message: '机器人已经创建,但长连接未就绪,请点击重试。',
580
+ });
581
+ this.#touch();
582
+ throw error;
583
+ }
584
+ }
585
+
586
+ async #startRuntime(config, appSecret) {
587
+ await this.#stopRuntime(config.id);
588
+ const runtime = await this.#createRuntime({
589
+ botId: config.id,
590
+ config,
591
+ appSecret,
592
+ });
593
+ this.#runtimes.set(config.id, runtime);
594
+ try {
595
+ await runtime.start();
596
+ } catch (error) {
597
+ if (this.#runtimes.get(config.id) === runtime) this.#runtimes.delete(config.id);
598
+ await runtime.stop({ preserveError: true }).catch(() => undefined);
599
+ throw error;
600
+ }
601
+ }
602
+
603
+ async #stopRuntime(botId) {
604
+ const runtime = this.#runtimes.get(botId);
605
+ this.#runtimes.delete(botId);
606
+ if (runtime) await runtime.stop();
607
+ }
608
+
609
+ async #deleteBot(botId) {
610
+ let config = this.#configStore.getBot(botId);
611
+ if (!config) return;
612
+ if (!config.deletionPending) {
613
+ config = await this.#configStore.saveBot({ ...config, deletionPending: true });
614
+ }
615
+ await this.#stopRuntime(botId);
616
+ try {
617
+ await this.#credentials.unset(config.secretRef);
618
+ } catch (error) {
619
+ this.#botErrors.set(botId, {
620
+ code: 'credential_removal_failed',
621
+ message: '无法删除机器人凭据,请稍后重试。',
622
+ });
623
+ throw new Error('Unable to remove the Feishu credential.', { cause: error });
624
+ }
625
+ try {
626
+ await this.#deleteState({ botId, config });
627
+ } catch (error) {
628
+ this.#botErrors.set(botId, {
629
+ code: 'state_cleanup_failed',
630
+ message: '无法删除机器人的本地会话数据,请稍后重试。',
631
+ });
632
+ throw new Error('Unable to remove the Feishu bot session state.', { cause: error });
633
+ }
634
+ await this.#configStore.removeBot(botId);
635
+ this.#botErrors.delete(botId);
636
+ this.#botOwnership.delete(botId);
637
+ }
638
+
639
+ async #restoreCredential(secretRef, previous) {
640
+ if (previous?.value) await this.#credentials.set(secretRef, previous.value);
641
+ else await this.#credentials.unset(secretRef);
642
+ }
643
+
644
+ #requireBot(botId) {
645
+ const config = this.#configStore.getBot(botId);
646
+ if (!config) throw new Error('Unknown Feishu bot');
647
+ return config;
648
+ }
649
+
650
+ #assertOpen() {
651
+ if (this.#closed) throw new Error('The Feishu controller is closed');
652
+ }
653
+
654
+ #serializeConfig(operation) {
655
+ const result = this.#configTransition.then(operation, operation);
656
+ this.#configTransition = result.then(() => undefined, () => undefined);
657
+ return result;
658
+ }
659
+
660
+ #withBotTransition(botId, operation) {
661
+ const previous = this.#botTransitions.get(botId) ?? Promise.resolve();
662
+ const result = previous.then(operation, operation);
663
+ const tail = result.then(() => undefined, () => undefined);
664
+ this.#botTransitions.set(botId, tail);
665
+ void tail.finally(() => {
666
+ if (this.#botTransitions.get(botId) === tail) this.#botTransitions.delete(botId);
667
+ });
668
+ return result;
669
+ }
670
+
671
+ #trimRegistrations() {
672
+ if (this.#registrations.size <= 32) return;
673
+ for (const [id, record] of this.#registrations) {
674
+ if (id === this.#latestRegistrationId) continue;
675
+ const state = record.manager.status().state;
676
+ if (!ACTIVE_REGISTRATION_STATES.has(state) && state !== 'saving') {
677
+ this.#registrations.delete(id);
678
+ }
679
+ if (this.#registrations.size <= 32) break;
680
+ }
681
+ }
682
+
683
+ #touch() {
684
+ this.#revision += 1;
685
+ }
686
+ }