feat: multi-module iteration
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
||||
|
||||
/** 兼容层:AI / Aibot 仍通过 Actions 入口调用 Capability */
|
||||
@Injectable()
|
||||
export class WecomBotActionsService {
|
||||
constructor(private readonly capability: WecomBotCapabilityService) {}
|
||||
|
||||
buildHelp(bot: WecomBotRuntimeConfig): string {
|
||||
return this.capability.buildHelp(bot);
|
||||
}
|
||||
|
||||
handleCommand(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
text: string,
|
||||
opts?: { skipNaturalFallback?: boolean },
|
||||
): Promise<string | null> {
|
||||
return this.capability.dispatch(bot, wecomUserId, text, opts);
|
||||
}
|
||||
|
||||
runTool(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
toolName: string,
|
||||
args: string,
|
||||
): Promise<string> {
|
||||
return this.capability.runTool(bot, wecomUserId, toolName, args);
|
||||
}
|
||||
|
||||
tryNaturalLanguageQuery(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
content: string,
|
||||
): Promise<string | null> {
|
||||
return this.capability.tryNaturalLanguageQuery(bot, wecomUserId, content);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { LlmChatClient } from '../llm/llm-chat.client';
|
||||
import { KnowledgeRetrievalService } from '../llm/knowledge-retrieval.service';
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
import { parseWecomToolLine, sanitizeWecomUserReply } from './wecom-bot-reply.util';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
import { wecomBotHasPermission } from './wecom-bot.types';
|
||||
|
||||
const DEFAULT_SYSTEM = [
|
||||
'你是杜康好客企业内部助手。',
|
||||
'优先依据提供的知识库内容回答;知识库未覆盖时如实说明不确定。',
|
||||
'不要编造订单号、金额、权限;涉及写操作请引导用户使用指令(如「帮助」)。',
|
||||
'回答简洁,使用中文。',
|
||||
].join('');
|
||||
|
||||
const TOOL_INSTRUCTION = [
|
||||
'当用户需要查询业务数据时,你必须在回复的第一行输出工具指令(仅一行,用户不可见后续会过滤):',
|
||||
'格式:`TOOL <工具名> [参数]`',
|
||||
'可用工具(按权限):',
|
||||
'- order_read <订单号>',
|
||||
'- delivery_read <单号>',
|
||||
'- store_read <关键词> · redeem_read <核销单号或门店>',
|
||||
'- user_read <用户号>',
|
||||
'- support_tickets_open · support_ticket_read [工单号]',
|
||||
'- support_ticket_approve <工单号> · support_ticket_reject <工单号> <理由>',
|
||||
'- finance_store_bill [门店] · finance_partner_bill · finance_winery_bill · finance_logistics_bill',
|
||||
'- finance_payout · finance_withdrawal',
|
||||
'- dev_plan_tasks [状态] · dev_plan_versions [版本号]',
|
||||
'- dev_plan_task_create <BUG|REQUIREMENT|OPTIMIZATION> <描述>',
|
||||
'- dev_plan_task_update_status <任务编号> <TODO|DEVELOPED|RELEASED>',
|
||||
'- dev_plan_version_create <版本号>',
|
||||
'- dev_plan_version_update_status <版本号> <PENDING|IN_PROGRESS|TESTING|RELEASED>',
|
||||
'- dev_plan_version_link_tasks <版本号> <任务编号1,任务编号2>',
|
||||
'- server_logs [关键词] · handbook_read [关键词]',
|
||||
'禁止输出 JSON、api 字段、「请稍等正在检索」等占位话术。',
|
||||
'若仅需解释概念、无需查库,第一行写 `ANSWER` 后直接回答。',
|
||||
].join('\n');
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotAiService {
|
||||
private readonly logger = new Logger(WecomBotAiService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly llm: LlmChatClient,
|
||||
private readonly kb: KnowledgeRetrievalService,
|
||||
private readonly actions: WecomBotActionsService,
|
||||
) {}
|
||||
|
||||
async replyIfConfigured(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
userText: string,
|
||||
wecomUserId: string,
|
||||
): Promise<string | null> {
|
||||
if (!bot.aiEnabled || !bot.llmConfigId) return null;
|
||||
|
||||
const cfg = await this.prisma.llmApiConfig.findUnique({
|
||||
where: { id: BigInt(bot.llmConfigId) },
|
||||
});
|
||||
if (!cfg?.enabled) {
|
||||
return '已开启 AI,但绑定的语言模型未启用或已删除。请在 HQ「企微机器人」检查配置。';
|
||||
}
|
||||
|
||||
let kbBlock = '';
|
||||
if (bot.knowledgeBaseId) {
|
||||
try {
|
||||
kbBlock = await this.kb.buildContext(BigInt(bot.knowledgeBaseId), userText);
|
||||
} catch (e) {
|
||||
this.logger.warn(`kb retrieve failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const systemParts = [
|
||||
cfg.systemPrompt?.trim() || DEFAULT_SYSTEM,
|
||||
this.buildToolHint(bot),
|
||||
TOOL_INSTRUCTION,
|
||||
kbBlock ? `\n\n以下为知识库检索片段:\n${kbBlock}` : '',
|
||||
];
|
||||
|
||||
try {
|
||||
this.logger.log(
|
||||
`wecom ai request bot=${bot.key} user=${wecomUserId} text=${userText.slice(0, 80)}`,
|
||||
);
|
||||
const raw = await this.llm.chat({
|
||||
baseUrl: cfg.baseUrl,
|
||||
apiKey: cfg.apiKey,
|
||||
model: cfg.modelName,
|
||||
temperature: cfg.temperature != null ? Number(cfg.temperature) : 0.3,
|
||||
maxTokens: cfg.maxTokens ?? 1024,
|
||||
messages: [
|
||||
{ role: 'system', content: systemParts.join('') },
|
||||
{ role: 'user', content: userText },
|
||||
],
|
||||
});
|
||||
|
||||
const tool = parseWecomToolLine(raw);
|
||||
if (tool) {
|
||||
this.logger.log(
|
||||
`wecom ai tool=${tool.name} bot=${bot.key} user=${wecomUserId} args=${tool.args.slice(0, 80)}`,
|
||||
);
|
||||
try {
|
||||
const result = await this.actions.runTool(bot, wecomUserId, tool.name, tool.args);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`wecom ai tool failed ${tool.name}: ${msg}`);
|
||||
return `查询失败:${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
const answerMatch = raw.match(/^ANSWER\s+([\s\S]*)/i);
|
||||
if (answerMatch) {
|
||||
return sanitizeWecomUserReply(answerMatch[1]);
|
||||
}
|
||||
|
||||
return sanitizeWecomUserReply(raw);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`wecom ai reply failed: ${msg}`);
|
||||
return `AI 回复失败:${msg}`;
|
||||
}
|
||||
}
|
||||
|
||||
private buildToolHint(bot: WecomBotRuntimeConfig): string {
|
||||
const tools: string[] = [];
|
||||
if (wecomBotHasPermission(bot, 'order.read')) tools.push('order_read');
|
||||
if (wecomBotHasPermission(bot, 'delivery.read')) tools.push('delivery_read');
|
||||
if (wecomBotHasPermission(bot, 'store.read')) tools.push('store_read');
|
||||
if (wecomBotHasPermission(bot, 'redeem.read')) tools.push('redeem_read');
|
||||
if (wecomBotHasPermission(bot, 'user.read')) tools.push('user_read');
|
||||
if (wecomBotHasPermission(bot, 'support_ticket.read')) {
|
||||
tools.push('support_tickets_open', 'support_ticket_read');
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'finance.store_bill.read')) tools.push('finance_store_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.partner_bill.read')) tools.push('finance_partner_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.winery_bill.read')) tools.push('finance_winery_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.logistics_bill.read')) tools.push('finance_logistics_bill');
|
||||
if (wecomBotHasPermission(bot, 'finance.payout.read')) tools.push('finance_payout');
|
||||
if (wecomBotHasPermission(bot, 'finance.withdrawal.read')) tools.push('finance_withdrawal');
|
||||
if (wecomBotHasPermission(bot, 'dev_plan.task.read')) tools.push('dev_plan_tasks');
|
||||
if (wecomBotHasPermission(bot, 'dev_plan.version.read')) tools.push('dev_plan_versions');
|
||||
if (wecomBotHasPermission(bot, 'dev_plan.version.link_tasks')) tools.push('dev_plan_version_link_tasks');
|
||||
if (wecomBotHasPermission(bot, 'server_log.read')) tools.push('server_logs');
|
||||
if (wecomBotHasPermission(bot, 'handbook.read')) tools.push('handbook_read');
|
||||
if (!tools.length) return '';
|
||||
return `\n\n当前机器人可用工具:${tools.join(', ')}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { WecomBotPermission } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
|
||||
export type WecomBotAuditContext = {
|
||||
bot: WecomBotRuntimeConfig;
|
||||
wecomUserId: string;
|
||||
action: string;
|
||||
permission?: WecomBotPermission | null;
|
||||
inputSummary?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotAuditService {
|
||||
private readonly logger = new Logger(WecomBotAuditService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async run<T>(ctx: WecomBotAuditContext, fn: () => Promise<T>): Promise<T> {
|
||||
const started = Date.now();
|
||||
try {
|
||||
const result = await fn();
|
||||
await this.write({ ...ctx, success: true, latencyMs: Date.now() - started });
|
||||
return result;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await this.write({
|
||||
...ctx,
|
||||
success: false,
|
||||
errorMessage: msg.slice(0, 512),
|
||||
latencyMs: Date.now() - started,
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async write(
|
||||
ctx: WecomBotAuditContext & {
|
||||
success: boolean;
|
||||
errorMessage?: string | null;
|
||||
latencyMs?: number | null;
|
||||
},
|
||||
) {
|
||||
const botId = ctx.bot.id ? BigInt(ctx.bot.id) : null;
|
||||
this.logger.log(
|
||||
`wecom bot audit action=${ctx.action} bot=${ctx.bot.key} user=${ctx.wecomUserId} success=${ctx.success}${ctx.inputSummary ? ` input=${ctx.inputSummary.slice(0, 80)}` : ''}`,
|
||||
);
|
||||
try {
|
||||
await this.prisma.logWecomBot.create({
|
||||
data: {
|
||||
botId,
|
||||
botKey: ctx.bot.key,
|
||||
wecomUserId: ctx.wecomUserId,
|
||||
action: ctx.action,
|
||||
permission: ctx.permission ?? null,
|
||||
inputSummary: ctx.inputSummary?.slice(0, 512) ?? null,
|
||||
success: ctx.success,
|
||||
errorMessage: ctx.errorMessage ?? null,
|
||||
latencyMs: ctx.latencyMs ?? null,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.warn(`wecom bot audit write failed: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(query: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
botId?: string;
|
||||
wecomUserId?: string;
|
||||
action?: string;
|
||||
success?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.LogWecomBotWhereInput = {};
|
||||
|
||||
if (query.botId) where.botId = BigInt(query.botId);
|
||||
if (query.wecomUserId?.trim()) where.wecomUserId = { contains: query.wecomUserId.trim() };
|
||||
if (query.action?.trim()) where.action = { contains: query.action.trim() };
|
||||
if (query.success === 'true' || query.success === 'false') {
|
||||
where.success = query.success === 'true';
|
||||
}
|
||||
if (query.from || query.to) {
|
||||
where.createdAt = {
|
||||
...(query.from ? { gte: new Date(query.from) } : {}),
|
||||
...(query.to ? { lte: new Date(query.to) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.logWecomBot.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { bot: { select: { id: true, name: true } } },
|
||||
}),
|
||||
this.prisma.logWecomBot.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: rows.map((row) => ({
|
||||
id: row.id.toString(),
|
||||
botId: row.botId?.toString() ?? null,
|
||||
botName: row.bot?.name ?? null,
|
||||
botKey: row.botKey,
|
||||
wecomUserId: row.wecomUserId,
|
||||
action: row.action,
|
||||
permission: row.permission,
|
||||
inputSummary: row.inputSummary,
|
||||
success: row.success,
|
||||
errorMessage: row.errorMessage,
|
||||
latencyMs: row.latencyMs,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
/** 去掉 LLM 误输出的 JSON / API 占位,避免展示给用户 */
|
||||
export function sanitizeWecomUserReply(text: string): string {
|
||||
let s = text.trim();
|
||||
s = s.replace(/```(?:json)?\s*[\s\S]*?```/gi, '').trim();
|
||||
s = s.replace(/\{\s*"api"\s*:[\s\S]*?\}/gi, '').trim();
|
||||
s = s.replace(/请稍等[,,]?系统正在检索[^\n]*/gi, '').trim();
|
||||
s = s.replace(/^我来帮您[^\n]*\n+/i, '').trim();
|
||||
if (!s) {
|
||||
return '未能生成有效回复。请使用「帮助」中的指令,或换一种问法。';
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 解析 LLM 工具行:TOOL support_tickets_open 或 TOOL order_lookup DK123 */
|
||||
export function parseWecomToolLine(raw: string): { name: string; args: string } | null {
|
||||
const line = raw
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.find((l) => /^TOOL\s+\S+/i.test(l));
|
||||
if (!line) return null;
|
||||
const m = line.match(/^TOOL\s+(\S+)(?:\s+(.*))?$/i);
|
||||
if (!m) return null;
|
||||
return { name: m[1].toLowerCase(), args: (m[2] ?? '').trim() };
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../../common/redis/redis.service';
|
||||
|
||||
const TTL_SECONDS = 30 * 60;
|
||||
|
||||
export type WecomUserVerifySession = {
|
||||
phone: string;
|
||||
/** 验证通过后可查看 */
|
||||
verified: boolean;
|
||||
pendingUserId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotSessionService {
|
||||
constructor(private readonly redis: RedisService) {}
|
||||
|
||||
private key(botKey: string, wecomUserId: string) {
|
||||
return `dukang:wecom:session:${botKey}:${wecomUserId}`;
|
||||
}
|
||||
|
||||
async get(botKey: string, wecomUserId: string): Promise<WecomUserVerifySession | null> {
|
||||
return this.redis.getJson<WecomUserVerifySession>(this.key(botKey, wecomUserId));
|
||||
}
|
||||
|
||||
async setPendingPhone(botKey: string, wecomUserId: string, phone: string) {
|
||||
await this.redis.setJson(
|
||||
this.key(botKey, wecomUserId),
|
||||
{ phone, verified: false } satisfies WecomUserVerifySession,
|
||||
TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
async markVerified(botKey: string, wecomUserId: string, phone: string, userId: string) {
|
||||
await this.redis.setJson(
|
||||
this.key(botKey, wecomUserId),
|
||||
{ phone, verified: true, pendingUserId: userId } satisfies WecomUserVerifySession,
|
||||
TTL_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
async clear(botKey: string, wecomUserId: string) {
|
||||
await this.redis.del(this.key(botKey, wecomUserId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { WecomBotPermission, WecomBotRole } from '@dukang/shared-types';
|
||||
import { normalizeWecomBotRole } from '@dukang/shared-types';
|
||||
|
||||
export type WecomBotRuntimeConfig = {
|
||||
id: string;
|
||||
key: string;
|
||||
role: WecomBotRole;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
botId: string;
|
||||
secret: string;
|
||||
welcome: string;
|
||||
avatarUrl: string | null;
|
||||
permissions: WecomBotPermission[];
|
||||
reviewSuperAdminWecomUserIds: string[];
|
||||
aiEnabled: boolean;
|
||||
llmConfigId: string | null;
|
||||
knowledgeBaseId: string | null;
|
||||
};
|
||||
|
||||
export function wecomBotHasPermission(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
permission: WecomBotPermission,
|
||||
): boolean {
|
||||
return bot.permissions.includes(permission);
|
||||
}
|
||||
|
||||
export function wecomBotNormalizeRole(raw: string): WecomBotRole {
|
||||
return normalizeWecomBotRole(raw);
|
||||
}
|
||||
|
||||
export function wecomBotCanReview(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
): boolean {
|
||||
if (
|
||||
!wecomBotHasPermission(bot, 'support_ticket.review') &&
|
||||
!wecomBotHasPermission(bot, 'support_ticket.approve') &&
|
||||
!wecomBotHasPermission(bot, 'support_ticket.reject')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const ids = bot.reviewSuperAdminWecomUserIds.map((s) => s.trim()).filter(Boolean);
|
||||
if (!ids.length) return false;
|
||||
return ids.includes(wecomUserId.trim());
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/** 团队助手手册条目(知识库摘要,供关键词检索) */
|
||||
export type HandbookEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
keywords: string[];
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const WECOM_HANDBOOK_ENTRIES: HandbookEntry[] = [
|
||||
{
|
||||
id: 'overview',
|
||||
title: '系统整体概述',
|
||||
keywords: ['概述', '四端', '整体', '是什么', '介绍'],
|
||||
body: [
|
||||
'杜康好客:购酒 → 1:1 好客权益 → 门店核销。',
|
||||
'四端:用户小程序 / 门店 H5 / 合伙人 H5 / HQ 后台。',
|
||||
'核心:门店结算=核销额×60%;权益永久;核销码 3 分钟。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'order',
|
||||
title: '订单与履约',
|
||||
keywords: ['订单', '同城', '跨城', '提货', '履约', '小飞侠'],
|
||||
body: [
|
||||
'状态:待付款 → 已付款 → 已完成(30 分钟未付取消)。',
|
||||
'同城≥2瓶:仓配/小飞侠;跨城≥1箱:总部物流到付。',
|
||||
'现场提货:支付后直接已完成并发权益。',
|
||||
'HQ「订单」可查看详情、填运单;「配送单」维护运单号。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'redeem',
|
||||
title: '好客权益与核销',
|
||||
keywords: ['权益', '核销', '出码', '扫码', '余额'],
|
||||
body: [
|
||||
'支付成功发放实付 1:1 权益,永久有效。',
|
||||
'用户出码 3 分钟;门店可扫码或手机号+验证码核销。',
|
||||
'直接核销:0 < 金额 ≤ 全部 ACTIVE 余额。',
|
||||
'门店账本按核销额×60% 入账,T+1 出账。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'city',
|
||||
title: '开城流程',
|
||||
keywords: ['开城', '城市', '合伙人', '仓库', '仓配'],
|
||||
body: [
|
||||
'HQ「开城」:①新增城市 ②配置合伙人(全城/区域+佣金)③仓库 ④仓配承运商。',
|
||||
'已开城走同城规则;未开城走跨城到付。',
|
||||
'订单佣金按收货区县解析区域/全城合伙人;跨城归总部。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'store',
|
||||
title: '开店/拓店流程',
|
||||
keywords: ['开店', '拓店', '入驻', '审核', '试核销'],
|
||||
body: [
|
||||
'合伙人三步录入 → 负责人复核 → HQ 审核 → 试核销 100 元 → 正式入驻。',
|
||||
'营业中门店才对 C 端可见。',
|
||||
'AUTO_APPROVE_STORE 开启时可自动审核(试点)。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
title: '财务结算',
|
||||
keywords: ['财务', '账单', '提现', '打款', '佣金'],
|
||||
body: [
|
||||
'门店账单:核销×60%,T+1 出账;HQ 确认打款。',
|
||||
'合伙人月账独立确认打款。',
|
||||
'门店可对未出账核销主动提现(受单日上限);申请后锁定明细不进入次日出账,HQ 审后打款并企微提醒。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'ticket',
|
||||
title: '工单与发票',
|
||||
keywords: ['工单', '售后', '退款', '补发', '发票', '技术支持'],
|
||||
body: [
|
||||
'售后四类型:仅退款 / 破损补发 / 破损退货 / 退货退款 → HQ 工单中心。',
|
||||
'技术支持:BUG/建议/其他,待评审→开发→测试→通过。',
|
||||
'发票:个人/企业 × 普票/专票;2 工作日 SLA。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'hq-roles',
|
||||
title: 'HQ 角色分工',
|
||||
keywords: ['运营', '财务', '客服', '权限', '角色', 'hq'],
|
||||
body: [
|
||||
'运营:商品/开城/门店/订单/配送/权益。',
|
||||
'财务:门店/合伙人/酒厂账单与打款、发票、酒厂账户。',
|
||||
'客服:用户/订单、售后工单、发票协助。',
|
||||
'超管:权限分配、技术支持评审、系统设置。',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: '系统设置',
|
||||
keywords: ['系统设置', 'mock', '短信', '微信', 'oss', '企微'],
|
||||
body: [
|
||||
'HQ「系统设置」:功能开关、短信、微信、OSS、应用链接、部署、酒厂账户。',
|
||||
'企微机器人在独立菜单「企微机器人」维护;总开关在功能开关「启用企微机器人长连接」。',
|
||||
'角色:客服 / 技术支持 / 团队助手;指令式,不依赖大模型。发「帮助」看命令。',
|
||||
'改完可「同步到 env」;密钥类变更后注意重启标识。',
|
||||
].join('\n'),
|
||||
},
|
||||
];
|
||||
|
||||
export function searchHandbook(query: string, limit = 3): HandbookEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return WECOM_HANDBOOK_ENTRIES.slice(0, limit);
|
||||
|
||||
const scored = WECOM_HANDBOOK_ENTRIES.map((e) => {
|
||||
let score = 0;
|
||||
const title = e.title.toLowerCase();
|
||||
if (title.includes(q)) score += 10;
|
||||
for (const kw of e.keywords) {
|
||||
const k = kw.toLowerCase();
|
||||
if (q.includes(k) || k.includes(q)) score += 5;
|
||||
}
|
||||
if (e.body.toLowerCase().includes(q)) score += 1;
|
||||
return { e, score };
|
||||
})
|
||||
.filter((x) => x.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
if (!scored.length) return [];
|
||||
return scored.slice(0, limit).map((x) => x.e);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { BadRequestException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import {
|
||||
WECOM_PUSH_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
||||
maskWecomWebhookUrl,
|
||||
parseWecomPushConditions,
|
||||
type WecomMessagePushDto,
|
||||
type WecomPushCondition,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { applyWecomAtMentionInContent } from '../../modules/dev-plan/dev-plan-wecom-mention.util';
|
||||
|
||||
type PushRow = {
|
||||
id: bigint;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
webhookUrl: string;
|
||||
enabled: boolean;
|
||||
mentionWecomUserId: string | null;
|
||||
pushConditions: string;
|
||||
sortOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomMessagePushService implements OnModuleInit {
|
||||
private readonly logger = new Logger(WecomMessagePushService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await this.ensureDefaults();
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`wecom message push ensureDefaults failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送(v3.4.11) */
|
||||
async ensureDefaults(): Promise<void> {
|
||||
const count = await this.prisma.wecomMessagePush.count();
|
||||
if (count > 0) return;
|
||||
|
||||
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
|
||||
if (alertUrl) {
|
||||
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '运营告警',
|
||||
webhookUrl: alertUrl,
|
||||
enabled: alertEnabled,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 运营告警');
|
||||
}
|
||||
|
||||
let devWebhook: string | null = null;
|
||||
let devUserId: string | null = null;
|
||||
let devEnabled = false;
|
||||
try {
|
||||
const rows = await this.prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
task_dispatch_webhook_url: string | null;
|
||||
task_dispatch_wecom_user_id: string | null;
|
||||
task_dispatch_enabled: number | boolean | null;
|
||||
}>
|
||||
>(
|
||||
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
devWebhook = row.task_dispatch_webhook_url;
|
||||
devUserId = row.task_dispatch_wecom_user_id;
|
||||
devEnabled = !!row.task_dispatch_enabled;
|
||||
}
|
||||
} catch {
|
||||
// 列已迁移删除,跳过
|
||||
}
|
||||
|
||||
if (devWebhook?.trim()) {
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '开发任务派发',
|
||||
webhookUrl: devWebhook.trim(),
|
||||
enabled: devEnabled,
|
||||
mentionWecomUserId: devUserId?.trim() || null,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
|
||||
sortOrder: 10,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 开发任务派发');
|
||||
}
|
||||
}
|
||||
|
||||
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
|
||||
const rows = await this.prisma.wecomMessagePush.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.filter((r) => parseWecomPushConditions(r.pushConditions).includes(eventKey));
|
||||
}
|
||||
|
||||
async hasEnabledPushes(eventKey: WecomPushCondition): Promise<boolean> {
|
||||
const pushes = await this.listMatchingPushes(eventKey);
|
||||
return pushes.length > 0;
|
||||
}
|
||||
|
||||
/** 向所有匹配 eventKey 的启用推送发送 markdown;返回成功发送数 */
|
||||
async dispatchMarkdown(
|
||||
eventKey: WecomPushCondition,
|
||||
content: string,
|
||||
options?: { applyMention?: boolean },
|
||||
): Promise<number> {
|
||||
const pushes = await this.listMatchingPushes(eventKey);
|
||||
if (!pushes.length) return 0;
|
||||
|
||||
const applyMention = options?.applyMention !== false;
|
||||
let sent = 0;
|
||||
for (const push of pushes) {
|
||||
let text = content.trim();
|
||||
if (applyMention && push.mentionWecomUserId) {
|
||||
text = applyWecomAtMentionInContent(text, push.mentionWecomUserId);
|
||||
}
|
||||
const ok = await this.sendMarkdownToWebhook(push.webhookUrl, text);
|
||||
if (ok) sent += 1;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
async dispatchMarkdownOrThrow(
|
||||
eventKey: WecomPushCondition,
|
||||
content: string,
|
||||
options?: { applyMention?: boolean },
|
||||
): Promise<number> {
|
||||
const sent = await this.dispatchMarkdown(eventKey, content, options);
|
||||
if (sent === 0) {
|
||||
throw new BadRequestException(
|
||||
`没有已启用且勾选「${eventKey}」条件的消息推送,请在 HQ「企微机器人 → 消息推送」中配置`,
|
||||
);
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
async sendMarkdownToWebhook(webhookUrl: string, content: string): Promise<boolean> {
|
||||
const url = (webhookUrl || '').trim();
|
||||
if (!url) return false;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'markdown',
|
||||
markdown: { content: content.slice(0, 4000) },
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
};
|
||||
if (!res.ok || (data.errcode != null && data.errcode !== 0)) {
|
||||
this.logger.warn(
|
||||
`wecom message push failed: HTTP ${res.status} errcode=${data.errcode} ${data.errmsg ?? ''}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.warn(
|
||||
`wecom message push network error: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async sendTest(id: bigint): Promise<{ ok: boolean; message: string }> {
|
||||
const row = await this.prisma.wecomMessagePush.findUnique({ where: { id } });
|
||||
if (!row) throw new BadRequestException('消息推送不存在');
|
||||
if (!row.webhookUrl.trim()) {
|
||||
return { ok: false, message: 'Webhook URL 未配置' };
|
||||
}
|
||||
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
let content = `**消息推送测试 · ${row.name}**\n时间:${now}`;
|
||||
if (row.mentionWecomUserId) {
|
||||
content = applyWecomAtMentionInContent(content, row.mentionWecomUserId);
|
||||
}
|
||||
const ok = await this.sendMarkdownToWebhook(row.webhookUrl, content);
|
||||
return ok
|
||||
? { ok: true, message: '已发送测试消息,请查看企微群' }
|
||||
: { ok: false, message: 'Webhook 调用失败,请检查 URL 或 API 日志' };
|
||||
}
|
||||
|
||||
toDto(row: PushRow): WecomMessagePushDto {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
avatarUrl: row.avatarUrl,
|
||||
webhookUrl: row.webhookUrl,
|
||||
webhookUrlMasked: maskWecomWebhookUrl(row.webhookUrl),
|
||||
enabled: row.enabled,
|
||||
mentionWecomUserId: row.mentionWecomUserId,
|
||||
pushConditions: parseWecomPushConditions(row.pushConditions),
|
||||
sortOrder: row.sortOrder,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
validatePushConditions(conditions: string[]): WecomPushCondition[] {
|
||||
const parsed = parseWecomPushConditions(conditions);
|
||||
if (!parsed.length) {
|
||||
throw new BadRequestException('请至少勾选一项推送条件');
|
||||
}
|
||||
const valid = new Set<string>(WECOM_PUSH_CONDITIONS);
|
||||
for (const c of conditions) {
|
||||
if (!valid.has(c)) throw new BadRequestException(`无效推送条件:${c}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { CommonModule } from '../../modules/common/common.module';
|
||||
|
||||
import { DevPlanModule } from '../../modules/dev-plan/dev-plan.module';
|
||||
|
||||
import { SettlementModule } from '../../modules/settlement/settlement.module';
|
||||
|
||||
import { IntegrationsModule } from '../integrations.module';
|
||||
|
||||
import { LlmModule } from '../llm/llm.module';
|
||||
|
||||
import { WecomAibotService } from './wecom-aibot.service';
|
||||
|
||||
import { WecomBotActionsService } from './wecom-bot-actions.service';
|
||||
|
||||
import { WecomBotAiService } from './wecom-bot-ai.service';
|
||||
|
||||
import { WecomBotAuditService } from './wecom-bot-audit.service';
|
||||
|
||||
import { WecomBotCapabilityService } from './wecom-bot-capability.service';
|
||||
|
||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
|
||||
|
||||
|
||||
/** 企微多机器人:依赖 Common(工单)+ Settlement/DevPlan + Integrations(短信)+ Llm */
|
||||
|
||||
@Module({
|
||||
|
||||
imports: [
|
||||
|
||||
forwardRef(() => CommonModule),
|
||||
Reference in New Issue
Block a user