Files
dukang/server/dukang-api/src/integrations/wecom/wecom-aibot.service.ts
T
2026-08-04 21:38:49 +08:00

349 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<WecomBotRole, string> = {
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<string, BotRuntime>();
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<WecomAibotStatus> {
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;
}
}