@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,752 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deriveDingtalkBotIdentity,
|
|
5
|
+
deriveDingtalkSenderKey,
|
|
6
|
+
maskDingtalkClientId,
|
|
7
|
+
maskDingtalkSenderId,
|
|
8
|
+
} from './config-store.mjs';
|
|
9
|
+
|
|
10
|
+
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
|
|
11
|
+
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
|
12
|
+
|
|
13
|
+
function cleanString(value) {
|
|
14
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function safeError(code, message) {
|
|
18
|
+
return Object.freeze({ code, message });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function nowFrom(clock) {
|
|
22
|
+
return typeof clock?.now === 'function' ? clock.now() : clock();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isoNow(clock) {
|
|
26
|
+
return new Date(nowFrom(clock)).toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function abortError() {
|
|
30
|
+
return new DOMException('DingTalk provisioning was cancelled', 'AbortError');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function publicAttempt(record) {
|
|
34
|
+
if (!record) return null;
|
|
35
|
+
return {
|
|
36
|
+
attemptId: record.id,
|
|
37
|
+
status: record.state,
|
|
38
|
+
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
|
39
|
+
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
40
|
+
...(record.pollIntervalMs ? { pollIntervalMs: record.pollIntervalMs } : {}),
|
|
41
|
+
...(record.botId ? { botId: record.botId } : {}),
|
|
42
|
+
...(record.alreadyConnected ? { alreadyConnected: true } : {}),
|
|
43
|
+
...(record.error ? { error: structuredClone(record.error) } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function runtimeStatus(runtime) {
|
|
48
|
+
if (!runtime) return {};
|
|
49
|
+
const value = typeof runtime.status === 'function' ? runtime.status() : runtime.status;
|
|
50
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isRuntimeConnected(runtime, status) {
|
|
54
|
+
if (!runtime) return false;
|
|
55
|
+
if (status.connected === false || status.ready === false) return false;
|
|
56
|
+
const state = cleanString(
|
|
57
|
+
status.dingtalkStreamState
|
|
58
|
+
?? status.dingtalkConnectionState
|
|
59
|
+
?? status.connectionState
|
|
60
|
+
?? status.state,
|
|
61
|
+
)?.toLowerCase();
|
|
62
|
+
if (['failed', 'error', 'offline', 'disconnected', 'stopped'].includes(state)) return false;
|
|
63
|
+
return status.connected === true
|
|
64
|
+
|| status.ready === true
|
|
65
|
+
|| state === 'connected'
|
|
66
|
+
|| state === 'ready';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizePendingSender(value) {
|
|
70
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
71
|
+
const staffId = cleanString(value.staffId ?? value.senderStaffId ?? value.senderId);
|
|
72
|
+
if (!staffId) return null;
|
|
73
|
+
const suppliedRequestId = cleanString(value.requestId);
|
|
74
|
+
const opaqueRequestId = suppliedRequestId
|
|
75
|
+
&& /^ding_sender_[A-Za-z0-9_-]{1,100}$/.test(suppliedRequestId)
|
|
76
|
+
&& !suppliedRequestId.includes(staffId)
|
|
77
|
+
? suppliedRequestId
|
|
78
|
+
: null;
|
|
79
|
+
if (!opaqueRequestId) return null;
|
|
80
|
+
return {
|
|
81
|
+
requestId: opaqueRequestId,
|
|
82
|
+
staffId,
|
|
83
|
+
displayName: cleanString(value.displayName ?? value.senderName ?? value.senderNick) ?? '钉钉用户',
|
|
84
|
+
requestedAt: cleanString(value.requestedAt),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function internalPendingSenders(status) {
|
|
89
|
+
if (!Array.isArray(status.pendingSenders)) return [];
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
const senders = [];
|
|
92
|
+
for (const value of status.pendingSenders) {
|
|
93
|
+
const sender = normalizePendingSender(value);
|
|
94
|
+
if (!sender || seen.has(sender.staffId)) continue;
|
|
95
|
+
seen.add(sender.staffId);
|
|
96
|
+
senders.push(sender);
|
|
97
|
+
}
|
|
98
|
+
return senders;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function publicPendingSender(sender) {
|
|
102
|
+
return {
|
|
103
|
+
requestId: sender.requestId,
|
|
104
|
+
displayName: sender.displayName,
|
|
105
|
+
senderIdMasked: maskDingtalkSenderId(sender.staffId),
|
|
106
|
+
requestedAt: sender.requestedAt,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function publicApprovedSender(sender) {
|
|
111
|
+
return {
|
|
112
|
+
senderKey: sender.senderKey,
|
|
113
|
+
displayName: cleanString(sender.displayName) ?? '钉钉用户',
|
|
114
|
+
senderIdMasked: maskDingtalkSenderId(sender.staffId),
|
|
115
|
+
approvedAt: cleanString(sender.approvedAt),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Coordinates DingTalk QR registration, credentials, runtimes, and sender approvals. */
|
|
120
|
+
export class DingtalkController {
|
|
121
|
+
#deviceAuth;
|
|
122
|
+
#credentials;
|
|
123
|
+
#configStore;
|
|
124
|
+
#createRuntime;
|
|
125
|
+
#deleteState;
|
|
126
|
+
#logger;
|
|
127
|
+
#clock;
|
|
128
|
+
#runtimes = new Map();
|
|
129
|
+
#errors = new Map();
|
|
130
|
+
#attempts = new Map();
|
|
131
|
+
#activeAttemptId = null;
|
|
132
|
+
#transitions = new Map();
|
|
133
|
+
#revision = 0;
|
|
134
|
+
#closed = false;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {object} options Controller dependencies.
|
|
138
|
+
* @param {object} options.deviceAuth Host-only DingTalk device auth client.
|
|
139
|
+
* @param {object} options.credentials DSH credential provider.
|
|
140
|
+
* @param {object} options.configStore Loaded DingTalk config store.
|
|
141
|
+
* @param {Function} options.createRuntime Runtime factory.
|
|
142
|
+
* @param {Function} [options.deleteState] Per-bot state cleanup callback.
|
|
143
|
+
* @param {Console} [options.logger] Host logger.
|
|
144
|
+
* @param {{now(): number}|(()=>number)} [options.clock] Injectable clock.
|
|
145
|
+
*/
|
|
146
|
+
constructor({
|
|
147
|
+
deviceAuth,
|
|
148
|
+
credentials,
|
|
149
|
+
configStore,
|
|
150
|
+
createRuntime,
|
|
151
|
+
deleteState = async () => {},
|
|
152
|
+
logger = console,
|
|
153
|
+
clock = Date,
|
|
154
|
+
}) {
|
|
155
|
+
if (!deviceAuth
|
|
156
|
+
|| typeof deviceAuth.start !== 'function'
|
|
157
|
+
|| typeof deviceAuth.poll !== 'function') {
|
|
158
|
+
throw new TypeError('DingtalkController requires a DingTalk device auth client');
|
|
159
|
+
}
|
|
160
|
+
if (!credentials
|
|
161
|
+
|| typeof credentials.resolve !== 'function'
|
|
162
|
+
|| typeof credentials.set !== 'function'
|
|
163
|
+
|| typeof credentials.unset !== 'function') {
|
|
164
|
+
throw new TypeError('DingtalkController requires the DSH credential provider');
|
|
165
|
+
}
|
|
166
|
+
if (!configStore
|
|
167
|
+
|| typeof configStore.list !== 'function'
|
|
168
|
+
|| typeof configStore.get !== 'function'
|
|
169
|
+
|| typeof configStore.getByClientId !== 'function'
|
|
170
|
+
|| typeof configStore.save !== 'function'
|
|
171
|
+
|| typeof configStore.remove !== 'function') {
|
|
172
|
+
throw new TypeError('DingtalkController requires a loaded config store');
|
|
173
|
+
}
|
|
174
|
+
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
175
|
+
if (typeof deleteState !== 'function') throw new TypeError('deleteState must be a function');
|
|
176
|
+
if (typeof clock !== 'function' && typeof clock?.now !== 'function') {
|
|
177
|
+
throw new TypeError('clock must be a function or expose now()');
|
|
178
|
+
}
|
|
179
|
+
this.#deviceAuth = deviceAuth;
|
|
180
|
+
this.#credentials = credentials;
|
|
181
|
+
this.#configStore = configStore;
|
|
182
|
+
this.#createRuntime = createRuntime;
|
|
183
|
+
this.#deleteState = deleteState;
|
|
184
|
+
this.#logger = logger;
|
|
185
|
+
this.#clock = clock;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Starts all configured DingTalk runtimes whose secrets are available. */
|
|
189
|
+
async initialize() {
|
|
190
|
+
if (this.#closed) return this.status();
|
|
191
|
+
for (const config of this.#configStore.list()) {
|
|
192
|
+
const current = this.#runtimes.get(config.botId);
|
|
193
|
+
try {
|
|
194
|
+
if (isRuntimeConnected(current, runtimeStatus(current))) continue;
|
|
195
|
+
} catch {
|
|
196
|
+
// A runtime with an unreadable status is replaced below.
|
|
197
|
+
}
|
|
198
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
199
|
+
const latest = this.#configStore.get(config.botId);
|
|
200
|
+
if (!latest || this.#closed) return;
|
|
201
|
+
const clientSecret = await this.#resolveSecret(latest.secretRef);
|
|
202
|
+
if (!clientSecret) {
|
|
203
|
+
this.#errors.set(
|
|
204
|
+
latest.botId,
|
|
205
|
+
safeError('missing-secret', '钉钉机器人凭据缺失,请移除后重新扫码。'),
|
|
206
|
+
);
|
|
207
|
+
this.#touch();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await this.#startRuntime(latest, clientSecret);
|
|
212
|
+
this.#errors.delete(latest.botId);
|
|
213
|
+
} catch {
|
|
214
|
+
this.#errors.set(
|
|
215
|
+
latest.botId,
|
|
216
|
+
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
|
|
217
|
+
);
|
|
218
|
+
this.#logger.warn?.(`[dsh-dingtalk] bot ${latest.botId} failed to initialize`);
|
|
219
|
+
}
|
|
220
|
+
this.#touch();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return this.status();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Starts one DingTalk QR registration, cancelling any prior active attempt. */
|
|
227
|
+
async startProvisioning({ signal } = {}) {
|
|
228
|
+
if (this.#closed) throw new Error('dsh-dingtalk controller is closed');
|
|
229
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
230
|
+
const record = {
|
|
231
|
+
id: randomUUID(),
|
|
232
|
+
state: 'starting',
|
|
233
|
+
controller: new AbortController(),
|
|
234
|
+
deviceCode: null,
|
|
235
|
+
verificationUrl: null,
|
|
236
|
+
expiresAt: null,
|
|
237
|
+
pollIntervalMs: null,
|
|
238
|
+
pollTask: null,
|
|
239
|
+
botId: null,
|
|
240
|
+
alreadyConnected: false,
|
|
241
|
+
error: null,
|
|
242
|
+
};
|
|
243
|
+
this.#attempts.set(record.id, record);
|
|
244
|
+
this.#activeAttemptId = record.id;
|
|
245
|
+
this.#touch();
|
|
246
|
+
const abortFromRequest = () => record.controller.abort(signal?.reason);
|
|
247
|
+
if (signal?.aborted) abortFromRequest();
|
|
248
|
+
else signal?.addEventListener('abort', abortFromRequest, { once: true });
|
|
249
|
+
try {
|
|
250
|
+
const begun = await this.#deviceAuth.start({ signal: record.controller.signal });
|
|
251
|
+
this.#assertAttemptActive(record);
|
|
252
|
+
record.deviceCode = cleanString(begun.deviceCode);
|
|
253
|
+
record.verificationUrl = cleanString(begun.verificationUrl);
|
|
254
|
+
record.expiresAt = Number(begun.expiresAt);
|
|
255
|
+
record.pollIntervalMs = Number(begun.pollIntervalMs);
|
|
256
|
+
if (!record.deviceCode
|
|
257
|
+
|| !record.verificationUrl
|
|
258
|
+
|| !Number.isFinite(record.expiresAt)
|
|
259
|
+
|| !Number.isFinite(record.pollIntervalMs)
|
|
260
|
+
|| record.pollIntervalMs <= 0) {
|
|
261
|
+
throw new Error('DingTalk device auth returned incomplete registration metadata');
|
|
262
|
+
}
|
|
263
|
+
record.state = 'pending';
|
|
264
|
+
this.#touch();
|
|
265
|
+
return publicAttempt(record);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
|
268
|
+
record.state = 'cancelled';
|
|
269
|
+
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
270
|
+
} else {
|
|
271
|
+
record.state = 'failed';
|
|
272
|
+
record.error = safeError('qr-start-failed', '无法生成钉钉二维码,请稍后重试。');
|
|
273
|
+
}
|
|
274
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
275
|
+
this.#touch();
|
|
276
|
+
if (record.state === 'failed') throw error;
|
|
277
|
+
return publicAttempt(record);
|
|
278
|
+
} finally {
|
|
279
|
+
signal?.removeEventListener('abort', abortFromRequest);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Binds one DingTalk application with an existing Client ID and Client Secret. */
|
|
284
|
+
async bindCredentials({ clientId, clientSecret } = {}) {
|
|
285
|
+
if (this.#closed) throw new Error('dsh-dingtalk controller is closed');
|
|
286
|
+
const normalizedClientId = cleanString(clientId);
|
|
287
|
+
const normalizedSecret = cleanString(clientSecret);
|
|
288
|
+
if (!normalizedClientId || !normalizedSecret) {
|
|
289
|
+
throw new TypeError('DingTalk Client ID and Client Secret are required');
|
|
290
|
+
}
|
|
291
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
292
|
+
if (this.#closed) throw new Error('dsh-dingtalk controller is closed');
|
|
293
|
+
const identity = deriveDingtalkBotIdentity(normalizedClientId);
|
|
294
|
+
await this.#withBotTransition(identity.botId, async () => {
|
|
295
|
+
if (this.#closed) throw abortError();
|
|
296
|
+
const previousConfig = this.#configStore.getByClientId(normalizedClientId);
|
|
297
|
+
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
298
|
+
if (this.#closed) throw abortError();
|
|
299
|
+
const config = {
|
|
300
|
+
botId: identity.botId,
|
|
301
|
+
clientId: normalizedClientId,
|
|
302
|
+
secretRef: identity.secretRef,
|
|
303
|
+
approvedSenders: previousConfig?.approvedSenders ?? [],
|
|
304
|
+
};
|
|
305
|
+
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
|
306
|
+
try {
|
|
307
|
+
await this.#configStore.save(config);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await this.#startRuntime(config, normalizedSecret);
|
|
314
|
+
this.#errors.delete(identity.botId);
|
|
315
|
+
} catch {
|
|
316
|
+
this.#errors.set(
|
|
317
|
+
identity.botId,
|
|
318
|
+
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
|
319
|
+
);
|
|
320
|
+
this.#logger.warn?.('[dsh-dingtalk] credential-bound bot saved but its connection is not ready');
|
|
321
|
+
}
|
|
322
|
+
this.#touch();
|
|
323
|
+
});
|
|
324
|
+
return this.status();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Polls one QR registration without exposing its device code or returned secret. */
|
|
328
|
+
async registrationStatus(attemptId) {
|
|
329
|
+
const record = this.#attempts.get(attemptId);
|
|
330
|
+
if (!record) return null;
|
|
331
|
+
if (TERMINAL_ATTEMPT_STATES.has(record.state) || record.state === 'starting') {
|
|
332
|
+
return publicAttempt(record);
|
|
333
|
+
}
|
|
334
|
+
if (nowFrom(this.#clock) >= record.expiresAt) {
|
|
335
|
+
record.state = 'expired';
|
|
336
|
+
record.error = safeError('expired', '二维码已过期,请重新生成。');
|
|
337
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
338
|
+
this.#touch();
|
|
339
|
+
return publicAttempt(record);
|
|
340
|
+
}
|
|
341
|
+
if (!record.pollTask) {
|
|
342
|
+
const task = this.#pollRegistration(record).finally(() => {
|
|
343
|
+
if (record.pollTask === task) record.pollTask = null;
|
|
344
|
+
});
|
|
345
|
+
record.pollTask = task;
|
|
346
|
+
}
|
|
347
|
+
await record.pollTask;
|
|
348
|
+
return publicAttempt(record);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Cancels an active QR registration. */
|
|
352
|
+
async cancelProvisioning(attemptId) {
|
|
353
|
+
const record = this.#attempts.get(attemptId);
|
|
354
|
+
if (!record) return null;
|
|
355
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
356
|
+
record.controller.abort();
|
|
357
|
+
await record.pollTask?.catch(() => undefined);
|
|
358
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
|
359
|
+
record.error ??= safeError('cancelled', '扫码接入已取消。');
|
|
360
|
+
}
|
|
361
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
362
|
+
this.#touch();
|
|
363
|
+
return publicAttempt(record);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Replaces one bot runtime using its stored credential. */
|
|
367
|
+
async reconnectBot(botId) {
|
|
368
|
+
const config = this.#configStore.get(botId);
|
|
369
|
+
if (!config) throw new Error('Unknown DingTalk bot');
|
|
370
|
+
await this.#withBotTransition(botId, async () => {
|
|
371
|
+
const clientSecret = await this.#resolveSecret(config.secretRef);
|
|
372
|
+
if (!clientSecret) throw new Error('The DingTalk client secret is missing');
|
|
373
|
+
try {
|
|
374
|
+
await this.#startRuntime(config, clientSecret);
|
|
375
|
+
this.#errors.delete(botId);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
this.#errors.set(
|
|
378
|
+
botId,
|
|
379
|
+
safeError('connection-failed', '钉钉连接仍未就绪,请稍后重试。'),
|
|
380
|
+
);
|
|
381
|
+
throw error;
|
|
382
|
+
} finally {
|
|
383
|
+
this.#touch();
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
return this.status();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Removes one bot, its secret, runtime, and local conversation state. */
|
|
390
|
+
async deleteBot(botId) {
|
|
391
|
+
const config = this.#configStore.get(botId);
|
|
392
|
+
if (!config) throw new Error('Unknown DingTalk bot');
|
|
393
|
+
await this.#withBotTransition(botId, async () => {
|
|
394
|
+
const previousSecret = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
|
|
395
|
+
await this.#stopRuntime(botId);
|
|
396
|
+
try {
|
|
397
|
+
await this.#credentials.unset(config.secretRef);
|
|
398
|
+
await this.#configStore.remove(botId);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (cleanString(previousSecret?.value)) {
|
|
401
|
+
await this.#credentials.set(config.secretRef, previousSecret.value).catch(() => undefined);
|
|
402
|
+
await this.#startRuntime(config, previousSecret.value).catch(() => undefined);
|
|
403
|
+
}
|
|
404
|
+
throw new Error('Unable to remove the DingTalk bot safely.', { cause: error });
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
await this.#deleteState({ botId, config });
|
|
408
|
+
} catch {
|
|
409
|
+
this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} state cleanup failed`);
|
|
410
|
+
}
|
|
411
|
+
this.#errors.delete(botId);
|
|
412
|
+
this.#touch();
|
|
413
|
+
});
|
|
414
|
+
return this.status();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** Approves one opaque pending-sender request for a bot. */
|
|
418
|
+
async approveSender(botId, requestId) {
|
|
419
|
+
const config = this.#configStore.get(botId);
|
|
420
|
+
if (!config) throw new Error('Unknown DingTalk bot');
|
|
421
|
+
const runtime = this.#runtimes.get(botId);
|
|
422
|
+
const direct = typeof runtime?.pendingSender === 'function'
|
|
423
|
+
? normalizePendingSender(runtime.pendingSender(requestId))
|
|
424
|
+
: null;
|
|
425
|
+
const pending = internalPendingSenders(runtimeStatus(runtime));
|
|
426
|
+
const sender = direct?.requestId === requestId
|
|
427
|
+
? direct
|
|
428
|
+
: pending.find((candidate) => candidate.requestId === requestId);
|
|
429
|
+
if (!sender) throw new Error('Unknown DingTalk sender approval request');
|
|
430
|
+
if (config.approvedSenders.some((approved) => approved.staffId === sender.staffId)) {
|
|
431
|
+
return this.status();
|
|
432
|
+
}
|
|
433
|
+
const updated = {
|
|
434
|
+
...config,
|
|
435
|
+
approvedSenders: [
|
|
436
|
+
...config.approvedSenders,
|
|
437
|
+
{
|
|
438
|
+
senderKey: deriveDingtalkSenderKey(),
|
|
439
|
+
staffId: sender.staffId,
|
|
440
|
+
displayName: sender.displayName,
|
|
441
|
+
approvedAt: isoNow(this.#clock),
|
|
442
|
+
},
|
|
443
|
+
],
|
|
444
|
+
};
|
|
445
|
+
await this.#saveAndRestart(config, updated);
|
|
446
|
+
return this.status();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Revokes one approved sender by its browser-safe sender key. */
|
|
450
|
+
async revokeSender(botId, senderKey) {
|
|
451
|
+
const config = this.#configStore.get(botId);
|
|
452
|
+
if (!config) throw new Error('Unknown DingTalk bot');
|
|
453
|
+
const index = config.approvedSenders.findIndex(
|
|
454
|
+
(sender) => sender.senderKey === senderKey,
|
|
455
|
+
);
|
|
456
|
+
if (index === -1) throw new Error('Unknown approved DingTalk sender');
|
|
457
|
+
const approvedSenders = [...config.approvedSenders];
|
|
458
|
+
approvedSenders.splice(index, 1);
|
|
459
|
+
await this.#saveAndRestart(config, { ...config, approvedSenders });
|
|
460
|
+
return this.status();
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Returns browser-safe bot, health, and sender-approval state. */
|
|
464
|
+
status() {
|
|
465
|
+
const bots = this.#configStore.list().map((config) => {
|
|
466
|
+
const runtime = this.#runtimes.get(config.botId);
|
|
467
|
+
let currentStatus = {};
|
|
468
|
+
try {
|
|
469
|
+
currentStatus = runtimeStatus(runtime);
|
|
470
|
+
} catch {
|
|
471
|
+
currentStatus = { state: 'error' };
|
|
472
|
+
}
|
|
473
|
+
const connected = isRuntimeConnected(runtime, currentStatus);
|
|
474
|
+
const accountError = this.#errors.get(config.botId);
|
|
475
|
+
const state = connected ? 'connected' : accountError ? 'error' : 'offline';
|
|
476
|
+
const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
|
|
477
|
+
const pending = internalPendingSenders(currentStatus)
|
|
478
|
+
.filter((sender) => !approvedIds.has(sender.staffId))
|
|
479
|
+
.map(publicPendingSender);
|
|
480
|
+
return {
|
|
481
|
+
botId: config.botId,
|
|
482
|
+
state,
|
|
483
|
+
connected,
|
|
484
|
+
configured: true,
|
|
485
|
+
bot: {
|
|
486
|
+
name: '钉钉机器人',
|
|
487
|
+
clientIdMasked: maskDingtalkClientId(config.clientId),
|
|
488
|
+
},
|
|
489
|
+
health: {
|
|
490
|
+
status: connected ? 'healthy' : accountError ? 'error' : 'offline',
|
|
491
|
+
summary: connected
|
|
492
|
+
? '钉钉 Stream 消息连接运行正常'
|
|
493
|
+
: accountError?.message ?? '钉钉消息连接当前离线',
|
|
494
|
+
lastCheckedAt: currentStatus.lastCheckedAt ?? null,
|
|
495
|
+
},
|
|
496
|
+
stats: {
|
|
497
|
+
messagesReceived: Number(currentStatus.messagesReceived) || 0,
|
|
498
|
+
messagesReplied: Number(currentStatus.messagesReplied) || 0,
|
|
499
|
+
},
|
|
500
|
+
senders: {
|
|
501
|
+
pending,
|
|
502
|
+
approved: config.approvedSenders.map(publicApprovedSender),
|
|
503
|
+
},
|
|
504
|
+
error: accountError ? structuredClone(accountError) : null,
|
|
505
|
+
};
|
|
506
|
+
});
|
|
507
|
+
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
508
|
+
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
|
509
|
+
return {
|
|
510
|
+
schemaVersion: 1,
|
|
511
|
+
revision: this.#revision,
|
|
512
|
+
state: active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
513
|
+
? 'provisioning'
|
|
514
|
+
: bots.length === 0
|
|
515
|
+
? 'disconnected'
|
|
516
|
+
: connectedCount === bots.length
|
|
517
|
+
? 'connected'
|
|
518
|
+
: connectedCount > 0
|
|
519
|
+
? 'degraded'
|
|
520
|
+
: 'offline',
|
|
521
|
+
bots,
|
|
522
|
+
totals: { configured: bots.length, connected: connectedCount },
|
|
523
|
+
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
524
|
+
? { provisioning: publicAttempt(active) }
|
|
525
|
+
: {}),
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** Cancels provisioning and stops every bot runtime. */
|
|
530
|
+
async close() {
|
|
531
|
+
if (this.#closed) return;
|
|
532
|
+
this.#closed = true;
|
|
533
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
534
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
535
|
+
await Promise.allSettled([...this.#transitions.values()]);
|
|
536
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async #pollRegistration(record) {
|
|
540
|
+
try {
|
|
541
|
+
this.#assertAttemptActive(record);
|
|
542
|
+
const response = await this.#deviceAuth.poll({
|
|
543
|
+
deviceCode: record.deviceCode,
|
|
544
|
+
signal: record.controller.signal,
|
|
545
|
+
});
|
|
546
|
+
this.#assertAttemptActive(record);
|
|
547
|
+
const state = cleanString(response.status)?.toUpperCase();
|
|
548
|
+
if (state === 'WAITING') {
|
|
549
|
+
record.state = 'pending';
|
|
550
|
+
record.error = null;
|
|
551
|
+
} else if (state === 'SUCCESS') {
|
|
552
|
+
const clientId = cleanString(response.clientId);
|
|
553
|
+
const clientSecret = cleanString(response.clientSecret);
|
|
554
|
+
if (!clientId || !clientSecret) throw new Error('DingTalk returned incomplete credentials');
|
|
555
|
+
record.state = 'connecting';
|
|
556
|
+
record.error = null;
|
|
557
|
+
this.#touch();
|
|
558
|
+
const activation = await this.#activateBot(record, { clientId, clientSecret });
|
|
559
|
+
record.botId = activation.botId;
|
|
560
|
+
record.alreadyConnected = activation.alreadyConnected;
|
|
561
|
+
record.state = 'connected';
|
|
562
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
563
|
+
} else if (state === 'EXPIRED') {
|
|
564
|
+
record.state = 'expired';
|
|
565
|
+
record.error = safeError('expired', '二维码已过期,请重新生成。');
|
|
566
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
567
|
+
} else if (state === 'FAIL') {
|
|
568
|
+
record.state = 'failed';
|
|
569
|
+
record.error = safeError('authorization-failed', '钉钉未完成机器人授权,请重新扫码。');
|
|
570
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
571
|
+
} else {
|
|
572
|
+
record.state = 'pending';
|
|
573
|
+
record.error = safeError('poll-pending', '钉钉授权状态暂时不可用,正在重试。');
|
|
574
|
+
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
|
577
|
+
record.state = 'cancelled';
|
|
578
|
+
record.error = safeError('cancelled', '扫码接入已取消。');
|
|
579
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
580
|
+
} else if (record.state === 'connecting') {
|
|
581
|
+
record.state = 'failed';
|
|
582
|
+
record.error = safeError(
|
|
583
|
+
'activation-failed',
|
|
584
|
+
'钉钉已授权,但无法安全保存接入配置。',
|
|
585
|
+
);
|
|
586
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
587
|
+
this.#logger.error?.('[dsh-dingtalk] bot activation failed');
|
|
588
|
+
} else {
|
|
589
|
+
record.state = 'pending';
|
|
590
|
+
record.error = safeError('poll-failed', '钉钉授权查询暂时失败,正在重试。');
|
|
591
|
+
}
|
|
592
|
+
} finally {
|
|
593
|
+
this.#touch();
|
|
594
|
+
this.#pruneAttempts();
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
async #activateBot(record, { clientId, clientSecret }) {
|
|
599
|
+
const identity = deriveDingtalkBotIdentity(clientId);
|
|
600
|
+
const previousConfig = this.#configStore.getByClientId(clientId);
|
|
601
|
+
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
602
|
+
const config = {
|
|
603
|
+
botId: identity.botId,
|
|
604
|
+
clientId,
|
|
605
|
+
secretRef: identity.secretRef,
|
|
606
|
+
approvedSenders: previousConfig?.approvedSenders ?? [],
|
|
607
|
+
};
|
|
608
|
+
return this.#withBotTransition(identity.botId, async () => {
|
|
609
|
+
const rollback = async () => {
|
|
610
|
+
await this.#stopRuntime(identity.botId);
|
|
611
|
+
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
612
|
+
else if (this.#configStore.get(identity.botId)) {
|
|
613
|
+
await this.#configStore.remove(identity.botId).catch(() => undefined);
|
|
614
|
+
}
|
|
615
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
616
|
+
if (previousConfig && cleanString(previousSecret?.value)) {
|
|
617
|
+
await this.#startRuntime(previousConfig, previousSecret.value).catch(() => undefined);
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
await this.#credentials.set(identity.secretRef, clientSecret);
|
|
621
|
+
try {
|
|
622
|
+
this.#assertAttemptActive(record);
|
|
623
|
+
await this.#configStore.save(config);
|
|
624
|
+
this.#assertAttemptActive(record);
|
|
625
|
+
} catch (error) {
|
|
626
|
+
await rollback();
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
try {
|
|
630
|
+
await this.#startRuntime(config, clientSecret);
|
|
631
|
+
this.#assertAttemptActive(record);
|
|
632
|
+
this.#errors.delete(identity.botId);
|
|
633
|
+
} catch (error) {
|
|
634
|
+
if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) {
|
|
635
|
+
await rollback();
|
|
636
|
+
throw abortError();
|
|
637
|
+
}
|
|
638
|
+
this.#errors.set(
|
|
639
|
+
identity.botId,
|
|
640
|
+
safeError('connection-failed', '钉钉已接入,但消息连接暂未就绪,请稍后重试。'),
|
|
641
|
+
);
|
|
642
|
+
this.#logger.warn?.('[dsh-dingtalk] authorized bot saved but its connection is not ready');
|
|
643
|
+
}
|
|
644
|
+
return { botId: identity.botId, alreadyConnected: Boolean(previousConfig) };
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async #saveAndRestart(previousConfig, nextConfig) {
|
|
649
|
+
return this.#withBotTransition(previousConfig.botId, async () => {
|
|
650
|
+
const clientSecret = await this.#resolveSecret(previousConfig.secretRef);
|
|
651
|
+
if (!clientSecret) throw new Error('The DingTalk client secret is missing');
|
|
652
|
+
await this.#configStore.save(nextConfig);
|
|
653
|
+
try {
|
|
654
|
+
await this.#startRuntime(nextConfig, clientSecret);
|
|
655
|
+
this.#errors.delete(previousConfig.botId);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
658
|
+
await this.#startRuntime(previousConfig, clientSecret).catch(() => undefined);
|
|
659
|
+
this.#errors.set(
|
|
660
|
+
previousConfig.botId,
|
|
661
|
+
safeError('connection-failed', '钉钉连接未就绪,请稍后重试。'),
|
|
662
|
+
);
|
|
663
|
+
throw error;
|
|
664
|
+
} finally {
|
|
665
|
+
this.#touch();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async #startRuntime(config, clientSecret) {
|
|
671
|
+
if (this.#closed) throw abortError();
|
|
672
|
+
await this.#stopRuntime(config.botId);
|
|
673
|
+
if (this.#closed) throw abortError();
|
|
674
|
+
const runtime = await this.#createRuntime({
|
|
675
|
+
botId: config.botId,
|
|
676
|
+
config: structuredClone(config),
|
|
677
|
+
clientSecret,
|
|
678
|
+
});
|
|
679
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
680
|
+
throw new TypeError('createRuntime returned an invalid DingTalk runtime');
|
|
681
|
+
}
|
|
682
|
+
if (this.#closed) {
|
|
683
|
+
await runtime.stop().catch(() => undefined);
|
|
684
|
+
throw abortError();
|
|
685
|
+
}
|
|
686
|
+
this.#runtimes.set(config.botId, runtime);
|
|
687
|
+
try {
|
|
688
|
+
await runtime.start();
|
|
689
|
+
if (this.#closed) {
|
|
690
|
+
await runtime.stop().catch(() => undefined);
|
|
691
|
+
throw abortError();
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
if (this.#runtimes.get(config.botId) === runtime) this.#runtimes.delete(config.botId);
|
|
695
|
+
await runtime.stop().catch(() => undefined);
|
|
696
|
+
throw error;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async #stopRuntime(botId) {
|
|
701
|
+
const runtime = this.#runtimes.get(botId);
|
|
702
|
+
this.#runtimes.delete(botId);
|
|
703
|
+
await runtime?.stop().catch(() => {
|
|
704
|
+
this.#logger.warn?.(`[dsh-dingtalk] bot ${botId} failed to stop cleanly`);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
async #resolveSecret(secretRef) {
|
|
709
|
+
const result = await this.#credentials.resolve(secretRef).catch(() => undefined);
|
|
710
|
+
return cleanString(result?.value);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async #restoreCredential(secretRef, previous) {
|
|
714
|
+
try {
|
|
715
|
+
if (cleanString(previous?.value)) await this.#credentials.set(secretRef, previous.value);
|
|
716
|
+
else await this.#credentials.unset(secretRef);
|
|
717
|
+
} catch {
|
|
718
|
+
this.#logger.error?.(`[dsh-dingtalk] failed to restore credential ${secretRef}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
#assertAttemptActive(record) {
|
|
723
|
+
if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError();
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
#withBotTransition(botId, operation) {
|
|
727
|
+
if (this.#closed) return Promise.reject(new Error('dsh-dingtalk controller is closed'));
|
|
728
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
729
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
730
|
+
const settled = current.finally(() => {
|
|
731
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
732
|
+
});
|
|
733
|
+
this.#transitions.set(botId, settled);
|
|
734
|
+
return settled;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
#pruneAttempts() {
|
|
738
|
+
for (const [id, record] of this.#attempts) {
|
|
739
|
+
if (id !== this.#activeAttemptId
|
|
740
|
+
&& TERMINAL_ATTEMPT_STATES.has(record.state)
|
|
741
|
+
&& this.#attempts.size > 16) {
|
|
742
|
+
this.#attempts.delete(id);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
#touch() {
|
|
748
|
+
this.#revision += 1;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export { DingtalkController as DingTalkController };
|