486 lines
12 KiB
Go
486 lines
12 KiB
Go
|
package wechat_kf_sdk
|
|||
|
|
|||
|
import (
|
|||
|
"bytes"
|
|||
|
"encoding/json"
|
|||
|
"encoding/xml"
|
|||
|
"errors"
|
|||
|
"fmt"
|
|||
|
"github.com/patrickmn/go-cache"
|
|||
|
"github.com/silenceper/wechat/v2/util"
|
|||
|
"github.com/tidwall/gjson"
|
|||
|
"io"
|
|||
|
"io/ioutil"
|
|||
|
"log"
|
|||
|
"net/http"
|
|||
|
"os"
|
|||
|
"strings"
|
|||
|
"sync"
|
|||
|
"time"
|
|||
|
)
|
|||
|
|
|||
|
// 定义获取access_token的响应数据结构体
|
|||
|
type AccessTokenResponse struct {
|
|||
|
ErrCode int `json:"errcode"` // 错误码
|
|||
|
ErrMsg string `json:"errmsg"` // 错误信息
|
|||
|
AccessToken string `json:"access_token"` // access_token
|
|||
|
ExpiresIn int `json:"expires_in"` // 过期时间
|
|||
|
}
|
|||
|
|
|||
|
// 定义微信客服API的封装结构体
|
|||
|
type KefuWework struct {
|
|||
|
corpid string // 企业ID
|
|||
|
corpsecret string // 企业密钥
|
|||
|
Token string // 令牌
|
|||
|
EncodingAESKey string // AES加密密钥
|
|||
|
mutex sync.Mutex // 互斥锁,用于保护access_token的获取
|
|||
|
}
|
|||
|
|
|||
|
// 微信回调消息
|
|||
|
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 SyncMsgRet struct {
|
|||
|
Errcode int `json:"errcode"`
|
|||
|
Errmsg string `json:"errmsg"`
|
|||
|
NextCursor string `json:"next_cursor"`
|
|||
|
MsgList []SyncMsg `json:"msg_list"`
|
|||
|
}
|
|||
|
type SyncMsg 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"`
|
|||
|
Image struct {
|
|||
|
MediaId string `json:"media_id"`
|
|||
|
} `json:"image"`
|
|||
|
Voice struct {
|
|||
|
MediaId string `json:"media_id"`
|
|||
|
} `json:"voice"`
|
|||
|
OpenKfid string `json:"open_kfid"`
|
|||
|
ExternalUserid string `json:"external_userid"`
|
|||
|
}
|
|||
|
|
|||
|
// 发送的文本消息
|
|||
|
type SendMsgText 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"`
|
|||
|
Image struct {
|
|||
|
MediaId string `json:"media_id,omitempty"`
|
|||
|
} `json:"image,omitempty"`
|
|||
|
Voice struct {
|
|||
|
MediaId string `json:"media_id,omitempty"`
|
|||
|
} `json:"voice,omitempty"`
|
|||
|
}
|
|||
|
|
|||
|
var weworkCache = cache.New(5*time.Minute, 10*time.Minute) // 缓存,用于存储access_token
|
|||
|
|
|||
|
// 创建微信客服API的封装结构体实例
|
|||
|
func NewKefuWework(corpid string, corpsecret, Token, EncodingAESKey string) *KefuWework {
|
|||
|
return &KefuWework{
|
|||
|
corpid: corpid,
|
|||
|
corpsecret: corpsecret,
|
|||
|
Token: Token,
|
|||
|
EncodingAESKey: EncodingAESKey,
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
// 获取access_token的函数
|
|||
|
func (s *KefuWework) GetAccessToken() (string, error) {
|
|||
|
// 加锁,避免并发调用获取access_token接口
|
|||
|
s.mutex.Lock()
|
|||
|
defer s.mutex.Unlock()
|
|||
|
|
|||
|
// 判断access_token是否过期,如果未过期则直接返回
|
|||
|
cacheKey := "wework_access_" + s.corpid
|
|||
|
if accessToken, ok := weworkCache.Get(cacheKey); ok {
|
|||
|
return accessToken.(string), nil
|
|||
|
}
|
|||
|
// 发送GET请求,构建请求URL
|
|||
|
reqURL := fmt.Sprintf("%s?corpid=%s&corpsecret=%s", "https://qyapi.weixin.qq.com/cgi-bin/gettoken", s.corpid, s.corpsecret)
|
|||
|
resp, err := http.Get(reqURL)
|
|||
|
if err != nil {
|
|||
|
return "", err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
// 解析响应数据到AccessTokenResponse结构体
|
|||
|
var tokenResp AccessTokenResponse
|
|||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
|||
|
return "", err
|
|||
|
}
|
|||
|
|
|||
|
// 判断获取access_token是否成功
|
|||
|
if tokenResp.ErrCode != 0 {
|
|||
|
return "", fmt.Errorf("GetAccessToken failed: %s", tokenResp.ErrMsg)
|
|||
|
}
|
|||
|
weworkCache.Set(cacheKey, tokenResp.AccessToken, time.Duration(tokenResp.ExpiresIn-3600)*time.Second)
|
|||
|
log.Printf("GetAccessToken kefuWework:%s\n", tokenResp.AccessToken)
|
|||
|
// 返回access_token
|
|||
|
return tokenResp.AccessToken, nil
|
|||
|
}
|
|||
|
|
|||
|
// GetAccountList 获取客服帐号列表,包括所有的客服帐号的客服ID、名称和头像。
|
|||
|
func (s *KefuWework) GetAccountList(offset, limit uint32) (string, error) {
|
|||
|
var respData string
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
// 构建请求数据
|
|||
|
reqData := map[string]uint32{
|
|||
|
"offset": offset, // 偏移量
|
|||
|
"limit": limit, // 限制数量
|
|||
|
}
|
|||
|
reqBody, err := json.Marshal(reqData)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
// 发送POST请求
|
|||
|
reqURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/kf/account/list?access_token=%s", accessToken)
|
|||
|
resp, err := http.Post(reqURL, "application/json", bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
|
|||
|
ioBytes, _ := io.ReadAll(resp.Body)
|
|||
|
|
|||
|
return string(ioBytes), nil
|
|||
|
}
|
|||
|
|
|||
|
// GetAccountList 获取客服帐号链接
|
|||
|
func (s *KefuWework) GetAccountLink(openKfId string) (string, error) {
|
|||
|
var respData string
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
// 构建请求数据
|
|||
|
reqData := map[string]string{
|
|||
|
"open_kfid": openKfId,
|
|||
|
"scene": "123456",
|
|||
|
}
|
|||
|
reqBody, err := json.Marshal(reqData)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
// 发送POST请求
|
|||
|
reqURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/kf/add_contact_way?access_token=%s", accessToken)
|
|||
|
resp, err := http.Post(reqURL, "application/json", bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
|
|||
|
ioBytes, _ := io.ReadAll(resp.Body)
|
|||
|
|
|||
|
return string(ioBytes), nil
|
|||
|
}
|
|||
|
|
|||
|
// 发送同步客服消息请求
|
|||
|
func (s *KefuWework) SyncMsg(reqData map[string]interface{}) (SyncMsgRet, error) {
|
|||
|
var msgRet SyncMsgRet
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return msgRet, err
|
|||
|
}
|
|||
|
|
|||
|
reqBody, err := json.Marshal(reqData)
|
|||
|
if err != nil {
|
|||
|
return msgRet, err
|
|||
|
}
|
|||
|
|
|||
|
// 发送POST请求
|
|||
|
reqURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/kf/sync_msg?access_token=%s", accessToken)
|
|||
|
resp, err := http.Post(reqURL, "application/json", bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
return msgRet, err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
|
|||
|
ioBytes, _ := ioutil.ReadAll(resp.Body)
|
|||
|
json.Unmarshal(ioBytes, &msgRet)
|
|||
|
return msgRet, nil
|
|||
|
}
|
|||
|
|
|||
|
// 发送消息
|
|||
|
func (s *KefuWework) SendMsg(reply interface{}) error {
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
url := "https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg?access_token=" + accessToken
|
|||
|
reqBody, err := json.Marshal(reply)
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
resp, err := http.Post(url, "application/json", bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
body, err := ioutil.ReadAll(resp.Body)
|
|||
|
if err != nil {
|
|||
|
log.Println("SendMsg:", body, err)
|
|||
|
return err
|
|||
|
}
|
|||
|
log.Println("SendMsg:", string(body))
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
// 发送文本消息
|
|||
|
func (s *KefuWework) SendTextMsg(kfId, toUser, content string) error {
|
|||
|
reply := SendMsgText{
|
|||
|
Touser: toUser,
|
|||
|
OpenKfid: kfId,
|
|||
|
Msgid: "",
|
|||
|
Msgtype: "text",
|
|||
|
Text: struct {
|
|||
|
Content string `json:"content,omitempty"`
|
|||
|
}{
|
|||
|
Content: content,
|
|||
|
},
|
|||
|
}
|
|||
|
err := s.SendMsg(reply)
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 发送图片消息消息
|
|||
|
func (s *KefuWework) SendImagesMsg(kfId, toUser, path string) error {
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
uri := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=%s&type=%s", accessToken, "image")
|
|||
|
response, err := util.PostFile("media", path, uri)
|
|||
|
mediaId := gjson.Get(string(response), "media_id").String()
|
|||
|
reply := SendMsgText{
|
|||
|
Touser: toUser,
|
|||
|
OpenKfid: kfId,
|
|||
|
Msgid: "",
|
|||
|
Msgtype: "image",
|
|||
|
Image: struct {
|
|||
|
MediaId string `json:"media_id,omitempty"`
|
|||
|
}{
|
|||
|
MediaId: mediaId,
|
|||
|
},
|
|||
|
}
|
|||
|
err = s.SendMsg(reply)
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 发送语音消息
|
|||
|
func (s *KefuWework) SendVoiceMsg(kfId, toUser, path string) error {
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
uri := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=%s&type=%s", accessToken, "voice")
|
|||
|
response, err := util.PostFile("media", path, uri)
|
|||
|
mediaId := gjson.Get(string(response), "media_id").String()
|
|||
|
reply := SendMsgText{
|
|||
|
Touser: toUser,
|
|||
|
OpenKfid: kfId,
|
|||
|
Msgid: "",
|
|||
|
Msgtype: "voice",
|
|||
|
Voice: struct {
|
|||
|
MediaId string `json:"media_id,omitempty"`
|
|||
|
}{
|
|||
|
MediaId: mediaId,
|
|||
|
},
|
|||
|
}
|
|||
|
err = s.SendMsg(reply)
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 发送客服欢迎语
|
|||
|
func (s *KefuWework) SendWelcomeMsg(content, welcomeCode string) error {
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
// 构建请求数据
|
|||
|
reqData := map[string]interface{}{
|
|||
|
"code": welcomeCode,
|
|||
|
"msgtype": "text",
|
|||
|
"text": map[string]string{
|
|||
|
"content": content,
|
|||
|
},
|
|||
|
}
|
|||
|
reqBody, err := json.Marshal(reqData)
|
|||
|
if err != nil {
|
|||
|
log.Println("SendWelcomeMsg:", err)
|
|||
|
return err
|
|||
|
}
|
|||
|
|
|||
|
// 发送POST请求
|
|||
|
reqURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/kf/send_msg_on_event?access_token=%s", accessToken)
|
|||
|
resp, err := http.Post(reqURL, "application/json", bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
log.Println("SendWelcomeMsg:", err)
|
|||
|
return err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
|
|||
|
resBody, err := io.ReadAll(resp.Body)
|
|||
|
log.Println("SendWelcomeMsg:", string(resBody), err)
|
|||
|
if err != nil {
|
|||
|
log.Println("SendWelcomeMsg:", err)
|
|||
|
return err
|
|||
|
}
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
// 发送WEBHOOK
|
|||
|
func (s *KefuWework) SendWebHook(url, content string) error {
|
|||
|
// 获取access_token
|
|||
|
_, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return err
|
|||
|
}
|
|||
|
return nil
|
|||
|
}
|
|||
|
|
|||
|
// 获取客户列表
|
|||
|
func (s *KefuWework) BatchGet(reqData map[string]interface{}) (string, error) {
|
|||
|
var respData string
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
reqBody, err := json.Marshal(reqData)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
|
|||
|
// 发送POST请求
|
|||
|
reqURL := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/kf/customer/batchget?access_token=%s", accessToken)
|
|||
|
resp, err := http.Post(reqURL, "application/json", bytes.NewReader(reqBody))
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
defer resp.Body.Close()
|
|||
|
|
|||
|
ioBytes, _ := io.ReadAll(resp.Body)
|
|||
|
|
|||
|
return string(ioBytes), nil
|
|||
|
}
|
|||
|
|
|||
|
// 验证签名
|
|||
|
func (s *KefuWework) CheckSign(signature, timestamp, nonce, echostr string) (string, error) {
|
|||
|
wxcpt := NewWXBizMsgCrypt(s.Token, s.EncodingAESKey, s.corpid, XmlType)
|
|||
|
echoStr, cryptErr := wxcpt.VerifyURL(signature, timestamp, nonce, echostr)
|
|||
|
if cryptErr != nil {
|
|||
|
return "", errors.New(cryptErr.ErrMsg)
|
|||
|
}
|
|||
|
return string(echoStr), nil
|
|||
|
}
|
|||
|
|
|||
|
// 解析数据
|
|||
|
func (s *KefuWework) DecryptMsg(signature, timestamp, nonce, data string) (WeixinUserAskMsg, error) {
|
|||
|
var weixinUserAskMsg WeixinUserAskMsg
|
|||
|
wxcpt := NewWXBizMsgCrypt(s.Token, s.EncodingAESKey, s.corpid, XmlType)
|
|||
|
msg, cryptErr := wxcpt.DecryptMsg(signature, timestamp, nonce, []byte(data))
|
|||
|
if cryptErr != nil {
|
|||
|
return weixinUserAskMsg, errors.New(cryptErr.ErrMsg)
|
|||
|
}
|
|||
|
err := xml.Unmarshal(msg, &weixinUserAskMsg)
|
|||
|
if err != nil {
|
|||
|
return weixinUserAskMsg, err
|
|||
|
}
|
|||
|
return weixinUserAskMsg, nil
|
|||
|
}
|
|||
|
|
|||
|
// 下载临时文件
|
|||
|
func (s *KefuWework) DownloadTempFileByMediaID(mediaId, path string) (string, error) {
|
|||
|
respData := ""
|
|||
|
// 获取access_token
|
|||
|
accessToken, err := s.GetAccessToken()
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
// 发起HTTP GET请求
|
|||
|
url := fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s", accessToken, mediaId)
|
|||
|
response, err := http.Get(url)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
defer response.Body.Close()
|
|||
|
|
|||
|
// 创建本地文件用于保存下载的文件
|
|||
|
file, err := os.Create(path)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
defer file.Close()
|
|||
|
|
|||
|
// 将HTTP响应的内容复制到本地文件
|
|||
|
_, err = io.Copy(file, response.Body)
|
|||
|
if err != nil {
|
|||
|
return respData, err
|
|||
|
}
|
|||
|
return path, nil
|
|||
|
}
|
|||
|
|
|||
|
// ConvertXMLToJSON 将 XML 转换为 JSON 字符串
|
|||
|
func ConvertXMLToJSON(xmlStr string) (string, error) {
|
|||
|
var result interface{}
|
|||
|
err := xml.Unmarshal([]byte(xmlStr), &result)
|
|||
|
if err != nil {
|
|||
|
return "", err
|
|||
|
}
|
|||
|
jsonStr, err := json.Marshal(result)
|
|||
|
if err != nil {
|
|||
|
return "", err
|
|||
|
}
|
|||
|
return string(jsonStr), nil
|
|||
|
}
|
|||
|
func parseFileName(response *http.Response) string {
|
|||
|
headers := response.Header["Content-Disposition"]
|
|||
|
if len(headers) > 0 {
|
|||
|
// 提取文件名
|
|||
|
fileParts := strings.Split(headers[0], "=")
|
|||
|
if len(fileParts) > 1 {
|
|||
|
return fileParts[1]
|
|||
|
}
|
|||
|
}
|
|||
|
return ""
|
|||
|
}
|