@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,545 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
normalizeWeixinApiBaseUrl,
|
|
5
|
+
WEIXIN_QR_BASE_URL,
|
|
6
|
+
WeixinApiError,
|
|
7
|
+
} from './weixin-api.mjs';
|
|
8
|
+
import { deriveWeixinBotIdentity, maskWeixinAccountId } from './config-store.mjs';
|
|
9
|
+
|
|
10
|
+
const ACTIVE_ATTEMPT_STATES = new Set([
|
|
11
|
+
'starting',
|
|
12
|
+
'pending',
|
|
13
|
+
'scanned',
|
|
14
|
+
'needs_verification',
|
|
15
|
+
'connecting',
|
|
16
|
+
]);
|
|
17
|
+
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
|
18
|
+
const QR_TTL_MS = 5 * 60_000;
|
|
19
|
+
|
|
20
|
+
function cleanString(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function abortError() {
|
|
25
|
+
return new DOMException('Provisioning was cancelled', 'AbortError');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function apiBaseFromServer(value, fallback) {
|
|
29
|
+
const raw = cleanString(value);
|
|
30
|
+
if (!raw) return normalizeWeixinApiBaseUrl(fallback);
|
|
31
|
+
return normalizeWeixinApiBaseUrl(raw.includes('://') ? raw : `https://${raw}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function publicAttempt(record) {
|
|
35
|
+
if (!record) return null;
|
|
36
|
+
return {
|
|
37
|
+
attemptId: record.id,
|
|
38
|
+
status: record.state,
|
|
39
|
+
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
|
40
|
+
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
41
|
+
pollIntervalMs: 1_000,
|
|
42
|
+
...(record.state === 'needs_verification' ? { verificationRequired: true } : {}),
|
|
43
|
+
...(record.botId ? { botId: record.botId } : {}),
|
|
44
|
+
...(record.alreadyConnected ? { alreadyConnected: true } : {}),
|
|
45
|
+
...(record.error ? { error: structuredClone(record.error) } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function safeAccountError(code, message) {
|
|
50
|
+
return Object.freeze({ code, message });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class WeixinController {
|
|
54
|
+
#api;
|
|
55
|
+
#credentials;
|
|
56
|
+
#configStore;
|
|
57
|
+
#createRuntime;
|
|
58
|
+
#deleteState;
|
|
59
|
+
#logger;
|
|
60
|
+
#runtimes = new Map();
|
|
61
|
+
#errors = new Map();
|
|
62
|
+
#attempts = new Map();
|
|
63
|
+
#activeAttemptId = null;
|
|
64
|
+
#transitions = new Map();
|
|
65
|
+
#revision = 0;
|
|
66
|
+
#closed = false;
|
|
67
|
+
|
|
68
|
+
constructor({
|
|
69
|
+
api,
|
|
70
|
+
credentials,
|
|
71
|
+
configStore,
|
|
72
|
+
createRuntime,
|
|
73
|
+
deleteState = async () => {},
|
|
74
|
+
logger = console,
|
|
75
|
+
}) {
|
|
76
|
+
if (!api || typeof api.beginLogin !== 'function' || typeof api.pollLogin !== 'function') {
|
|
77
|
+
throw new TypeError('WeixinController requires a Weixin API client');
|
|
78
|
+
}
|
|
79
|
+
if (!credentials
|
|
80
|
+
|| typeof credentials.resolve !== 'function'
|
|
81
|
+
|| typeof credentials.set !== 'function'
|
|
82
|
+
|| typeof credentials.unset !== 'function') {
|
|
83
|
+
throw new TypeError('WeixinController requires the DSH credential provider');
|
|
84
|
+
}
|
|
85
|
+
if (!configStore
|
|
86
|
+
|| typeof configStore.list !== 'function'
|
|
87
|
+
|| typeof configStore.save !== 'function'
|
|
88
|
+
|| typeof configStore.remove !== 'function') {
|
|
89
|
+
throw new TypeError('WeixinController requires a config store');
|
|
90
|
+
}
|
|
91
|
+
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
92
|
+
this.#api = api;
|
|
93
|
+
this.#credentials = credentials;
|
|
94
|
+
this.#configStore = configStore;
|
|
95
|
+
this.#createRuntime = createRuntime;
|
|
96
|
+
this.#deleteState = deleteState;
|
|
97
|
+
this.#logger = logger;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async initialize() {
|
|
101
|
+
if (this.#closed) return this.status();
|
|
102
|
+
for (const config of this.#configStore.list()) {
|
|
103
|
+
const current = this.#runtimes.get(config.botId);
|
|
104
|
+
if (current?.status?.ready === true) continue;
|
|
105
|
+
await this.#withBotTransition(config.botId, async () => {
|
|
106
|
+
const latest = this.#configStore.get(config.botId);
|
|
107
|
+
if (!latest || this.#closed) return;
|
|
108
|
+
try {
|
|
109
|
+
const token = await this.#resolveToken(latest.tokenRef);
|
|
110
|
+
if (!token) {
|
|
111
|
+
this.#errors.set(
|
|
112
|
+
latest.botId,
|
|
113
|
+
safeAccountError('missing-token', '登录凭据缺失,请移除账号后重新扫码。'),
|
|
114
|
+
);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
await this.#startRuntime(latest, token);
|
|
118
|
+
this.#errors.delete(latest.botId);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
this.#errors.set(
|
|
121
|
+
latest.botId,
|
|
122
|
+
safeAccountError('connection-failed', '微信连接未就绪,插件会自动重试。'),
|
|
123
|
+
);
|
|
124
|
+
this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error);
|
|
125
|
+
} finally {
|
|
126
|
+
this.#touch();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return this.status();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async startProvisioning() {
|
|
134
|
+
if (this.#closed) throw new Error('dsh-weixin controller is closed');
|
|
135
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
136
|
+
|
|
137
|
+
const record = {
|
|
138
|
+
id: randomUUID(),
|
|
139
|
+
state: 'starting',
|
|
140
|
+
createdAt: Date.now(),
|
|
141
|
+
expiresAt: Date.now() + QR_TTL_MS,
|
|
142
|
+
controller: new AbortController(),
|
|
143
|
+
pendingVerifyCode: null,
|
|
144
|
+
verifyResolve: null,
|
|
145
|
+
currentBaseUrl: WEIXIN_QR_BASE_URL,
|
|
146
|
+
error: null,
|
|
147
|
+
botId: null,
|
|
148
|
+
task: null,
|
|
149
|
+
};
|
|
150
|
+
this.#attempts.set(record.id, record);
|
|
151
|
+
this.#activeAttemptId = record.id;
|
|
152
|
+
this.#touch();
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const localTokens = (await Promise.all(
|
|
156
|
+
this.#configStore.list().slice(-10).map(async (config) => this.#resolveToken(config.tokenRef)),
|
|
157
|
+
)).filter(Boolean);
|
|
158
|
+
const login = await this.#api.beginLogin({
|
|
159
|
+
localTokens,
|
|
160
|
+
signal: record.controller.signal,
|
|
161
|
+
});
|
|
162
|
+
this.#assertAttemptActive(record);
|
|
163
|
+
record.qrcode = login.qrcode;
|
|
164
|
+
record.verificationUrl = login.qrcodeUrl;
|
|
165
|
+
record.state = 'pending';
|
|
166
|
+
record.expiresAt = Date.now() + QR_TTL_MS;
|
|
167
|
+
this.#touch();
|
|
168
|
+
record.task = this.#runProvisioning(record);
|
|
169
|
+
return publicAttempt(record);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
if (record.controller.signal.aborted) {
|
|
172
|
+
record.state = 'cancelled';
|
|
173
|
+
record.error = safeAccountError('cancelled', '扫码绑定已取消。');
|
|
174
|
+
} else {
|
|
175
|
+
record.state = 'failed';
|
|
176
|
+
record.error = safeAccountError(
|
|
177
|
+
error instanceof WeixinApiError ? error.code : 'qr-start-failed',
|
|
178
|
+
error instanceof WeixinApiError ? error.message : '无法生成微信二维码,请稍后重试。',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
182
|
+
this.#touch();
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
registrationStatus(attemptId) {
|
|
188
|
+
return publicAttempt(this.#attempts.get(attemptId));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async submitVerification(attemptId, verifyCode) {
|
|
192
|
+
const record = this.#attempts.get(attemptId);
|
|
193
|
+
if (!record || record.state !== 'needs_verification') {
|
|
194
|
+
throw new Error('The provisioning attempt is not waiting for a verification code');
|
|
195
|
+
}
|
|
196
|
+
const code = cleanString(verifyCode);
|
|
197
|
+
if (!code || !/^\d{4,8}$/.test(code)) {
|
|
198
|
+
throw new TypeError('Verification code must contain 4 to 8 digits');
|
|
199
|
+
}
|
|
200
|
+
record.pendingVerifyCode = code;
|
|
201
|
+
record.state = 'scanned';
|
|
202
|
+
record.verifyResolve?.();
|
|
203
|
+
record.verifyResolve = null;
|
|
204
|
+
this.#touch();
|
|
205
|
+
return publicAttempt(record);
|
|
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
|
+
record.verifyResolve?.();
|
|
214
|
+
record.verifyResolve = null;
|
|
215
|
+
await record.task?.catch(() => undefined);
|
|
216
|
+
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
|
217
|
+
record.error ??= safeAccountError('cancelled', '扫码绑定已取消。');
|
|
218
|
+
}
|
|
219
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
220
|
+
this.#touch();
|
|
221
|
+
return publicAttempt(record);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async reconnectBot(botId) {
|
|
225
|
+
const config = this.#configStore.get(botId);
|
|
226
|
+
if (!config) throw new Error('Unknown Weixin account');
|
|
227
|
+
await this.#withBotTransition(botId, async () => {
|
|
228
|
+
const token = await this.#resolveToken(config.tokenRef);
|
|
229
|
+
if (!token) throw new Error('The Weixin token is missing');
|
|
230
|
+
try {
|
|
231
|
+
await this.#startRuntime(config, token);
|
|
232
|
+
this.#errors.delete(botId);
|
|
233
|
+
} catch (error) {
|
|
234
|
+
this.#errors.set(botId, safeAccountError('connection-failed', '微信连接仍未就绪,请稍后重试。'));
|
|
235
|
+
throw error;
|
|
236
|
+
} finally {
|
|
237
|
+
this.#touch();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
return this.status();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async deleteBot(botId) {
|
|
244
|
+
const config = this.#configStore.get(botId);
|
|
245
|
+
if (!config) throw new Error('Unknown Weixin account');
|
|
246
|
+
await this.#withBotTransition(botId, async () => {
|
|
247
|
+
const previousToken = await this.#credentials.resolve(config.tokenRef).catch(() => undefined);
|
|
248
|
+
await this.#stopRuntime(botId);
|
|
249
|
+
try {
|
|
250
|
+
await this.#credentials.unset(config.tokenRef);
|
|
251
|
+
await this.#configStore.remove(botId);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (previousToken?.value) {
|
|
254
|
+
await this.#credentials.set(config.tokenRef, previousToken.value).catch(() => undefined);
|
|
255
|
+
await this.#startRuntime(config, previousToken.value).catch(() => undefined);
|
|
256
|
+
}
|
|
257
|
+
throw new Error('Unable to remove the Weixin account safely.', { cause: error });
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
await this.#deleteState({ botId, config });
|
|
261
|
+
} catch (error) {
|
|
262
|
+
this.#logger.warn?.(`[dsh-weixin] account ${botId} state cleanup failed:`, error);
|
|
263
|
+
}
|
|
264
|
+
this.#errors.delete(botId);
|
|
265
|
+
this.#touch();
|
|
266
|
+
});
|
|
267
|
+
return this.status();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
status() {
|
|
271
|
+
const accounts = this.#configStore.list().map((config) => {
|
|
272
|
+
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
273
|
+
const connected = runtimeStatus?.ready === true
|
|
274
|
+
&& runtimeStatus.weixinConnectionState === 'connected'
|
|
275
|
+
&& runtimeStatus.harnessReachable === true;
|
|
276
|
+
const state = connected
|
|
277
|
+
? 'connected'
|
|
278
|
+
: runtimeStatus?.weixinConnectionState === 'connecting'
|
|
279
|
+
? 'connecting'
|
|
280
|
+
: this.#errors.has(config.botId) || runtimeStatus?.weixinConnectionState === 'failed'
|
|
281
|
+
? 'error'
|
|
282
|
+
: 'offline';
|
|
283
|
+
const error = this.#errors.get(config.botId) ?? (state === 'error'
|
|
284
|
+
? safeAccountError('connection-failed', '微信连接未就绪,插件会自动重试。')
|
|
285
|
+
: null);
|
|
286
|
+
return {
|
|
287
|
+
botId: config.botId,
|
|
288
|
+
state,
|
|
289
|
+
connected,
|
|
290
|
+
configured: true,
|
|
291
|
+
bot: {
|
|
292
|
+
name: '微信机器人',
|
|
293
|
+
accountIdMasked: maskWeixinAccountId(config.accountId),
|
|
294
|
+
},
|
|
295
|
+
health: {
|
|
296
|
+
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
297
|
+
summary: connected
|
|
298
|
+
? '微信消息长轮询运行正常'
|
|
299
|
+
: state === 'error'
|
|
300
|
+
? '微信连接未就绪,插件会自动重试'
|
|
301
|
+
: '微信连接当前离线',
|
|
302
|
+
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
303
|
+
},
|
|
304
|
+
stats: {
|
|
305
|
+
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
306
|
+
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
307
|
+
},
|
|
308
|
+
error: error ? structuredClone(error) : null,
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
const connectedCount = accounts.filter((account) => account.connected).length;
|
|
312
|
+
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
|
313
|
+
return {
|
|
314
|
+
schemaVersion: 1,
|
|
315
|
+
revision: this.#revision,
|
|
316
|
+
state: active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
317
|
+
? 'provisioning'
|
|
318
|
+
: accounts.length === 0
|
|
319
|
+
? 'disconnected'
|
|
320
|
+
: connectedCount === accounts.length
|
|
321
|
+
? 'connected'
|
|
322
|
+
: connectedCount > 0
|
|
323
|
+
? 'degraded'
|
|
324
|
+
: 'offline',
|
|
325
|
+
bots: accounts,
|
|
326
|
+
totals: { configured: accounts.length, connected: connectedCount },
|
|
327
|
+
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
|
328
|
+
? { provisioning: publicAttempt(active) }
|
|
329
|
+
: {}),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async close() {
|
|
334
|
+
if (this.#closed) return;
|
|
335
|
+
this.#closed = true;
|
|
336
|
+
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
337
|
+
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async #runProvisioning(record) {
|
|
341
|
+
try {
|
|
342
|
+
while (!record.controller.signal.aborted && Date.now() < record.expiresAt) {
|
|
343
|
+
if (record.state === 'needs_verification' && !record.pendingVerifyCode) {
|
|
344
|
+
await new Promise((resolve) => {
|
|
345
|
+
record.verifyResolve = resolve;
|
|
346
|
+
if (record.controller.signal.aborted) resolve();
|
|
347
|
+
});
|
|
348
|
+
record.verifyResolve = null;
|
|
349
|
+
this.#assertAttemptActive(record);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const response = await this.#api.pollLogin({
|
|
353
|
+
qrcode: record.qrcode,
|
|
354
|
+
baseUrl: record.currentBaseUrl,
|
|
355
|
+
verifyCode: record.pendingVerifyCode,
|
|
356
|
+
signal: record.controller.signal,
|
|
357
|
+
});
|
|
358
|
+
this.#assertAttemptActive(record);
|
|
359
|
+
if (response.status === 'wait') {
|
|
360
|
+
record.state = 'pending';
|
|
361
|
+
} else if (response.status === 'scaned') {
|
|
362
|
+
record.pendingVerifyCode = null;
|
|
363
|
+
record.state = 'scanned';
|
|
364
|
+
} else if (response.status === 'need_verifycode') {
|
|
365
|
+
record.pendingVerifyCode = null;
|
|
366
|
+
record.state = 'needs_verification';
|
|
367
|
+
} else if (response.status === 'verify_code_blocked') {
|
|
368
|
+
record.state = 'failed';
|
|
369
|
+
record.error = safeAccountError('verification-blocked', '配对码多次错误,请重新生成二维码。');
|
|
370
|
+
break;
|
|
371
|
+
} else if (response.status === 'expired') {
|
|
372
|
+
record.state = 'expired';
|
|
373
|
+
record.error = safeAccountError('expired', '二维码已过期,请重新生成。');
|
|
374
|
+
break;
|
|
375
|
+
} else if (response.status === 'scaned_but_redirect') {
|
|
376
|
+
record.currentBaseUrl = apiBaseFromServer(response.redirect_host, record.currentBaseUrl);
|
|
377
|
+
record.state = 'scanned';
|
|
378
|
+
} else if (response.status === 'binded_redirect') {
|
|
379
|
+
const existing = this.#configStore.list().find(
|
|
380
|
+
(config) => this.#runtimes.get(config.botId)?.status?.ready === true,
|
|
381
|
+
) ?? this.#configStore.list()[0];
|
|
382
|
+
if (!existing) {
|
|
383
|
+
record.state = 'failed';
|
|
384
|
+
record.error = safeAccountError('already-bound', '该微信账号已绑定,但本机没有可恢复的凭据。');
|
|
385
|
+
} else {
|
|
386
|
+
record.state = 'connected';
|
|
387
|
+
record.botId = existing.botId;
|
|
388
|
+
record.alreadyConnected = true;
|
|
389
|
+
}
|
|
390
|
+
break;
|
|
391
|
+
} else if (response.status === 'confirmed') {
|
|
392
|
+
const token = cleanString(response.bot_token);
|
|
393
|
+
const accountId = cleanString(response.ilink_bot_id);
|
|
394
|
+
const ownerUserId = cleanString(response.ilink_user_id);
|
|
395
|
+
if (!token || !accountId || !ownerUserId) {
|
|
396
|
+
throw new WeixinApiError('incomplete-login', '微信授权成功,但返回的账号凭据不完整。');
|
|
397
|
+
}
|
|
398
|
+
record.state = 'connecting';
|
|
399
|
+
this.#touch();
|
|
400
|
+
const baseUrl = apiBaseFromServer(response.baseurl, record.currentBaseUrl);
|
|
401
|
+
record.botId = await this.#activateAccount(record, {
|
|
402
|
+
token,
|
|
403
|
+
accountId,
|
|
404
|
+
ownerUserId,
|
|
405
|
+
baseUrl,
|
|
406
|
+
});
|
|
407
|
+
record.state = 'connected';
|
|
408
|
+
record.error = null;
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
this.#touch();
|
|
412
|
+
}
|
|
413
|
+
if (!record.controller.signal.aborted && Date.now() >= record.expiresAt
|
|
414
|
+
&& !TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
415
|
+
record.state = 'expired';
|
|
416
|
+
record.error = safeAccountError('expired', '二维码已过期,请重新生成。');
|
|
417
|
+
}
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
|
420
|
+
record.state = 'cancelled';
|
|
421
|
+
record.error = safeAccountError('cancelled', '扫码绑定已取消。');
|
|
422
|
+
} else {
|
|
423
|
+
record.state = 'failed';
|
|
424
|
+
record.error = safeAccountError(
|
|
425
|
+
error instanceof WeixinApiError ? error.code : 'activation-failed',
|
|
426
|
+
error instanceof WeixinApiError
|
|
427
|
+
? error.message
|
|
428
|
+
: '微信已授权,但无法保存凭据或启动消息连接。',
|
|
429
|
+
);
|
|
430
|
+
this.#logger.error?.('[dsh-weixin] provisioning failed:', error);
|
|
431
|
+
}
|
|
432
|
+
} finally {
|
|
433
|
+
record.pendingVerifyCode = null;
|
|
434
|
+
record.verifyResolve?.();
|
|
435
|
+
record.verifyResolve = null;
|
|
436
|
+
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
437
|
+
this.#touch();
|
|
438
|
+
this.#pruneAttempts();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async #activateAccount(record, { token, accountId, ownerUserId, baseUrl }) {
|
|
443
|
+
const identity = deriveWeixinBotIdentity(accountId);
|
|
444
|
+
const previousConfig = this.#configStore.getByAccountId(accountId);
|
|
445
|
+
const config = {
|
|
446
|
+
botId: identity.botId,
|
|
447
|
+
accountId,
|
|
448
|
+
tokenRef: identity.tokenRef,
|
|
449
|
+
ownerUserId,
|
|
450
|
+
baseUrl,
|
|
451
|
+
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
452
|
+
connectedAt: new Date().toISOString(),
|
|
453
|
+
};
|
|
454
|
+
const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined);
|
|
455
|
+
|
|
456
|
+
return this.#withBotTransition(identity.botId, async () => {
|
|
457
|
+
await this.#credentials.set(identity.tokenRef, token);
|
|
458
|
+
try {
|
|
459
|
+
this.#assertAttemptActive(record);
|
|
460
|
+
await this.#configStore.save(config);
|
|
461
|
+
this.#assertAttemptActive(record);
|
|
462
|
+
await this.#startRuntime(config, token);
|
|
463
|
+
this.#assertAttemptActive(record);
|
|
464
|
+
this.#errors.delete(identity.botId);
|
|
465
|
+
this.#touch();
|
|
466
|
+
return identity.botId;
|
|
467
|
+
} catch (error) {
|
|
468
|
+
await this.#stopRuntime(identity.botId);
|
|
469
|
+
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
470
|
+
else if (this.#configStore.get(identity.botId)) {
|
|
471
|
+
await this.#configStore.remove(identity.botId).catch(() => undefined);
|
|
472
|
+
}
|
|
473
|
+
await this.#restoreCredential(identity.tokenRef, previousToken);
|
|
474
|
+
if (previousConfig && previousToken?.value) {
|
|
475
|
+
await this.#startRuntime(previousConfig, previousToken.value).catch(() => undefined);
|
|
476
|
+
}
|
|
477
|
+
throw error;
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async #startRuntime(config, token) {
|
|
483
|
+
await this.#stopRuntime(config.botId);
|
|
484
|
+
const runtime = await this.#createRuntime({ botId: config.botId, config, token });
|
|
485
|
+
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
486
|
+
throw new TypeError('createRuntime returned an invalid Weixin runtime');
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
await runtime.start();
|
|
490
|
+
this.#runtimes.set(config.botId, runtime);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
await runtime.stop().catch(() => undefined);
|
|
493
|
+
throw error;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async #stopRuntime(botId) {
|
|
498
|
+
const runtime = this.#runtimes.get(botId);
|
|
499
|
+
this.#runtimes.delete(botId);
|
|
500
|
+
await runtime?.stop().catch((error) => {
|
|
501
|
+
this.#logger.warn?.(`[dsh-weixin] account ${botId} failed to stop cleanly:`, error);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async #resolveToken(ref) {
|
|
506
|
+
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
|
507
|
+
return cleanString(result?.value);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async #restoreCredential(ref, previous) {
|
|
511
|
+
try {
|
|
512
|
+
if (previous?.value) await this.#credentials.set(ref, previous.value);
|
|
513
|
+
else await this.#credentials.unset(ref);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
this.#logger.error?.(`[dsh-weixin] failed to restore credential ${ref}:`, error);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#assertAttemptActive(record) {
|
|
520
|
+
if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
#withBotTransition(botId, operation) {
|
|
524
|
+
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
|
525
|
+
const current = previous.catch(() => undefined).then(operation);
|
|
526
|
+
const settled = current.finally(() => {
|
|
527
|
+
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
|
528
|
+
});
|
|
529
|
+
this.#transitions.set(botId, settled);
|
|
530
|
+
return settled;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
#pruneAttempts() {
|
|
534
|
+
for (const [id, record] of this.#attempts) {
|
|
535
|
+
if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES.has(record.state)
|
|
536
|
+
&& this.#attempts.size > 16) {
|
|
537
|
+
this.#attempts.delete(id);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#touch() {
|
|
543
|
+
this.#revision += 1;
|
|
544
|
+
}
|
|
545
|
+
}
|