@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,237 @@
1
+ import QRCode from 'qrcode';
2
+ import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+
4
+ export const DINGTALK_RPC_CHANNEL = '/dingtalk';
5
+ export const DINGTALK_ENDPOINTS = Object.freeze({
6
+ status: 'connection.status',
7
+ beginProvisioning: 'provision.begin',
8
+ pollProvisioning: 'provision.poll',
9
+ cancelProvisioning: 'provision.cancel',
10
+ bindCredentials: 'bot.bind-credentials',
11
+ reconnectBot: 'bot.reconnect',
12
+ deleteBot: 'bot.delete',
13
+ approveSender: 'bot.sender.approve',
14
+ revokeSender: 'bot.sender.revoke',
15
+ });
16
+ export const DINGTALK_RPC_ENDPOINTS = Object.freeze(Object.values(DINGTALK_ENDPOINTS));
17
+
18
+ const FORBIDDEN_PUBLIC_KEYS = new Set([
19
+ 'clientSecret',
20
+ 'client_secret',
21
+ 'deviceCode',
22
+ 'device_code',
23
+ 'secretRef',
24
+ 'staffId',
25
+ 'senderStaffId',
26
+ 'verificationUrl',
27
+ 'verificationUri',
28
+ 'userCode',
29
+ ]);
30
+
31
+ function isRecord(value) {
32
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
33
+ }
34
+
35
+ function exactKeys(value, allowed) {
36
+ return isRecord(value) && Object.keys(value).every((key) => allowed.includes(key));
37
+ }
38
+
39
+ function validId(value) {
40
+ return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
41
+ }
42
+
43
+ function validCredential(value, maxLength) {
44
+ return typeof value === 'string' && value.trim().length > 0 && value.length <= maxLength;
45
+ }
46
+
47
+ function payloadFailure(endpoint, payload) {
48
+ if (!isRecord(payload)) return 'Payload must be an object.';
49
+ if (endpoint === DINGTALK_ENDPOINTS.status) {
50
+ return exactKeys(payload, []) ? null : 'connection.status does not accept fields.';
51
+ }
52
+ if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
53
+ return exactKeys(payload, ['locale']) && (payload.locale === undefined || payload.locale === 'zh-CN')
54
+ ? null
55
+ : 'provision.begin received unsupported fields.';
56
+ }
57
+ if ([DINGTALK_ENDPOINTS.pollProvisioning, DINGTALK_ENDPOINTS.cancelProvisioning].includes(endpoint)) {
58
+ return exactKeys(payload, ['attemptId']) && validId(payload.attemptId)
59
+ ? null
60
+ : `${endpoint} requires an attemptId.`;
61
+ }
62
+ if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
63
+ return exactKeys(payload, ['clientId', 'clientSecret'])
64
+ && validCredential(payload.clientId, 256)
65
+ && validCredential(payload.clientSecret, 1024)
66
+ ? null
67
+ : 'bot.bind-credentials requires Client ID and Client Secret.';
68
+ }
69
+ if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
70
+ return exactKeys(payload, ['botId']) && validId(payload.botId)
71
+ ? null
72
+ : 'bot.reconnect requires a botId.';
73
+ }
74
+ if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
75
+ return exactKeys(payload, ['botId', 'confirm']) && validId(payload.botId) && payload.confirm === true
76
+ ? null
77
+ : 'bot.delete requires a botId and confirm=true.';
78
+ }
79
+ if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
80
+ return exactKeys(payload, ['botId', 'requestId', 'confirm'])
81
+ && validId(payload.botId)
82
+ && validId(payload.requestId)
83
+ && payload.confirm === true
84
+ ? null
85
+ : 'bot.sender.approve requires botId, requestId, and confirm=true.';
86
+ }
87
+ if (endpoint === DINGTALK_ENDPOINTS.revokeSender) {
88
+ return exactKeys(payload, ['botId', 'senderKey', 'confirm'])
89
+ && validId(payload.botId)
90
+ && validId(payload.senderKey)
91
+ && payload.confirm === true
92
+ ? null
93
+ : 'bot.sender.revoke requires botId, senderKey, and confirm=true.';
94
+ }
95
+ return 'Unknown DingTalk endpoint.';
96
+ }
97
+
98
+ function badRequest(message) {
99
+ return { ok: false, error: { code: 'bad-request', message } };
100
+ }
101
+
102
+ function cancelled() {
103
+ return { ok: false, error: { code: 'cancelled', message: 'The request was cancelled.' } };
104
+ }
105
+
106
+ function internalFailure() {
107
+ return {
108
+ ok: false,
109
+ error: { code: 'dingtalk-operation-failed', message: '钉钉操作失败,请稍后重试。' },
110
+ };
111
+ }
112
+
113
+ function sanitizePublic(value) {
114
+ if (Array.isArray(value)) return value.map(sanitizePublic);
115
+ if (!isRecord(value)) return value;
116
+ const safe = {};
117
+ for (const [key, child] of Object.entries(value)) {
118
+ if (!FORBIDDEN_PUBLIC_KEYS.has(key)) safe[key] = sanitizePublic(child);
119
+ }
120
+ return safe;
121
+ }
122
+
123
+ async function qrDataUrl(value) {
124
+ return QRCode.toDataURL(value, {
125
+ type: 'image/png',
126
+ errorCorrectionLevel: 'M',
127
+ margin: 2,
128
+ width: 320,
129
+ });
130
+ }
131
+
132
+ async function withEncodedQr(value, encodeQr) {
133
+ if (!value || typeof value.verificationUrl !== 'string') return sanitizePublic(value);
134
+ return sanitizePublic({
135
+ ...value,
136
+ qrCodeDataUrl: await encodeQr(value.verificationUrl),
137
+ });
138
+ }
139
+
140
+ async function publicStatus(status, encodeQr) {
141
+ const value = structuredClone(status);
142
+ if (value?.provisioning) {
143
+ value.provisioning = await withEncodedQr(value.provisioning, encodeQr);
144
+ }
145
+ return sanitizePublic(value);
146
+ }
147
+
148
+ function assertController(controller) {
149
+ for (const method of [
150
+ 'status',
151
+ 'startProvisioning',
152
+ 'registrationStatus',
153
+ 'cancelProvisioning',
154
+ 'bindCredentials',
155
+ 'reconnectBot',
156
+ 'deleteBot',
157
+ 'approveSender',
158
+ 'revokeSender',
159
+ ]) {
160
+ if (typeof controller?.[method] !== 'function') {
161
+ throw new TypeError(`A complete DingTalk controller is required (${method})`);
162
+ }
163
+ }
164
+ }
165
+
166
+ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } = {}) {
167
+ assertController(controller);
168
+ const qrCache = new Map();
169
+ const cachedEncode = (url) => {
170
+ let encoded = qrCache.get(url);
171
+ if (!encoded) {
172
+ if (qrCache.size >= 16) qrCache.delete(qrCache.keys().next().value);
173
+ encoded = Promise.resolve().then(() => encodeQr(url));
174
+ qrCache.set(url, encoded);
175
+ }
176
+ return encoded;
177
+ };
178
+
179
+ return async (endpoint, payload, signal) => {
180
+ if (signal?.aborted) return cancelled();
181
+ if (!DINGTALK_RPC_ENDPOINTS.includes(endpoint)) return badRequest('Unknown DingTalk endpoint.');
182
+ const invalid = payloadFailure(endpoint, payload);
183
+ if (invalid) return badRequest(invalid);
184
+
185
+ try {
186
+ let value;
187
+ if (endpoint === DINGTALK_ENDPOINTS.status) {
188
+ value = await publicStatus(await controller.status(), cachedEncode);
189
+ } else if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
190
+ const started = await controller.startProvisioning({ signal });
191
+ if (signal?.aborted) {
192
+ await controller.cancelProvisioning(started.attemptId);
193
+ return cancelled();
194
+ }
195
+ value = await withEncodedQr(started, cachedEncode);
196
+ } else if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) {
197
+ const current = await controller.registrationStatus(payload.attemptId);
198
+ if (!current) return badRequest('The provisioning attempt no longer exists.');
199
+ value = await withEncodedQr(current, cachedEncode);
200
+ } else if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) {
201
+ value = await controller.cancelProvisioning(payload.attemptId);
202
+ if (!value) return badRequest('The provisioning attempt no longer exists.');
203
+ value = sanitizePublic(value);
204
+ } else if (endpoint === DINGTALK_ENDPOINTS.bindCredentials) {
205
+ value = await publicStatus(await controller.bindCredentials(payload), cachedEncode);
206
+ } else if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
207
+ value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
208
+ } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
209
+ value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
210
+ } else if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
211
+ value = await publicStatus(
212
+ await controller.approveSender(payload.botId, payload.requestId),
213
+ cachedEncode,
214
+ );
215
+ } else {
216
+ value = await publicStatus(
217
+ await controller.revokeSender(payload.botId, payload.senderKey),
218
+ cachedEncode,
219
+ );
220
+ }
221
+ return signal?.aborted ? cancelled() : { ok: true, value };
222
+ } catch {
223
+ return signal?.aborted ? cancelled() : internalFailure();
224
+ }
225
+ };
226
+ }
227
+
228
+ export function installDingtalkRpc(ctx, controller, options, authority) {
229
+ if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
230
+ throw new TypeError('DSH Host Connection RPC is required');
231
+ }
232
+ return ctx.connection.rpc.handle(
233
+ DINGTALK_RPC_CHANNEL,
234
+ createDingtalkRpcHandler(controller, options),
235
+ { authority: resolveRpcAuthority(authority) },
236
+ );
237
+ }
@@ -0,0 +1,30 @@
1
+ import { createProductionController } from './production.mjs';
2
+ import { installDiscordRpc } from './rpc.mjs';
3
+
4
+ export const name = 'dsh-im-discord-host';
5
+ export const inject = ['connection', 'credentials', 'webServer'];
6
+
7
+ export async function apply(ctx, config = {}) {
8
+ if (config?.controller) {
9
+ return installDiscordRpc(ctx, config.controller, config.rpcAuthority);
10
+ }
11
+ const production = await createProductionController(ctx, config, config.internals ?? {});
12
+ const disposeRpc = installDiscordRpc(ctx, production.controller, config.rpcAuthority);
13
+ ctx.effect(() => async () => production.close(), 'dsh-im: close Discord bot connections');
14
+ return disposeRpc;
15
+ }
16
+
17
+ export function createDiscordHostPlugin(config) {
18
+ return Object.freeze({ name, inject, apply: (ctx) => apply(ctx, config) });
19
+ }
20
+
21
+ export { createProductionController } from './production.mjs';
22
+ export {
23
+ DISCORD_ENDPOINTS,
24
+ DISCORD_RPC_CHANNEL,
25
+ DISCORD_RPC_ENDPOINTS,
26
+ createDiscordRpcHandler,
27
+ installDiscordRpc,
28
+ } from './rpc.mjs';
29
+ export { DiscordController } from '../../../../src/channels/discord/discord-controller.mjs';
30
+ export { DiscordRuntime } from '../../../../src/channels/discord/discord-runtime.mjs';
@@ -0,0 +1,17 @@
1
+ import { DiscordConfigStore } from '../../../../src/channels/discord/config-store.mjs';
2
+ import { DiscordController } from '../../../../src/channels/discord/discord-controller.mjs';
3
+ import { DiscordHarnessClient } from '../../../../src/channels/discord/harness-client.mjs';
4
+ import { DiscordRuntime } from '../../../../src/channels/discord/discord-runtime.mjs';
5
+ import { DiscordStateStore } from '../../../../src/channels/discord/state-store.mjs';
6
+ import { createTokenProductionController } from '../shared/production.mjs';
7
+
8
+ export function createProductionController(ctx, config = {}, internals = {}) {
9
+ return createTokenProductionController(ctx, config, internals, {
10
+ channel: 'discord',
11
+ ConfigStore: DiscordConfigStore,
12
+ StateStore: DiscordStateStore,
13
+ HarnessClient: DiscordHarnessClient,
14
+ Controller: DiscordController,
15
+ Runtime: DiscordRuntime,
16
+ });
17
+ }
@@ -0,0 +1,21 @@
1
+ import {
2
+ TOKEN_BOT_ENDPOINTS,
3
+ createTokenBotRpcHandler,
4
+ installTokenBotRpc,
5
+ } from '../shared/rpc.mjs';
6
+
7
+ export const DISCORD_RPC_CHANNEL = '/discord';
8
+ export const DISCORD_ENDPOINTS = TOKEN_BOT_ENDPOINTS;
9
+ export const DISCORD_RPC_ENDPOINTS = Object.freeze(Object.values(DISCORD_ENDPOINTS));
10
+
11
+ export function createDiscordRpcHandler(controller) {
12
+ return createTokenBotRpcHandler(controller, { channel: 'Discord' });
13
+ }
14
+
15
+ export function installDiscordRpc(ctx, controller, authority) {
16
+ return installTokenBotRpc(ctx, controller, {
17
+ channel: 'Discord',
18
+ rpcChannel: DISCORD_RPC_CHANNEL,
19
+ authority,
20
+ });
21
+ }
@@ -0,0 +1,167 @@
1
+ const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
2
+
3
+ function safeDelay(value, fallback) {
4
+ return Number.isFinite(value) && value >= 0 ? value : fallback;
5
+ }
6
+
7
+ function safeRetryDelays(value) {
8
+ if (!Array.isArray(value) || value.length === 0) return [...DEFAULT_RETRY_DELAYS_MS];
9
+ const delays = value.map((delay) => safeDelay(delay, -1)).filter((delay) => delay >= 0);
10
+ return delays.length > 0 ? delays : [...DEFAULT_RETRY_DELAYS_MS];
11
+ }
12
+
13
+ function totals(status) {
14
+ const configured = Number.isInteger(status?.totals?.configured)
15
+ ? status.totals.configured
16
+ : (Array.isArray(status?.bots) ? status.bots.length : 0);
17
+ const connected = Number.isInteger(status?.totals?.connected)
18
+ ? status.totals.connected
19
+ : (Array.isArray(status?.bots)
20
+ ? status.bots.filter((bot) => bot?.connected === true).length
21
+ : 0);
22
+ return { configured, connected };
23
+ }
24
+
25
+ /**
26
+ * Starts bot connections only after the in-process Harness HTTP API is ready,
27
+ * then periodically reconciles failed/offline bots. Timers never keep the Host
28
+ * alive on their own and shutdown waits for an in-flight reconciliation.
29
+ */
30
+ export class ConnectionSupervisor {
31
+ #controller;
32
+ #harness;
33
+ #logger;
34
+ #retryDelaysMs;
35
+ #healthyIntervalMs;
36
+ #setTimeout;
37
+ #clearTimeout;
38
+ #timer = null;
39
+ #running = null;
40
+ #retryIndex = 0;
41
+ #started = false;
42
+ #closed = false;
43
+ #ready;
44
+ #resolveReady;
45
+
46
+ constructor({
47
+ controller,
48
+ harness,
49
+ logger = console,
50
+ retryDelaysMs,
51
+ healthyIntervalMs = 15_000,
52
+ setTimeoutImpl = setTimeout,
53
+ clearTimeoutImpl = clearTimeout,
54
+ }) {
55
+ if (!controller
56
+ || typeof controller.initialize !== 'function'
57
+ || typeof controller.status !== 'function') {
58
+ throw new TypeError('ConnectionSupervisor requires a controller');
59
+ }
60
+ if (!harness || typeof harness.ensureRunning !== 'function') {
61
+ throw new TypeError('ConnectionSupervisor requires a Harness client');
62
+ }
63
+ this.#controller = controller;
64
+ this.#harness = harness;
65
+ this.#logger = logger;
66
+ this.#retryDelaysMs = safeRetryDelays(retryDelaysMs);
67
+ this.#healthyIntervalMs = safeDelay(healthyIntervalMs, 15_000);
68
+ this.#setTimeout = setTimeoutImpl;
69
+ this.#clearTimeout = clearTimeoutImpl;
70
+ this.#ready = new Promise((resolve) => {
71
+ this.#resolveReady = resolve;
72
+ });
73
+ }
74
+
75
+ get ready() {
76
+ return this.#ready;
77
+ }
78
+
79
+ start() {
80
+ if (this.#started || this.#closed) return this;
81
+ this.#started = true;
82
+ this.#schedule(0);
83
+ return this;
84
+ }
85
+
86
+ async close() {
87
+ if (this.#closed) return;
88
+ this.#closed = true;
89
+ if (this.#timer !== null) {
90
+ this.#clearTimeout(this.#timer);
91
+ this.#timer = null;
92
+ }
93
+ await this.#running?.catch(() => undefined);
94
+ this.#resolveReady?.(null);
95
+ this.#resolveReady = null;
96
+ }
97
+
98
+ #schedule(delayMs) {
99
+ if (this.#closed) return;
100
+ this.#timer = this.#setTimeout(() => {
101
+ this.#timer = null;
102
+ void this.#run();
103
+ }, delayMs);
104
+ this.#timer?.unref?.();
105
+ }
106
+
107
+ async #run() {
108
+ if (this.#closed || this.#running) return;
109
+ const operation = this.#reconcile();
110
+ this.#running = operation;
111
+ try {
112
+ await operation;
113
+ } finally {
114
+ if (this.#running === operation) this.#running = null;
115
+ }
116
+ }
117
+
118
+ async #reconcile() {
119
+ try {
120
+ await this.#harness.ensureRunning();
121
+ } catch (error) {
122
+ if (this.#closed) return;
123
+ this.#retry('Harness Host is not ready', error);
124
+ return;
125
+ }
126
+ if (this.#closed) return;
127
+
128
+ try {
129
+ await this.#controller.initialize();
130
+ if (this.#closed) return;
131
+
132
+ const status = this.#controller.status();
133
+ this.#resolveReady?.(status);
134
+ this.#resolveReady = null;
135
+ const current = totals(status);
136
+ if (current.connected < current.configured) {
137
+ const delay = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)];
138
+ this.#retryIndex += 1;
139
+ this.#logger.warn?.(
140
+ `[dsh-feishu] ${current.connected}/${current.configured} bots connected; retrying automatically in ${delay}ms`,
141
+ );
142
+ this.#schedule(delay);
143
+ return;
144
+ }
145
+
146
+ this.#retryIndex = 0;
147
+ this.#schedule(this.#healthyIntervalMs);
148
+ } catch (error) {
149
+ if (this.#closed) return;
150
+ this.#retry('Bot connection reconciliation failed', error);
151
+ }
152
+ }
153
+
154
+ #retry(message, error) {
155
+ const delay = this.#retryDelaysMs[Math.min(this.#retryIndex, this.#retryDelaysMs.length - 1)];
156
+ this.#retryIndex += 1;
157
+ this.#logger.warn?.(
158
+ `[dsh-feishu] ${message}; retrying automatically in ${delay}ms`,
159
+ error,
160
+ );
161
+ this.#schedule(delay);
162
+ }
163
+ }
164
+
165
+ export function createConnectionSupervisor(options) {
166
+ return new ConnectionSupervisor(options);
167
+ }
@@ -0,0 +1,172 @@
1
+ const ACTIVE_REGISTRATION_STATES = new Set([
2
+ 'starting',
3
+ 'qr_ready',
4
+ 'polling',
5
+ 'slow_down',
6
+ 'domain_switched',
7
+ ]);
8
+
9
+ function credentialResult(result) {
10
+ const appId = result?.client_id ?? result?.appId;
11
+ const appSecret = result?.client_secret ?? result?.appSecret;
12
+ if (typeof appId !== 'string' || appId.length === 0
13
+ || typeof appSecret !== 'string' || appSecret.length === 0) {
14
+ throw new TypeError('Feishu registration returned invalid credentials');
15
+ }
16
+ return {
17
+ appId,
18
+ appSecret,
19
+ userInfo: result?.user_info ?? result?.userInfo,
20
+ };
21
+ }
22
+
23
+ async function readConnectionStatus(connectionManager) {
24
+ if (typeof connectionManager.status !== 'function') return {};
25
+ return await connectionManager.status();
26
+ }
27
+
28
+ function isConnected(status) {
29
+ if (status?.connected === true) return true;
30
+ return status?.ready === true
31
+ && status?.feishuLongConnectionState === 'connected'
32
+ && status?.harnessReachable === true;
33
+ }
34
+
35
+ /**
36
+ * Minimal orchestration boundary between QR provisioning and the live bot.
37
+ *
38
+ * `createProvisioningManager` receives the only callback that can observe the
39
+ * App Secret. The callback persists credentials and starts the long-lived
40
+ * connection before the provisioning manager may report `succeeded`.
41
+ */
42
+ export class ProvisioningBackedController {
43
+ #credentialStore;
44
+ #connectionManager;
45
+ #registrationOptions;
46
+ #manager;
47
+ #knownConfigured = false;
48
+ #lastError = null;
49
+
50
+ constructor({
51
+ createProvisioningManager,
52
+ credentialStore,
53
+ connectionManager,
54
+ registrationOptions = {},
55
+ } = {}) {
56
+ if (typeof createProvisioningManager !== 'function') {
57
+ throw new TypeError('createProvisioningManager is required');
58
+ }
59
+ if (!credentialStore
60
+ || typeof credentialStore.save !== 'function'
61
+ || typeof credentialStore.clear !== 'function') {
62
+ throw new TypeError('credentialStore.save/clear are required');
63
+ }
64
+ if (!connectionManager
65
+ || typeof connectionManager.connect !== 'function'
66
+ || typeof connectionManager.disconnect !== 'function') {
67
+ throw new TypeError('connectionManager.connect/disconnect are required');
68
+ }
69
+ if (registrationOptions === null
70
+ || typeof registrationOptions !== 'object'
71
+ || Array.isArray(registrationOptions)) {
72
+ throw new TypeError('registrationOptions must be an object');
73
+ }
74
+
75
+ this.#credentialStore = credentialStore;
76
+ this.#connectionManager = connectionManager;
77
+ this.#registrationOptions = structuredClone(registrationOptions);
78
+ this.#manager = createProvisioningManager({
79
+ onCredentials: (result) => this.#acceptCredentials(result),
80
+ });
81
+ if (!this.#manager
82
+ || typeof this.#manager.start !== 'function'
83
+ || typeof this.#manager.status !== 'function'
84
+ || typeof this.#manager.cancel !== 'function') {
85
+ throw new TypeError('The provisioning manager must implement start/status/cancel');
86
+ }
87
+ }
88
+
89
+ async startRegistration() {
90
+ this.#lastError = null;
91
+ await this.#manager.start(structuredClone(this.#registrationOptions));
92
+ return this.status();
93
+ }
94
+
95
+ async cancelRegistration() {
96
+ await this.#manager.cancel();
97
+ return this.status();
98
+ }
99
+
100
+ async disconnect() {
101
+ await this.#manager.cancel();
102
+ await this.#connectionManager.disconnect();
103
+ try {
104
+ await this.#credentialStore.clear();
105
+ this.#knownConfigured = false;
106
+ this.#lastError = null;
107
+ } catch {
108
+ // Do not reflect a credential provider's error text across the RPC
109
+ // boundary. It may include a backend path or secret reference detail.
110
+ this.#lastError = {
111
+ code: 'credential_removal_failed',
112
+ message: 'Unable to remove the Feishu credentials.',
113
+ };
114
+ }
115
+ return this.status();
116
+ }
117
+
118
+ async status() {
119
+ const registration = await this.#manager.status();
120
+ const connection = await readConnectionStatus(this.#connectionManager);
121
+ const connected = isConnected(connection);
122
+ let configured = this.#knownConfigured;
123
+ if (typeof this.#credentialStore.configured === 'function') {
124
+ try {
125
+ configured = await this.#credentialStore.configured();
126
+ } catch {
127
+ configured = this.#knownConfigured;
128
+ }
129
+ }
130
+
131
+ let phase = 'unconfigured';
132
+ if (connected) phase = 'connected';
133
+ else if (ACTIVE_REGISTRATION_STATES.has(registration?.state)) phase = 'registering';
134
+ else if (registration?.state === 'saving') phase = 'connecting';
135
+ else if (this.#lastError || registration?.state === 'error') phase = 'error';
136
+ else if (configured) phase = 'disconnected';
137
+
138
+ return {
139
+ phase,
140
+ connected,
141
+ configured,
142
+ registration,
143
+ connection,
144
+ error: this.#lastError ?? registration?.error ?? null,
145
+ };
146
+ }
147
+
148
+ async close() {
149
+ await this.#manager.cancel();
150
+ await this.#connectionManager.disconnect();
151
+ }
152
+
153
+ async #acceptCredentials(result) {
154
+ const credentials = credentialResult(result);
155
+ try {
156
+ await this.#credentialStore.save(credentials);
157
+ this.#knownConfigured = true;
158
+ await this.#connectionManager.connect(credentials);
159
+ this.#lastError = null;
160
+ } catch {
161
+ this.#lastError = {
162
+ code: 'connection_failed',
163
+ message: 'The bot was created, but its connection could not be started.',
164
+ };
165
+ throw new Error('Unable to activate the Feishu connection.');
166
+ }
167
+ }
168
+ }
169
+
170
+ export function createProvisioningBackedController(options) {
171
+ return new ProvisioningBackedController(options);
172
+ }