微信企业机器人创建
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
SUPPORT_TICKET_STATUS_LABELS,
|
||||
SUPPORT_TICKET_TYPE_LABELS,
|
||||
TICKET_TYPE_LABELS,
|
||||
type AfterSaleTicketType,
|
||||
type SupportTicketTypeDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { TicketService } from '../../modules/common/ticket.service';
|
||||
import { SupportTicketService } from '../../modules/common/support-ticket.service';
|
||||
import { SMS_PROVIDER } from '../integrations.constants';
|
||||
import type { ISmsProvider } from '../sms/sms.interface';
|
||||
import type { WecomBotRuntimeConfig } from './wecom-bot.types';
|
||||
import { wecomBotHasPermission } from './wecom-bot.types';
|
||||
import { WecomBotSessionService } from './wecom-bot-session.service';
|
||||
import { searchHandbook } from './wecom-handbook';
|
||||
|
||||
const SMS_SCENE = 'WECOM_USER_VIEW';
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
|
||||
const TICKET_TYPE_ALIASES: Record<string, AfterSaleTicketType> = {
|
||||
仅退款: 'REFUND',
|
||||
退款: 'REFUND',
|
||||
refund: 'REFUND',
|
||||
破损补发: 'RESHIPMENT',
|
||||
补发: 'RESHIPMENT',
|
||||
reshipment: 'RESHIPMENT',
|
||||
破损退货: 'DAMAGE_RETURN',
|
||||
退货: 'DAMAGE_RETURN',
|
||||
damage_return: 'DAMAGE_RETURN',
|
||||
退货退款: 'RETURN_REFUND',
|
||||
return_refund: 'RETURN_REFUND',
|
||||
};
|
||||
|
||||
const SUPPORT_TYPE_ALIASES: Record<string, SupportTicketTypeDto> = {
|
||||
bug: 'BUG',
|
||||
BUG: 'BUG',
|
||||
缺陷: 'BUG',
|
||||
建议: 'SUGGESTION',
|
||||
suggestion: 'SUGGESTION',
|
||||
其他: 'OTHER',
|
||||
other: 'OTHER',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WecomBotActionsService {
|
||||
private readonly logger = new Logger(WecomBotActionsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly supportTicketService: SupportTicketService,
|
||||
private readonly session: WecomBotSessionService,
|
||||
@Inject(SMS_PROVIDER) private readonly sms: ISmsProvider,
|
||||
) {}
|
||||
|
||||
buildHelp(bot: WecomBotRuntimeConfig): string {
|
||||
const lines = [`**${bot.name}**`, '', '通用:`帮助` · `状态`', ''];
|
||||
if (wecomBotHasPermission(bot, 'ticket.create')) {
|
||||
lines.push(
|
||||
'**售后工单**',
|
||||
'`工单 <订单号> <类型> [备注]`',
|
||||
'类型:仅退款 / 破损补发 / 破损退货 / 退货退款',
|
||||
'例:`工单 DK123 仅退款 用户要求退款`',
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'user.view_sms')) {
|
||||
lines.push(
|
||||
'**查用户(需短信验证)**',
|
||||
'`查用户 <手机号>` → 向该手机发验证码',
|
||||
'`验证 <验证码>` → 验证通过后展示用户摘要',
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'delivery.view')) {
|
||||
lines.push('**查快递**', '`快递 <订单号|运单号>`', '');
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'support_ticket.create')) {
|
||||
lines.push(
|
||||
'**技术支持提单**',
|
||||
'`提单 <BUG|建议|其他> <标题> [| 详情]`',
|
||||
'例:`提单 BUG 支付回调偶发失败 | 订单号xxx`',
|
||||
'',
|
||||
);
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'support_ticket.progress')) {
|
||||
lines.push('**开发进度**', '`进度` 最近工单 · `进度 <工单号>` 详情', '');
|
||||
}
|
||||
if (wecomBotHasPermission(bot, 'handbook.query')) {
|
||||
lines.push(
|
||||
'**使用手册**',
|
||||
'`手册` 目录 · `手册 <关键词>` 如:开城、核销、订单、财务',
|
||||
'',
|
||||
);
|
||||
}
|
||||
lines.push(`当前权限:${bot.permissions.join(', ') || '无'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async handleCommand(
|
||||
bot: WecomBotRuntimeConfig,
|
||||
wecomUserId: string,
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
const content = text.trim();
|
||||
if (!content) return this.buildHelp(bot);
|
||||
|
||||
// 查用户 / 验证
|
||||
if (/^(查用户|用户)\s+/i.test(content)) {
|
||||
this.requirePerm(bot, 'user.view_sms');
|
||||
const phone = content.replace(/^(查用户|用户)\s+/i, '').trim();
|
||||
return this.startUserView(bot, wecomUserId, phone);
|
||||
}
|
||||
if (/^(验证|verify)\s+/i.test(content)) {
|
||||
this.requirePerm(bot, 'user.view_sms');
|
||||
const code = content.replace(/^(验证|verify)\s+/i, '').trim();
|
||||
return this.verifyUserView(bot, wecomUserId, code);
|
||||
}
|
||||
|
||||
// 快递
|
||||
if (/^(快递|配送|物流)\s+/i.test(content)) {
|
||||
this.requirePerm(bot, 'delivery.view');
|
||||
const q = content.replace(/^(快递|配送|物流)\s+/i, '').trim();
|
||||
return this.lookupDelivery(q);
|
||||
}
|
||||
|
||||
// 售后工单
|
||||
if (/^(工单|创建工单)\s+/i.test(content)) {
|
||||
this.requirePerm(bot, 'ticket.create');
|
||||
return this.createAfterSaleTicket(content.replace(/^(工单|创建工单)\s+/i, '').trim(), wecomUserId);
|
||||
}
|
||||
|
||||
// 技术支持提单
|
||||
if (/^(提单|技术支持)\s+/i.test(content)) {
|
||||
this.requirePerm(bot, 'support_ticket.create');
|
||||
return this.createSupportTicket(content.replace(/^(提单|技术支持)\s+/i, '').trim(), wecomUserId);
|
||||
}
|
||||
|
||||
// 进度
|
||||
if (/^(进度|开发进度)/i.test(content)) {
|
||||
this.requirePerm(bot, 'support_ticket.progress');
|
||||
const rest = content.replace(/^(进度|开发进度)\s*/i, '').trim();
|
||||
return this.supportProgress(rest);
|
||||
}
|
||||
|
||||
// 手册
|
||||
if (/^(手册|帮助文档|文档)/i.test(content)) {
|
||||
this.requirePerm(bot, 'handbook.query');
|
||||
const q = content.replace(/^(手册|帮助文档|文档)\s*/i, '').trim();
|
||||
return this.queryHandbook(q);
|
||||
}
|
||||
|
||||
// 自然语言手册(仅团队助手有 handbook 权限时)
|
||||
if (wecomBotHasPermission(bot, 'handbook.query') && content.length >= 2) {
|
||||
const hit = searchHandbook(content, 1);
|
||||
if (hit.length) {
|
||||
return formatHandbook(hit);
|
||||
}
|
||||
}
|
||||
|
||||
return `未识别指令。\n\n${this.buildHelp(bot)}`;
|
||||
}
|
||||
|
||||
private requirePerm(bot: WecomBotRuntimeConfig, perm: Parameters<typeof wecomBotHasPermission>[1]) {
|
||||
if (!wecomBotHasPermission(bot, perm)) {
|
||||
throw new Error(`当前机器人无权限:${perm}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async startUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, phone: string) {
|
||||
if (!PHONE_RE.test(phone)) return '请输入 11 位手机号,例如:`查用户 13800138000`';
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { phone },
|
||||
select: { id: true, userNo: true, phone: true },
|
||||
});
|
||||
if (!user) return `未找到手机号 ${phone} 对应的用户`;
|
||||
|
||||
await this.session.setPendingPhone(bot.key, wecomUserId, phone);
|
||||
await this.sms.send(phone, SMS_SCENE);
|
||||
return [
|
||||
`已向 **${maskPhone(phone)}** 发送验证码(用户 ${user.userNo})。`,
|
||||
'请回复:`验证 123456`',
|
||||
'验证码约 3 分钟有效。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async verifyUserView(bot: WecomBotRuntimeConfig, wecomUserId: string, code: string) {
|
||||
const sess = await this.session.get(bot.key, wecomUserId);
|
||||
if (!sess?.phone) return '请先发送:`查用户 <手机号>`';
|
||||
if (!code) return '请提供验证码,例如:`验证 123456`';
|
||||
|
||||
try {
|
||||
await this.sms.verify(sess.phone, code, SMS_SCENE);
|
||||
} catch {
|
||||
return '验证码错误或已过期,请重新 `查用户`';
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: { phone: sess.phone },
|
||||
select: {
|
||||
id: true,
|
||||
userNo: true,
|
||||
phone: true,
|
||||
nickname: true,
|
||||
status: true,
|
||||
phoneVerifiedAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
});
|
||||
if (!user) return '用户不存在';
|
||||
|
||||
await this.session.markVerified(bot.key, wecomUserId, sess.phone, user.id.toString());
|
||||
|
||||
const coupons = await this.prisma.benefitCoupon.aggregate({
|
||||
where: { userId: user.id, status: 'ACTIVE' },
|
||||
_sum: { balance: true },
|
||||
});
|
||||
|
||||
return [
|
||||
'**用户摘要**(短信验证已通过)',
|
||||
`- 用户号:${user.userNo}`,
|
||||
`- 昵称:${user.nickname || '—'}`,
|
||||
`- 手机:${user.phone}`,
|
||||
`- 手机已验:${user.phoneVerifiedAt ? '是' : '否'}`,
|
||||
`- 状态:${user.status}`,
|
||||
`- 订单数:${user._count.orders}`,
|
||||
`- 权益余额:¥${Number(coupons._sum.balance ?? 0).toFixed(2)}`,
|
||||
`- 注册:${user.createdAt.toISOString().slice(0, 19).replace('T', ' ')}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async lookupDelivery(q: string) {
|
||||
if (!q) return '请提供订单号或运单号,例如:`快递 DK123`';
|
||||
|
||||
const byOrder = await this.prisma.orderDelivery.findMany({
|
||||
where: { order: { orderNo: { contains: q } } },
|
||||
take: 5,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
order: {
|
||||
select: {
|
||||
orderNo: true,
|
||||
status: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
deliveryType: true,
|
||||
productName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const byTrack =
|
||||
byOrder.length > 0
|
||||
? []
|
||||
: await this.prisma.orderDelivery.findMany({
|
||||
where: { trackingNo: { contains: q } },
|
||||
take: 5,
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
include: {
|
||||
order: {
|
||||
select: {
|
||||
orderNo: true,
|
||||
status: true,
|
||||
receiverName: true,
|
||||
receiverPhone: true,
|
||||
deliveryType: true,
|
||||
productName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = byOrder.length ? byOrder : byTrack;
|
||||
if (!rows.length) return `未找到与「${q}」匹配的配送单`;
|
||||
|
||||
return rows
|
||||
.map((d, i) => {
|
||||
const o = d.order;
|
||||
return [
|
||||
`**配送 ${i + 1}**`,
|
||||
`- 订单:${o.orderNo}(${o.status})`,
|
||||
`- 商品:${o.productName}`,
|
||||
`- 类型:${o.deliveryType}`,
|
||||
`- 承运:${d.provider}`,
|
||||
`- 运单:${d.trackingNo || '—'}`,
|
||||
`- 第三方单号:${d.providerOrderNo || '—'}`,
|
||||
`- 收货:${o.receiverName} ${maskPhone(o.receiverPhone || '')}`,
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
private async createAfterSaleTicket(rest: string, wecomUserId: string) {
|
||||
// 订单号 类型 备注
|
||||
const parts = rest.split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return '格式:`工单 <订单号> <类型> [备注]`\n类型:仅退款 / 破损补发 / 破损退货 / 退货退款';
|
||||
}
|
||||
const orderNo = parts[0];
|
||||
const typeRaw = parts[1];
|
||||
const remark = parts.slice(2).join(' ') || `企微客服创建 by ${wecomUserId}`;
|
||||
const ticketType = TICKET_TYPE_ALIASES[typeRaw] || TICKET_TYPE_ALIASES[typeRaw.toLowerCase()];
|
||||
if (!ticketType) {
|
||||
return `未知类型「${typeRaw}」。可用:仅退款 / 破损补发 / 破损退货 / 退货退款`;
|
||||
}
|
||||
|
||||
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||
if (!order) return `订单不存在:${orderNo}`;
|
||||
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
|
||||
return '当前订单状态不可创建售后工单';
|
||||
}
|
||||
|
||||
const pending = await this.prisma.commonTicket.findFirst({
|
||||
where: {
|
||||
ticketType: ticketType as never,
|
||||
refType: 'ORDER',
|
||||
refId: order.id,
|
||||
status: { in: ['PENDING', 'OPEN'] },
|
||||
},
|
||||
});
|
||||
if (pending) return `该类型工单已在处理中:${pending.ticketNo}`;
|
||||
|
||||
const ticket = await this.ticketService.create({
|
||||
ticketType,
|
||||
refType: 'ORDER',
|
||||
refId: order.id.toString(),
|
||||
remark: `${remark} [wecom:${wecomUserId}]`,
|
||||
});
|
||||
|
||||
return [
|
||||
'**售后工单已创建**',
|
||||
`- 工单号:${ticket.ticketNo}`,
|
||||
`- 类型:${TICKET_TYPE_LABELS[ticketType]}`,
|
||||
`- 订单:${orderNo}`,
|
||||
`- 状态:${ticket.status}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async createSupportTicket(rest: string, wecomUserId: string) {
|
||||
const m = rest.match(/^(\S+)\s+(.+)$/);
|
||||
if (!m) return '格式:`提单 <BUG|建议|其他> <标题> [| 详情]`';
|
||||
const typeRaw = m[1];
|
||||
const restTitle = m[2];
|
||||
const ticketType =
|
||||
SUPPORT_TYPE_ALIASES[typeRaw] || SUPPORT_TYPE_ALIASES[typeRaw.toLowerCase()];
|
||||
if (!ticketType) return '类型请使用:BUG / 建议 / 其他';
|
||||
|
||||
const [titlePart, ...contentParts] = restTitle.split('|');
|
||||
const title = titlePart.trim();
|
||||
const content = contentParts.join('|').trim();
|
||||
if (!title) return '请填写标题';
|
||||
|
||||
const creator = await this.resolveCreator(wecomUserId);
|
||||
const ticket = await this.supportTicketService.create(
|
||||
{
|
||||
ticketType,
|
||||
title,
|
||||
content: content || undefined,
|
||||
remark: `企微技术支持机器人`,
|
||||
},
|
||||
creator,
|
||||
);
|
||||
|
||||
return [
|
||||
'**技术支持工单已创建**',
|
||||
`- 工单号:${ticket.ticketNo}`,
|
||||
`- 类型:${SUPPORT_TICKET_TYPE_LABELS[ticketType]}`,
|
||||
`- 状态:${SUPPORT_TICKET_STATUS_LABELS[ticket.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || ticket.status}`,
|
||||
`- 标题:${title}`,
|
||||
'等待最高管理员评审。',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private async supportProgress(ticketNo: string) {
|
||||
if (ticketNo) {
|
||||
const ticket = await this.prisma.commonSupportTicket.findFirst({
|
||||
where: { ticketNo: { contains: ticketNo } },
|
||||
});
|
||||
if (!ticket) return `未找到工单:${ticketNo}`;
|
||||
return [
|
||||
`**${ticket.ticketNo}**`,
|
||||
`- 类型:${SUPPORT_TICKET_TYPE_LABELS[ticket.ticketType as SupportTicketTypeDto] || ticket.ticketType}`,
|
||||
`- 状态:${SUPPORT_TICKET_STATUS_LABELS[ticket.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || ticket.status}`,
|
||||
`- 标题:${ticket.title}`,
|
||||
`- 创建人:${ticket.creatorName}`,
|
||||
`- 评审人:${ticket.reviewerName || '—'}`,
|
||||
ticket.rejectReason ? `- 驳回原因:${ticket.rejectReason}` : '',
|
||||
`- 更新:${ticket.updatedAt.toISOString().slice(0, 19).replace('T', ' ')}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const items = await this.prisma.commonSupportTicket.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 8,
|
||||
});
|
||||
if (!items.length) return '暂无技术支持工单';
|
||||
|
||||
const byStatus = await this.prisma.commonSupportTicket.groupBy({
|
||||
by: ['status'],
|
||||
_count: { _all: true },
|
||||
});
|
||||
const summary = byStatus
|
||||
.map(
|
||||
(s) =>
|
||||
`${SUPPORT_TICKET_STATUS_LABELS[s.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || s.status}:${s._count._all}`,
|
||||
)
|
||||
.join(' · ');
|
||||
|
||||
const list = items
|
||||
.map(
|
||||
(t) =>
|
||||
`- ${t.ticketNo} [${SUPPORT_TICKET_STATUS_LABELS[t.status as keyof typeof SUPPORT_TICKET_STATUS_LABELS] || t.status}] ${t.title}`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return [`**开发进度概览**`, summary, '', '**最近工单**', list, '', '详情:`进度 <工单号>`'].join(
|
||||
'\n',
|
||||
);
|
||||
}
|
||||
|
||||
private queryHandbook(q: string) {
|
||||
if (!q) {
|
||||
const catalog = searchHandbook('', 20)
|
||||
.map((e) => `- ${e.title}(关键词:${e.keywords.slice(0, 4).join('、')})`)
|
||||
.join('\n');
|
||||
return `**手册目录**\n${catalog}\n\n查询:\`手册 <关键词>\``;
|
||||
}
|
||||
const hits = searchHandbook(q, 3);
|
||||
if (!hits.length) return `未找到与「${q}」相关的手册内容。可试:开城、核销、订单、财务、工单`;
|
||||
return formatHandbook(hits);
|
||||
}
|
||||
|
||||
private async resolveCreator(wecomUserId: string) {
|
||||
const admin = await this.prisma.hqAccount.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: [{ id: 'asc' }],
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!admin) {
|
||||
this.logger.warn('no hq account for wecom support ticket creator');
|
||||
throw new Error('系统未配置 HQ 账号,无法创建技术支持工单');
|
||||
}
|
||||
return { id: admin.id, name: `${admin.name || 'HQ'}(企微:${wecomUserId})` };
|
||||
}
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
if (!phone || phone.length < 7) return phone || '—';
|
||||
return `${phone.slice(0, 3)}****${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatHandbook(entries: ReturnType<typeof searchHandbook>): string {
|
||||
return entries.map((e) => `**${e.title}**\n${e.body}`).join('\n\n---\n\n');
|
||||
}
|
||||
Reference in New Issue
Block a user