微信企业机器人创建
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user