499 lines
14 KiB
Go
499 lines
14 KiB
Go
|
package kefuWework
|
|||
|
|
|||
|
import (
|
|||
|
"bytes"
|
|||
|
"encoding/json"
|
|||
|
"encoding/xml"
|
|||
|
"fmt"
|
|||
|
"github.com/tidwall/gjson"
|
|||
|
"io/ioutil"
|
|||
|
"kefu/lib"
|
|||
|
"kefu/models"
|
|||
|
"kefu/service"
|
|||
|
"kefu/tools"
|
|||
|
"kefu/ws"
|
|||
|
"log"
|
|||
|
"net/http"
|
|||
|
"net/url"
|
|||
|
"strings"
|
|||
|
"time"
|
|||
|
|
|||
|
"github.com/gin-gonic/gin"
|
|||
|
"github.com/patrickmn/go-cache"
|
|||
|
)
|
|||
|
|
|||
|
// 验证企业微信回调的token
|
|||
|
//var token = "taooshihan"
|
|||
|
//
|
|||
|
//// 验证企业微信回调的key
|
|||
|
//var encodingAesKey = "ORSH1J7CaQtQi2b4dGY5gAJDUJFDOd5cO7Yd7knIxUF"
|
|||
|
//
|
|||
|
//// 企业微信企业id
|
|||
|
//var corpid = "wwa4266261c4ea2c08"
|
|||
|
//
|
|||
|
//// 企业微信secret
|
|||
|
//var corpsecret = "SPU2s8t5cmI3GfkAuggiTyPk5LsuPY22hgep4cGBzlQ"
|
|||
|
|
|||
|
// 企业微信的重试缓存,如果服务器延迟低,可以去掉该变量以及 isRetry 逻辑
|
|||
|
var retryCache = cache.New(60*time.Minute, 10*time.Minute)
|
|||
|
|
|||
|
// 企业微信 token 缓存,请求频次过高可能有一些额外的问题
|
|||
|
var tokenCache = cache.New(5*time.Minute, 5*time.Minute)
|
|||
|
|
|||
|
// 上下文对话能力,默认是 3, 可以根据需要修改对话长度
|
|||
|
var weworkConversationSize = 3
|
|||
|
|
|||
|
type WeixinUserAskMsg struct {
|
|||
|
ToUserName string `xml:"ToUserName"`
|
|||
|
CreateTime int64 `xml:"CreateTime"`
|
|||
|
MsgType string `xml:"MsgType"`
|
|||
|
Event string `xml:"Event"`
|
|||
|
Token string `xml:"Token"`
|
|||
|
OpenKfId string `xml:"OpenKfId"`
|
|||
|
}
|
|||
|
|
|||
|
type AccessToken struct {
|
|||
|
Errcode int `json:"errcode"`
|
|||
|
Errmsg string `json:"errmsg"`
|
|||
|
AccessToken string `json:"access_token"`
|
|||
|
ExpiresIn int `json:"expires_in"`
|
|||
|
}
|
|||
|
|
|||
|
type MsgRet struct {
|
|||
|
Errcode int `json:"errcode"`
|
|||
|
Errmsg string `json:"errmsg"`
|
|||
|
NextCursor string `json:"next_cursor"`
|
|||
|
MsgList []Msg `json:"msg_list"`
|
|||
|
}
|
|||
|
type Msg struct {
|
|||
|
Msgid string `json:"msgid"`
|
|||
|
SendTime int64 `json:"send_time"`
|
|||
|
Origin int `json:"origin"`
|
|||
|
Msgtype string `json:"msgtype"`
|
|||
|
Event struct {
|
|||
|
EventType string `json:"event_type"`
|
|||
|
Scene string `json:"scene"`
|
|||
|
OpenKfid string `json:"open_kfid"`
|
|||
|
ExternalUserid string `json:"external_userid"`
|
|||
|
WelcomeCode string `json:"welcome_code"`
|
|||
|
} `json:"event"`
|
|||
|
Text struct {
|
|||
|
Content string `json:"content"`
|
|||
|
} `json:"text"`
|
|||
|
OpenKfid string `json:"open_kfid"`
|
|||
|
ExternalUserid string `json:"external_userid"`
|
|||
|
}
|
|||
|
|
|||
|
type ReplyMsg struct {
|
|||
|
Touser string `json:"touser,omitempty"`
|
|||
|
OpenKfid string `json:"open_kfid,omitempty"`
|
|||
|
Msgid string `json:"msgid,omitempty"`
|
|||
|
Msgtype string `json:"msgtype,omitempty"`
|
|||
|
Text struct {
|
|||
|
Content string `json:"content,omitempty"`
|
|||
|
} `json:"text,omitempty"`
|
|||
|
}
|
|||
|
|
|||
|
// 定义微信客服API的封装结构体
|
|||
|
type KefuWework struct {
|
|||
|
corpid string
|
|||
|
corpsecret string
|
|||
|
Token string
|
|||
|
EncodingAESKey string
|
|||
|
}
|
|||
|
|
|||
|
// 创建微信客服API的封装结构体实例
|
|||
|
func NewKefuWework(corpid, corpsecret, Token, EncodingAESKey string) *KefuWework {
|
|||
|
return &KefuWework{
|
|||
|
corpid: corpid,
|
|||
|
corpsecret: corpsecret,
|
|||
|
Token: Token,
|
|||
|
EncodingAESKey: EncodingAESKey,
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 微信客服消息
|
|||
|
func TalkWeixin(c *gin.Context) {
|
|||
|
entId := c.Param("entId")
|
|||
|
verifyMsgSign := c.Query("msg_signature")
|
|||
|
verifyTimestamp := c.Query("timestamp")
|
|||
|
verifyNonce := c.Query("nonce")
|
|||
|
|
|||
|
//获取配置
|
|||
|
configs := models.GetEntConfigsMap(entId, "kefuWeworkCorpid", "kefuWeworkSecret", "kefuWeworkToken", "kefuWeworkEncodingAESKey")
|
|||
|
kefuWework := NewKefuWework(configs["kefuWeworkCorpid"], configs["kefuWeworkSecret"], configs["kefuWeworkToken"], configs["kefuWeworkEncodingAESKey"])
|
|||
|
//读取xml数据
|
|||
|
bodyBytes, _ := ioutil.ReadAll(c.Request.Body)
|
|||
|
crypt := NewWXBizMsgCrypt(kefuWework.Token, kefuWework.EncodingAESKey, kefuWework.corpid, 1)
|
|||
|
data, _ := crypt.DecryptMsg(verifyMsgSign, verifyTimestamp, verifyNonce, bodyBytes)
|
|||
|
var weixinUserAskMsg WeixinUserAskMsg
|
|||
|
err := xml.Unmarshal(data, &weixinUserAskMsg)
|
|||
|
if err != nil {
|
|||
|
log.Println("微信客服xml解析失败,error: " + err.Error())
|
|||
|
return
|
|||
|
}
|
|||
|
accessToken, err := kefuWework.accessToken()
|
|||
|
if err != nil {
|
|||
|
log.Println("微信客服获取access_token失败,error: " + err.Error())
|
|||
|
return
|
|||
|
}
|
|||
|
//获取最新的消息
|
|||
|
msgToken := weixinUserAskMsg.Token
|
|||
|
msgRet, err := getMsgs(accessToken, msgToken)
|
|||
|
if err != nil {
|
|||
|
log.Println("微信客服获取最新消息失败,error: " + err.Error())
|
|||
|
return
|
|||
|
}
|
|||
|
if isRetry(verifyMsgSign) {
|
|||
|
log.Println("微信客服重试机制 ")
|
|||
|
return
|
|||
|
}
|
|||
|
//访客入库
|
|||
|
handleVisitor(msgRet, entId, c, kefuWework)
|
|||
|
//返回消息
|
|||
|
go handleMsgRet(msgRet, entId, kefuWework)
|
|||
|
c.JSON(200, "ok")
|
|||
|
}
|
|||
|
|
|||
|
func (this *KefuWework) TalkToUser(external_userid, open_kfid, content string) {
|
|||
|
reply := ReplyMsg{
|
|||
|
Touser: external_userid,
|
|||
|
OpenKfid: open_kfid,
|
|||
|
Msgtype: "text",
|
|||
|
Text: struct {
|
|||
|
Content string `json:"content,omitempty"`
|
|||
|
}{Content: content},
|
|||
|
}
|
|||
|
atoken, err := this.accessToken()
|
|||
|
if err != nil {
|
|||
|
return
|
|||
|
}
|
|||
|
callTalk(reply, atoken)
|
|||
|
}
|
|||
|
|
|||
|
// 获取最新的消息处理是否自动回复
|
|||
|
func handleMsgRet(msgRet MsgRet, entId string, wework *KefuWework) {
|
|||
|
size := len(msgRet.MsgList)
|
|||
|
if size < 1 {
|
|||
|
return
|
|||
|
}
|
|||
|
current := msgRet.MsgList[size-1]
|
|||
|
userId := current.ExternalUserid
|
|||
|
kfId := current.OpenKfid
|
|||
|
//消息类型
|
|||
|
msgtype := current.Msgtype
|
|||
|
//事件消息
|
|||
|
if msgtype == "event" {
|
|||
|
//用户进入会话事件
|
|||
|
//current.Event.WelcomeCode
|
|||
|
}
|
|||
|
//文本消息
|
|||
|
content := current.Text.Content
|
|||
|
if content == "" {
|
|||
|
go wework.TalkToUser(userId, kfId, "非常抱歉,当前仅支持文本消息,请输入文本。")
|
|||
|
return
|
|||
|
}
|
|||
|
|
|||
|
//查询机器人信息
|
|||
|
configs := models.GetEntConfigsMap(entId, "QiyeWechatKefuPreRobot", "RobotStatus", "chatGPTSecret", "QdrantAIStatus")
|
|||
|
if configs["RobotStatus"] == "3" {
|
|||
|
return
|
|||
|
}
|
|||
|
kefuInfo := models.FindUser(kfId)
|
|||
|
if kefuInfo.ID == 0 {
|
|||
|
kefuInfo = models.FindUserByUid(entId)
|
|||
|
}
|
|||
|
visitorId := fmt.Sprintf("wxkf|%s|%s", entId, userId)
|
|||
|
vistorInfo := models.FindVisitorByVistorId(visitorId)
|
|||
|
ret := SearchLearn(entId, content)
|
|||
|
//判断chatGPT
|
|||
|
if ret == "" && configs["QdrantAIStatus"] == "true" && models.CheckVisitorRobotReply(vistorInfo.State) {
|
|||
|
if configs["QiyeWechatKefuPreRobot"] != "" {
|
|||
|
go wework.TalkToUser(userId, kfId, configs["QiyeWechatKefuPreRobot"])
|
|||
|
}
|
|||
|
ret = Gpt3Knowledge(entId, visitorId, kefuInfo, content)
|
|||
|
}
|
|||
|
//if ret == "" && configs["chatGPTSecret"] != "" {
|
|||
|
// ret = service.Gpt3dot5Message(entId, "robot_message", content)
|
|||
|
// //ret, _ = AskOnConversation(configs["chatGPTSecret"], content, userId, weworkConversationSize)
|
|||
|
// //TalkToUser(userId, kfId, content, "该问题暂时无法回答,已收录到学习库")
|
|||
|
//}
|
|||
|
if ret == "" {
|
|||
|
return
|
|||
|
}
|
|||
|
|
|||
|
models.CreateMessage(kefuInfo.Name, visitorId, ret, "kefu", entId, "read")
|
|||
|
go ws.KefuMessage(visitorId, ret, kefuInfo)
|
|||
|
wework.TalkToUser(userId, kfId, ret)
|
|||
|
}
|
|||
|
func handleVisitor(msgRet MsgRet, entId string, c *gin.Context, wework *KefuWework) {
|
|||
|
size := len(msgRet.MsgList)
|
|||
|
if size < 1 {
|
|||
|
return
|
|||
|
}
|
|||
|
current := msgRet.MsgList[size-1]
|
|||
|
userId := current.ExternalUserid
|
|||
|
kfId := current.OpenKfid
|
|||
|
content := current.Text.Content
|
|||
|
if content == "" {
|
|||
|
return
|
|||
|
}
|
|||
|
|
|||
|
//查找访客插入访客表
|
|||
|
visitorId := fmt.Sprintf("wxkf|%s|%s", entId, userId)
|
|||
|
vistorInfo := models.FindVisitorByVistorId(visitorId)
|
|||
|
if vistorInfo.ID == 0 {
|
|||
|
accessToken, _ := wework.accessToken()
|
|||
|
ret, _ := getVisitor(accessToken, userId)
|
|||
|
visitorName := gjson.Get(ret, "customer_list.0.nickname").String()
|
|||
|
avator := gjson.Get(ret, "customer_list.0.avatar").String()
|
|||
|
if visitorName == "" {
|
|||
|
visitorName = "微信客服用户"
|
|||
|
}
|
|||
|
if avator == "" {
|
|||
|
avator = "/static/images/we-chat-wx.png"
|
|||
|
}
|
|||
|
vistorInfo = models.Visitor{
|
|||
|
Model: models.Model{
|
|||
|
CreatedAt: time.Now(),
|
|||
|
UpdatedAt: time.Now(),
|
|||
|
},
|
|||
|
Name: visitorName,
|
|||
|
Avator: avator,
|
|||
|
ToId: kfId,
|
|||
|
VisitorId: visitorId,
|
|||
|
Status: 1,
|
|||
|
Refer: "来自微信客服",
|
|||
|
EntId: entId,
|
|||
|
VisitNum: 0,
|
|||
|
City: "微信客服",
|
|||
|
ClientIp: c.ClientIP(),
|
|||
|
}
|
|||
|
vistorInfo.AddVisitor()
|
|||
|
} else {
|
|||
|
go models.UpdateVisitorStatus(visitorId, 3)
|
|||
|
}
|
|||
|
|
|||
|
kefuInfo := models.FindUser(kfId)
|
|||
|
if kefuInfo.ID == 0 {
|
|||
|
kefuInfo = models.FindUserByUid(entId)
|
|||
|
}
|
|||
|
go ws.VisitorOnline(kefuInfo.Name, vistorInfo)
|
|||
|
go ws.VisitorSendToKefu(kefuInfo.Name, vistorInfo, content)
|
|||
|
go service.SendWorkWechatMesage(vistorInfo.EntId, vistorInfo, kefuInfo, content, c)
|
|||
|
}
|
|||
|
func isRetry(signature string) bool {
|
|||
|
var base = "retry:signature:%s"
|
|||
|
key := fmt.Sprintf(base, signature)
|
|||
|
_, found := retryCache.Get(key)
|
|||
|
if found {
|
|||
|
return true
|
|||
|
}
|
|||
|
retryCache.Set(key, "1", 1*time.Minute)
|
|||
|
return false
|
|||
|
}
|
|||
|
|
|||
|
func getMsgs(accessToken, msgToken string) (MsgRet, error) {
|
|||
|
var msgRet MsgRet
|
|||
|
url := "https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token=" + accessToken
|
|||
|
method := "POST"
|
|||
|
payload := strings.NewReader(fmt.Sprintf(`{"token" : "%s"}`, msgToken))
|
|||
|
client := &http.Client{}
|
|||
|
req, err := http.NewRequest(method, url, payload)
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return msgRet, err
|
|||
|
}
|
|||
|
req.Header.Add("Content-Type", "application/json")
|
|||
|
|
|||
|
res, err := client.Do(req)
|
|||
|
if err != nil {
|
|||
|
return msgRet, err
|
|||
|
}
|
|||
|
defer res.Body.Close()
|
|||
|
|
|||
|
body, err := ioutil.ReadAll(res.Body)
|
|||
|
if err != nil {
|
|||
|
log.Println("微信客服读取消息失败,error:", err)
|
|||
|
return msgRet, err
|
|||
|
}
|
|||
|
json.Unmarshal(body, &msgRet)
|
|||
|
return msgRet, nil
|
|||
|
}
|
|||
|
func getVisitor(accessToken, visitorId string) (string, error) {
|
|||
|
var respData string
|
|||
|
url := "https://qyapi.weixin.qq.com/cgi-bin/kf/customer/batchget?access_token=" + accessToken
|
|||
|
method := "POST"
|
|||
|
reqData := map[string]interface{}{
|
|||
|
"external_userid_list": []string{
|
|||
|
visitorId,
|
|||
|
},
|
|||
|
}
|
|||
|
reqBody, err := json.Marshal(reqData)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
client := &http.Client{}
|
|||
|
req, err := http.NewRequest(method, url, bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
req.Header.Add("Content-Type", "application/json")
|
|||
|
|
|||
|
res, err := client.Do(req)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
defer res.Body.Close()
|
|||
|
|
|||
|
body, err := ioutil.ReadAll(res.Body)
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
return string(body), nil
|
|||
|
}
|
|||
|
func (this *KefuWework) accessToken() (string, error) {
|
|||
|
var tokenCacheKey = "tokenCache"
|
|||
|
data, found := tokenCache.Get(tokenCacheKey)
|
|||
|
if found {
|
|||
|
return fmt.Sprintf("%v", data), nil
|
|||
|
}
|
|||
|
urlBase := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s"
|
|||
|
url := fmt.Sprintf(urlBase, this.corpid, this.corpsecret)
|
|||
|
method := "GET"
|
|||
|
client := &http.Client{}
|
|||
|
req, err := http.NewRequest(method, url, nil)
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return "", err
|
|||
|
}
|
|||
|
res, err := client.Do(req)
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return "", err
|
|||
|
}
|
|||
|
defer res.Body.Close()
|
|||
|
|
|||
|
body, err := ioutil.ReadAll(res.Body)
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return "", err
|
|||
|
}
|
|||
|
s := string(body)
|
|||
|
var accessToken AccessToken
|
|||
|
json.Unmarshal([]byte(s), &accessToken)
|
|||
|
token := accessToken.AccessToken
|
|||
|
tokenCache.Set(tokenCacheKey, token, 5*time.Minute)
|
|||
|
return token, nil
|
|||
|
}
|
|||
|
func Gpt3Knowledge(entId, visitorId string, kefuInfo models.User, content string) string {
|
|||
|
config := models.GetEntConfigsMap(entId, "chatGPTUrl", "chatGPTSecret", "QdrantAIStatus", "QdrantAICollect", "chatGPTSystem", "chatGPTPrompt",
|
|||
|
"RobotName", "RobotAvator",
|
|||
|
"chatGPTHistory")
|
|||
|
api := models.FindConfig("BaseGPTKnowledge")
|
|||
|
|
|||
|
messageContent := ""
|
|||
|
if config["QdrantAIStatus"] == "true" && config["QdrantAICollect"] != "" && api != "" {
|
|||
|
kefuInfo.Nickname = tools.Ifelse(config["RobotName"] != "", config["RobotName"], kefuInfo.Nickname).(string)
|
|||
|
path := fmt.Sprintf("%s/%s/searchStream", api, config["QdrantAICollect"])
|
|||
|
data := url.Values{}
|
|||
|
data.Set("keywords", content)
|
|||
|
system := fmt.Sprintf("我希望你扮演%s客服人员", kefuInfo.Nickname)
|
|||
|
if config["chatGPTSystem"] != "" {
|
|||
|
system = config["chatGPTSystem"]
|
|||
|
}
|
|||
|
data.Set("system", system)
|
|||
|
data.Set("prompt", config["chatGPTPrompt"])
|
|||
|
data.Set("gptUrl", config["chatGPTUrl"])
|
|||
|
data.Set("gptSecret", config["chatGPTSecret"])
|
|||
|
//是否传递历史记录
|
|||
|
if config["chatGPTHistory"] == "true" {
|
|||
|
messages := models.FindMessageByQueryPage(1, 6, "visitor_id = ?", visitorId)
|
|||
|
gptMessages := make([]lib.Gpt3Dot5Message, 0)
|
|||
|
for i := len(messages) - 1; i >= 1; i-- {
|
|||
|
reqContent := messages[i].Content
|
|||
|
if messages[i].MesType == "visitor" {
|
|||
|
gptMessages = append(gptMessages, lib.Gpt3Dot5Message{
|
|||
|
Role: "user",
|
|||
|
Content: reqContent,
|
|||
|
})
|
|||
|
} else {
|
|||
|
gptMessages = append(gptMessages, lib.Gpt3Dot5Message{
|
|||
|
Role: "assistant",
|
|||
|
Content: reqContent,
|
|||
|
})
|
|||
|
}
|
|||
|
}
|
|||
|
historyJson, _ := json.Marshal(gptMessages)
|
|||
|
data.Set("history", string(historyJson))
|
|||
|
}
|
|||
|
messageContent, _ = tools.PostForm(path, data)
|
|||
|
}
|
|||
|
return messageContent
|
|||
|
}
|
|||
|
|
|||
|
func callTalk(reply ReplyMsg, accessToken string) error {
|
|||
|
url := "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token=" + accessToken
|
|||
|
method := "POST"
|
|||
|
data, err := json.Marshal(reply)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
reqBody := string(data)
|
|||
|
fmt.Println(reqBody)
|
|||
|
payload := strings.NewReader(reqBody)
|
|||
|
client := &http.Client{}
|
|||
|
req, err := http.NewRequest(method, url, payload)
|
|||
|
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
req.Header.Add("Content-Type", "application/json")
|
|||
|
|
|||
|
res, err := client.Do(req)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
defer res.Body.Close()
|
|||
|
body, err := ioutil.ReadAll(res.Body)
|
|||
|
if err != nil {
|
|||
|
fmt.Println(err)
|
|||
|
return err
|
|||
|
}
|
|||
|
s := string(body)
|
|||
|
fmt.Println(s)
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
// 查询自己知识库内容
|
|||
|
func SearchLearn(entId, content string) string {
|
|||
|
articles := models.FindArticleList(1, 10, "score desc", "ent_id= ? and title like ?", entId, "%"+content+"%")
|
|||
|
|
|||
|
result := ""
|
|||
|
articleLen := len(articles)
|
|||
|
//未匹配,进入学习库
|
|||
|
if articleLen == 0 {
|
|||
|
if len([]rune(content)) > 1 {
|
|||
|
go service.AddUpdateLearn(entId, content)
|
|||
|
}
|
|||
|
} else if articleLen == 1 {
|
|||
|
result, _ = tools.ReplaceStringByRegex(articles[0].Content, `<[^a>]+>|target="[^"]+"`, "")
|
|||
|
article := models.Article{
|
|||
|
Score: articles[0].Score + 1,
|
|||
|
}
|
|||
|
go article.SaveArticle("ent_id = ? and id = ?", entId, articles[0].Id)
|
|||
|
} else if articleLen > 1 {
|
|||
|
result = "您是不是想问:"
|
|||
|
for _, article := range articles {
|
|||
|
title := strings.Split(strings.ReplaceAll(article.Title, ",", ","), ",")[0]
|
|||
|
result += fmt.Sprintf("\r\n<a href='weixin://bizmsgmenu?msgmenucontent=%s&msgmenuid=%d'>%s</a>", title, article.Id, title)
|
|||
|
}
|
|||
|
}
|
|||
|
return result
|
|||
|
}
|
|||
|
|
|||
|
//
|