fix(weixin): classify activation failures

This commit is contained in:
xmanrui 2026-08-21 10:30:43 +08:00
parent 832bd539a2
commit 77dea74326
7 changed files with 431 additions and 134 deletions

View file

@ -621,6 +621,13 @@ var EN = Object.freeze({
"\u8FD9\u662F\u5FAE\u4FE1\u9644\u52A0\u7684\u5B89\u5168\u786E\u8BA4\u6B65\u9AA4\u3002\u914D\u5BF9\u7801\u53EA\u7528\u4E8E\u672C\u6B21\u626B\u7801\u8F6E\u8BE2\uFF0C\u4E0D\u4F1A\u5199\u5165\u914D\u7F6E\u6216\u65E5\u5FD7\u3002": "This is an additional WeChat confirmation step. The pairing code is used only for this connection and is never stored.",
"\u6B63\u5728\u4FDD\u5B58\u51ED\u636E\u5E76\u9A8C\u8BC1 Harness \u4E0E\u5FAE\u4FE1\u957F\u8F6E\u8BE2\u3002": "Saving credentials and verifying the WeChat connection.",
"\u5FAE\u4FE1\u5DF2\u786E\u8BA4\uFF0C\u6B63\u5728\u542F\u52A8\u6D88\u606F\u8FDE\u63A5": "Confirmed in WeChat. Starting the message connection",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u8BFB\u53D6\u73B0\u6709\u767B\u5F55\u51ED\u636E\u3002\u8BF7\u68C0\u67E5 DSH \u51ED\u636E\u5B58\u50A8\u3002": "WeChat was authorized, but the existing login credential could not be read. Check the DSH credential store.",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u767B\u5F55\u51ED\u636E\u65E0\u6CD5\u5199\u5165 DSH \u51ED\u636E\u5B58\u50A8\u3002\u8BF7\u68C0\u67E5\u51ED\u636E\u5B58\u50A8\u662F\u5426\u53EF\u5199\u3002": "WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u8D26\u53F7\u914D\u7F6E\u65E0\u6CD5\u5199\u5165\u672C\u673A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u76EE\u5F55\u6743\u9650\u3002": "WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u65E0\u6CD5\u521D\u59CB\u5316\u8D26\u53F7\u72B6\u6001\u6216\u5DE5\u4F5C\u533A\u3002\u8BF7\u68C0\u67E5 DSH_HOME \u548C\u5DE5\u4F5C\u533A\u76EE\u5F55\u3002": "WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u63D2\u4EF6\u65E0\u6CD5\u8FDE\u63A5\u672C\u673A Harness\u3002\u8BF7\u786E\u8BA4 dsh web \u5DF2\u6B63\u5E38\u542F\u52A8\u3002": "WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6D88\u606F\u8FDE\u63A5\u521D\u59CB\u5316\u5931\u8D25\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u540E\u91CD\u8BD5\u3002": "WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.",
"\u5FAE\u4FE1\u5DF2\u6388\u6743\uFF0C\u4F46\u6FC0\u6D3B\u8FC7\u7A0B\u4E2D\u53D1\u751F\u672A\u77E5\u9519\u8BEF\u3002\u8BF7\u67E5\u770B dsh web \u65E5\u5FD7\u3002": "WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.",
"\u5FAE\u4FE1\u5DF2\u7ED1\u5B9A\uFF0C\u53EF\u4EE5\u5F00\u59CB\u5411\u5DF2\u7ED1\u5B9A\u7684\u673A\u5668\u4EBA\u53D1\u6D88\u606F\u3002": "WeChat is connected and ready for messages.",
"\u8FD9\u4E2A\u5FAE\u4FE1\u8D26\u53F7\u5DF2\u7ECF\u7ED1\u5B9A\u5E76\u4FDD\u6301\u5728\u7EBF\u3002": "This WeChat account is connected and online.",
"\u5FAE\u4FE1\u8D26\u53F7\u53CA\u672C\u673A\u51ED\u636E\u5DF2\u79FB\u9664\u3002": "The WeChat account and local credentials were removed.",

File diff suppressed because one or more lines are too long

View file

@ -231,6 +231,13 @@ const EN = Object.freeze({
'这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。': 'This is an additional WeChat confirmation step. The pairing code is used only for this connection and is never stored.',
'正在保存凭据并验证 Harness 与微信长轮询。': 'Saving credentials and verifying the WeChat connection.',
'微信已确认,正在启动消息连接': 'Confirmed in WeChat. Starting the message connection',
'微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Check the DSH credential store.',
'微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。': 'WeChat was authorized, but the login credential could not be written to the DSH credential store. Check that the store is writable.',
'微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。': 'WeChat was authorized, but the account configuration could not be saved locally. Check the DSH_HOME directory permissions.',
'微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。': 'WeChat was authorized, but the account state or workspace could not be initialized. Check DSH_HOME and the workspace directory.',
'微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。': 'WeChat was authorized, but the plugin could not reach the local Harness. Confirm that dsh web is running normally.',
'微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。': 'WeChat was authorized, but the message connection could not be initialized. Check the dsh web logs and try again.',
'微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。': 'WeChat was authorized, but an unknown error occurred during activation. Check the dsh web logs.',
'微信已绑定,可以开始向已绑定的机器人发消息。': 'WeChat is connected and ready for messages.',
'这个微信账号已经绑定并保持在线。': 'This WeChat account is connected and online.',
'微信账号及本机凭据已移除。': 'The WeChat account and local credentials were removed.',

View file

@ -20,6 +20,14 @@ const ACTIVE_ATTEMPT_STATES = new Set([
]);
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
const QR_TTL_MS = 5 * 60_000;
const ACTIVATION_ERROR_MESSAGES = Object.freeze({
'credential-read-failed': '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。',
'credential-save-failed': '微信已授权,但登录凭据无法写入 DSH 凭据存储。请检查凭据存储是否可写。',
'account-config-save-failed': '微信已授权,但账号配置无法写入本机。请检查 DSH_HOME 目录权限。',
'runtime-prepare-failed': '微信已授权,但无法初始化账号状态或工作区。请检查 DSH_HOME 和工作区目录。',
'harness-unreachable': '微信已授权,但插件无法连接本机 Harness。请确认 dsh web 已正常启动。',
'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
});
function cleanString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@ -54,6 +62,26 @@ function safeAccountError(code, message) {
return Object.freeze({ code, message });
}
function activationStageError(code, cause) {
const error = new Error(`Weixin activation failed during ${code}`, { cause });
error.name = 'WeixinActivationStageError';
error.code = code;
return error;
}
function publicProvisioningError(error) {
if (error instanceof WeixinApiError) return safeAccountError(error.code, error.message);
const message = ACTIVATION_ERROR_MESSAGES[error?.code];
return message
? safeAccountError(error.code, message)
: safeAccountError('activation-unknown-failed', '微信已授权,但激活过程中发生未知错误。请查看 dsh web 日志。');
}
function preserveActivationError(error, fallbackCode) {
if (error instanceof WeixinApiError || ACTIVATION_ERROR_MESSAGES[error?.code]) return error;
return activationStageError(fallbackCode, error);
}
export class WeixinController {
#api;
#credentials;
@ -439,12 +467,7 @@ export class WeixinController {
record.error = safeAccountError('cancelled', '扫码绑定已取消。');
} else {
record.state = 'failed';
record.error = safeAccountError(
error instanceof WeixinApiError ? error.code : 'activation-failed',
error instanceof WeixinApiError
? error.message
: '微信已授权,但无法保存凭据或启动消息连接。',
);
record.error = publicProvisioningError(error);
this.#logger.error?.('[dsh-weixin] provisioning failed:', error);
}
} finally {
@ -469,13 +492,26 @@ export class WeixinController {
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
connectedAt: new Date().toISOString(),
};
const previousToken = await this.#credentials.resolve(identity.tokenRef).catch(() => undefined);
let previousToken;
try {
previousToken = await this.#credentials.resolve(identity.tokenRef);
} catch (error) {
throw activationStageError('credential-read-failed', error);
}
return this.#withBotTransition(identity.botId, async () => {
await this.#credentials.set(identity.tokenRef, token);
try {
try {
await this.#credentials.set(identity.tokenRef, token);
} catch (error) {
throw activationStageError('credential-save-failed', error);
}
this.#assertAttemptActive(record);
await this.#configStore.save(config);
try {
await this.#configStore.save(config);
} catch (error) {
throw activationStageError('account-config-save-failed', error);
}
this.#assertAttemptActive(record);
await this.#startRuntime(config, token);
this.#assertAttemptActive(record);
@ -504,16 +540,24 @@ export class WeixinController {
async #startRuntime(config, token) {
await this.#stopRuntime(config.botId);
const runtime = await this.#createRuntime({ botId: config.botId, config, token });
let runtime;
try {
runtime = await this.#createRuntime({ botId: config.botId, config, token });
} catch (error) {
throw preserveActivationError(error, 'runtime-prepare-failed');
}
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
throw new TypeError('createRuntime returned an invalid Weixin runtime');
throw activationStageError(
'runtime-prepare-failed',
new TypeError('createRuntime returned an invalid Weixin runtime'),
);
}
try {
await runtime.start();
this.#runtimes.set(config.botId, runtime);
} catch (error) {
await runtime.stop().catch(() => undefined);
throw error;
throw preserveActivationError(error, 'connection-start-failed');
}
}

View file

@ -25,6 +25,13 @@ function retryableStartError(error) {
&& (error.status === 408 || error.status === 425 || error.status === 429 || error.status >= 500);
}
function runtimeStartError(code, cause) {
const error = new Error(`Weixin runtime failed during ${code}`, { cause });
error.name = 'WeixinRuntimeStartError';
error.code = code;
return error;
}
function delay(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
@ -117,7 +124,11 @@ export class WeixinRuntime {
this.#status.weixinConnectionState = 'connecting';
this.#status.lastError = null;
try {
await this.#harness.ensureRunning();
try {
await this.#harness.ensureRunning();
} catch (error) {
throw runtimeStartError('harness-unreachable', error);
}
this.#status.harnessReachable = true;
await this.#notifyStart();
this.#abortController = new AbortController();

View file

@ -47,7 +47,7 @@ function configFixture() {
};
}
function runtimeFactory({ failStart = false } = {}) {
function runtimeFactory({ failStart = false, startError } = {}) {
const runtimes = [];
const connectionTests = [];
const createRuntime = async ({ config, token }) => {
@ -64,6 +64,7 @@ function runtimeFactory({ failStart = false } = {}) {
};
},
async start() {
if (startError) throw startError;
if (failStart) throw new Error('runtime start failed with host-only detail');
ready = true;
},
@ -169,7 +170,7 @@ test('verification-code state pauses polling and resumes with the submitted digi
await controller.close();
});
test('activation failure rolls credentials and non-secret config back', async () => {
test('runtime activation failure is classified and rolls credentials and config back', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory({ failStart: true });
@ -195,13 +196,216 @@ test('activation failure rolls credentials and non-secret config back', async ()
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'activation-failed');
assert.equal(failed.error.code, 'connection-start-failed');
assert.match(failed.error.message, /消息连接初始化失败/);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /must-be-rolled-back|host-only detail/);
await controller.close();
});
test('credential write failure is classified and rolls back a post-commit error', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
credentials.provider.set = async (ref, value) => {
credentials.values.set(ref, value);
throw new Error('credential backend failed after commit with private detail');
};
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: 'credential-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'credential-save-failed');
assert.match(failed.error.message, /DSH 凭据存储/);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /private detail|must-be-rolled-back/);
await controller.close();
});
test('credential read failure stops activation before any durable write', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
credentials.provider.resolve = async () => {
throw new Error('credential read host-only detail');
};
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-never-be-written',
ilink_bot_id: 'credential-read-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'credential-read-failed');
assert.equal(credentials.calls.length, 0);
assert.equal(credentials.values.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-never-be-written/);
await controller.close();
});
test('account config write and runtime preparation failures have distinct safe codes', async () => {
for (const scenario of [
{
expectedCode: 'account-config-save-failed',
prepare: ({ configs }) => {
configs.store.save = async (account) => {
configs.accounts.set(account.botId, structuredClone(account));
throw new Error('config path host-only detail');
};
},
createRuntime: runtimeFactory().createRuntime,
},
{
expectedCode: 'runtime-prepare-failed',
prepare: () => {},
createRuntime: async () => { throw new Error('workspace path host-only detail'); },
},
]) {
const credentials = credentialsFixture();
const configs = configFixture();
scenario.prepare({ credentials, configs });
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: `${scenario.expectedCode}@im.bot`,
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: scenario.createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, scenario.expectedCode);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
await controller.close();
}
});
test('known runtime activation codes cross the provisioning boundary unchanged', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory({
startError: Object.assign(new Error('loopback transport host-only detail'), {
code: 'harness-unreachable',
}),
});
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: 'harness-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'harness-unreachable');
assert.match(failed.error.message, /无法连接本机 Harness/);
assert.doesNotMatch(JSON.stringify(failed), /host-only detail|must-be-rolled-back/);
await controller.close();
});
test('an unclassified activation error uses the explicit unknown fallback code', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
configs.store.getByAccountId = () => {
throw new Error('unexpected host-only activation detail');
};
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-never-cross-the-browser-boundary',
ilink_bot_id: 'unknown-failure@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'activation-unknown-failed');
assert.notEqual(failed.error.code, 'activation-failed');
assert.match(failed.error.message, /未知错误/);
assert.doesNotMatch(
JSON.stringify(failed),
/unexpected host-only activation detail|must-never-cross-the-browser-boundary/,
);
await controller.close();
});
test('cancelling an in-flight QR long poll is terminal and writes no credentials', async () => {
const credentials = credentialsFixture();
const configs = configFixture();

View file

@ -308,6 +308,30 @@ test('runtime refuses to report ready when notifyStart rejects the stored token'
assert.equal(runtime.status.weixinConnectionState, 'failed');
});
test('runtime identifies a local Harness health failure without exposing its detail', async () => {
const runtime = new WeixinRuntime({
api: {
notifyStart: async () => assert.fail('notifyStart must not run while Harness is unavailable'),
notifyStop: async () => {},
sendText: async () => {},
getUpdates: async () => ({ ret: 0, msgs: [] }),
},
config: { botId: 'wx_harness', baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
token: 'bot-token',
harness: { ensureRunning: async () => { throw new Error('private loopback transport detail'); } },
state: {},
});
await assert.rejects(runtime.start(), (error) => {
assert.equal(error.code, 'harness-unreachable');
assert.doesNotMatch(error.message, /private loopback transport detail/);
assert.match(error.cause?.message ?? '', /private loopback transport detail/);
return true;
});
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.weixinConnectionState, 'failed');
});
test('runtime retries a transient notifyStart failure before reporting the account offline', async () => {
let startCalls = 0;
const runtime = new WeixinRuntime({