349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
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;
|
||
}
|
||
}
|