@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,474 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { deriveWecomBotIdentity, maskWecomBotId } from './config-store.mjs';
|
|
4
|
+
|
|
5
|
+
const ACTIVE_ATTEMPT_STATES = new Set(['pending', 'connecting']);
|
|
6
|
+
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled', 'expired']);
|
|
7
|
+
|
|
8
|
+
function cleanString(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function safeError(code, message) {
|
|
13
|
+
return Object.freeze({ code, message });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function publicAttempt(record) {
|
|
17
|
+
if (!record) return null;
|
|
18
|
+
return {
|
|
19
|
+
attemptId: record.id,
|
|
20
|
+
status: record.state,
|
|
21
|
+
pollIntervalMs: record.pollIntervalMs,
|
|
22
|
+
qrRevision: record.qrRevision,
|
|
23
|
+
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
|
24
|
+
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
25
|
+
...(record.botId ? { botId: record.botId } : {}),
|
|
26
|
+
...(record.error ? { error: structuredClone(record.error) } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class WecomController {
|
|
31
|
+
#qrAuth;
|
|
32
|
+
#credentials;
|
|
33
|
+
#configStore;
|
|
34
|
+
#createRuntime;
|
|
35
|
+
#deleteState;
|
|
36
|
+
#logger;
|
|
37
|
+
#runtimes = new Map();
|
|
38
|
+
#errors = new Map();
|
|
39
|
+
#attempts = new Map();
|
|
40
|
+
#activeAttemptId = null;
|
|
41
|
+
#transitions = new Map();
|
|
42
|
+
#revision = 0;
|
|
43
|
+
#closed = false;
|
|
44
|
+
|
|
45
|
+
constructor({
|
|
46
|
+
qrAuth,
|
|
47
|
+
credentials,
|
|
48
|
+
configStore,
|
|
49
|
+
createRuntime,
|
|
50
|
+
deleteState = async () => {},
|
|
51
|
+
logger = console,
|
|
52
|
+
}) {
|
|
53
|
+
if (!qrAuth || typeof qrAuth.start !== 'function' || typeof qrAuth.poll !== 'function') {
|
|
54
|
+
throw new TypeError('Enterprise WeChat QR auth is required');
|
|
55
|
+
}
|
|
56
|
+
if (!credentials || typeof credentials.resolve !== 'function'
|
|
57
|
+
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
|
58
|
+
throw new TypeError('WecomController requires the DSH credential provider');
|
|
59
|
+
}
|
|
60
|
+
if (!configStore || typeof configStore.list !== 'function'
|
|
61
|
+
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
62
|
+
throw new TypeError('WecomController requires a config store');
|
|
63
|
+
}
|
|
64
|
+
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
65
|
+
this.#qrAuth = qrAuth;
|
|
66
|
+
this.#credentials = credentials;
|
|
67
|
+
this.#configStore = configStore;
|
|
68
|
+
this.#createRuntime = createRuntime;
|
|
69
|
+
this.#deleteState = deleteState;
|
|
70
|
+
this.#logger = logger;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async initialize() {
|
|
74
|
+
if (this.#closed) return this.status();
|
|
75
|
+
for (const config of this.#configStore.list()) {
|
|
76
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
77
|
+
const existing = this.#runtimes.get(config.botId)?.status;
|
|
78
|
+
if (this.#closed || existing?.ready || existing?.wecomConnectionState === 'connecting') return;
|
|
79
|
+
const secret = await this.#resolveSecret(config.secretRef);
|
|
80
|
+
if (!secret) {
|
|
81
|
+
this.#errors.set(config.botId, safeError('missing-secret', '企业微信机器人凭据缺失,请移除后重新扫码。'));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
await this.#startRuntime(config, secret);
|
|
86
|
+
this.#errors.delete(config.botId);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
this.#errors.set(config.botId, safeError('connection-failed', '企业微信连接未就绪,插件会自动重试。'));
|
|
89
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${config.botId} failed to initialize`);
|
|
90
|
+
} finally {
|
|
91
|
+
this.#touch();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return this.status();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async startProvisioning() {
|
|
99
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
100
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
101
|
+
const record = {
|
|
102
|
+
id: randomUUID(),
|
|
103
|
+
state: 'pending',
|
|
104
|
+
createdAt: Date.now(),
|
|
105
|
+
expiresAt: null,
|
|
106
|
+
pollIntervalMs: 3_000,
|
|
107
|
+
qrRevision: 1,
|
|
108
|
+
verificationUrl: null,
|
|
109
|
+
scode: null,
|
|
110
|
+
botId: null,
|
|
111
|
+
error: null,
|
|
112
|
+
controller: new AbortController(),
|
|
113
|
+
polling: null,
|
|
114
|
+
transition: null,
|
|
115
|
+
};
|
|
116
|
+
this.#attempts.set(record.id, record);
|
|
117
|
+
this.#activeAttemptId = record.id;
|
|
118
|
+
this.#touch();
|
|
119
|
+
try {
|
|
120
|
+
const started = await this.#qrAuth.start({ signal: record.controller.signal });
|
|
121
|
+
record.scode = cleanString(started.scode);
|
|
122
|
+
record.verificationUrl = cleanString(started.verificationUrl);
|
|
123
|
+
record.expiresAt = Number(started.expiresAt);
|
|
124
|
+
record.pollIntervalMs = Math.min(10_000, Math.max(500, Number(started.pollIntervalMs) || 3_000));
|
|
125
|
+
if (!record.scode || !record.verificationUrl || !Number.isFinite(record.expiresAt)) {
|
|
126
|
+
throw new Error('Enterprise WeChat QR auth returned incomplete data');
|
|
127
|
+
}
|
|
128
|
+
this.#touch();
|
|
129
|
+
return publicAttempt(record);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
record.state = record.controller.signal.aborted ? 'cancelled' : 'failed';
|
|
132
|
+
record.error = record.controller.signal.aborted
|
|
133
|
+
? safeError('cancelled', '扫码绑定已取消。')
|
|
134
|
+
: safeError('qr-start-failed', '无法生成企业微信二维码,请稍后重试。');
|
|
135
|
+
this.#finishAttempt(record);
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async registrationStatus(attemptId) {
|
|
141
|
+
const record = this.#attempts.get(attemptId);
|
|
142
|
+
if (!record || TERMINAL_ATTEMPT_STATES.has(record.state)) return publicAttempt(record);
|
|
143
|
+
if (record.state === 'connecting') {
|
|
144
|
+
await record.transition?.catch(() => undefined);
|
|
145
|
+
return publicAttempt(record);
|
|
146
|
+
}
|
|
147
|
+
if (Date.now() >= record.expiresAt) {
|
|
148
|
+
record.state = 'expired';
|
|
149
|
+
record.error = safeError('expired', '企业微信二维码已过期,请重新生成。');
|
|
150
|
+
record.controller.abort();
|
|
151
|
+
this.#finishAttempt(record);
|
|
152
|
+
return publicAttempt(record);
|
|
153
|
+
}
|
|
154
|
+
if (!record.polling) {
|
|
155
|
+
const polling = this.#pollAttempt(record).finally(() => {
|
|
156
|
+
if (record.polling === polling) record.polling = null;
|
|
157
|
+
});
|
|
158
|
+
record.polling = polling;
|
|
159
|
+
}
|
|
160
|
+
await record.polling.catch(() => undefined);
|
|
161
|
+
return publicAttempt(record);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async bindCredentials({ botId, secret } = {}) {
|
|
165
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
166
|
+
const remoteBotId = cleanString(botId);
|
|
167
|
+
const normalizedSecret = cleanString(secret);
|
|
168
|
+
if (!remoteBotId || !normalizedSecret) {
|
|
169
|
+
throw new TypeError('Enterprise WeChat Bot ID and Secret are required');
|
|
170
|
+
}
|
|
171
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
172
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
173
|
+
const identity = deriveWecomBotIdentity(remoteBotId);
|
|
174
|
+
await this.#withBotTransition(identity.botId, async () => {
|
|
175
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
176
|
+
const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId);
|
|
177
|
+
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
178
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
179
|
+
const config = {
|
|
180
|
+
botId: identity.botId,
|
|
181
|
+
remoteBotId,
|
|
182
|
+
secretRef: identity.secretRef,
|
|
183
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
184
|
+
connectedAt: new Date().toISOString(),
|
|
185
|
+
};
|
|
186
|
+
await this.#credentials.set(identity.secretRef, normalizedSecret);
|
|
187
|
+
try {
|
|
188
|
+
await this.#configStore.save(config);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
await this.#startRuntime(config, normalizedSecret);
|
|
195
|
+
this.#errors.delete(identity.botId);
|
|
196
|
+
} catch {
|
|
197
|
+
this.#errors.set(
|
|
198
|
+
identity.botId,
|
|
199
|
+
safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。'),
|
|
200
|
+
);
|
|
201
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} credential connection failed`);
|
|
202
|
+
}
|
|
203
|
+
this.#touch();
|
|
204
|
+
});
|
|
205
|
+
return this.status();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async cancelProvisioning(attemptId) {
|
|
209
|
+
const record = this.#attempts.get(attemptId);
|
|
210
|
+
if (!record) return null;
|
|
211
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
212
|
+
record.controller.abort();
|
|
213
|
+
await Promise.allSettled([record.polling, record.transition].filter(Boolean));
|
|
214
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
|
215
|
+
record.error ??= safeError('cancelled', '扫码绑定已取消。');
|
|
216
|
+
this.#finishAttempt(record);
|
|
217
|
+
}
|
|
218
|
+
return publicAttempt(record);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async reconnectBot(botId) {
|
|
222
|
+
const config = this.#configStore.get(botId);
|
|
223
|
+
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
|
224
|
+
await this.#withBotTransition(botId, async () => {
|
|
225
|
+
const secret = await this.#resolveSecret(config.secretRef);
|
|
226
|
+
if (!secret) throw new Error('Enterprise WeChat bot secret is missing');
|
|
227
|
+
try {
|
|
228
|
+
await this.#startRuntime(config, secret);
|
|
229
|
+
this.#errors.delete(botId);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
this.#errors.set(botId, safeError('connection-failed', '企业微信连接仍未就绪,请稍后重试。'));
|
|
232
|
+
throw error;
|
|
233
|
+
} finally {
|
|
234
|
+
this.#touch();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
return this.status();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async deleteBot(botId) {
|
|
241
|
+
const config = this.#configStore.get(botId);
|
|
242
|
+
if (!config) throw new Error('Unknown Enterprise WeChat bot');
|
|
243
|
+
await this.#withBotTransition(botId, async () => {
|
|
244
|
+
const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
|
|
245
|
+
await this.#stopRuntime(botId);
|
|
246
|
+
try {
|
|
247
|
+
await this.#credentials.unset(config.secretRef);
|
|
248
|
+
await this.#configStore.remove(botId);
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (previous?.value) {
|
|
251
|
+
await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined);
|
|
252
|
+
await this.#startRuntime(config, previous.value).catch(() => undefined);
|
|
253
|
+
}
|
|
254
|
+
throw new Error('Unable to remove the Enterprise WeChat bot safely.', { cause: error });
|
|
255
|
+
}
|
|
256
|
+
await this.#deleteState({ botId, config }).catch((error) => {
|
|
257
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} state cleanup failed:`, error);
|
|
258
|
+
});
|
|
259
|
+
this.#errors.delete(botId);
|
|
260
|
+
this.#touch();
|
|
261
|
+
});
|
|
262
|
+
return this.status();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
status() {
|
|
266
|
+
const bots = this.#configStore.list().map((config) => {
|
|
267
|
+
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
268
|
+
const connected = runtimeStatus?.ready === true
|
|
269
|
+
&& runtimeStatus.wecomConnectionState === 'connected'
|
|
270
|
+
&& runtimeStatus.harnessReachable === true;
|
|
271
|
+
const state = connected ? 'connected'
|
|
272
|
+
: runtimeStatus?.wecomConnectionState === 'connecting' ? 'connecting'
|
|
273
|
+
: this.#errors.has(config.botId) || runtimeStatus?.wecomConnectionState === 'failed'
|
|
274
|
+
? 'error' : 'offline';
|
|
275
|
+
return {
|
|
276
|
+
botId: config.botId,
|
|
277
|
+
state,
|
|
278
|
+
connected,
|
|
279
|
+
configured: true,
|
|
280
|
+
bot: { name: '企业微信机器人', appIdMasked: maskWecomBotId(config.remoteBotId) },
|
|
281
|
+
health: {
|
|
282
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
283
|
+
summary: connected ? '企业微信 WebSocket 长连接运行正常'
|
|
284
|
+
: state === 'error' ? '企业微信连接未就绪,插件会自动重试' : '企业微信连接当前离线',
|
|
285
|
+
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
286
|
+
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
287
|
+
},
|
|
288
|
+
stats: {
|
|
289
|
+
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
290
|
+
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
291
|
+
},
|
|
292
|
+
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
const connectedCount = bots.filter((bot) => bot.connected).length;
|
|
296
|
+
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
|
297
|
+
return {
|
|
298
|
+
schemaVersion: 1,
|
|
299
|
+
revision: this.#revision,
|
|
300
|
+
state: active && ACTIVE_ATTEMPT_STATES.has(active.state) ? 'provisioning'
|
|
301
|
+
: bots.length === 0 ? 'disconnected'
|
|
302
|
+
: connectedCount === bots.length ? 'connected'
|
|
303
|
+
: connectedCount > 0 ? 'degraded' : 'offline',
|
|
304
|
+
bots,
|
|
305
|
+
totals: { configured: bots.length, connected: connectedCount },
|
|
306
|
+
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
307
|
+
? { provisioning: publicAttempt(active) } : {}),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async close() {
|
|
312
|
+
if (this.#closed) return;
|
|
313
|
+
this.#closed = true;
|
|
314
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
315
|
+
await Promise.allSettled([...this.#transitions.values()]);
|
|
316
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async #pollAttempt(record) {
|
|
320
|
+
try {
|
|
321
|
+
const result = await this.#qrAuth.poll({ scode: record.scode, signal: record.controller.signal });
|
|
322
|
+
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
323
|
+
if (result.status === 'waiting') return;
|
|
324
|
+
if (result.status === 'expired') {
|
|
325
|
+
record.state = 'expired';
|
|
326
|
+
record.error = safeError('expired', '企业微信二维码已过期,请重新生成。');
|
|
327
|
+
this.#finishAttempt(record);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (result.status !== 'success') {
|
|
331
|
+
record.state = 'failed';
|
|
332
|
+
record.error = safeError('qr-connect-failed', '企业微信扫码没有完成,请重新生成二维码。');
|
|
333
|
+
this.#finishAttempt(record);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
record.state = 'connecting';
|
|
337
|
+
record.verificationUrl = null;
|
|
338
|
+
record.expiresAt = null;
|
|
339
|
+
record.scode = null;
|
|
340
|
+
this.#touch();
|
|
341
|
+
const transition = this.#completeProvisioning(record, result);
|
|
342
|
+
record.transition = transition;
|
|
343
|
+
await transition;
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (record.controller.signal.aborted) return;
|
|
346
|
+
record.state = 'failed';
|
|
347
|
+
record.error = safeError('qr-connect-failed', '企业微信扫码服务暂时不可用,请重新生成二维码。');
|
|
348
|
+
this.#logger.warn?.('[dsh-im:wecom] QR polling failed');
|
|
349
|
+
this.#finishAttempt(record);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async #completeProvisioning(record, result) {
|
|
354
|
+
try {
|
|
355
|
+
const remoteBotId = cleanString(result.remoteBotId);
|
|
356
|
+
const secret = cleanString(result.secret);
|
|
357
|
+
if (!remoteBotId || !secret) throw new Error('Enterprise WeChat authorization returned incomplete credentials');
|
|
358
|
+
record.botId = await this.#activateBot(record, { remoteBotId, secret });
|
|
359
|
+
record.state = 'connected';
|
|
360
|
+
record.error = null;
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (record.controller.signal.aborted) {
|
|
363
|
+
record.state = 'cancelled';
|
|
364
|
+
record.error = safeError('cancelled', '扫码绑定已取消。');
|
|
365
|
+
} else {
|
|
366
|
+
record.state = 'failed';
|
|
367
|
+
record.error = safeError('activation-failed', '企业微信已授权,但无法安全保存接入配置。');
|
|
368
|
+
this.#logger.error?.('[dsh-im:wecom] provisioning failed');
|
|
369
|
+
}
|
|
370
|
+
} finally {
|
|
371
|
+
this.#finishAttempt(record);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async #activateBot(record, { remoteBotId, secret }) {
|
|
376
|
+
const identity = deriveWecomBotIdentity(remoteBotId);
|
|
377
|
+
const previousConfig = this.#configStore.getByRemoteBotId(remoteBotId);
|
|
378
|
+
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
379
|
+
const config = {
|
|
380
|
+
botId: identity.botId,
|
|
381
|
+
remoteBotId,
|
|
382
|
+
secretRef: identity.secretRef,
|
|
383
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
384
|
+
connectedAt: new Date().toISOString(),
|
|
385
|
+
};
|
|
386
|
+
return this.#withBotTransition(identity.botId, async () => {
|
|
387
|
+
await this.#credentials.set(identity.secretRef, secret);
|
|
388
|
+
try {
|
|
389
|
+
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
|
390
|
+
await this.#configStore.save(config);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
|
397
|
+
await this.#startRuntime(config, secret);
|
|
398
|
+
this.#errors.delete(identity.botId);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
if (record.controller.signal.aborted) {
|
|
401
|
+
await this.#stopRuntime(identity.botId);
|
|
402
|
+
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
403
|
+
else await this.#configStore.remove(identity.botId).catch(() => undefined);
|
|
404
|
+
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
405
|
+
throw error;
|
|
406
|
+
}
|
|
407
|
+
this.#errors.set(identity.botId, safeError('connection-failed', '企业微信机器人已绑定,消息连接暂未就绪。'));
|
|
408
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${identity.botId} activation connection failed`);
|
|
409
|
+
}
|
|
410
|
+
this.#touch();
|
|
411
|
+
return identity.botId;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async #startRuntime(config, secret) {
|
|
416
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
417
|
+
await this.#stopRuntime(config.botId);
|
|
418
|
+
if (this.#closed) throw new Error('Enterprise WeChat controller is closed');
|
|
419
|
+
const runtime = await this.#createRuntime({ botId: config.botId, config, secret });
|
|
420
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
421
|
+
throw new TypeError('createRuntime returned an invalid Enterprise WeChat runtime');
|
|
422
|
+
}
|
|
423
|
+
this.#runtimes.set(config.botId, runtime);
|
|
424
|
+
try {
|
|
425
|
+
await runtime.start();
|
|
426
|
+
} catch (error) {
|
|
427
|
+
await runtime.stop().catch(() => undefined);
|
|
428
|
+
this.#runtimes.delete(config.botId);
|
|
429
|
+
throw error;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async #stopRuntime(botId) {
|
|
434
|
+
const runtime = this.#runtimes.get(botId);
|
|
435
|
+
this.#runtimes.delete(botId);
|
|
436
|
+
await runtime?.stop().catch((error) => {
|
|
437
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${botId} failed to stop cleanly:`, error);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async #resolveSecret(ref) {
|
|
442
|
+
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
|
443
|
+
return cleanString(result?.value);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async #restoreCredential(ref, previous) {
|
|
447
|
+
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
|
448
|
+
else await this.#credentials.unset(ref).catch(() => undefined);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#withBotTransition(botId, operation) {
|
|
452
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
453
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
454
|
+
const settled = current.finally(() => {
|
|
455
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
456
|
+
});
|
|
457
|
+
this.#transitions.set(botId, settled);
|
|
458
|
+
return settled;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#finishAttempt(record) {
|
|
462
|
+
record.scode = null;
|
|
463
|
+
record.verificationUrl = null;
|
|
464
|
+
record.expiresAt = null;
|
|
465
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
466
|
+
this.#touch();
|
|
467
|
+
const terminal = [...this.#attempts.values()].filter((attempt) => TERMINAL_ATTEMPT_STATES.has(attempt.state));
|
|
468
|
+
while (terminal.length > 16) this.#attempts.delete(terminal.shift().id);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#touch() {
|
|
472
|
+
this.#revision += 1;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
|
|
2
|
+
|
|
3
|
+
import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
|
|
4
|
+
|
|
5
|
+
function timeoutError() {
|
|
6
|
+
const error = new Error('Enterprise WeChat WebSocket authentication timed out');
|
|
7
|
+
error.code = 'connect-timeout';
|
|
8
|
+
return error;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createWecomRuntimeStatus() {
|
|
12
|
+
return {
|
|
13
|
+
startedAt: null,
|
|
14
|
+
ready: false,
|
|
15
|
+
wecomConnectionState: 'idle',
|
|
16
|
+
harnessReachable: false,
|
|
17
|
+
lastCheckedAt: null,
|
|
18
|
+
lastConnectedAt: null,
|
|
19
|
+
lastError: null,
|
|
20
|
+
...createWecomBridgeStatus(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class WecomRuntime {
|
|
25
|
+
#config;
|
|
26
|
+
#secret;
|
|
27
|
+
#harness;
|
|
28
|
+
#state;
|
|
29
|
+
#logger;
|
|
30
|
+
#replyTimeoutMs;
|
|
31
|
+
#connectTimeoutMs;
|
|
32
|
+
#maxReconnectAttempts;
|
|
33
|
+
#createClient;
|
|
34
|
+
#status = createWecomRuntimeStatus();
|
|
35
|
+
#client = null;
|
|
36
|
+
#bridge = null;
|
|
37
|
+
#starting = null;
|
|
38
|
+
#startController = null;
|
|
39
|
+
|
|
40
|
+
constructor({
|
|
41
|
+
config,
|
|
42
|
+
secret,
|
|
43
|
+
harness,
|
|
44
|
+
state,
|
|
45
|
+
logger = console,
|
|
46
|
+
replyTimeoutMs = 600_000,
|
|
47
|
+
connectTimeoutMs = 20_000,
|
|
48
|
+
maxReconnectAttempts = 10,
|
|
49
|
+
createClient = (options) => new WSClient(options),
|
|
50
|
+
}) {
|
|
51
|
+
if (!config || !secret || !harness || !state) {
|
|
52
|
+
throw new TypeError('WecomRuntime requires config, secret, Harness, and state');
|
|
53
|
+
}
|
|
54
|
+
this.#config = config;
|
|
55
|
+
this.#secret = secret;
|
|
56
|
+
this.#harness = harness;
|
|
57
|
+
this.#state = state;
|
|
58
|
+
this.#logger = logger;
|
|
59
|
+
this.#replyTimeoutMs = replyTimeoutMs;
|
|
60
|
+
this.#connectTimeoutMs = connectTimeoutMs;
|
|
61
|
+
this.#maxReconnectAttempts = maxReconnectAttempts;
|
|
62
|
+
this.#createClient = createClient;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get status() {
|
|
66
|
+
return structuredClone(this.#status);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async start() {
|
|
70
|
+
if (this.#status.ready && this.#client) return this.status;
|
|
71
|
+
if (this.#starting) return this.#starting;
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
this.#startController = controller;
|
|
74
|
+
this.#starting = this.#start(controller.signal).finally(() => {
|
|
75
|
+
if (this.#startController === controller) this.#startController = null;
|
|
76
|
+
this.#starting = null;
|
|
77
|
+
});
|
|
78
|
+
return this.#starting;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async #start(signal) {
|
|
82
|
+
await this.#stopActive();
|
|
83
|
+
signal.throwIfAborted();
|
|
84
|
+
this.#status.startedAt = new Date().toISOString();
|
|
85
|
+
this.#status.wecomConnectionState = 'connecting';
|
|
86
|
+
this.#status.lastError = null;
|
|
87
|
+
await this.#harness.ensureRunning();
|
|
88
|
+
this.#status.harnessReachable = true;
|
|
89
|
+
|
|
90
|
+
const silentSdkLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
91
|
+
const client = this.#createClient({
|
|
92
|
+
botId: this.#config.remoteBotId,
|
|
93
|
+
secret: this.#secret,
|
|
94
|
+
logger: silentSdkLogger,
|
|
95
|
+
maxReconnectAttempts: this.#maxReconnectAttempts,
|
|
96
|
+
});
|
|
97
|
+
if (!client || typeof client.connect !== 'function' || typeof client.disconnect !== 'function') {
|
|
98
|
+
throw new TypeError('Enterprise WeChat client factory returned an invalid client');
|
|
99
|
+
}
|
|
100
|
+
this.#client = client;
|
|
101
|
+
this.#bridge = new WecomHarnessBridge({
|
|
102
|
+
client,
|
|
103
|
+
harness: this.#harness,
|
|
104
|
+
state: this.#state,
|
|
105
|
+
status: this.#status,
|
|
106
|
+
logger: this.#logger,
|
|
107
|
+
replyTimeoutMs: this.#replyTimeoutMs,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
let readyResolve;
|
|
111
|
+
let readyReject;
|
|
112
|
+
let authenticated = false;
|
|
113
|
+
const ready = new Promise((resolve, reject) => {
|
|
114
|
+
readyResolve = resolve;
|
|
115
|
+
readyReject = reject;
|
|
116
|
+
});
|
|
117
|
+
const onAuthenticated = () => {
|
|
118
|
+
if (this.#client !== client || signal.aborted) return;
|
|
119
|
+
authenticated = true;
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
this.#status.ready = true;
|
|
122
|
+
this.#status.wecomConnectionState = 'connected';
|
|
123
|
+
this.#status.lastCheckedAt = now;
|
|
124
|
+
this.#status.lastConnectedAt = now;
|
|
125
|
+
this.#status.lastError = null;
|
|
126
|
+
readyResolve();
|
|
127
|
+
};
|
|
128
|
+
const onDisconnected = () => {
|
|
129
|
+
if (this.#client !== client) return;
|
|
130
|
+
this.#status.ready = false;
|
|
131
|
+
this.#status.wecomConnectionState = 'connecting';
|
|
132
|
+
this.#status.lastCheckedAt = Date.now();
|
|
133
|
+
};
|
|
134
|
+
const onReconnecting = () => {
|
|
135
|
+
if (this.#client !== client) return;
|
|
136
|
+
this.#status.ready = false;
|
|
137
|
+
this.#status.wecomConnectionState = 'connecting';
|
|
138
|
+
this.#status.lastCheckedAt = Date.now();
|
|
139
|
+
};
|
|
140
|
+
const onError = (error) => {
|
|
141
|
+
if (this.#client !== client) return;
|
|
142
|
+
const terminal = error instanceof WSAuthFailureError || error instanceof WSReconnectExhaustedError;
|
|
143
|
+
if (!authenticated && terminal) readyReject(error);
|
|
144
|
+
if (terminal) {
|
|
145
|
+
this.#status.ready = false;
|
|
146
|
+
this.#status.wecomConnectionState = 'failed';
|
|
147
|
+
}
|
|
148
|
+
this.#status.lastError = terminal ? error.name : 'connection-error';
|
|
149
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} connection error`);
|
|
150
|
+
};
|
|
151
|
+
const onMessage = (frame) => this.#bridge?.accept(frame);
|
|
152
|
+
client.on('authenticated', onAuthenticated);
|
|
153
|
+
client.on('disconnected', onDisconnected);
|
|
154
|
+
client.on('reconnecting', onReconnecting);
|
|
155
|
+
client.on('error', onError);
|
|
156
|
+
client.on('message', onMessage);
|
|
157
|
+
|
|
158
|
+
let timer;
|
|
159
|
+
try {
|
|
160
|
+
client.connect();
|
|
161
|
+
await Promise.race([
|
|
162
|
+
ready,
|
|
163
|
+
new Promise((_, reject) => {
|
|
164
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
165
|
+
}),
|
|
166
|
+
new Promise((_, reject) => {
|
|
167
|
+
timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs);
|
|
168
|
+
}),
|
|
169
|
+
]);
|
|
170
|
+
return this.status;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
if (signal.aborted) {
|
|
173
|
+
await this.#stopActive();
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
this.#status.ready = false;
|
|
177
|
+
this.#status.wecomConnectionState = 'failed';
|
|
178
|
+
this.#status.lastError = error?.message ?? String(error);
|
|
179
|
+
await this.#stopActive();
|
|
180
|
+
throw error;
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timer);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async stop() {
|
|
187
|
+
const starting = this.#starting;
|
|
188
|
+
this.#startController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
|
|
189
|
+
await this.#stopActive();
|
|
190
|
+
await starting?.catch(() => undefined);
|
|
191
|
+
return this.status;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async #stopActive() {
|
|
195
|
+
const client = this.#client;
|
|
196
|
+
const bridge = this.#bridge;
|
|
197
|
+
this.#client = null;
|
|
198
|
+
this.#bridge = null;
|
|
199
|
+
try {
|
|
200
|
+
client?.disconnect();
|
|
201
|
+
client?.removeAllListeners?.();
|
|
202
|
+
} catch (error) {
|
|
203
|
+
this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} failed to stop cleanly`);
|
|
204
|
+
}
|
|
205
|
+
await bridge?.waitForIdle();
|
|
206
|
+
this.#status.ready = false;
|
|
207
|
+
this.#status.wecomConnectionState = 'idle';
|
|
208
|
+
}
|
|
209
|
+
}
|