mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 17:53:13 +08:00
407 lines
15 KiB
JavaScript
407 lines
15 KiB
JavaScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import { deriveQqBotIdentity, maskQqAppId } from './config-store.mjs';
|
|
|
|
const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting']);
|
|
const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'failed', 'cancelled']);
|
|
const QR_TTL_MS = 5 * 60_000;
|
|
|
|
function cleanString(value) {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
}
|
|
|
|
function safeError(code, message) {
|
|
return Object.freeze({ code, message });
|
|
}
|
|
|
|
function publicAttempt(record) {
|
|
if (!record) return null;
|
|
return {
|
|
attemptId: record.id,
|
|
status: record.state,
|
|
qrRevision: record.qrRevision,
|
|
pollIntervalMs: 1_000,
|
|
...(record.verificationUrl ? { verificationUrl: record.verificationUrl } : {}),
|
|
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
|
|
...(record.botId ? { botId: record.botId } : {}),
|
|
...(record.error ? { error: structuredClone(record.error) } : {}),
|
|
};
|
|
}
|
|
|
|
export class QqController {
|
|
#qrAuth;
|
|
#credentials;
|
|
#configStore;
|
|
#createRuntime;
|
|
#deleteState;
|
|
#logger;
|
|
#runtimes = new Map();
|
|
#errors = new Map();
|
|
#attempts = new Map();
|
|
#activeAttemptId = null;
|
|
#transitions = new Map();
|
|
#revision = 0;
|
|
#closed = false;
|
|
|
|
constructor({
|
|
qrAuth,
|
|
credentials,
|
|
configStore,
|
|
createRuntime,
|
|
deleteState = async () => {},
|
|
logger = console,
|
|
}) {
|
|
if (!qrAuth || typeof qrAuth.start !== 'function') throw new TypeError('QQ QR auth is required');
|
|
if (!credentials || typeof credentials.resolve !== 'function'
|
|
|| typeof credentials.set !== 'function' || typeof credentials.unset !== 'function') {
|
|
throw new TypeError('QqController requires the DSH credential provider');
|
|
}
|
|
if (!configStore || typeof configStore.list !== 'function'
|
|
|| typeof configStore.save !== 'function' || typeof configStore.remove !== 'function') {
|
|
throw new TypeError('QqController requires a config store');
|
|
}
|
|
if (typeof createRuntime !== 'function') throw new TypeError('createRuntime is required');
|
|
this.#qrAuth = qrAuth;
|
|
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()) {
|
|
await this.#withBotTransition(config.botId, async () => {
|
|
if (this.#closed || this.#runtimes.get(config.botId)?.status?.ready) return;
|
|
const appSecret = await this.#resolveSecret(config.secretRef);
|
|
if (!appSecret) {
|
|
this.#errors.set(config.botId, safeError('missing-secret', 'QQ 机器人凭据缺失,请移除后重新扫码。'));
|
|
return;
|
|
}
|
|
try {
|
|
await this.#startRuntime(config, appSecret);
|
|
this.#errors.delete(config.botId);
|
|
} catch (error) {
|
|
this.#errors.set(config.botId, safeError('connection-failed', 'QQ 连接未就绪,插件会自动重试。'));
|
|
this.#logger.warn?.(`[dsh-im:qq] bot ${config.botId} failed to initialize:`, error);
|
|
} finally {
|
|
this.#touch();
|
|
}
|
|
});
|
|
}
|
|
return this.status();
|
|
}
|
|
|
|
async startProvisioning() {
|
|
if (this.#closed) throw new Error('QQ controller is closed');
|
|
if (this.#activeAttemptId) await this.cancelProvisioning(this.#activeAttemptId);
|
|
let firstQrResolve;
|
|
let firstQrReject;
|
|
const firstQr = new Promise((resolve, reject) => {
|
|
firstQrResolve = resolve;
|
|
firstQrReject = reject;
|
|
});
|
|
const record = {
|
|
id: randomUUID(),
|
|
state: 'starting',
|
|
createdAt: Date.now(),
|
|
expiresAt: null,
|
|
qrRevision: 0,
|
|
verificationUrl: null,
|
|
controller: new AbortController(),
|
|
dispose: null,
|
|
task: null,
|
|
error: null,
|
|
botId: null,
|
|
};
|
|
this.#attempts.set(record.id, record);
|
|
this.#activeAttemptId = record.id;
|
|
this.#touch();
|
|
|
|
try {
|
|
record.dispose = this.#qrAuth.start({
|
|
onQrDisplayed: (url) => {
|
|
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
const verificationUrl = cleanString(url);
|
|
if (!verificationUrl) return;
|
|
record.verificationUrl = verificationUrl;
|
|
record.qrRevision += 1;
|
|
record.expiresAt = Date.now() + QR_TTL_MS;
|
|
record.state = 'pending';
|
|
this.#touch();
|
|
firstQrResolve();
|
|
},
|
|
onQrExpired: () => {
|
|
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
record.state = 'refreshing';
|
|
record.verificationUrl = null;
|
|
record.expiresAt = null;
|
|
this.#touch();
|
|
},
|
|
onSuccess: (credentials) => {
|
|
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
record.task = this.#completeProvisioning(record, credentials);
|
|
},
|
|
onFailure: (error) => {
|
|
if (record.controller.signal.aborted || TERMINAL_ATTEMPT_STATES.has(record.state)) return;
|
|
record.state = 'failed';
|
|
record.error = safeError('qr-connect-failed', 'QQ 扫码服务暂时不可用,请重新生成二维码。');
|
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
this.#touch();
|
|
firstQrReject(error);
|
|
},
|
|
}, { signal: record.controller.signal });
|
|
await firstQr;
|
|
return publicAttempt(record);
|
|
} catch (error) {
|
|
if (record.controller.signal.aborted) {
|
|
record.state = 'cancelled';
|
|
record.error = safeError('cancelled', '扫码绑定已取消。');
|
|
} else if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
record.state = 'failed';
|
|
record.error = safeError('qr-start-failed', '无法生成 QQ 二维码,请稍后重试。');
|
|
}
|
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
this.#touch();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
registrationStatus(attemptId) {
|
|
return publicAttempt(this.#attempts.get(attemptId));
|
|
}
|
|
|
|
async cancelProvisioning(attemptId) {
|
|
const record = this.#attempts.get(attemptId);
|
|
if (!record) return null;
|
|
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) {
|
|
record.controller.abort();
|
|
record.dispose?.();
|
|
await record.task?.catch(() => undefined);
|
|
if (!TERMINAL_ATTEMPT_STATES.has(record.state)) record.state = 'cancelled';
|
|
record.error ??= safeError('cancelled', '扫码绑定已取消。');
|
|
}
|
|
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 QQ bot');
|
|
await this.#withBotTransition(botId, async () => {
|
|
const secret = await this.#resolveSecret(config.secretRef);
|
|
if (!secret) throw new Error('QQ bot secret is missing');
|
|
try {
|
|
await this.#startRuntime(config, secret);
|
|
this.#errors.delete(botId);
|
|
} catch (error) {
|
|
this.#errors.set(botId, safeError('connection-failed', 'QQ 连接仍未就绪,请稍后重试。'));
|
|
throw error;
|
|
} finally {
|
|
this.#touch();
|
|
}
|
|
});
|
|
return this.status();
|
|
}
|
|
|
|
async deleteBot(botId) {
|
|
const config = this.#configStore.get(botId);
|
|
if (!config) throw new Error('Unknown QQ bot');
|
|
await this.#withBotTransition(botId, async () => {
|
|
const previous = await this.#credentials.resolve(config.secretRef).catch(() => undefined);
|
|
await this.#stopRuntime(botId);
|
|
try {
|
|
await this.#credentials.unset(config.secretRef);
|
|
await this.#configStore.remove(botId);
|
|
} catch (error) {
|
|
if (previous?.value) {
|
|
await this.#credentials.set(config.secretRef, previous.value).catch(() => undefined);
|
|
await this.#startRuntime(config, previous.value).catch(() => undefined);
|
|
}
|
|
throw new Error('Unable to remove the QQ bot safely.', { cause: error });
|
|
}
|
|
await this.#deleteState({ botId, config }).catch((error) => {
|
|
this.#logger.warn?.(`[dsh-im:qq] bot ${botId} state cleanup failed:`, error);
|
|
});
|
|
this.#errors.delete(botId);
|
|
this.#touch();
|
|
});
|
|
return this.status();
|
|
}
|
|
|
|
status() {
|
|
const bots = this.#configStore.list().map((config) => {
|
|
const runtimeStatus = this.#runtimes.get(config.botId)?.status ?? null;
|
|
const connected = runtimeStatus?.ready === true
|
|
&& runtimeStatus.qqConnectionState === 'connected'
|
|
&& runtimeStatus.harnessReachable === true;
|
|
const state = connected ? 'connected'
|
|
: runtimeStatus?.qqConnectionState === 'connecting' ? 'connecting'
|
|
: this.#errors.has(config.botId) || runtimeStatus?.qqConnectionState === 'failed'
|
|
? 'error' : 'offline';
|
|
return {
|
|
botId: config.botId,
|
|
state,
|
|
connected,
|
|
configured: true,
|
|
bot: { name: 'QQ机器人', appIdMasked: maskQqAppId(config.appId) },
|
|
health: {
|
|
status: connected ? 'healthy' : state === 'error' ? 'error' : 'offline',
|
|
summary: connected ? 'QQ WebSocket 长连接运行正常'
|
|
: state === 'error' ? 'QQ 连接未就绪,插件会自动重试' : 'QQ 连接当前离线',
|
|
lastCheckedAt: runtimeStatus?.lastCheckedAt ?? null,
|
|
lastConnectedAt: runtimeStatus?.lastConnectedAt ?? null,
|
|
},
|
|
stats: {
|
|
messagesReceived: runtimeStatus?.messagesReceived ?? 0,
|
|
messagesReplied: runtimeStatus?.messagesReplied ?? 0,
|
|
},
|
|
error: structuredClone(this.#errors.get(config.botId) ?? null),
|
|
};
|
|
});
|
|
const connectedCount = bots.filter((bot) => bot.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'
|
|
: bots.length === 0 ? 'disconnected'
|
|
: connectedCount === bots.length ? 'connected'
|
|
: connectedCount > 0 ? 'degraded' : 'offline',
|
|
bots,
|
|
totals: { configured: bots.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 #completeProvisioning(record, credentials) {
|
|
try {
|
|
record.state = 'connecting';
|
|
record.verificationUrl = null;
|
|
record.expiresAt = null;
|
|
this.#touch();
|
|
const credential = Array.isArray(credentials) ? credentials[0] : null;
|
|
const appId = cleanString(credential?.appId);
|
|
const appSecret = cleanString(credential?.appSecret);
|
|
const ownerUserOpenid = cleanString(credential?.userOpenid);
|
|
if (!appId || !appSecret || !ownerUserOpenid) {
|
|
throw new Error('QQ authorization returned incomplete credentials');
|
|
}
|
|
record.botId = await this.#activateBot(record, { appId, appSecret, ownerUserOpenid });
|
|
record.state = 'connected';
|
|
record.error = null;
|
|
} catch (error) {
|
|
if (record.controller.signal.aborted) {
|
|
record.state = 'cancelled';
|
|
record.error = safeError('cancelled', '扫码绑定已取消。');
|
|
} else {
|
|
record.state = 'failed';
|
|
record.error = safeError('activation-failed', 'QQ 已授权,但无法安全保存接入配置。');
|
|
this.#logger.error?.('[dsh-im:qq] provisioning failed:', error);
|
|
}
|
|
} finally {
|
|
record.dispose?.();
|
|
if (this.#activeAttemptId === record.id) this.#activeAttemptId = null;
|
|
this.#touch();
|
|
}
|
|
}
|
|
|
|
async #activateBot(record, { appId, appSecret, ownerUserOpenid }) {
|
|
const identity = deriveQqBotIdentity(appId);
|
|
const previousConfig = this.#configStore.getByAppId(appId);
|
|
const previousSecret = await this.#credentials.resolve(identity.secretRef).catch(() => undefined);
|
|
const config = {
|
|
botId: identity.botId,
|
|
appId,
|
|
secretRef: identity.secretRef,
|
|
ownerUserOpenid,
|
|
createdAt: previousConfig?.createdAt ?? new Date().toISOString(),
|
|
connectedAt: new Date().toISOString(),
|
|
};
|
|
return this.#withBotTransition(identity.botId, async () => {
|
|
await this.#credentials.set(identity.secretRef, appSecret);
|
|
try {
|
|
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
|
await this.#configStore.save(config);
|
|
} catch (error) {
|
|
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
throw error;
|
|
}
|
|
try {
|
|
if (record.controller.signal.aborted) throw new DOMException('Cancelled', 'AbortError');
|
|
await this.#startRuntime(config, appSecret);
|
|
this.#errors.delete(identity.botId);
|
|
} catch (error) {
|
|
if (record.controller.signal.aborted) {
|
|
await this.#stopRuntime(identity.botId);
|
|
if (previousConfig) await this.#configStore.save(previousConfig).catch(() => undefined);
|
|
else await this.#configStore.remove(identity.botId).catch(() => undefined);
|
|
await this.#restoreCredential(identity.secretRef, previousSecret);
|
|
throw error;
|
|
}
|
|
this.#errors.set(identity.botId, safeError('connection-failed', 'QQ 机器人已绑定,消息连接暂未就绪。'));
|
|
this.#logger.warn?.(`[dsh-im:qq] bot ${identity.botId} activation connection failed:`, error);
|
|
}
|
|
this.#touch();
|
|
return identity.botId;
|
|
});
|
|
}
|
|
|
|
async #startRuntime(config, appSecret) {
|
|
await this.#stopRuntime(config.botId);
|
|
const runtime = await this.#createRuntime({ botId: config.botId, config, appSecret });
|
|
if (!runtime || typeof runtime.start !== 'function' || typeof runtime.stop !== 'function') {
|
|
throw new TypeError('createRuntime returned an invalid QQ runtime');
|
|
}
|
|
this.#runtimes.set(config.botId, runtime);
|
|
try {
|
|
await runtime.start();
|
|
} catch (error) {
|
|
await runtime.stop().catch(() => undefined);
|
|
this.#runtimes.delete(config.botId);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async #stopRuntime(botId) {
|
|
const runtime = this.#runtimes.get(botId);
|
|
this.#runtimes.delete(botId);
|
|
await runtime?.stop().catch((error) => {
|
|
this.#logger.warn?.(`[dsh-im:qq] bot ${botId} failed to stop cleanly:`, error);
|
|
});
|
|
}
|
|
|
|
async #resolveSecret(ref) {
|
|
const result = await this.#credentials.resolve(ref).catch(() => undefined);
|
|
return cleanString(result?.value);
|
|
}
|
|
|
|
async #restoreCredential(ref, previous) {
|
|
if (previous?.value) await this.#credentials.set(ref, previous.value).catch(() => undefined);
|
|
else await this.#credentials.unset(ref).catch(() => undefined);
|
|
}
|
|
|
|
#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;
|
|
}
|
|
|
|
#touch() {
|
|
this.#revision += 1;
|
|
}
|
|
}
|