mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
626 lines
24 KiB
JavaScript
626 lines
24 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
||
|
||
import {
|
||
normalizeWeixinApiBaseUrl,
|
||
WEIXIN_QR_BASE_URL,
|
||
WeixinApiError,
|
||
} from './weixin-api.mjs';
|
||
import { deriveWeixinBotIdentity, maskWeixinAccountId } from './config-store.mjs';
|
||
import {
|
||
connectionTestMessage,
|
||
connectionTestTargetUnavailable,
|
||
} from '../shared/connection-test.mjs';
|
||
import { publicMessageFailure } from '../shared/message-failure.mjs';
|
||
import { t } from '../shared/i18n.mjs';
|
||
|
||
const ACTIVE_ATTEMPT_STATES = new Set([
|
||
'starting',
|
||
'pending',
|
||
'scanned',
|
||
'needs_verification',
|
||
'connecting',
|
||
]);
|
||
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-connect-failed': '微信已授权,但插件无法连接本机 Harness。请检查 dsh web 地址和端口。',
|
||
'harness-timeout': '微信已授权,但 Harness 健康检查超时。请确认 dsh web 未阻塞。',
|
||
'harness-auth-required': '微信已授权,但 Harness 健康检查需要身份认证。请检查代理、网关或自定义鉴权配置。',
|
||
'harness-proxy-auth-required': '微信已授权,但本机 Harness 请求被代理要求认证。请让回环地址绕过代理,并检查 NO_PROXY 配置。',
|
||
'harness-loopback-forbidden': '微信已授权,但 Harness 异常拒绝了回环地址的健康检查。请检查 HTTP 代理、Harness 源码版本和构建产物。',
|
||
'harness-host-untrusted': '微信已授权,但 Harness 的 Host 信任检查拒绝了非回环地址请求。请检查 harnessBaseUrl 与 trustedHosts 配置。',
|
||
'harness-request-forbidden': '微信已授权,但健康检查收到了非 Harness 标准的 403 拒绝响应。请检查代理或网关配置。',
|
||
'harness-api-not-found': '微信已授权,但找不到 Harness 健康检查接口。请确认 Harness 与插件版本兼容。',
|
||
'harness-http-failed': '微信已授权,但 Harness 健康检查返回服务错误。请查看 dsh web 日志。',
|
||
'harness-response-invalid': '微信已授权,但 Harness 返回了无法识别的响应。请确认 Harness 与插件版本兼容。',
|
||
'harness-rpc-rejected': '微信已授权,但 Harness 拒绝了健康检查请求。请查看 dsh web 日志。',
|
||
'harness-check-unknown-failed': '微信已授权,但 Harness 健康检查发生未知错误。请查看 dsh web 日志。',
|
||
'connection-start-failed': '微信已授权,但消息连接初始化失败。请查看 dsh web 日志后重试。',
|
||
});
|
||
|
||
function cleanString(value) {
|
||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||
}
|
||
|
||
function abortError() {
|
||
return new DOMException('Provisioning was cancelled', 'AbortError');
|
||
}
|
||
|
||
function apiBaseFromServer(value, fallback) {
|
||
const raw = cleanString(value);
|
||
if (!raw) return normalizeWeixinApiBaseUrl(fallback);
|
||
return normalizeWeixinApiBaseUrl(raw.includes('://') ? raw : `https://${raw}`);
|
||
}
|
||
|
||
function publicAttempt(record) {
|
||
if (!record) return null;
|
||
return {
|
||
attemptId: record.id,
|
||
status: record.state,
|
||
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
||
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
||
pollIntervalMs: 1_000,
|
||
...(record.state === 'needs_verification' ? { verificationRequired: true } : {}),
|
||
...(record.botId ? { botId: record.botId } : {}),
|
||
...(record.alreadyConnected ? { alreadyConnected: true } : {}),
|
||
...(record.error ? { error: structuredClone(record.error) } : {}),
|
||
};
|
||
}
|
||
|
||
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, t(message))
|
||
: safeAccountError('activation-unknown-failed', t('微信已授权,但激活过程中发生未知错误。请查看 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;
|
||
#configStore;
|
||
#createRuntime;
|
||
#deleteState;
|
||
#logger;
|
||
#runtimes = new Map();
|
||
#errors = new Map();
|
||
#attempts = new Map();
|
||
#activeAttemptId = null;
|
||
#transitions = new Map();
|
||
#revision = 0;
|
||
#closed = false;
|
||
|
||
constructor({
|
||
api,
|
||
credentials,
|
||
configStore,
|
||
createRuntime,
|
||
deleteState = async () => {},
|
||
logger = console,
|
||
}) {
|
||
if (!api || typeof api.beginLogin !== 'function' || typeof api.pollLogin !== 'function') {
|
||
throw new TypeError('WeixinController requires a Weixin API client');
|
||
}
|
||
if (!credentials
|
||
|| typeof credentials.resolve !== 'function'
|
||
|| typeof credentials.set !== 'function'
|
||
|| typeof credentials.unset !== 'function') {
|
||
throw new TypeError('WeixinController requires the DSH credential provider');
|
||
}
|
||
if (!configStore
|
||
|| typeof configStore.list !== 'function'
|
||
|| typeof configStore.save !== 'function'
|
||
|| typeof configStore.remove !== 'function') {
|
||
throw new TypeError('WeixinController requires a config store');
|
||
}
|
||
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
||
this.#api = api;
|
||
this.#credentials = credentials;
|
||
this.#configStore = configStore;
|
||
this.#createRuntime = createRuntime;
|
||
this.#deleteState = deleteState;
|
||
this.#logger = logger;
|
||
}
|
||
|
||
async initialize() {
|
||
if (this.#closed) return this.status();
|
||
for (const config of this.#configStore.list()) {
|
||
const current = this.#runtimes.get(config.botId);
|
||
if (current?.status?.ready === true) continue;
|
||
await this.#withBotTransition(config.botId, async () => {
|
||
const latest = this.#configStore.get(config.botId);
|
||
if (!latest || this.#closed) return;
|
||
try {
|
||
const token = await this.#resolveToken(latest.tokenRef);
|
||
if (!token) {
|
||
this.#errors.set(
|
||
latest.botId,
|
||
safeAccountError('missing-token', t('登录凭据缺失,请移除账号后重新扫码。')),
|
||
);
|
||
return;
|
||
}
|
||
await this.#startRuntime(latest, token);
|
||
this.#errors.delete(latest.botId);
|
||
} catch (error) {
|
||
this.#errors.set(
|
||
latest.botId,
|
||
safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。')),
|
||
);
|
||
this.#logger.warn?.(`[dsh-weixin] account ${latest.botId} failed to initialize:`, error);
|
||
} finally {
|
||
this.#touch();
|
||
}
|
||
});
|
||
}
|
||
return this.status();
|
||
}
|
||
|
||
async startProvisioning() {
|
||
if (this.#closed) throw new Error('dsh-weixin controller is closed');
|
||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||
|
||
const record = {
|
||
id: randomUUID(),
|
||
state: 'starting',
|
||
createdAt: Date.now(),
|
||
expiresAt: Date.now() + QR_TTL_MS,
|
||
controller: new AbortController(),
|
||
pendingVerifyCode: null,
|
||
verifyResolve: null,
|
||
currentBaseUrl: WEIXIN_QR_BASE_URL,
|
||
error: null,
|
||
botId: null,
|
||
task: null,
|
||
};
|
||
this.#attempts.set(record.id, record);
|
||
this.#activeAttemptId = record.id;
|
||
this.#touch();
|
||
|
||
try {
|
||
const localTokens = (await Promise.all(
|
||
this.#configStore.list().slice(-10).map(async (config) => this.#resolveToken(config.tokenRef)),
|
||
)).filter(Boolean);
|
||
const login = await this.#api.beginLogin({
|
||
localTokens,
|
||
signal: record.controller.signal,
|
||
});
|
||
this.#assertAttemptActive(record);
|
||
record.qrcode = login.qrcode;
|
||
record.verificationUrl = login.qrcodeUrl;
|
||
record.state = 'pending';
|
||
record.expiresAt = Date.now() + QR_TTL_MS;
|
||
this.#touch();
|
||
record.task = this.#runProvisioning(record);
|
||
return publicAttempt(record);
|
||
} catch (error) {
|
||
if (record.controller.signal.aborted) {
|
||
record.state = 'cancelled';
|
||
record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
|
||
} else {
|
||
record.state = 'failed';
|
||
record.error = safeAccountError(
|
||
error instanceof WeixinApiError ? error.code : 'qr-start-failed',
|
||
error instanceof WeixinApiError ? error.message : t('无法生成微信二维码,请稍后重试。'),
|
||
);
|
||
}
|
||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||
this.#touch();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
registrationStatus(attemptId) {
|
||
return publicAttempt(this.#attempts.get(attemptId));
|
||
}
|
||
|
||
async submitVerification(attemptId, verifyCode) {
|
||
const record = this.#attempts.get(attemptId);
|
||
if (!record || record.state !== 'needs_verification') {
|
||
throw new Error('The provisioning attempt is not waiting for a verification code');
|
||
}
|
||
const code = cleanString(verifyCode);
|
||
if (!code || !/^\d{4,8}$/.test(code)) {
|
||
throw new TypeError('Verification code must contain 4 to 8 digits');
|
||
}
|
||
record.pendingVerifyCode = code;
|
||
record.state = 'scanned';
|
||
record.verifyResolve?.();
|
||
record.verifyResolve = null;
|
||
this.#touch();
|
||
return publicAttempt(record);
|
||
}
|
||
|
||
async cancelProvisioning(attemptId) {
|
||
const record = this.#attempts.get(attemptId);
|
||
if (!record) return null;
|
||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||
record.controller.abort();
|
||
record.verifyResolve?.();
|
||
record.verifyResolve = null;
|
||
await record.task?.catch(() => undefined);
|
||
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
||
record.error ??= safeAccountError('cancelled', t('扫码绑定已取消。'));
|
||
}
|
||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||
this.#touch();
|
||
return publicAttempt(record);
|
||
}
|
||
|
||
async reconnectBot(botId) {
|
||
const config = this.#configStore.get(botId);
|
||
if (!config) throw new Error('Unknown Weixin account');
|
||
await this.#withBotTransition(botId, async () => {
|
||
const token = await this.#resolveToken(config.tokenRef);
|
||
if (!token) throw new Error('The Weixin token is missing');
|
||
try {
|
||
await this.#startRuntime(config, token);
|
||
this.#errors.delete(botId);
|
||
} catch (error) {
|
||
this.#errors.set(botId, safeAccountError('connection-failed', t('微信连接仍未就绪,请稍后重试。')));
|
||
throw error;
|
||
} finally {
|
||
this.#touch();
|
||
}
|
||
});
|
||
return this.status();
|
||
}
|
||
|
||
async sendConnectionTest(botId) {
|
||
const config = this.#configStore.get(botId);
|
||
if (!config) throw new Error('Unknown Weixin account');
|
||
return this.#withBotTransition(botId, async () => {
|
||
const runtime = this.#runtimes.get(botId);
|
||
if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
|
||
throw connectionTestTargetUnavailable(t('微信机器人'));
|
||
}
|
||
return runtime.sendConnectionTest(connectionTestMessage(
|
||
t('微信机器人({name})', { name: maskWeixinAccountId(config.accountId) }),
|
||
));
|
||
});
|
||
}
|
||
|
||
async deleteBot(botId) {
|
||
const config = this.#configStore.get(botId);
|
||
if (!config) throw new Error('Unknown Weixin account');
|
||
await this.#withBotTransition(botId, async () => {
|
||
const previousToken = await this.#credentials.resolve(config.tokenRef).catch(() => undefined);
|
||
await this.#stopRuntime(botId);
|
||
try {
|
||
await this.#credentials.unset(config.tokenRef);
|
||
await this.#configStore.remove(botId);
|
||
} catch (error) {
|
||
if (previousToken?.value) {
|
||
await this.#credentials.set(config.tokenRef, previousToken.value).catch(() => undefined);
|
||
await this.#startRuntime(config, previousToken.value).catch(() => undefined);
|
||
}
|
||
throw new Error('Unable to remove the Weixin account safely.', { cause: error });
|
||
}
|
||
try {
|
||
await this.#deleteState({ botId, config });
|
||
} catch (error) {
|
||
this.#logger.warn?.(`[dsh-weixin] account ${botId} state cleanup failed:`, error);
|
||
}
|
||
this.#errors.delete(botId);
|
||
this.#touch();
|
||
});
|
||
return this.status();
|
||
}
|
||
|
||
status() {
|
||
const accounts = this.#configStore.list().map((config) => {
|
||
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
||
const connected = runtimeStatus?.ready === true
|
||
&& runtimeStatus.weixinConnectionState === 'connected'
|
||
&& runtimeStatus.harnessReachable === true;
|
||
const state = connected
|
||
? 'connected'
|
||
: runtimeStatus?.weixinConnectionState === 'connecting'
|
||
? 'connecting'
|
||
: this.#errors.has(config.botId) || runtimeStatus?.weixinConnectionState === 'failed'
|
||
? 'error'
|
||
: 'offline';
|
||
const error = this.#errors.get(config.botId) ?? (state === 'error'
|
||
? safeAccountError('connection-failed', t('微信连接未就绪,插件会自动重试。'))
|
||
: null);
|
||
return {
|
||
botId: config.botId,
|
||
state,
|
||
connected,
|
||
configured: true,
|
||
bot: {
|
||
name: t('微信机器人'),
|
||
accountIdMasked: maskWeixinAccountId(config.accountId),
|
||
},
|
||
health: {
|
||
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
||
summary: connected
|
||
? t('微信消息长轮询运行正常')
|
||
: state === 'error'
|
||
? t('微信连接未就绪,插件会自动重试')
|
||
: t('微信连接当前离线'),
|
||
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
||
},
|
||
stats: {
|
||
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
||
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
||
},
|
||
lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
|
||
error: error ? structuredClone(error) : null,
|
||
};
|
||
});
|
||
const connectedCount = accounts.filter((account) => account.connected).length;
|
||
const active = this.#activeAttemptId ? this.#attempts.get(this.#activeAttemptId) : null;
|
||
return {
|
||
schemaVersion: 1,
|
||
revision: this.#revision,
|
||
state: active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
||
? 'provisioning'
|
||
: accounts.length === 0
|
||
? 'disconnected'
|
||
: connectedCount === accounts.length
|
||
? 'connected'
|
||
: connectedCount > 0
|
||
? 'degraded'
|
||
: 'offline',
|
||
bots: accounts,
|
||
totals: { configured: accounts.length, connected: connectedCount },
|
||
...(active && ACTIVE_ATTEMPT_STATES.has(active.state)
|
||
? { provisioning: publicAttempt(active) }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
async close() {
|
||
if (this.#closed) return;
|
||
this.#closed = true;
|
||
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
||
await Promise.allSettled([...this.#runtimes.keys()].map((botId) => this.#stopRuntime(botId)));
|
||
}
|
||
|
||
async #runProvisioning(record) {
|
||
try {
|
||
while (!record.controller.signal.aborted && Date.now() < record.expiresAt) {
|
||
if (record.state === 'needs_verification' && !record.pendingVerifyCode) {
|
||
await new Promise((resolve) => {
|
||
record.verifyResolve = resolve;
|
||
if (record.controller.signal.aborted) resolve();
|
||
});
|
||
record.verifyResolve = null;
|
||
this.#assertAttemptActive(record);
|
||
}
|
||
|
||
const response = await this.#api.pollLogin({
|
||
qrcode: record.qrcode,
|
||
baseUrl: record.currentBaseUrl,
|
||
verifyCode: record.pendingVerifyCode,
|
||
signal: record.controller.signal,
|
||
});
|
||
this.#assertAttemptActive(record);
|
||
if (response.status === 'wait') {
|
||
record.state = 'pending';
|
||
} else if (response.status === 'scaned') {
|
||
record.pendingVerifyCode = null;
|
||
record.state = 'scanned';
|
||
} else if (response.status === 'need_verifycode') {
|
||
record.pendingVerifyCode = null;
|
||
record.state = 'needs_verification';
|
||
} else if (response.status === 'verify_code_blocked') {
|
||
record.state = 'failed';
|
||
record.error = safeAccountError('verification-blocked', t('配对码多次错误,请重新生成二维码。'));
|
||
break;
|
||
} else if (response.status === 'expired') {
|
||
record.state = 'expired';
|
||
record.error = safeAccountError('expired', t('二维码已过期,请重新生成。'));
|
||
break;
|
||
} else if (response.status === 'scaned_but_redirect') {
|
||
record.currentBaseUrl = apiBaseFromServer(response.redirect_host, record.currentBaseUrl);
|
||
record.state = 'scanned';
|
||
} else if (response.status === 'binded_redirect') {
|
||
const existing = this.#configStore.list().find(
|
||
(config) => this.#runtimes.get(config.botId)?.status?.ready === true,
|
||
) ?? this.#configStore.list()[0];
|
||
if (!existing) {
|
||
record.state = 'failed';
|
||
record.error = safeAccountError('already-bound', t('该微信账号已绑定,但本机没有可恢复的凭据。'));
|
||
} else {
|
||
record.state = 'connected';
|
||
record.botId = existing.botId;
|
||
record.alreadyConnected = true;
|
||
}
|
||
break;
|
||
} else if (response.status === 'confirmed') {
|
||
const token = cleanString(response.bot_token);
|
||
const accountId = cleanString(response.ilink_bot_id);
|
||
const ownerUserId = cleanString(response.ilink_user_id);
|
||
if (!token || !accountId || !ownerUserId) {
|
||
throw new WeixinApiError('incomplete-login', t('微信授权成功,但返回的账号凭据不完整。'));
|
||
}
|
||
record.state = 'connecting';
|
||
this.#touch();
|
||
const baseUrl = apiBaseFromServer(response.baseurl, record.currentBaseUrl);
|
||
record.botId = await this.#activateAccount(record, {
|
||
token,
|
||
accountId,
|
||
ownerUserId,
|
||
baseUrl,
|
||
});
|
||
record.state = 'connected';
|
||
record.error = null;
|
||
break;
|
||
}
|
||
this.#touch();
|
||
}
|
||
if (!record.controller.signal.aborted && Date.now() >= record.expiresAt
|
||
&& !TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
||
record.state = 'expired';
|
||
record.error = safeAccountError('expired', t('二维码已过期,请重新生成。'));
|
||
}
|
||
} catch (error) {
|
||
if (record.controller.signal.aborted || error?.name === 'AbortError') {
|
||
record.state = 'cancelled';
|
||
record.error = safeAccountError('cancelled', t('扫码绑定已取消。'));
|
||
} else {
|
||
record.state = 'failed';
|
||
record.error = publicProvisioningError(error);
|
||
this.#logger.error?.('[dsh-weixin] provisioning failed:', error);
|
||
}
|
||
} finally {
|
||
record.pendingVerifyCode = null;
|
||
record.verifyResolve?.();
|
||
record.verifyResolve = null;
|
||
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
||
this.#touch();
|
||
this.#pruneAttempts();
|
||
}
|
||
}
|
||
|
||
async #activateAccount(record, { token, accountId, ownerUserId, baseUrl }) {
|
||
const identity = deriveWeixinBotIdentity(accountId);
|
||
const previousConfig = this.#configStore.getByAccountId(accountId);
|
||
const config = {
|
||
botId: identity.botId,
|
||
accountId,
|
||
tokenRef: identity.tokenRef,
|
||
ownerUserId,
|
||
baseUrl,
|
||
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
||
connectedAt: new Date().toISOString(),
|
||
};
|
||
let previousToken;
|
||
try {
|
||
previousToken = await this.#credentials.resolve(identity.tokenRef);
|
||
} catch (error) {
|
||
throw activationStageError('credential-read-failed', error);
|
||
}
|
||
|
||
return this.#withBotTransition(identity.botId, async () => {
|
||
try {
|
||
try {
|
||
await this.#credentials.set(identity.tokenRef, token);
|
||
} catch (error) {
|
||
throw activationStageError('credential-save-failed', error);
|
||
}
|
||
this.#assertAttemptActive(record);
|
||
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);
|
||
this.#errors.delete(identity.botId);
|
||
this.#touch();
|
||
return identity.botId;
|
||
} catch (error) {
|
||
await this.#stopRuntime(identity.botId);
|
||
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
||
else if (this.#configStore.get(identity.botId)) {
|
||
const removed = await this.#configStore.remove(identity.botId).catch(() => null);
|
||
if (removed) {
|
||
await this.#deleteState({ botId: identity.botId, config }).catch((cleanupError) => {
|
||
this.#logger.warn?.('[dsh-weixin] failed to clean up cancelled bot state:', cleanupError);
|
||
});
|
||
}
|
||
}
|
||
await this.#restoreCredential(identity.tokenRef, previousToken);
|
||
if (previousConfig && previousToken?.value) {
|
||
await this.#startRuntime(previousConfig, previousToken.value).catch(() => undefined);
|
||
}
|
||
throw error;
|
||
}
|
||
});
|
||
}
|
||
|
||
async #startRuntime(config, token) {
|
||
await this.#stopRuntime(config.botId);
|
||
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 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 preserveActivationError(error, 'connection-start-failed');
|
||
}
|
||
}
|
||
|
||
async #stopRuntime(botId) {
|
||
const runtime = this.#runtimes.get(botId);
|
||
this.#runtimes.delete(botId);
|
||
await runtime?.stop().catch((error) => {
|
||
this.#logger.warn?.(`[dsh-weixin] account ${botId} failed to stop cleanly:`, error);
|
||
});
|
||
}
|
||
|
||
async #resolveToken(ref) {
|
||
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
||
return cleanString(result?.value);
|
||
}
|
||
|
||
async #restoreCredential(ref, previous) {
|
||
try {
|
||
if (previous?.value) await this.#credentials.set(ref, previous.value);
|
||
else await this.#credentials.unset(ref);
|
||
} catch (error) {
|
||
this.#logger.error?.(`[dsh-weixin] failed to restore credential ${ref}:`, error);
|
||
}
|
||
}
|
||
|
||
#assertAttemptActive(record) {
|
||
if (record.controller.signal.aborted || this.#activeAttemptId !== record.id) throw abortError();
|
||
}
|
||
|
||
#withBotTransition(botId, operation) {
|
||
const previous = this.#transitions.get(botId) ?? Promise.resolve();
|
||
const current = previous.catch(() => undefined).then(operation);
|
||
const settled = current.finally(() => {
|
||
if (this.#transitions.get(botId) === settled) this.#transitions.delete(botId);
|
||
});
|
||
this.#transitions.set(botId, settled);
|
||
return settled;
|
||
}
|
||
|
||
#pruneAttempts() {
|
||
for (const [id, record] of this.#attempts) {
|
||
if (id !== this.#activeAttemptId && TERMINAL_ATTEMPT_STATES.has(record.state)
|
||
&& this.#attempts.size > 16) {
|
||
this.#attempts.delete(id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#touch() {
|
||
this.#revision += 1;
|
||
}
|
||
}
|