170 lines
5.3 KiB
Go
170 lines
5.3 KiB
Go
package biz
|
||
|
||
import (
|
||
"ai_scheduler/internal/config"
|
||
"ai_scheduler/internal/data/mongo_model"
|
||
"ai_scheduler/internal/pkg"
|
||
"ai_scheduler/internal/pkg/wx"
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"math/rand"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// AdviceWxSendBiz 微信文本消息发送助手
|
||
// 供 AI 策略回复、熟客超时兜底、主动触达等链路统一发送:调用上游 PostText 接口,
|
||
// 并将发出内容登记到消息流水(direction=ai,参与后续画像迭代分析)。
|
||
type AdviceWxSendBiz struct {
|
||
cfg *config.Config
|
||
mongo *pkg.Mongo
|
||
wxMsgMongo *mongo_model.AdvicerWxMsgMongo
|
||
clientBiz *AdviceClientBiz
|
||
advicerBiz *AdviceAdvicerBiz
|
||
}
|
||
|
||
func NewAdviceWxSendBiz(
|
||
cfg *config.Config,
|
||
mongo *pkg.Mongo,
|
||
wxMsgMongo *mongo_model.AdvicerWxMsgMongo,
|
||
clientBiz *AdviceClientBiz,
|
||
advicerBiz *AdviceAdvicerBiz,
|
||
) *AdviceWxSendBiz {
|
||
return &AdviceWxSendBiz{
|
||
cfg: cfg,
|
||
mongo: mongo,
|
||
wxMsgMongo: wxMsgMongo,
|
||
clientBiz: clientBiz,
|
||
advicerBiz: advicerBiz,
|
||
}
|
||
}
|
||
|
||
// Available 是否具备发送条件(配置了上游 Token)
|
||
func (a *AdviceWxSendBiz) Available() bool {
|
||
return a != nil && len(a.cfg.Advicer.WxToken) != 0
|
||
}
|
||
|
||
// SendText 向客户发送一条文本消息(appId 为空时自动按 wxid 取绑定客户的 appId)。
|
||
// 发送成功后登记消息流水并更新客户互动时间(lastReplyAt)。
|
||
// 使用全局配置 cfg.Advicer.WxToken 作为协议 Token。
|
||
func (a *AdviceWxSendBiz) SendText(ctx context.Context, appId, wxid, content, source string) (err error) {
|
||
token := a.cfg.Advicer.WxToken
|
||
if len(token) == 0 {
|
||
return errors.New("未配置 advicer.wx_token,无法发送微信消息")
|
||
}
|
||
return a.SendTextWithToken(ctx, token, appId, wxid, content, source)
|
||
}
|
||
|
||
// SendTextWithToken 使用指定的 token 发送文本消息(托管场景使用项目级 wx_token)。
|
||
func (a *AdviceWxSendBiz) SendTextWithToken(ctx context.Context, token, appId, wxid, content, source string) (err error) {
|
||
content = strings.TrimSpace(content)
|
||
if len(wxid) == 0 {
|
||
return errors.New("wxid 不能为空")
|
||
}
|
||
if len(content) == 0 {
|
||
return errors.New("发送内容不能为空")
|
||
}
|
||
// appId 缺省补全:从已绑定客户记录取
|
||
if len(appId) == 0 && a.clientBiz != nil {
|
||
if c, found, _ := a.clientBiz.FindByWxid(ctx, wxid); found {
|
||
appId = c.AppId
|
||
}
|
||
}
|
||
|
||
var res wx.PostTextResData
|
||
if err = wx.Request(ctx, token, wx.PostText, wx.PostTextReq{
|
||
AppId: appId,
|
||
ToWxid: wxid,
|
||
Content: content,
|
||
}, &res); err != nil {
|
||
return fmt.Errorf("发送微信消息失败: %w", err)
|
||
}
|
||
|
||
// 反查销售本人 wxid(selfWxid),保证 AI 发出的消息在按 selfWxid 维度的会话查询中可见
|
||
var selfWxid string
|
||
if len(appId) != 0 && a.advicerBiz != nil {
|
||
if adv, e := a.advicerBiz.FindByWxDeviceId(ctx, appId); e == nil {
|
||
selfWxid = adv.WxId
|
||
}
|
||
}
|
||
|
||
// 登记消息流水(analyzed=false,下一轮画像迭代会连同客户消息一起分析)
|
||
now := time.Now()
|
||
_, _ = a.mongo.Co(a.wxMsgMongo).InsertOne(ctx, &mongo_model.AdvicerWxMsgMongo{
|
||
AppId: appId,
|
||
Wxid: wxid,
|
||
SelfWxid: selfWxid,
|
||
Direction: mongo_model.WxMsgDirectionAi,
|
||
MsgType: mongo_model.WxMsgTypeText,
|
||
Content: content,
|
||
MsgId: fmt.Sprintf("ai-%d", now.UnixNano()),
|
||
Source: source,
|
||
CreateAt: now,
|
||
})
|
||
// 更新客户互动时间(lastContactAt / lastReplyAt / 消息数)
|
||
if a.clientBiz != nil {
|
||
_ = a.clientBiz.TouchInteraction(ctx, wxid, mongo_model.WxMsgDirectionAi)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SendMulti 按顺序发送多条短消息(拆条后的 AI 回复)
|
||
func (a *AdviceWxSendBiz) SendMulti(ctx context.Context, appId, wxid string, contents []string, source string) (sent []string, err error) {
|
||
for _, content := range contents {
|
||
content = strings.TrimSpace(content)
|
||
if len(content) == 0 {
|
||
continue
|
||
}
|
||
if e := a.SendText(ctx, appId, wxid, content, source); e != nil {
|
||
return sent, e
|
||
}
|
||
sent = append(sent, content)
|
||
// 多条消息之间稍作停顿,贴近真人连续发送的节奏
|
||
time.Sleep(1200 * time.Millisecond)
|
||
}
|
||
return sent, nil
|
||
}
|
||
|
||
// SendMultiWithToken 使用项目级 token 按顺序发送多条短消息,每条之间按打字速度计算延迟。
|
||
func (a *AdviceWxSendBiz) SendMultiWithToken(ctx context.Context, token, appId, wxid string, contents []string, source string) (sent []string, err error) {
|
||
for i, content := range contents {
|
||
content = strings.TrimSpace(content)
|
||
if len(content) == 0 {
|
||
continue
|
||
}
|
||
if e := a.SendTextWithToken(ctx, token, appId, wxid, content, source); e != nil {
|
||
return sent, e
|
||
}
|
||
sent = append(sent, content)
|
||
// 最后一条之后不需要等待
|
||
if i < len(contents)-1 {
|
||
delay := typingDelay(len(content))
|
||
time.Sleep(delay)
|
||
}
|
||
}
|
||
return sent, nil
|
||
}
|
||
|
||
// typingDelay 根据消息长度计算模拟真人打字延迟。
|
||
// 模型:阅读上一条消息 + 思考 + 打字,每字约 300~500ms,加思考时间和随机抖动。
|
||
func typingDelay(charCount int) time.Duration {
|
||
if charCount <= 0 {
|
||
return 3 * time.Second
|
||
}
|
||
// 思考时间:2~5秒(每条消息前都要想一想)
|
||
thinkMs := 2000 + rand.Intn(3001)
|
||
// 打字时间:每字 300~500ms(模拟手机端打字速度)
|
||
typeMsPerChar := 300 + rand.Intn(201)
|
||
typeMs := charCount * typeMsPerChar
|
||
totalMs := thinkMs + typeMs
|
||
// 下限 3秒,上限 20秒
|
||
if totalMs < 3000 {
|
||
totalMs = 3000
|
||
}
|
||
if totalMs > 20000 {
|
||
totalMs = 20000
|
||
}
|
||
return time.Duration(totalMs) * time.Millisecond
|
||
}
|