dsh-im-ops/src/channels/qq/qq-runtime.mjs

253 lines
7.6 KiB
JavaScript

import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
import {
connectionTestTarget,
connectionTestTargetUnavailable,
} from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
import { createQqBridgeStatus, QqHarnessBridge } from './qq-bridge.mjs';
function timeoutError() {
const error = new Error('QQ WebSocket did not become ready in time');
error.code = 'connect-timeout';
return error;
}
export function createQqRuntimeStatus() {
return {
startedAt: null,
ready: false,
qqConnectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createQqBridgeStatus(),
};
}
export class QqRuntime {
#config;
#appSecret;
#harness;
#state;
#contextEnhancement;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#createBot;
#typingMiddleware;
#status = createQqRuntimeStatus();
#bot = null;
#bridge = null;
#abortController = null;
#runTask = null;
#starting = null;
constructor({
config,
appSecret,
harness,
state,
contextEnhancement,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
createBot = (options) => new QQBot(options),
typingMiddleware = typingIndicator,
}) {
if (!config || !appSecret || !harness || !state) {
throw new TypeError('QqRuntime requires config, app secret, Harness, and state');
}
this.#config = config;
this.#appSecret = appSecret;
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#createBot = createBot;
this.#typingMiddleware = typingMiddleware;
}
get status() {
return structuredClone(this.#status);
}
async sendConnectionTest(text) {
if (!this.#status.ready || !this.#bot) {
throw connectionTestTargetUnavailable(t('QQ机器人'));
}
const ownerUserOpenid = typeof this.#config.ownerUserOpenid === 'string'
? this.#config.ownerUserOpenid.trim()
: '';
const remembered = connectionTestTarget(this.#state);
const rememberedUserOpenid = remembered?.scope === 'c2c'
&& typeof remembered.targetId === 'string'
? remembered.targetId.trim()
: '';
const target = rememberedUserOpenid
? { scope: 'c2c', targetId: rememberedUserOpenid }
: (ownerUserOpenid && ownerUserOpenid !== '*'
? { scope: 'c2c', targetId: ownerUserOpenid }
: null);
if (!target) throw connectionTestTargetUnavailable(t('QQ机器人'));
await this.#bot.sendText(target, text);
return { sent: true };
}
async start() {
if (this.#status.ready && this.#bot) return this.status;
if (this.#starting) return this.#starting;
this.#starting = this.#start().finally(() => {
this.#starting = null;
});
return this.#starting;
}
async #start() {
await this.stop();
this.#status.startedAt = new Date().toISOString();
this.#status.qqConnectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const sdkLogger = {
error: (...args) => this.#logger.error?.(...args),
warn: (...args) => this.#logger.warn?.(...args),
info: (...args) => this.#logger.info?.(...args),
debug: () => {},
};
const bot = this.#createBot({
appId: this.#config.appId,
appSecret: this.#appSecret,
accountId: this.#config.botId,
logger: sdkLogger,
transport: 'websocket',
tokenPrefetch: 'sync',
// sendText is reserved for literal notices/connection tests. Markdown
// replies use the explicit msg_type=2 path in sendMarkdownReply().
markdownSupport: false,
});
if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
throw new TypeError('QQ bot factory returned an invalid client');
}
const controller = new AbortController();
this.#abortController = controller;
this.#bot = bot;
this.#bridge = new QqHarnessBridge({
bot,
ownerUserOpenid: this.#config.ownerUserOpenid,
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
signal: controller.signal,
});
bot.use?.(this.#typingMiddleware({
keepAlive: true,
predicate: (ctx) => this.#config.ownerUserOpenid === '*'
|| ctx?.message?.senderId === this.#config.ownerUserOpenid,
}));
let readyResolve;
let readyReject;
const ready = new Promise((resolve, reject) => {
readyResolve = resolve;
readyReject = reject;
});
const onReady = () => {
const now = Date.now();
this.#status.ready = true;
this.#status.qqConnectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
this.#status.lastError = null;
readyResolve();
};
const onError = (error) => {
if (!this.#status.ready) readyReject(error);
else {
this.#status.lastError = error?.message ?? String(error);
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} connection error:`, error);
}
};
const onMessage = (_ctx, message) => {
const task = this.#bridge?.accept(message);
if (!task) return;
void task.catch((error) => {
if (controller.signal.aborted) return;
this.#logger.error?.(
`[dsh-im:qq] bot ${this.#config.botId} message handling failed:`,
error,
);
});
};
bot.on('ready', onReady);
bot.on('resumed', onReady);
bot.on('error', onError);
bot.on('message', onMessage);
const runTask = Promise.resolve().then(() => bot.start(controller.signal));
this.#runTask = runTask;
runTask.catch((error) => {
if (controller.signal.aborted) return;
readyReject(error);
this.#status.ready = false;
this.#status.qqConnectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
this.#logger.error?.(`[dsh-im:qq] bot ${this.#config.botId} connection stopped:`, error);
});
let timer;
try {
await Promise.race([
ready,
new Promise((_, reject) => {
timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs);
}),
]);
this.#status.ready = true;
this.#status.qqConnectionState = 'connected';
this.#status.lastCheckedAt = Date.now();
this.#status.lastConnectedAt = Date.now();
return this.status;
} catch (error) {
this.#status.ready = false;
this.#status.qqConnectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.stop();
throw error;
} finally {
clearTimeout(timer);
}
}
async stop() {
const bot = this.#bot;
const bridge = this.#bridge;
const runTask = this.#runTask;
this.#abortController?.abort();
this.#abortController = null;
this.#bot = null;
this.#bridge = null;
this.#runTask = null;
try {
bot?.stop();
} catch (error) {
this.#logger.warn?.(`[dsh-im:qq] bot ${this.#config.botId} failed to stop cleanly:`, error);
}
await Promise.race([
runTask?.catch(() => undefined) ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
await bridge?.waitForIdle();
this.#status.ready = false;
this.#status.qqConnectionState = 'idle';
return this.status;
}
}