@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,204 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
6
+
7
+ function cleanString(value) {
8
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
9
+ }
10
+
11
+ function safeId(value) {
12
+ const id = cleanString(value);
13
+ return id && /^[A-Za-z0-9_-]{1,128}$/.test(id) ? id : null;
14
+ }
15
+
16
+ function legacyBotId(appId) {
17
+ return `bot_${createHash('sha256').update(appId).digest('hex').slice(0, 24)}`;
18
+ }
19
+
20
+ function normalizeOwners(value) {
21
+ const candidates = Array.isArray(value.ownerOpenIds)
22
+ ? value.ownerOpenIds
23
+ : [value.ownerOpenId];
24
+ return [...new Set(candidates.map(cleanString).filter(Boolean))];
25
+ }
26
+
27
+ function normalizeBot(value, { legacy = false } = {}) {
28
+ if (!value || typeof value !== 'object') return null;
29
+ const appId = cleanString(value.appId);
30
+ const ownerOpenIds = normalizeOwners(value);
31
+ if (!appId || ownerOpenIds.length === 0) return null;
32
+ const id = safeId(value.id) ?? (legacy ? legacyBotId(appId) : null);
33
+ const secretRef = cleanString(value.secretRef) ?? (legacy ? LEGACY_FEISHU_SECRET_REF : null);
34
+ if (!id || !secretRef) return null;
35
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(secretRef)) return null;
36
+ const domain = value.domain === 'lark' ? 'lark' : 'feishu';
37
+ return Object.freeze({
38
+ id,
39
+ appId,
40
+ secretRef,
41
+ ownerOpenIds: Object.freeze(ownerOpenIds),
42
+ domain,
43
+ botName: cleanString(value.botName),
44
+ botOpenId: cleanString(value.botOpenId),
45
+ activated: value.activated ?? null,
46
+ deletionPending: value.deletionPending === true,
47
+ connectedAt: cleanString(value.connectedAt),
48
+ createdAt: cleanString(value.createdAt) ?? cleanString(value.connectedAt),
49
+ });
50
+ }
51
+
52
+ function normalizeDocument(value) {
53
+ if (!value || typeof value !== 'object') return null;
54
+ if (value.version === 2 && Array.isArray(value.bots)) {
55
+ const bots = value.bots.map((bot) => normalizeBot(bot));
56
+ if (bots.some((bot) => bot === null)) {
57
+ throw new Error('dsh-feishu config contains an invalid bot entry');
58
+ }
59
+ const ids = new Set();
60
+ const refs = new Set();
61
+ const appIds = new Set();
62
+ for (const bot of bots) {
63
+ if (ids.has(bot.id) || refs.has(bot.secretRef) || appIds.has(bot.appId)) {
64
+ throw new Error('dsh-feishu config contains duplicate bot identities');
65
+ }
66
+ ids.add(bot.id);
67
+ refs.add(bot.secretRef);
68
+ appIds.add(bot.appId);
69
+ }
70
+ return { value: Object.freeze({ version: 2, bots: Object.freeze(bots) }), migrated: false };
71
+ }
72
+
73
+ // Version 1 was a single non-secret bot object. Preserve its existing
74
+ // credential reference so an environment-backed secret remains usable.
75
+ const legacyBot = normalizeBot(value, { legacy: true });
76
+ if (!legacyBot) return null;
77
+ return {
78
+ value: Object.freeze({ version: 2, bots: Object.freeze([legacyBot]) }),
79
+ migrated: true,
80
+ };
81
+ }
82
+
83
+ /** Stores only non-secret onboarding facts for all Feishu bots. */
84
+ export class PluginConfigStore {
85
+ #path;
86
+ #value = Object.freeze({ version: 2, bots: Object.freeze([]) });
87
+ #writeQueue = Promise.resolve();
88
+
89
+ constructor(path) {
90
+ this.#path = path;
91
+ }
92
+
93
+ async load() {
94
+ try {
95
+ const parsed = JSON.parse(await readFile(this.#path, 'utf8'));
96
+ const normalized = normalizeDocument(parsed);
97
+ if (!normalized) throw new Error('dsh-feishu config is incomplete or invalid');
98
+ this.#value = normalized.value;
99
+ if (normalized.migrated) await this.#writeDocument(this.#value);
100
+ } catch (error) {
101
+ if (error?.code !== 'ENOENT') throw error;
102
+ this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) });
103
+ }
104
+ return this;
105
+ }
106
+
107
+ /** Backward-compatible single-bot view used by the original controller. */
108
+ get() {
109
+ const bot = this.#value.bots[0];
110
+ if (!bot) return null;
111
+ const result = structuredClone(bot);
112
+ result.ownerOpenId = result.ownerOpenIds[0];
113
+ return result;
114
+ }
115
+
116
+ list() {
117
+ return structuredClone(this.#value.bots);
118
+ }
119
+
120
+ getBot(id) {
121
+ const bot = this.#value.bots.find((candidate) => candidate.id === id);
122
+ return bot ? structuredClone(bot) : null;
123
+ }
124
+
125
+ /** Backward-compatible save replaces the original single-bot view. */
126
+ async save(value) {
127
+ const fallback = { ...value };
128
+ if (!fallback.id) fallback.id = legacyBotId(cleanString(fallback.appId) ?? 'invalid');
129
+ if (!fallback.secretRef) fallback.secretRef = LEGACY_FEISHU_SECRET_REF;
130
+ const normalized = normalizeBot(fallback);
131
+ if (!normalized) throw new Error('Refusing to persist incomplete dsh-feishu configuration');
132
+ await this.#replaceBots([normalized]);
133
+ return this.get();
134
+ }
135
+
136
+ async saveBot(value) {
137
+ const normalized = normalizeBot(value);
138
+ if (!normalized) throw new Error('Refusing to persist incomplete dsh-feishu bot configuration');
139
+ return this.#mutate((bots) => {
140
+ const collision = bots.find((bot) => bot.secretRef === normalized.secretRef && bot.id !== normalized.id);
141
+ if (collision) throw new Error('Refusing to share a credential reference between Feishu bots');
142
+ const appCollision = bots.find((bot) => bot.appId === normalized.appId && bot.id !== normalized.id);
143
+ if (appCollision) throw new Error('Refusing to persist the same Feishu app twice');
144
+ const index = bots.findIndex((bot) => bot.id === normalized.id);
145
+ if (index === -1) bots.push(normalized);
146
+ else bots[index] = normalized;
147
+ return structuredClone(normalized);
148
+ });
149
+ }
150
+
151
+ async removeBot(id) {
152
+ if (!safeId(id)) throw new TypeError('Invalid Feishu bot id');
153
+ return this.#mutate((bots) => {
154
+ const index = bots.findIndex((bot) => bot.id === id);
155
+ if (index === -1) return null;
156
+ const [removed] = bots.splice(index, 1);
157
+ return structuredClone(removed);
158
+ });
159
+ }
160
+
161
+ async clear() {
162
+ const operation = this.#writeQueue.then(async () => {
163
+ try {
164
+ await unlink(this.#path);
165
+ } catch (error) {
166
+ if (error?.code !== 'ENOENT') throw error;
167
+ }
168
+ this.#value = Object.freeze({ version: 2, bots: Object.freeze([]) });
169
+ });
170
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
171
+ await operation;
172
+ }
173
+
174
+ async #replaceBots(bots) {
175
+ const document = Object.freeze({ version: 2, bots: Object.freeze([...bots]) });
176
+ const operation = this.#writeQueue.then(async () => {
177
+ await this.#writeDocument(document);
178
+ this.#value = document;
179
+ });
180
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
181
+ await operation;
182
+ }
183
+
184
+ async #mutate(mutator) {
185
+ let result;
186
+ const operation = this.#writeQueue.then(async () => {
187
+ const bots = [...this.#value.bots];
188
+ result = mutator(bots);
189
+ const document = Object.freeze({ version: 2, bots: Object.freeze(bots) });
190
+ await this.#writeDocument(document);
191
+ this.#value = document;
192
+ });
193
+ this.#writeQueue = operation.then(() => undefined, () => undefined);
194
+ await operation;
195
+ return result;
196
+ }
197
+
198
+ async #writeDocument(document) {
199
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
200
+ const temporary = `${this.#path}.tmp`;
201
+ await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
202
+ await rename(temporary, this.#path);
203
+ }
204
+ }
@@ -0,0 +1,248 @@
1
+ import { RegistrationManager } from './registration-manager.mjs';
2
+
3
+ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
4
+
5
+ export const REQUIRED_TENANT_SCOPES = Object.freeze([
6
+ 'im:message.p2p_msg:readonly',
7
+ 'im:message.group_at_msg:readonly',
8
+ 'im:message:send_as_bot',
9
+ 'im:message.reactions:write_only',
10
+ 'im:message:recall',
11
+ 'cardkit:card:write',
12
+ ]);
13
+
14
+ function safeConnectionStatus(runtime) {
15
+ if (!runtime) return {
16
+ ready: false,
17
+ feishuLongConnectionState: 'idle',
18
+ harnessReachable: false,
19
+ };
20
+ return runtime.status;
21
+ }
22
+
23
+ function publicBot(config) {
24
+ if (!config) return null;
25
+ const appIdMasked = config.appId.length > 12
26
+ ? `${config.appId.slice(0, 8)}••••${config.appId.slice(-4)}`
27
+ : 'cli_••••';
28
+ return {
29
+ name: config.botName,
30
+ appIdMasked,
31
+ openId: config.botOpenId,
32
+ activated: config.activated,
33
+ domain: config.domain,
34
+ };
35
+ }
36
+
37
+ /** Coordinates QR provisioning, durable credentials and the live chat runtime. */
38
+ export class DshFeishuController {
39
+ #registerApp;
40
+ #verifyApp;
41
+ #credentials;
42
+ #configStore;
43
+ #createRuntime;
44
+ #registration;
45
+ #runtime = null;
46
+ #lastError = null;
47
+ #transition = Promise.resolve();
48
+
49
+ constructor({ registerApp, verifyApp, credentials, configStore, createRuntime }) {
50
+ if (typeof registerApp !== 'function') throw new Error('registerApp is required');
51
+ if (typeof verifyApp !== 'function') throw new Error('verifyApp is required');
52
+ if (!credentials) throw new Error('credentials service is required');
53
+ if (!configStore) throw new Error('config store is required');
54
+ if (typeof createRuntime !== 'function') throw new Error('createRuntime is required');
55
+
56
+ this.#registerApp = registerApp;
57
+ this.#verifyApp = verifyApp;
58
+ this.#credentials = credentials;
59
+ this.#configStore = configStore;
60
+ this.#createRuntime = createRuntime;
61
+ this.#registration = new RegistrationManager({
62
+ registerApp: this.#registerApp,
63
+ onCredentials: (result) => this.#serialize(() => this.#acceptCredentials(result)),
64
+ });
65
+ }
66
+
67
+ async initialize() {
68
+ const config = this.#configStore.get();
69
+ if (!config) return this.status();
70
+ return this.#serialize(async () => {
71
+ const resolved = await this.#credentials.resolve(FEISHU_SECRET_REF);
72
+ if (!resolved?.value) {
73
+ this.#lastError = {
74
+ code: 'missing_credentials',
75
+ message: '机器人凭据缺失,请重新扫码接入。',
76
+ };
77
+ return this.status();
78
+ }
79
+ try {
80
+ await this.#startRuntime(config, resolved.value);
81
+ this.#lastError = null;
82
+ } catch {
83
+ this.#lastError = {
84
+ code: 'connection_failed',
85
+ message: '机器人暂时无法连接飞书,请重试。',
86
+ };
87
+ }
88
+ return this.status();
89
+ });
90
+ }
91
+
92
+ startRegistration() {
93
+ this.#lastError = null;
94
+ this.#registration.start({
95
+ source: 'deepseek-harness',
96
+ createOnly: true,
97
+ appPreset: {
98
+ name: '{user} 的北汇星河 AI 助手',
99
+ desc: '连接飞书与 DeepSeek Harness,在聊天中使用企业 AI 助手。',
100
+ },
101
+ addons: {
102
+ preset: false,
103
+ scopes: { tenant: [...REQUIRED_TENANT_SCOPES] },
104
+ events: { items: { tenant: ['im.message.receive_v1'] } },
105
+ },
106
+ });
107
+ return this.status();
108
+ }
109
+
110
+ cancelRegistration() {
111
+ this.#registration.cancel();
112
+ return this.status();
113
+ }
114
+
115
+ async reconnect() {
116
+ return this.#serialize(async () => {
117
+ const config = this.#configStore.get();
118
+ const resolved = await this.#credentials.resolve(FEISHU_SECRET_REF);
119
+ if (!config || !resolved?.value) {
120
+ this.#lastError = {
121
+ code: 'missing_credentials',
122
+ message: '没有可用的机器人凭据,请重新扫码接入。',
123
+ };
124
+ return this.status();
125
+ }
126
+ try {
127
+ await this.#startRuntime(config, resolved.value);
128
+ this.#lastError = null;
129
+ } catch {
130
+ this.#lastError = {
131
+ code: 'connection_failed',
132
+ message: '机器人暂时无法连接飞书,请重试。',
133
+ };
134
+ }
135
+ return this.status();
136
+ });
137
+ }
138
+
139
+ async disconnect() {
140
+ this.#registration.cancel();
141
+ return this.#serialize(async () => {
142
+ await this.#stopRuntime();
143
+ await this.#configStore.clear();
144
+ try {
145
+ await this.#credentials.unset(FEISHU_SECRET_REF);
146
+ } catch {
147
+ // A read-only environment value may shadow the managed store. Clearing
148
+ // the non-secret config still prevents automatic reuse of that value.
149
+ }
150
+ this.#lastError = null;
151
+ return this.status();
152
+ });
153
+ }
154
+
155
+ async close() {
156
+ this.#registration.cancel();
157
+ await this.#serialize(() => this.#stopRuntime());
158
+ }
159
+
160
+ status() {
161
+ const config = this.#configStore.get();
162
+ const registration = this.#registration.status();
163
+ const connection = safeConnectionStatus(this.#runtime);
164
+ const connected = connection.ready === true
165
+ && connection.feishuLongConnectionState === 'connected'
166
+ && connection.harnessReachable === true;
167
+
168
+ let phase = 'unconfigured';
169
+ if (connected) phase = 'connected';
170
+ else if (['starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched'].includes(registration.state)) {
171
+ phase = 'registering';
172
+ } else if (registration.state === 'saving') phase = 'connecting';
173
+ else if (this.#lastError || registration.state === 'error') phase = 'error';
174
+ else if (config) phase = 'disconnected';
175
+
176
+ return {
177
+ phase,
178
+ connected,
179
+ configured: Boolean(config),
180
+ bot: publicBot(config),
181
+ registration,
182
+ connection,
183
+ error: this.#lastError ?? registration.error ?? null,
184
+ };
185
+ }
186
+
187
+ async #acceptCredentials(result) {
188
+ const appId = result.client_id;
189
+ const appSecret = result.client_secret;
190
+ const ownerOpenId = result.user_info?.open_id;
191
+ const domain = result.user_info?.tenant_brand === 'lark' ? 'lark' : 'feishu';
192
+ if (!ownerOpenId) throw new Error('Feishu registration returned no owner open_id');
193
+
194
+ const bot = await this.#verifyApp({ appId, appSecret, domain });
195
+ await this.#credentials.set(FEISHU_SECRET_REF, appSecret);
196
+ let config;
197
+ try {
198
+ config = await this.#configStore.save({
199
+ appId,
200
+ ownerOpenId,
201
+ domain,
202
+ botName: bot.name,
203
+ botOpenId: bot.openId,
204
+ activated: bot.activated,
205
+ connectedAt: new Date().toISOString(),
206
+ });
207
+ } catch (error) {
208
+ await this.#credentials.unset(FEISHU_SECRET_REF).catch(() => undefined);
209
+ throw error;
210
+ }
211
+
212
+ try {
213
+ await this.#startRuntime(config, appSecret);
214
+ this.#lastError = null;
215
+ } catch (error) {
216
+ this.#lastError = {
217
+ code: 'connection_failed',
218
+ message: '机器人已经创建,但长连接未就绪,请点击重试。',
219
+ };
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ async #startRuntime(config, appSecret) {
225
+ await this.#stopRuntime();
226
+ const runtime = await this.#createRuntime({ config, appSecret });
227
+ this.#runtime = runtime;
228
+ try {
229
+ await runtime.start();
230
+ } catch (error) {
231
+ if (this.#runtime === runtime) this.#runtime = null;
232
+ await runtime.stop({ preserveError: true }).catch(() => undefined);
233
+ throw error;
234
+ }
235
+ }
236
+
237
+ async #stopRuntime() {
238
+ const runtime = this.#runtime;
239
+ this.#runtime = null;
240
+ if (runtime) await runtime.stop();
241
+ }
242
+
243
+ #serialize(operation) {
244
+ const result = this.#transition.then(operation, operation);
245
+ this.#transition = result.then(() => undefined, () => undefined);
246
+ return result;
247
+ }
248
+ }