ai_scheduler/internal/biz/advice_wx_send.go

170 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}