mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-13 23:36:36 +08:00
Add QQ bot channel
This commit is contained in:
parent
a422084eb5
commit
601271a7f8
33 changed files with 4971 additions and 642 deletions
207
src/channels/qq/qq-runtime.mjs
Normal file
207
src/channels/qq/qq-runtime.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import { QQBot, typingIndicator } from '@tencent-connect/qqbot-nodejs';
|
||||
|
||||
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;
|
||||
#logger;
|
||||
#replyTimeoutMs;
|
||||
#connectTimeoutMs;
|
||||
#createBot;
|
||||
#typingMiddleware;
|
||||
#status = createQqRuntimeStatus();
|
||||
#bot = null;
|
||||
#bridge = null;
|
||||
#abortController = null;
|
||||
#runTask = null;
|
||||
#starting = null;
|
||||
|
||||
constructor({
|
||||
config,
|
||||
appSecret,
|
||||
harness,
|
||||
state,
|
||||
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.#logger = logger;
|
||||
this.#replyTimeoutMs = replyTimeoutMs;
|
||||
this.#connectTimeoutMs = connectTimeoutMs;
|
||||
this.#createBot = createBot;
|
||||
this.#typingMiddleware = typingMiddleware;
|
||||
}
|
||||
|
||||
get status() {
|
||||
return structuredClone(this.#status);
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
if (!bot || typeof bot.start !== 'function' || typeof bot.stop !== 'function') {
|
||||
throw new TypeError('QQ bot factory returned an invalid client');
|
||||
}
|
||||
this.#bot = bot;
|
||||
this.#bridge = new QqHarnessBridge({
|
||||
bot,
|
||||
ownerUserOpenid: this.#config.ownerUserOpenid,
|
||||
harness: this.#harness,
|
||||
state: this.#state,
|
||||
status: this.#status,
|
||||
logger: this.#logger,
|
||||
replyTimeoutMs: this.#replyTimeoutMs,
|
||||
});
|
||||
bot.use?.(this.#typingMiddleware({
|
||||
keepAlive: true,
|
||||
predicate: (ctx) => ctx?.message?.senderId === this.#config.ownerUserOpenid,
|
||||
}));
|
||||
|
||||
const controller = new AbortController();
|
||||
this.#abortController = controller;
|
||||
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) => this.#bridge?.accept(message);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue