import { Injectable, Logger, OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; import { WSClient, generateReqId, type WsFrame } from '@wecom/aibot-node-sdk'; import { normalizeWecomBotRole, parseWecomUserIdList, resolveWecomBotPermissions, type WecomBotRole, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { WecomBotActionsService } from './wecom-bot-actions.service'; import { WecomBotAiService } from './wecom-bot-ai.service'; import type { WecomBotRuntimeConfig } from './wecom-bot.types'; const DEFAULT_WELCOMES: Record = { CUSTOMER_SERVICE: '您好,我是杜康好客【客服助手】。发送「帮助」查看:订单、配送、售后工单等指令。', FINANCE: '您好,我是杜康好客【财务助手】。发送「帮助」查看:门店/合伙人/酒厂/物流账单与打款提现查询。', OPERATIONS: '您好,我是杜康好客【运营助手】。发送「帮助」查看:订单、门店、用户、核销等只读查询。', TECH_SUPPORT: '您好,我是杜康好客【技术支持】。发送「帮助」查看:技术支持工单、开发计划与审批指令。', CUSTOM: '您好!发送「帮助」查看可用指令。', }; export type WecomAibotSlotStatus = { id: string; role: string; key: string; name: string; enabled: boolean; configured: boolean; connected: boolean; botIdMasked: string | null; avatarUrl: string | null; permissions: string[]; lastError: string | null; }; export type WecomAibotStatus = { masterEnabled: boolean; bots: WecomAibotSlotStatus[]; }; type BotRuntime = { config: WecomBotRuntimeConfig; client: WSClient | null; lastError: string | null; }; @Injectable() export class WecomAibotService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(WecomAibotService.name); private runtimes = new Map(); private starting = false; constructor( private readonly prisma: PrismaService, private readonly actions: WecomBotActionsService, private readonly ai: WecomBotAiService, ) {} async onModuleInit() { await this.reload('boot'); } async onModuleDestroy() { this.stopAll('shutdown'); } getStatus(): WecomAibotStatus { const masterEnabled = process.env.WECOM_AIBOT_ENABLED === 'true'; const bots: WecomAibotSlotStatus[] = []; for (const rt of this.runtimes.values()) { const cfg = rt.config; bots.push({ id: cfg.id, role: cfg.role, key: cfg.key, name: cfg.name, enabled: cfg.enabled, configured: !!(cfg.botId && cfg.secret), connected: !!rt.client?.isConnected, botIdMasked: cfg.botId ? maskId(cfg.botId) : null, avatarUrl: cfg.avatarUrl, permissions: cfg.permissions, lastError: rt.lastError, }); } return { masterEnabled, bots }; } async reload(reason = 'config'): Promise { if (this.starting) { this.logger.warn(`wecom aibot reload skipped (busy): ${reason}`); return this.getStatus(); } this.starting = true; try { this.stopAll(reason); const masterEnabled = process.env.WECOM_AIBOT_ENABLED === 'true'; const rows = await this.prisma.wecomBot.findMany({ orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); if (!masterEnabled) { this.logger.log(`wecom aibot master disabled (${reason})`); for (const row of rows) { const cfg = this.rowToConfig(row); this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: null }); } return this.getStatus(); } for (const row of rows) { const cfg = this.rowToConfig(row); if (!cfg.enabled || !cfg.botId || !cfg.secret) { this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: null }); continue; } try { await this.startBot(cfg); } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.logger.error(`wecom bot ${cfg.key} start failed: ${msg}`); this.runtimes.set(cfg.key, { config: cfg, client: null, lastError: msg }); } } return this.getStatus(); } catch (e) { // 表未创建时不阻断启动 const msg = e instanceof Error ? e.message : String(e); this.logger.warn(`wecom aibot reload failed (${reason}): ${msg}`); return this.getStatus(); } finally { this.starting = false; } } private rowToConfig(row: { id: bigint; name: string; role: string; botId: string; secret: string; avatarUrl: string | null; welcome: string | null; permissions: string; reviewSuperAdminWecomUserIds: string | null; aiEnabled: boolean; llmConfigId: bigint | null; knowledgeBaseId: bigint | null; enabled: boolean; }): WecomBotRuntimeConfig { const role = normalizeWecomBotRole(row.role); return { id: row.id.toString(), key: `db_${row.id.toString()}`, role, name: row.name, enabled: row.enabled, botId: row.botId, secret: row.secret, avatarUrl: row.avatarUrl, welcome: row.welcome?.trim() || DEFAULT_WELCOMES[role], permissions: resolveWecomBotPermissions(role, row.permissions), reviewSuperAdminWecomUserIds: parseWecomUserIdList(row.reviewSuperAdminWecomUserIds), aiEnabled: row.aiEnabled, llmConfigId: row.llmConfigId?.toString() ?? null, knowledgeBaseId: row.knowledgeBaseId?.toString() ?? null, }; } private stopAll(reason: string) { for (const [key, rt] of this.runtimes) { if (!rt.client) continue; try { rt.client.removeAllListeners(); rt.client.disconnect(); this.logger.log(`wecom bot ${key} disconnected (${reason})`); } catch (e) { this.logger.warn(`wecom bot ${key} disconnect error: ${String(e)}`); } } this.runtimes.clear(); } private async startBot(cfg: WecomBotRuntimeConfig) { const client = new WSClient({ botId: cfg.botId, secret: cfg.secret, maxReconnectAttempts: -1, maxAuthFailureAttempts: 5, heartbeatInterval: 30_000, logger: { debug: (msg, ...args) => this.logger.debug(`[${cfg.key}] ${formatSdkLog(msg, args)}`), info: (msg, ...args) => this.logger.log(`[${cfg.key}] ${formatSdkLog(msg, args)}`), warn: (msg, ...args) => this.logger.warn(`[${cfg.key}] ${formatSdkLog(msg, args)}`), error: (msg, ...args) => this.logger.error(`[${cfg.key}] ${formatSdkLog(msg, args)}`), }, }); const rt: BotRuntime = { config: cfg, client, lastError: null }; this.runtimes.set(cfg.key, rt); client.on('authenticated', () => { rt.lastError = null; this.logger.log(`wecom bot ${cfg.key} authenticated bot=${maskId(cfg.botId)}`); }); client.on('disconnected', (reason) => { this.logger.warn(`wecom bot ${cfg.key} disconnected: ${reason || 'unknown'}`); }); client.on('error', (err) => { rt.lastError = err instanceof Error ? err.message : String(err); this.logger.error(`wecom bot ${cfg.key} error: ${rt.lastError}`); }); client.on('event.enter_chat', (frame: WsFrame) => { void this.handleEnterChat(cfg, client, frame); }); client.on('message.text', (frame: WsFrame) => { void this.handleText(cfg, client, frame); }); for (const evt of [ 'message.image', 'message.file', 'message.voice', 'message.video', 'message.mixed', ] as const) { client.on(evt, (frame: WsFrame) => { void this.replyText(client, frame, '暂仅支持文本消息,请发送「帮助」。'); }); } client.connect(); this.logger.log(`wecom bot ${cfg.key} connecting bot=${maskId(cfg.botId)}`); } private async handleEnterChat(cfg: WecomBotRuntimeConfig, client: WSClient, frame: WsFrame) { try { await client.replyWelcome(frame, { msgtype: 'text', text: { content: cfg.welcome }, }); } catch (e) { this.logger.error(`[${cfg.key}] welcome failed: ${String(e)}`); } } private async handleText(cfg: WecomBotRuntimeConfig, client: WSClient, frame: WsFrame) { const raw = String(frame.body?.text?.content ?? '').trim(); const content = raw.replace(/@[^\s]+\s*/g, '').trim(); const wecomUserId = String(frame.body?.from?.userid ?? 'unknown'); const lower = content.toLowerCase(); this.logger.log(`[${cfg.key}] inbound user=${wecomUserId} text=${content.slice(0, 120)}`); try { if (!content || lower === '帮助' || lower === 'help' || content === '?' || content === '?') { const reply = this.actions.buildHelp(cfg); this.logger.log(`[${cfg.key}] route=help`); await this.replyText(client, frame, reply); return; } if (lower === '状态' || lower === 'status' || lower === 'ping') { const reply = this.formatStatusMarkdown(); this.logger.log(`[${cfg.key}] route=status`); await this.replyText(client, frame, reply); return; } const useAi = cfg.aiEnabled && !!cfg.llmConfigId; const reply = await this.actions.handleCommand(cfg, wecomUserId, content, { skipNaturalFallback: useAi, }); if (reply != null) { this.logger.log(`[${cfg.key}] route=command len=${reply.length}`); await this.replyText(client, frame, reply); return; } if (useAi) { const aiReply = await this.ai.replyIfConfigured(cfg, content, wecomUserId); this.logger.log(`[${cfg.key}] route=ai len=${aiReply?.length ?? 0}`); await this.replyText( client, frame, aiReply ?? `未识别指令。\n\n${this.actions.buildHelp(cfg)}`, ); return; } this.logger.log(`[${cfg.key}] route=fallback`); await this.replyText(client, frame, `未识别指令。\n\n${this.actions.buildHelp(cfg)}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); this.logger.error(`[${cfg.key}] handle text failed: ${msg}`); await this.replyText(client, frame, `处理失败:${msg}`); } } private formatStatusMarkdown(): string { const st = this.getStatus(); const lines = [ '**企微机器人状态**', `- 总开关:${st.masterEnabled ? '开' : '关'}(系统设置 → 功能开关)`, '', ]; for (const b of st.bots) { lines.push( `**${b.name}**`, `- 角色:${b.role}`, `- 启用/配置/连接:${b.enabled ? '是' : '否'} / ${b.configured ? '是' : '否'} / ${b.connected ? '是' : '否'}`, `- BotID:${b.botIdMasked ?? '—'}`, `- 权限:${b.permissions.join(', ') || '—'}`, b.lastError ? `- 错误:${b.lastError}` : '', '', ); } return lines.filter((l, i, arr) => l !== '' || arr[i - 1] !== '').join('\n'); } private async replyText(client: WSClient, frame: WsFrame, content: string) { const streamId = generateReqId('stream'); try { await client.replyStream(frame, streamId, content, true); } catch (e) { this.logger.error(`reply failed: ${String(e)}`); } } } function maskId(id: string): string { if (id.length <= 8) return `${id.slice(0, 2)}***`; return `${id.slice(0, 4)}…${id.slice(-4)}`; } function formatSdkLog(message: string, args: unknown[]): string { if (!args.length) return message; try { return `${message} ${args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}`; } catch { return message; } }