微信企业机器人创建

This commit is contained in:
2026-07-26 08:16:56 +08:00
parent f053908acb
commit 34532dc9bf
26 changed files with 1963 additions and 4 deletions
@@ -0,0 +1,313 @@
import {
Injectable,
Logger,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { WSClient, generateReqId, type WsFrame } from '@wecom/aibot-node-sdk';
import {
WECOM_BOT_ROLES,
resolveWecomBotPermissions,
type WecomBotRole,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { WecomBotActionsService } from './wecom-bot-actions.service';
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
const DEFAULT_WELCOMES: Record<WecomBotRole, string> = {
CUSTOMER_SERVICE:
'您好,我是杜康好客【客服】助手。发送「帮助」查看:创建售后工单、查用户(需短信验证)、查快递。',
TECH_SUPPORT:
'您好,我是杜康好客【技术支持】助手。发送「帮助」查看:创建技术支持工单、查看开发进度。',
TEAM_ASSISTANT:
'您好,我是杜康好客【团队助手】。发送「帮助」或「手册 关键词」查询系统使用说明。',
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;
};
function isWecomRole(v: string): v is WecomBotRole {
return (WECOM_BOT_ROLES as readonly string[]).includes(v);
}
@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,
) {}
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;
enabled: boolean;
}): WecomBotRuntimeConfig {
const role = (isWecomRole(row.role) ? row.role : 'CUSTOM') as WecomBotRole;
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),
};
}
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();
try {
if (!content || lower === '帮助' || lower === 'help' || content === '?' || content === '') {
await this.replyText(client, frame, this.actions.buildHelp(cfg));
return;
}
if (lower === '状态' || lower === 'status' || lower === 'ping') {
await this.replyText(client, frame, this.formatStatusMarkdown());
return;
}
const reply = await this.actions.handleCommand(cfg, wecomUserId, content);
await this.replyText(client, frame, reply);
} 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;
}
}