blob: 0348d78d77d7b02a7f902b46aa45b32a04640e49 [file]
import { WSClient, type TextMessage, type WsFrame } from '@wecom/aibot-node-sdk';
import type { BotChannelSettings } from '@maka/core';
import { generalizedErrorMessage } from '@maka/core';
import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js';
import type { BotSendOptions, BotStatus, SendCapable } from './types.js';
const AUTH_TIMEOUT_MS = 15_000;
type ClosableWeComClient = Pick<WSClient, 'disconnect' | 'removeAllListeners'>;
/** Detach bridge callbacks before disconnect can synchronously emit. */
export function closeWeComClient(client: ClosableWeComClient): void {
client.removeAllListeners();
client.disconnect();
}
const quietLogger = {
debug() {},
info() {},
warn() {},
error() {},
};
export function wecomTextFrameToEvent(
frame: WsFrame<TextMessage>,
receivedAt: number,
allowedUserIds?: ReadonlyArray<string>,
) {
const body = frame.body;
if (!body || body.msgtype !== 'text' || typeof body.text?.content !== 'string') return null;
const userId = body.from?.userid;
if (typeof userId !== 'string' || !userId) return null;
if (allowedUserIds?.length && !allowedUserIds.includes(userId)) return null;
const isGroup = body.chattype === 'group';
const chatId = isGroup ? body.chatid : userId;
if (typeof chatId !== 'string' || !chatId) return null;
return {
platform: 'wecom' as const,
userId,
userName: userId,
chatId,
isGroup,
text: body.text.content,
sourceMessageId: body.msgid,
receivedAt,
};
}
/** Enterprise WeChat AI Bot WebSocket bridge backed by the official SDK. */
export class WeComBotBridge extends BaseBotAdapter implements SendCapable {
private client: WSClient | null = null;
private explicitlyStopped = false;
constructor(settings: BotChannelSettings) {
super('wecom', settings);
}
async start(): Promise<void> {
if (this.running) return;
if (!this.settings.enabled) {
this.reason = 'disabled';
this.readiness = 'scaffolded';
return;
}
const botId = this.settings.appId?.trim() ?? '';
const secret = this.settings.appSecret?.trim() ?? '';
if (!botId || !secret) {
this.reason = 'no-credentials';
this.readiness = 'scaffolded';
this.emitStatusChange();
return;
}
this.explicitlyStopped = false;
const client = new WSClient({
botId,
secret,
maxReconnectAttempts: -1,
logger: quietLogger,
});
this.client = client;
this.wire(client, botId);
await new Promise<void>((resolve, reject) => {
let settled = false;
const onAuthenticated = () => finish(resolve);
const onError = (error: Error) => finish(() => reject(error));
const cleanup = () => {
clearTimeout(timer);
client.off('authenticated', onAuthenticated);
client.off('error', onError);
};
const finish = (fn: () => void) => {
if (settled) return;
settled = true;
cleanup();
fn();
};
const timer = setTimeout(
() => finish(() => reject(new Error('WeCom authentication timed out'))),
AUTH_TIMEOUT_MS,
);
client.once('authenticated', onAuthenticated);
client.once('error', onError);
client.connect();
})
.then(() => {
if (this.explicitlyStopped || this.client !== client) return;
this.running = true;
this.startedAt = Date.now();
this.identity = { id: botId, username: botId, displayName: botId };
this.reason = undefined;
this.readiness = 'credentials_valid';
this.emitStatusChange();
})
.catch((error) => {
if (this.explicitlyStopped || this.client !== client) return;
this.running = false;
this.reason = generalizedErrorMessage(error);
this.readiness = 'configured';
this.emitStatusChange();
this.client = null;
closeWeComClient(client);
});
}
async stop(): Promise<void> {
this.explicitlyStopped = true;
this.running = false;
const client = this.client;
this.client = null;
if (client) closeWeComClient(client);
this.reason = 'stopped';
this.readiness = botReadinessFromSettings(this.settings);
this.emitStatusChange();
}
async sendMessage(
chatId: string,
text: string,
_options?: BotSendOptions,
): Promise<string | null> {
const client = this.client;
if (!client || !this.running || !chatId.trim()) return null;
try {
const response = await client.sendMessage(chatId, {
msgtype: 'markdown',
markdown: { content: text },
});
this.readiness = 'operational';
this.reason = undefined;
this.lastEventAt = Date.now();
this.emitStatusChange();
return response.headers?.req_id ?? null;
} catch (error) {
this.readiness = this.readiness === 'operational' ? 'degraded' : 'credentials_valid';
this.reason = generalizedErrorMessage(error);
this.emitStatusChange();
return null;
}
}
override updateSettings(next: BotChannelSettings): { needsRestart: boolean } {
const needsRestart =
next.enabled !== this.settings.enabled ||
next.appId !== this.settings.appId ||
next.appSecret !== this.settings.appSecret;
this.settings = next;
return { needsRestart };
}
protected override connectionKind(): BotStatus['connection'] {
return 'gateway';
}
private wire(client: WSClient, botId: string): void {
client.on('message.text', (frame) => {
const receivedAt = Date.now();
const event = wecomTextFrameToEvent(frame, receivedAt, this.settings.allowedUserIds);
if (!event || this.client !== client) return;
this.lastEventAt = receivedAt;
this.readiness = 'operational';
this.reason = undefined;
this.emitIncomingMessage(event);
this.emitStatusChange();
});
client.on('authenticated', () => {
if (this.client !== client) return;
this.running = true;
this.identity = { id: botId, username: botId, displayName: botId };
this.reason = undefined;
this.readiness = 'credentials_valid';
this.emitStatusChange();
});
client.on('disconnected', (reason) => {
if (this.client !== client || this.explicitlyStopped) return;
this.running = false;
this.reason = reason || 'disconnected';
this.readiness = this.readiness === 'operational' ? 'degraded' : 'configured';
this.emitStatusChange();
});
client.on('reconnecting', () => {
if (this.client !== client || this.explicitlyStopped) return;
this.reason = 'reconnecting';
this.emitStatusChange();
});
client.on('error', (error) => {
if (this.client !== client || this.explicitlyStopped) return;
this.reason = generalizedErrorMessage(error);
this.readiness = this.readiness === 'operational' ? 'degraded' : 'configured';
this.emitStatusChange();
});
}
}