@xmanrui/dsh-im 4.19.2 → 4.20.1
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/README.en.md +79 -1
- package/README.md +79 -1
- package/lib/client.js +594 -217
- package/lib/index.js +279 -276
- package/package.json +9 -1
- package/plugin-src/client/channels/weixin/api.js +35 -17
- package/plugin-src/client/channels/weixin/connection-error.js +68 -0
- package/plugin-src/client/channels/weixin/index.js +36 -12
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/host/channels/shared/startup.mjs +7 -4
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
- package/plugin-src/host/channels/weixin/index.mjs +12 -3
- package/plugin-src/host/channels/weixin/production.mjs +53 -3
- package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
- package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
- package/src/channels/feishu/bridge.mjs +44 -13
- package/src/channels/qq/qq-bridge.mjs +12 -4
- package/src/channels/qq/qq-menu.mjs +11 -8
- package/src/channels/shared/bot-workspace-store.mjs +532 -40
- package/src/channels/shared/command-catalog.mjs +5 -0
- package/src/channels/shared/compact-command.mjs +14 -4
- package/src/channels/shared/control-command.mjs +1 -1
- package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
- package/src/channels/shared/history-command.mjs +1 -1
- package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
- package/src/channels/shared/i18n-en/wecom.mjs +1 -1
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/model-command.mjs +5 -3
- package/src/channels/shared/workspace-command.mjs +114 -9
- package/src/channels/shared/workspace-session.mjs +55 -5
- package/src/channels/telegram/telegram-runtime.mjs +1 -1
- package/src/channels/wecom/wecom-bridge.mjs +14 -46
- package/src/channels/wecom/wecom-runtime.mjs +1 -1
- package/src/channels/weixin/connection-error.en.mjs +116 -0
- package/src/channels/weixin/connection-error.mjs +204 -0
- package/src/channels/weixin/diagnostic-details.mjs +40 -0
- package/src/channels/weixin/state-store.mjs +4 -3
- package/src/channels/weixin/weixin-api.mjs +20 -8
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-controller.mjs +133 -104
- package/src/channels/weixin/weixin-runtime.mjs +35 -24
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createWeixinDiagnostics, knownWeixinErrorCode, weixinStageError } from './connection-error.mjs';
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
normalizeWeixinApiBaseUrl,
|
|
@@ -22,26 +23,6 @@ const ACTIVE_ATTEMPT_STATES = new Set([
|
|
|
22
23
|
]);
|
|
23
24
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
|
|
24
25
|
const QR_TTL_MS = 5 * 60_000;
|
|
25
|
-
const ACTIVATION_ERROR_MESSAGES = Object.freeze({
|
|
26
|
-
'credential-read-failed': '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。',
|
|
27
|
-
'credential-save-failed': '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
|
|
28
|
-
'account-config-save-failed': '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
|
|
29
|
-
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
|
|
30
|
-
'harness-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
|
|
31
|
-
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
|
32
|
-
'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
|
|
33
|
-
'harness-proxy-auth-required': '微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
|
|
34
|
-
'harness-loopback-forbidden': '微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
|
|
35
|
-
'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
|
|
36
|
-
'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
|
|
37
|
-
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
|
38
|
-
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
|
|
39
|
-
'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
|
40
|
-
'harness-rpc-rejected': '微信已授权,但 Harness 拒绝了健康检查请求。请查看 dsh web 日志。',
|
|
41
|
-
'harness-check-unknown-failed': '微信已授权,但 Harness 健康检查发生未知错误。请查看 dsh web 日志。',
|
|
42
|
-
'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
|
|
43
|
-
});
|
|
44
|
-
|
|
45
26
|
function cleanString(value) {
|
|
46
27
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
47
28
|
}
|
|
@@ -75,24 +56,8 @@ function safeAccountError(code, message) {
|
|
|
75
56
|
return Object.freeze({ code, message });
|
|
76
57
|
}
|
|
77
58
|
|
|
78
|
-
function activationStageError(code, cause) {
|
|
79
|
-
const error = new Error(`Weixin activation failed during ${code}`, { cause });
|
|
80
|
-
error.name = 'WeixinActivationStageError';
|
|
81
|
-
error.code = code;
|
|
82
|
-
return error;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function publicProvisioningError(error) {
|
|
86
|
-
if (error instanceof WeixinApiError) return safeAccountError(error.code, error.message);
|
|
87
|
-
const message = ACTIVATION_ERROR_MESSAGES[error?.code];
|
|
88
|
-
return message
|
|
89
|
-
? safeAccountError(error.code, t(message))
|
|
90
|
-
: safeAccountError('activation-unknown-failed', t('微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。'));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
59
|
function preserveActivationError(error, fallbackCode) {
|
|
94
|
-
|
|
95
|
-
return activationStageError(fallbackCode, error);
|
|
60
|
+
return knownWeixinErrorCode(error?.code) ? error : weixinStageError(fallbackCode, error);
|
|
96
61
|
}
|
|
97
62
|
|
|
98
63
|
export class WeixinController {
|
|
@@ -101,7 +66,7 @@ export class WeixinController {
|
|
|
101
66
|
#configStore;
|
|
102
67
|
#createRuntime;
|
|
103
68
|
#deleteState;
|
|
104
|
-
#
|
|
69
|
+
#diagnostics;
|
|
105
70
|
#runtimes = new Map();
|
|
106
71
|
#errors = new Map();
|
|
107
72
|
#attempts = new Map();
|
|
@@ -117,6 +82,7 @@ export class WeixinController {
|
|
|
117
82
|
createRuntime,
|
|
118
83
|
deleteState = async () => {},
|
|
119
84
|
logger = console,
|
|
85
|
+
diagnostics,
|
|
120
86
|
}) {
|
|
121
87
|
if (!api || typeof api.beginLogin !== 'function' || typeof api.pollLogin !== 'function') {
|
|
122
88
|
throw new TypeError('WeixinController requires a Weixin API client');
|
|
@@ -139,7 +105,7 @@ export class WeixinController {
|
|
|
139
105
|
this.#configStore = configStore;
|
|
140
106
|
this.#createRuntime = createRuntime;
|
|
141
107
|
this.#deleteState = deleteState;
|
|
142
|
-
this.#
|
|
108
|
+
this.#diagnostics = diagnostics ?? createWeixinDiagnostics({ logger });
|
|
143
109
|
}
|
|
144
110
|
|
|
145
111
|
async initialize() {
|
|
@@ -152,21 +118,14 @@ export class WeixinController {
|
|
|
152
118
|
if (!latest || this.#closed) return;
|
|
153
119
|
try {
|
|
154
120
|
const token = await this.#resolveToken(latest.tokenRef);
|
|
155
|
-
if (!token)
|
|
156
|
-
|
|
157
|
-
latest.botId,
|
|
158
|
-
safeAccountError('missing-token', t('登录凭据缺失,请移除账号后重新扫码。')),
|
|
159
|
-
);
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
await this.#startRuntime(latest, token);
|
|
121
|
+
if (!token) throw weixinStageError('missing-token');
|
|
122
|
+
await this.#startRuntime(latest, token, { operation: 'connection.restore', automatic: true });
|
|
163
123
|
this.#errors.delete(latest.botId);
|
|
124
|
+
this.#diagnostics.clear(latest.botId);
|
|
164
125
|
} catch (error) {
|
|
165
|
-
this.#errors.set(
|
|
166
|
-
latest.botId,
|
|
167
|
-
|
|
168
|
-
);
|
|
169
|
-
this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error);
|
|
126
|
+
this.#errors.set(latest.botId, this.#diagnostics.report(error, {
|
|
127
|
+
operation: 'connection.restore', stage: 'connection.start', botId: latest.botId, automatic: true,
|
|
128
|
+
}).publicError);
|
|
170
129
|
} finally {
|
|
171
130
|
this.#touch();
|
|
172
131
|
}
|
|
@@ -175,6 +134,17 @@ export class WeixinController {
|
|
|
175
134
|
return this.status();
|
|
176
135
|
}
|
|
177
136
|
|
|
137
|
+
reportRestoreFailure(error) {
|
|
138
|
+
for (const config of this.#configStore.list()) {
|
|
139
|
+
if (this.#runtimes.get(config.botId)?.status?.ready) continue;
|
|
140
|
+
this.#errors.set(config.botId, this.#diagnostics.report(error, {
|
|
141
|
+
operation: 'connection.restore', stage: 'harness.check', code: 'harness-check-unknown-failed',
|
|
142
|
+
botId: config.botId, automatic: true,
|
|
143
|
+
}).publicError);
|
|
144
|
+
}
|
|
145
|
+
this.#touch();
|
|
146
|
+
}
|
|
147
|
+
|
|
178
148
|
async startProvisioning() {
|
|
179
149
|
if (this.#closed) throw new Error('dsh-weixin controller is closed');
|
|
180
150
|
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
@@ -198,7 +168,13 @@ export class WeixinController {
|
|
|
198
168
|
|
|
199
169
|
try {
|
|
200
170
|
const localTokens = (await Promise.all(
|
|
201
|
-
this.#configStore.list().slice(-10).map(async (config) =>
|
|
171
|
+
this.#configStore.list().slice(-10).map(async (config) => {
|
|
172
|
+
try { return await this.#resolveToken(config.tokenRef); }
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.#diagnostics.report(error, { operation: 'provision.begin', botId: config.botId, warning: true });
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
}),
|
|
202
178
|
)).filter(Boolean);
|
|
203
179
|
const login = await this.#api.beginLogin({
|
|
204
180
|
localTokens,
|
|
@@ -218,10 +194,8 @@ export class WeixinController {
|
|
|
218
194
|
record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
|
|
219
195
|
} else {
|
|
220
196
|
record.state = 'failed';
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
error instanceof WeixinApiError ? error.message : t('无法生成微信二维码,请稍后重试。'),
|
|
224
|
-
);
|
|
197
|
+
error = this.#diagnostics.report(error, { operation: 'provision.begin', stage: 'qr.begin', code: 'qr-start-failed' });
|
|
198
|
+
record.error = error.publicError;
|
|
225
199
|
}
|
|
226
200
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
227
201
|
this.#touch();
|
|
@@ -236,7 +210,7 @@ export class WeixinController {
|
|
|
236
210
|
async submitVerification(attemptId, verifyCode) {
|
|
237
211
|
const record = this.#attempts.get(attemptId);
|
|
238
212
|
if (!record || record.state !== 'needs_verification') {
|
|
239
|
-
throw
|
|
213
|
+
throw weixinStageError(record ? 'provision-state-invalid' : 'provision-attempt-not-found', undefined, 'qr.verify');
|
|
240
214
|
}
|
|
241
215
|
const code = cleanString(verifyCode);
|
|
242
216
|
if (!code || !/^\d{4,8}$/.test(code)) {
|
|
@@ -270,14 +244,16 @@ export class WeixinController {
|
|
|
270
244
|
const config = this.#configStore.get(botId);
|
|
271
245
|
if (!config) throw new Error('Unknown Weixin account');
|
|
272
246
|
await this.#withBotTransition(botId, async () => {
|
|
273
|
-
const token = await this.#resolveToken(config.tokenRef);
|
|
274
|
-
if (!token) throw new Error('The Weixin token is missing');
|
|
275
247
|
try {
|
|
276
|
-
await this.#
|
|
248
|
+
const token = await this.#resolveToken(config.tokenRef);
|
|
249
|
+
if (!token) throw weixinStageError('missing-token');
|
|
250
|
+
await this.#startRuntime(config, token, { operation: 'bot.reconnect' });
|
|
277
251
|
this.#errors.delete(botId);
|
|
252
|
+
this.#diagnostics.clear(botId);
|
|
278
253
|
} catch (error) {
|
|
279
|
-
this.#
|
|
280
|
-
|
|
254
|
+
const failure = this.#diagnostics.report(error, { operation: 'bot.reconnect', stage: 'connection.start', botId });
|
|
255
|
+
this.#errors.set(botId, failure.publicError);
|
|
256
|
+
throw failure;
|
|
281
257
|
} finally {
|
|
282
258
|
this.#touch();
|
|
283
259
|
}
|
|
@@ -315,29 +291,53 @@ export class WeixinController {
|
|
|
315
291
|
|
|
316
292
|
async deleteBot(botId) {
|
|
317
293
|
const config = this.#configStore.get(botId);
|
|
318
|
-
if (!config) throw
|
|
294
|
+
if (!config) throw weixinStageError('workspace-bot-not-found', undefined, 'account.remove');
|
|
295
|
+
const warnings = [];
|
|
296
|
+
const context = { operation: 'bot.delete', botId };
|
|
319
297
|
await this.#withBotTransition(botId, async () => {
|
|
320
|
-
const previousToken = await this.#credentials.resolve(config.tokenRef).catch(
|
|
321
|
-
|
|
298
|
+
const previousToken = await this.#credentials.resolve(config.tokenRef).catch(error => {
|
|
299
|
+
warnings.push(this.#diagnostics.report(weixinStageError('credential-read-failed', error), { ...context, warning: true }).publicError);
|
|
300
|
+
});
|
|
301
|
+
warnings.push(...await this.#stopRuntime(botId));
|
|
302
|
+
let stage = 'credential-remove-failed';
|
|
322
303
|
try {
|
|
323
|
-
await this.#
|
|
304
|
+
await this.#writeCredential(config.tokenRef, undefined);
|
|
305
|
+
stage = 'account-config-remove-failed';
|
|
324
306
|
await this.#configStore.remove(botId);
|
|
325
307
|
} catch (error) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
308
|
+
// A config removal observer may throw after the account was durably removed.
|
|
309
|
+
if (stage === 'account-config-remove-failed' && !this.#configStore.get(botId)) {
|
|
310
|
+
warnings.push(this.#diagnostics.report(weixinStageError('workspace-cleanup-failed', error), { ...context, warning: true }).publicError);
|
|
311
|
+
} else {
|
|
312
|
+
const failure = this.#diagnostics.report(weixinStageError(stage, error), context);
|
|
313
|
+
let rollback = previousToken?.value ? 'succeeded' : 'unknown';
|
|
314
|
+
if (previousToken?.value) {
|
|
315
|
+
for (const restore of [
|
|
316
|
+
() => this.#credentials.set(config.tokenRef, previousToken.value),
|
|
317
|
+
() => this.#startRuntime(config, previousToken.value, context),
|
|
318
|
+
]) {
|
|
319
|
+
try { await restore(); }
|
|
320
|
+
catch (restoreError) {
|
|
321
|
+
rollback = 'failed';
|
|
322
|
+
this.#diagnostics.report(weixinStageError('rollback-failed', restoreError), { ...context, warning: true, parentReferenceId: failure.publicError.details.referenceId });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (rollback === 'succeeded' && !this.#runtimes.get(botId)?.status?.ready) rollback = 'unknown';
|
|
327
|
+
this.#diagnostics.outcome(failure, rollback);
|
|
328
|
+
throw failure;
|
|
329
329
|
}
|
|
330
|
-
throw new Error('Unable to remove the Weixin account safely.', { cause: error });
|
|
331
330
|
}
|
|
332
331
|
try {
|
|
333
332
|
await this.#deleteState({ botId, config });
|
|
334
333
|
} catch (error) {
|
|
335
|
-
this.#
|
|
334
|
+
warnings.push(this.#diagnostics.report(weixinStageError('account-state-cleanup-failed', error), { ...context, warning: true }).publicError);
|
|
336
335
|
}
|
|
337
336
|
this.#errors.delete(botId);
|
|
337
|
+
this.#diagnostics.clear(botId);
|
|
338
338
|
this.#touch();
|
|
339
339
|
});
|
|
340
|
-
return this.status();
|
|
340
|
+
return { ...this.status(), ...(warnings.length ? { warnings } : {}) };
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
status() {
|
|
@@ -353,7 +353,7 @@ export class WeixinController {
|
|
|
353
353
|
: this.#errors.has(config.botId) || runtimeStatus?.weixinConnectionState === 'failed'
|
|
354
354
|
? 'error'
|
|
355
355
|
: 'offline';
|
|
356
|
-
const error = this.#errors.get(config.botId) ?? (state === 'error'
|
|
356
|
+
const error = connected ? null : this.#errors.get(config.botId) ?? runtimeStatus?.connectionError ?? (state === 'error'
|
|
357
357
|
? safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。'))
|
|
358
358
|
: null);
|
|
359
359
|
return {
|
|
@@ -409,6 +409,7 @@ export class WeixinController {
|
|
|
409
409
|
this.#closed = true;
|
|
410
410
|
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
411
411
|
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
|
412
|
+
this.#diagnostics.clear();
|
|
412
413
|
}
|
|
413
414
|
|
|
414
415
|
async #runProvisioning(record) {
|
|
@@ -494,9 +495,12 @@ export class WeixinController {
|
|
|
494
495
|
record.state = 'cancelled';
|
|
495
496
|
record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
|
|
496
497
|
} else {
|
|
498
|
+
const failedStage = record.state === 'connecting' ? 'activation' : 'qr.poll';
|
|
497
499
|
record.state = 'failed';
|
|
498
|
-
record.error =
|
|
499
|
-
|
|
500
|
+
record.error = this.#diagnostics.report(error, {
|
|
501
|
+
operation: 'provision.poll', stage: failedStage,
|
|
502
|
+
code: 'activation-unknown-failed', rollback: error?.rollback,
|
|
503
|
+
}).publicError;
|
|
500
504
|
}
|
|
501
505
|
} finally {
|
|
502
506
|
record.pendingVerifyCode = null;
|
|
@@ -524,49 +528,64 @@ export class WeixinController {
|
|
|
524
528
|
try {
|
|
525
529
|
previousToken = await this.#credentials.resolve(identity.tokenRef);
|
|
526
530
|
} catch (error) {
|
|
527
|
-
throw
|
|
531
|
+
throw weixinStageError('credential-read-failed', error);
|
|
528
532
|
}
|
|
529
533
|
|
|
530
534
|
return this.#withBotTransition(identity.botId, async () => {
|
|
531
535
|
try {
|
|
532
536
|
try {
|
|
533
|
-
await this.#
|
|
537
|
+
await this.#writeCredential(identity.tokenRef, token);
|
|
534
538
|
} catch (error) {
|
|
535
|
-
throw
|
|
539
|
+
throw weixinStageError('credential-save-failed', error);
|
|
536
540
|
}
|
|
537
541
|
this.#assertAttemptActive(record);
|
|
538
542
|
try {
|
|
539
543
|
await this.#configStore.save(config);
|
|
540
544
|
} catch (error) {
|
|
541
|
-
throw
|
|
545
|
+
throw weixinStageError('account-config-save-failed', error);
|
|
542
546
|
}
|
|
543
547
|
this.#assertAttemptActive(record);
|
|
544
|
-
await this.#startRuntime(config, token);
|
|
548
|
+
await this.#startRuntime(config, token, { operation: 'provision.poll' });
|
|
545
549
|
this.#assertAttemptActive(record);
|
|
546
550
|
this.#errors.delete(identity.botId);
|
|
547
551
|
this.#touch();
|
|
548
552
|
return identity.botId;
|
|
549
553
|
} catch (error) {
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
554
|
+
const cancelled = record.controller.signal.aborted || error?.name === 'AbortError';
|
|
555
|
+
const failure = cancelled ? error : this.#diagnostics.report(error, {
|
|
556
|
+
operation: 'provision.poll', stage: 'activation', code: 'activation-unknown-failed', botId: identity.botId,
|
|
557
|
+
});
|
|
558
|
+
let rollback = 'succeeded';
|
|
559
|
+
const recover = async (action) => {
|
|
560
|
+
try { return await action(); }
|
|
561
|
+
catch (restoreError) {
|
|
562
|
+
rollback = 'failed';
|
|
563
|
+
this.#diagnostics.report(weixinStageError('rollback-failed', restoreError), {
|
|
564
|
+
operation: cancelled ? 'provision.cancel' : 'provision.poll', botId: identity.botId, warning: true,
|
|
565
|
+
parentReferenceId: failure.publicError?.details.referenceId,
|
|
557
566
|
});
|
|
558
567
|
}
|
|
568
|
+
};
|
|
569
|
+
await this.#stopRuntime(identity.botId);
|
|
570
|
+
if (previousConfig) await recover(() => this.#configStore.save(previousConfig));
|
|
571
|
+
else if (this.#configStore.get(identity.botId)) {
|
|
572
|
+
const removed = await recover(() => this.#configStore.remove(identity.botId));
|
|
573
|
+
if (removed) await recover(() => this.#deleteState({ botId: identity.botId, config }));
|
|
559
574
|
}
|
|
560
|
-
await
|
|
575
|
+
await recover(() => previousToken?.value
|
|
576
|
+
? this.#credentials.set(identity.tokenRef, previousToken.value)
|
|
577
|
+
: this.#credentials.unset(identity.tokenRef));
|
|
561
578
|
if (previousConfig && previousToken?.value) {
|
|
562
|
-
await this.#startRuntime(previousConfig, previousToken.value
|
|
579
|
+
await recover(() => this.#startRuntime(previousConfig, previousToken.value, { operation: 'provision.poll' }));
|
|
580
|
+
if (rollback === 'succeeded' && !this.#runtimes.get(identity.botId)?.status?.ready) rollback = 'unknown';
|
|
563
581
|
}
|
|
564
|
-
|
|
582
|
+
this.#diagnostics.outcome(failure, rollback);
|
|
583
|
+
throw failure;
|
|
565
584
|
}
|
|
566
585
|
});
|
|
567
586
|
}
|
|
568
587
|
|
|
569
|
-
async #startRuntime(config, token) {
|
|
588
|
+
async #startRuntime(config, token, context = {}) {
|
|
570
589
|
await this.#stopRuntime(config.botId);
|
|
571
590
|
let runtime;
|
|
572
591
|
try {
|
|
@@ -575,13 +594,13 @@ export class WeixinController {
|
|
|
575
594
|
throw preserveActivationError(error, 'runtime-prepare-failed');
|
|
576
595
|
}
|
|
577
596
|
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
578
|
-
throw
|
|
597
|
+
throw weixinStageError(
|
|
579
598
|
'runtime-prepare-failed',
|
|
580
599
|
new TypeError('createRuntime returned an invalid Weixin runtime'),
|
|
581
600
|
);
|
|
582
601
|
}
|
|
583
602
|
try {
|
|
584
|
-
await runtime.start();
|
|
603
|
+
await runtime.start({ ...context, botId: config.botId });
|
|
585
604
|
this.#runtimes.set(config.botId, runtime);
|
|
586
605
|
} catch (error) {
|
|
587
606
|
await runtime.stop().catch(() => undefined);
|
|
@@ -592,22 +611,32 @@ export class WeixinController {
|
|
|
592
611
|
async #stopRuntime(botId) {
|
|
593
612
|
const runtime = this.#runtimes.get(botId);
|
|
594
613
|
this.#runtimes.delete(botId);
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
614
|
+
try {
|
|
615
|
+
const stopped = await runtime?.stop();
|
|
616
|
+
return stopped?.warnings ?? [];
|
|
617
|
+
} catch (error) {
|
|
618
|
+
return [this.#diagnostics.report(error, {
|
|
619
|
+
operation: 'connection.close', stage: 'connection.stop', code: 'connection-stop-failed', botId, warning: true,
|
|
620
|
+
}).publicError];
|
|
621
|
+
}
|
|
598
622
|
}
|
|
599
623
|
|
|
600
624
|
async #resolveToken(ref) {
|
|
601
|
-
|
|
602
|
-
|
|
625
|
+
try { return cleanString((await this.#credentials.resolve(ref))?.value); }
|
|
626
|
+
catch (error) { throw weixinStageError('credential-read-failed', error); }
|
|
603
627
|
}
|
|
604
628
|
|
|
605
|
-
async #
|
|
629
|
+
async #writeCredential(ref, value) {
|
|
606
630
|
try {
|
|
607
|
-
if (
|
|
608
|
-
else await this.#credentials.
|
|
609
|
-
} catch (
|
|
610
|
-
|
|
631
|
+
if (value === undefined) await this.#credentials.unset(ref);
|
|
632
|
+
else await this.#credentials.set(ref, value);
|
|
633
|
+
} catch (cause) {
|
|
634
|
+
let description;
|
|
635
|
+
try { description = await this.#credentials.describe?.(ref); } catch { /* Preserve the original write failure. */ }
|
|
636
|
+
if (description?.writable === false) {
|
|
637
|
+
cause = Object.assign(new Error('Credential source is read-only', { cause }), { code: 'read-only' });
|
|
638
|
+
}
|
|
639
|
+
throw weixinStageError(value === undefined ? 'credential-remove-failed' : 'credential-save-failed', cause);
|
|
611
640
|
}
|
|
612
641
|
}
|
|
613
642
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createWeixinDiagnostics } from './connection-error.mjs';
|
|
2
|
+
import { DEFAULT_WEIXIN_MAX_MESSAGE_CHARS, WeixinApiError, rejectedProviderResponse } from './weixin-api.mjs';
|
|
2
3
|
import {
|
|
3
4
|
createWeixinBridgeStatus, WeixinHarnessBridge, weixinSendError, weixinSendFailureOptions,
|
|
4
5
|
} from './weixin-bridge.mjs';
|
|
@@ -104,6 +105,7 @@ export function createWeixinRuntimeStatus() {
|
|
|
104
105
|
harnessReachable: false,
|
|
105
106
|
lastCheckedAt: null,
|
|
106
107
|
lastError: null,
|
|
108
|
+
connectionError: null,
|
|
107
109
|
...createWeixinBridgeStatus(),
|
|
108
110
|
};
|
|
109
111
|
}
|
|
@@ -117,6 +119,8 @@ export class WeixinRuntime {
|
|
|
117
119
|
#contextEnhancement;
|
|
118
120
|
#accessPolicy;
|
|
119
121
|
#logger;
|
|
122
|
+
#diagnostics;
|
|
123
|
+
#startContext = {};
|
|
120
124
|
#replyTimeoutMs;
|
|
121
125
|
#maxMessageChars;
|
|
122
126
|
#startRetryDelaysMs;
|
|
@@ -135,6 +139,7 @@ export class WeixinRuntime {
|
|
|
135
139
|
contextEnhancement,
|
|
136
140
|
accessPolicy,
|
|
137
141
|
logger = console,
|
|
142
|
+
diagnostics,
|
|
138
143
|
replyTimeoutMs = 600_000,
|
|
139
144
|
maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
|
|
140
145
|
startRetryDelaysMs,
|
|
@@ -150,6 +155,7 @@ export class WeixinRuntime {
|
|
|
150
155
|
this.#contextEnhancement = contextEnhancement;
|
|
151
156
|
this.#accessPolicy = accessPolicy;
|
|
152
157
|
this.#logger = logger;
|
|
158
|
+
this.#diagnostics = diagnostics ?? createWeixinDiagnostics({ logger });
|
|
153
159
|
this.#replyTimeoutMs = replyTimeoutMs;
|
|
154
160
|
this.#maxMessageChars = maxMessageChars;
|
|
155
161
|
this.#startRetryDelaysMs = startRetryDelays(startRetryDelaysMs);
|
|
@@ -159,9 +165,10 @@ export class WeixinRuntime {
|
|
|
159
165
|
return structuredClone(this.#status);
|
|
160
166
|
}
|
|
161
167
|
|
|
162
|
-
async start() {
|
|
168
|
+
async start(context = {}) {
|
|
163
169
|
if (this.#status.ready && this.#monitor) return this.status;
|
|
164
170
|
if (this.#starting) return this.#starting;
|
|
171
|
+
this.#startContext = context;
|
|
165
172
|
this.#starting = this.#start().finally(() => {
|
|
166
173
|
this.#starting = null;
|
|
167
174
|
});
|
|
@@ -173,6 +180,7 @@ export class WeixinRuntime {
|
|
|
173
180
|
this.#status.startedAt = new Date().toISOString();
|
|
174
181
|
this.#status.weixinConnectionState = 'connecting';
|
|
175
182
|
this.#status.lastError = null;
|
|
183
|
+
this.#status.connectionError = null;
|
|
176
184
|
try {
|
|
177
185
|
try {
|
|
178
186
|
await this.#harness.ensureRunning();
|
|
@@ -206,8 +214,11 @@ export class WeixinRuntime {
|
|
|
206
214
|
if (signal.aborted) return;
|
|
207
215
|
this.#status.ready = false;
|
|
208
216
|
this.#status.weixinConnectionState = 'failed';
|
|
209
|
-
this.#
|
|
210
|
-
|
|
217
|
+
const failure = this.#diagnostics.report(error, {
|
|
218
|
+
operation: 'connection.monitor', stage: 'connection.poll', botId: this.#config.botId, automatic: true,
|
|
219
|
+
});
|
|
220
|
+
this.#status.connectionError = failure.publicError;
|
|
221
|
+
this.#status.lastError = failure.publicError.message;
|
|
211
222
|
});
|
|
212
223
|
return this.status;
|
|
213
224
|
} catch (error) {
|
|
@@ -216,7 +227,6 @@ export class WeixinRuntime {
|
|
|
216
227
|
this.#bridge = null;
|
|
217
228
|
this.#status.ready = false;
|
|
218
229
|
this.#status.weixinConnectionState = 'failed';
|
|
219
|
-
this.#status.lastError = error?.message ?? String(error);
|
|
220
230
|
throw error;
|
|
221
231
|
}
|
|
222
232
|
}
|
|
@@ -231,10 +241,9 @@ export class WeixinRuntime {
|
|
|
231
241
|
} catch (error) {
|
|
232
242
|
const wait = this.#startRetryDelaysMs[attempt];
|
|
233
243
|
if (wait === undefined || !retryableStartError(error)) throw error;
|
|
234
|
-
this.#
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
);
|
|
244
|
+
this.#diagnostics.report(error, {
|
|
245
|
+
...this.#startContext, stage: 'connection.start', botId: this.#config.botId, automatic: true, warning: true,
|
|
246
|
+
});
|
|
238
247
|
await delay(wait);
|
|
239
248
|
}
|
|
240
249
|
}
|
|
@@ -251,13 +260,12 @@ export class WeixinRuntime {
|
|
|
251
260
|
signal,
|
|
252
261
|
});
|
|
253
262
|
if (signal.aborted) return;
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
if (rejected) {
|
|
257
|
-
const code = response.errcode ?? response.ret;
|
|
263
|
+
const providerCode = rejectedProviderResponse(response, ['errcode', 'ret']);
|
|
264
|
+
if (providerCode) {
|
|
258
265
|
throw new WeixinApiError(
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
providerCode === '-14' ? 'stale-token' : 'updates-rejected',
|
|
267
|
+
providerCode === '-14' ? t('微信登录凭据已失效,请移除账号后重新扫码。') : t('微信消息同步请求被拒绝。'),
|
|
268
|
+
{ providerCode },
|
|
261
269
|
);
|
|
262
270
|
}
|
|
263
271
|
consecutiveFailures = 0;
|
|
@@ -265,6 +273,7 @@ export class WeixinRuntime {
|
|
|
265
273
|
this.#status.weixinConnectionState = 'connected';
|
|
266
274
|
this.#status.lastCheckedAt = Date.now();
|
|
267
275
|
this.#status.lastError = null;
|
|
276
|
+
this.#status.connectionError = null;
|
|
268
277
|
|
|
269
278
|
for (const message of orderWeixinMessages(response?.msgs)) {
|
|
270
279
|
void this.#bridge.accept(message).catch((error) => {
|
|
@@ -281,19 +290,19 @@ export class WeixinRuntime {
|
|
|
281
290
|
} catch (error) {
|
|
282
291
|
if (signal.aborted) return;
|
|
283
292
|
consecutiveFailures += 1;
|
|
284
|
-
this.#
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
);
|
|
289
|
-
if (
|
|
290
|
-
if (consecutiveFailures >= 3) throw error;
|
|
293
|
+
const failure = this.#diagnostics.report(error, {
|
|
294
|
+
operation: 'connection.monitor', stage: 'connection.poll', botId: this.#config.botId, automatic: true, warning: true,
|
|
295
|
+
});
|
|
296
|
+
this.#status.lastError = failure.publicError.message;
|
|
297
|
+
if (error instanceof WeixinApiError && error.code === 'stale-token') throw failure;
|
|
298
|
+
if (consecutiveFailures >= 3) throw failure;
|
|
291
299
|
await delay(Math.min(2_000 * (2 ** (consecutiveFailures - 1)), 10_000), signal);
|
|
292
300
|
}
|
|
293
301
|
}
|
|
294
302
|
}
|
|
295
303
|
|
|
296
304
|
async stop() {
|
|
305
|
+
const warnings = [];
|
|
297
306
|
const monitor = this.#monitor;
|
|
298
307
|
const bridge = this.#bridge;
|
|
299
308
|
const wasStarted = Boolean(this.#abortController || monitor || this.#status.ready);
|
|
@@ -312,12 +321,14 @@ export class WeixinRuntime {
|
|
|
312
321
|
signal: AbortSignal.timeout(10_000),
|
|
313
322
|
});
|
|
314
323
|
} catch (error) {
|
|
315
|
-
this.#
|
|
324
|
+
warnings.push(this.#diagnostics.report(error, {
|
|
325
|
+
operation: 'connection.close', stage: 'connection.stop', code: 'connection-stop-failed', botId: this.#config.botId, warning: true,
|
|
326
|
+
}).publicError);
|
|
316
327
|
}
|
|
317
328
|
}
|
|
318
329
|
this.#status.ready = false;
|
|
319
330
|
this.#status.weixinConnectionState = 'idle';
|
|
320
|
-
return this.status;
|
|
331
|
+
return { ...this.status, ...(warnings.length ? { warnings } : {}) };
|
|
321
332
|
}
|
|
322
333
|
|
|
323
334
|
async sendConnectionTest(text) {
|