dsh-im-ops/src/channels/wecom/wecom-runtime.mjs
2026-08-30 11:24:53 +08:00

259 lines
8.1 KiB
JavaScript

import { WSAuthFailureError, WSClient, WSReconnectExhaustedError } from '@wecom/aibot-node-sdk';
import { createWecomBridgeStatus, WecomHarnessBridge } from './wecom-bridge.mjs';
import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
import { t } from '../shared/i18n.mjs';
function timeoutError() {
const error = new Error('Enterprise WeChat WebSocket authentication timed out');
error.code = 'connect-timeout';
return error;
}
export function createWecomRuntimeStatus() {
return {
startedAt: null,
ready: false,
wecomConnectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createWecomBridgeStatus(),
};
}
export class WecomRuntime {
#config;
#secret;
#harness;
#state;
#contextEnhancement;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#maxReconnectAttempts;
#createClient;
#status = createWecomRuntimeStatus();
#client = null;
#bridge = null;
#starting = null;
#startController = null;
#runtimeController = null;
constructor({
config,
secret,
harness,
state,
contextEnhancement,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
maxReconnectAttempts = 10,
createClient = (options) => new WSClient(options),
}) {
if (!config || !secret || !harness || !state) {
throw new TypeError('WecomRuntime requires config, secret, Harness, and state');
}
this.#config = config;
this.#secret = secret;
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#maxReconnectAttempts = maxReconnectAttempts;
this.#createClient = createClient;
}
get status() {
return structuredClone(this.#status);
}
async start() {
if (this.#status.ready && this.#client) return this.status;
if (this.#starting) return this.#starting;
this.#runtimeController?.abort(new DOMException('Enterprise WeChat runtime replaced', 'AbortError'));
const controller = new AbortController();
this.#startController = controller;
this.#runtimeController = controller;
this.#starting = this.#start(controller.signal).finally(() => {
if (this.#startController === controller) this.#startController = null;
this.#starting = null;
});
return this.#starting;
}
async #start(signal) {
await this.#stopActive();
signal.throwIfAborted();
this.#status.startedAt = new Date().toISOString();
this.#status.wecomConnectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const silentSdkLogger = { debug() {}, info() {}, warn() {}, error() {} };
const client = this.#createClient({
botId: this.#config.remoteBotId,
secret: this.#secret,
logger: silentSdkLogger,
maxReconnectAttempts: this.#maxReconnectAttempts,
});
if (!client || typeof client.connect !== 'function' || typeof client.disconnect !== 'function') {
throw new TypeError('Enterprise WeChat client factory returned an invalid client');
}
this.#client = client;
this.#bridge = new WecomHarnessBridge({
client,
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
signal,
});
let readyResolve;
let readyReject;
let authenticated = false;
const ready = new Promise((resolve, reject) => {
readyResolve = resolve;
readyReject = reject;
});
const onAuthenticated = () => {
if (this.#client !== client || signal.aborted) return;
authenticated = true;
const now = Date.now();
this.#status.ready = true;
this.#status.wecomConnectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
this.#status.lastError = null;
readyResolve();
};
const onDisconnected = () => {
if (this.#client !== client) return;
this.#status.ready = false;
this.#status.wecomConnectionState = 'connecting';
this.#status.lastCheckedAt = Date.now();
};
const onReconnecting = () => {
if (this.#client !== client) return;
this.#status.ready = false;
this.#status.wecomConnectionState = 'connecting';
this.#status.lastCheckedAt = Date.now();
};
const onError = (error) => {
if (this.#client !== client) return;
const terminal = error instanceof WSAuthFailureError || error instanceof WSReconnectExhaustedError;
if (!authenticated && terminal) readyReject(error);
if (terminal) {
this.#status.ready = false;
this.#status.wecomConnectionState = 'failed';
}
this.#status.lastError = terminal ? error.name : 'connection-error';
this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} connection error`);
};
const onMessage = (frame) => this.#bridge?.accept(frame);
client.on('authenticated', onAuthenticated);
client.on('disconnected', onDisconnected);
client.on('reconnecting', onReconnecting);
client.on('error', onError);
client.on('message', onMessage);
let timer;
try {
client.connect();
await Promise.race([
ready,
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
new Promise((_, reject) => {
timer = setTimeout(() => reject(timeoutError()), this.#connectTimeoutMs);
}),
]);
return this.status;
} catch (error) {
if (signal.aborted) {
await this.#stopActive();
throw error;
}
this.#status.ready = false;
this.#status.wecomConnectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.#stopActive();
throw error;
} finally {
clearTimeout(timer);
}
}
async stop() {
const starting = this.#starting;
this.#startController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
this.#runtimeController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
this.#runtimeController = null;
await this.#stopActive();
await starting?.catch(() => undefined);
return this.status;
}
async sendConnectionTest(text) {
return sendRememberedConnectionTest({
state: this.#state,
text,
channelLabel: t('企业微信机器人'),
send: async ({ chatId }, content) => {
if (!this.#status.ready || !this.#client) {
throw new Error('Enterprise WeChat runtime is not connected');
}
await this.#client.sendMessage(chatId, {
msgtype: 'markdown',
markdown: { content },
});
},
});
}
async sendProactiveText(target, text, { signal } = {}) {
const chatId = typeof target?.route?.chatId === 'string'
? target.route.chatId.trim() : '';
if ((target?.kind !== 'user' && target?.kind !== 'group') || !chatId) {
const error = new TypeError('Invalid Enterprise WeChat proactive delivery target');
error.code = 'invalid-target';
throw error;
}
if (!this.#status.ready || !this.#client) {
const error = new Error('Enterprise WeChat runtime is not connected');
error.code = 'bot-not-connected';
throw error;
}
signal?.throwIfAborted();
await this.#client.sendMessage(chatId, {
msgtype: 'markdown',
markdown: { content: text },
});
return { sent: true };
}
async #stopActive() {
const client = this.#client;
const bridge = this.#bridge;
this.#client = null;
this.#bridge = null;
try {
client?.disconnect();
client?.removeAllListeners?.();
} catch (error) {
this.#logger.warn?.(`[dsh-im:wecom] bot ${this.#config.botId} failed to stop cleanly`);
}
await bridge?.waitForIdle();
this.#status.ready = false;
this.#status.wecomConnectionState = 'idle';
}
}