@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.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/THIRD_PARTY_NOTICES.md +15 -0
- package/bin/dsh-im.mjs +99 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +6211 -0
- package/lib/index.js +13339 -0
- package/package.json +73 -0
- package/plugin-src/client/build.mjs +38 -0
- package/plugin-src/client/channel-logos.js +107 -0
- package/plugin-src/client/channels/dingtalk/api.js +203 -0
- package/plugin-src/client/channels/dingtalk/index.js +709 -0
- package/plugin-src/client/channels/dingtalk/styles.js +140 -0
- package/plugin-src/client/channels/discord/api.js +11 -0
- package/plugin-src/client/channels/discord/index.js +25 -0
- package/plugin-src/client/channels/discord/styles.js +19 -0
- package/plugin-src/client/channels/feishu/api.js +325 -0
- package/plugin-src/client/channels/feishu/index.js +986 -0
- package/plugin-src/client/channels/feishu/styles.js +505 -0
- package/plugin-src/client/channels/qq/api.js +119 -0
- package/plugin-src/client/channels/qq/index.js +402 -0
- package/plugin-src/client/channels/qq/styles.js +19 -0
- package/plugin-src/client/channels/shared/token-api.js +88 -0
- package/plugin-src/client/channels/shared/token-channel.js +277 -0
- package/plugin-src/client/channels/telegram/api.js +11 -0
- package/plugin-src/client/channels/telegram/index.js +25 -0
- package/plugin-src/client/channels/telegram/styles.js +19 -0
- package/plugin-src/client/channels/wecom/api.js +119 -0
- package/plugin-src/client/channels/wecom/index.js +402 -0
- package/plugin-src/client/channels/wecom/styles.js +19 -0
- package/plugin-src/client/channels/weixin/api.js +156 -0
- package/plugin-src/client/channels/weixin/index.js +577 -0
- package/plugin-src/client/channels/weixin/styles.js +120 -0
- package/plugin-src/client/credential-binding.js +117 -0
- package/plugin-src/client/index.js +192 -0
- package/plugin-src/client/lifecycle.js +86 -0
- package/plugin-src/client/styles.js +192 -0
- package/plugin-src/host/build.mjs +24 -0
- package/plugin-src/host/channels/dingtalk/connection-supervisor.mjs +130 -0
- package/plugin-src/host/channels/dingtalk/index.mjs +39 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +122 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +237 -0
- package/plugin-src/host/channels/discord/index.mjs +30 -0
- package/plugin-src/host/channels/discord/production.mjs +17 -0
- package/plugin-src/host/channels/discord/rpc.mjs +21 -0
- package/plugin-src/host/channels/feishu/connection-supervisor.mjs +167 -0
- package/plugin-src/host/channels/feishu/controller.mjs +172 -0
- package/plugin-src/host/channels/feishu/credential-store.mjs +84 -0
- package/plugin-src/host/channels/feishu/index.mjs +73 -0
- package/plugin-src/host/channels/feishu/production.mjs +138 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +454 -0
- package/plugin-src/host/channels/qq/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/qq/index.mjs +30 -0
- package/plugin-src/host/channels/qq/production.mjs +109 -0
- package/plugin-src/host/channels/qq/rpc.mjs +146 -0
- package/plugin-src/host/channels/shared/connection-supervisor.mjs +133 -0
- package/plugin-src/host/channels/shared/production.mjs +104 -0
- package/plugin-src/host/channels/shared/rpc.mjs +119 -0
- package/plugin-src/host/channels/telegram/index.mjs +30 -0
- package/plugin-src/host/channels/telegram/production.mjs +17 -0
- package/plugin-src/host/channels/telegram/rpc.mjs +21 -0
- package/plugin-src/host/channels/wecom/connection-supervisor.mjs +124 -0
- package/plugin-src/host/channels/wecom/index.mjs +36 -0
- package/plugin-src/host/channels/wecom/production.mjs +113 -0
- package/plugin-src/host/channels/wecom/rpc.mjs +151 -0
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +127 -0
- package/plugin-src/host/channels/weixin/index.mjs +39 -0
- package/plugin-src/host/channels/weixin/production.mjs +119 -0
- package/plugin-src/host/channels/weixin/rpc.mjs +178 -0
- package/plugin-src/host/index.mjs +44 -0
- package/plugin-src/host/rpc-authority.mjs +11 -0
- package/scripts/verify-package.mjs +97 -0
- package/src/channels/dingtalk/config-store.mjs +282 -0
- package/src/channels/dingtalk/device-auth.mjs +237 -0
- package/src/channels/dingtalk/dingtalk-api.mjs +579 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +281 -0
- package/src/channels/dingtalk/dingtalk-card-stream.mjs +233 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +752 -0
- package/src/channels/dingtalk/dingtalk-runtime.mjs +358 -0
- package/src/channels/dingtalk/harness-client.mjs +299 -0
- package/src/channels/dingtalk/state-store.mjs +212 -0
- package/src/channels/discord/config-store.mjs +24 -0
- package/src/channels/discord/discord-api.mjs +153 -0
- package/src/channels/discord/discord-bridge.mjs +15 -0
- package/src/channels/discord/discord-controller.mjs +16 -0
- package/src/channels/discord/discord-runtime.mjs +460 -0
- package/src/channels/discord/harness-client.mjs +3 -0
- package/src/channels/discord/state-store.mjs +3 -0
- package/src/channels/feishu/bridge.mjs +216 -0
- package/src/channels/feishu/config.mjs +71 -0
- package/src/channels/feishu/feishu-app.mjs +51 -0
- package/src/channels/feishu/feishu-channel.mjs +153 -0
- package/src/channels/feishu/feishu-runtime.mjs +222 -0
- package/src/channels/feishu/harness-client.mjs +268 -0
- package/src/channels/feishu/message-utils.mjs +51 -0
- package/src/channels/feishu/multi-bot-controller.mjs +686 -0
- package/src/channels/feishu/plugin-config-store.mjs +204 -0
- package/src/channels/feishu/plugin-controller.mjs +248 -0
- package/src/channels/feishu/registration-manager.mjs +345 -0
- package/src/channels/feishu/state-store.mjs +71 -0
- package/src/channels/qq/config-store.mjs +171 -0
- package/src/channels/qq/harness-client.mjs +3 -0
- package/src/channels/qq/qq-bridge.mjs +177 -0
- package/src/channels/qq/qq-controller.mjs +452 -0
- package/src/channels/qq/qq-runtime.mjs +208 -0
- package/src/channels/qq/qr-auth.mjs +24 -0
- package/src/channels/qq/state-store.mjs +96 -0
- package/src/channels/shared/conversation-state-store.mjs +107 -0
- package/src/channels/shared/editable-message-stream.mjs +81 -0
- package/src/channels/shared/text-harness-bridge.mjs +180 -0
- package/src/channels/shared/token-bot-controller.mjs +298 -0
- package/src/channels/shared/token-config-store.mjs +180 -0
- package/src/channels/telegram/config-store.mjs +24 -0
- package/src/channels/telegram/harness-client.mjs +3 -0
- package/src/channels/telegram/state-store.mjs +3 -0
- package/src/channels/telegram/telegram-api.mjs +120 -0
- package/src/channels/telegram/telegram-bridge.mjs +15 -0
- package/src/channels/telegram/telegram-controller.mjs +16 -0
- package/src/channels/telegram/telegram-runtime.mjs +278 -0
- package/src/channels/wecom/config-store.mjs +170 -0
- package/src/channels/wecom/harness-client.mjs +3 -0
- package/src/channels/wecom/qr-auth.mjs +95 -0
- package/src/channels/wecom/state-store.mjs +90 -0
- package/src/channels/wecom/wecom-bridge.mjs +243 -0
- package/src/channels/wecom/wecom-controller.mjs +474 -0
- package/src/channels/wecom/wecom-runtime.mjs +209 -0
- package/src/channels/weixin/config-store.mjs +182 -0
- package/src/channels/weixin/harness-client.mjs +259 -0
- package/src/channels/weixin/state-store.mjs +112 -0
- package/src/channels/weixin/weixin-api.mjs +319 -0
- package/src/channels/weixin/weixin-bridge.mjs +173 -0
- package/src/channels/weixin/weixin-controller.mjs +545 -0
- 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
|
+
}
|