From 7e24cb9e8d9d3569d3b79d06c7cf0506597ca2ec Mon Sep 17 00:00:00 2001 From: hb Date: Thu, 28 Oct 2021 14:21:02 +0800 Subject: [PATCH 01/23] =?UTF-8?q?=E5=AE=8C=E5=96=84=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- util/signature.go | 14 +++ work/message/message.go | 238 +++++++++++++++++++++++++++++++++++ work/message/reply.go | 15 +++ work/oauth/oauth.go | 23 ++++ work/server/error.go | 66 ++++++++++ work/server/server.go | 269 ++++++++++++++++++++++++++++++++++++++++ work/server/util.go | 58 +++++++++ work/tools/calendar.go | 21 ++++ work/user/user.go | 226 +++++++++++++++++++++++++++++++++ work/work.go | 22 ++++ 10 files changed, 952 insertions(+) create mode 100644 work/message/message.go create mode 100644 work/message/reply.go create mode 100644 work/server/error.go create mode 100644 work/server/server.go create mode 100644 work/server/util.go create mode 100644 work/tools/calendar.go create mode 100644 work/user/user.go diff --git a/util/signature.go b/util/signature.go index 22a9cc5..e9a1798 100644 --- a/util/signature.go +++ b/util/signature.go @@ -1,6 +1,7 @@ package util import ( + "bytes" "crypto/sha1" "fmt" "io" @@ -16,3 +17,16 @@ func Signature(params ...string) string { } return fmt.Sprintf("%x", h.Sum(nil)) } + +func CalSignature(params ...string) string { + sort.Strings(params) + var buffer bytes.Buffer + for _, value := range params { + buffer.WriteString(value) + } + + sha := sha1.New() + sha.Write(buffer.Bytes()) + signature := fmt.Sprintf("%x", sha.Sum(nil)) + return string(signature) +} diff --git a/work/message/message.go b/work/message/message.go new file mode 100644 index 0000000..ecbe12a --- /dev/null +++ b/work/message/message.go @@ -0,0 +1,238 @@ +package message + +import ( + "encoding/xml" + + "github.com/silenceper/wechat/v2/officialaccount/device" +) + +// MsgType 基本消息类型 +type MsgType string + +// EventType 事件类型 +type EventType string + +// InfoType 第三方平台授权事件类型 +type InfoType string + +const ( + //MsgTypeText 表示文本消息 + MsgTypeText MsgType = "text" + //MsgTypeImage 表示图片消息 + MsgTypeImage = "image" + //MsgTypeVoice 表示语音消息 + MsgTypeVoice = "voice" + //MsgTypeVideo 表示视频消息 + MsgTypeVideo = "video" + //MsgTypeMiniprogrampage 表示小程序卡片消息 + MsgTypeMiniprogrampage = "miniprogrampage" + //MsgTypeShortVideo 表示短视频消息[限接收] + MsgTypeShortVideo = "shortvideo" + //MsgTypeLocation 表示坐标消息[限接收] + MsgTypeLocation = "location" + //MsgTypeLink 表示链接消息[限接收] + MsgTypeLink = "link" + //MsgTypeMusic 表示音乐消息[限回复] + MsgTypeMusic = "music" + //MsgTypeNews 表示图文消息[限回复] + MsgTypeNews = "news" + //MsgTypeTransfer 表示消息消息转发到客服 + MsgTypeTransfer = "transfer_customer_service" + //MsgTypeEvent 表示事件推送消息 + MsgTypeEvent = "event" +) + +const ( + //EventSubscribe 订阅 + EventSubscribe EventType = "subscribe" + //EventUnsubscribe 取消订阅 + EventUnsubscribe = "unsubscribe" + //EventScan 用户已经关注公众号,则微信会将带场景值扫描事件推送给开发者 + EventScan = "SCAN" + //EventLocation 上报地理位置事件 + EventLocation = "LOCATION" + //EventClick 点击菜单拉取消息时的事件推送 + EventClick = "CLICK" + //EventView 点击菜单跳转链接时的事件推送 + EventView = "VIEW" + //EventScancodePush 扫码推事件的事件推送 + EventScancodePush = "scancode_push" + //EventScancodeWaitmsg 扫码推事件且弹出“消息接收中”提示框的事件推送 + EventScancodeWaitmsg = "scancode_waitmsg" + //EventPicSysphoto 弹出系统拍照发图的事件推送 + EventPicSysphoto = "pic_sysphoto" + //EventPicPhotoOrAlbum 弹出拍照或者相册发图的事件推送 + EventPicPhotoOrAlbum = "pic_photo_or_album" + //EventPicWeixin 弹出微信相册发图器的事件推送 + EventPicWeixin = "pic_weixin" + //EventLocationSelect 弹出地理位置选择器的事件推送 + EventLocationSelect = "location_select" + //EventTemplateSendJobFinish 发送模板消息推送通知 + EventTemplateSendJobFinish = "TEMPLATESENDJOBFINISH" + //EventMassSendJobFinish 群发消息推送通知 + EventMassSendJobFinish = "MASSSENDJOBFINISH" + //EventWxaMediaCheck 异步校验图片/音频是否含有违法违规内容推送事件 + EventWxaMediaCheck = "wxa_media_check" +) + +const ( + //微信开放平台需要用到 + + // InfoTypeVerifyTicket 返回ticket + InfoTypeVerifyTicket InfoType = "component_verify_ticket" + // InfoTypeAuthorized 授权 + InfoTypeAuthorized = "authorized" + // InfoTypeUnauthorized 取消授权 + InfoTypeUnauthorized = "unauthorized" + // InfoTypeUpdateAuthorized 更新授权 + InfoTypeUpdateAuthorized = "updateauthorized" +) + +//MixMessage 存放所有微信发送过来的消息和事件 +type MixMessage struct { + CommonToken + + //基本消息 + MsgID int64 `xml:"MsgId"` //其他消息推送过来是MsgId + TemplateMsgID int64 `xml:"MsgID"` //模板消息推送成功的消息是MsgID + Content string `xml:"Content"` + Recognition string `xml:"Recognition"` + PicURL string `xml:"PicUrl"` + MediaID string `xml:"MediaId"` + Format string `xml:"Format"` + ThumbMediaID string `xml:"ThumbMediaId"` + LocationX float64 `xml:"Location_X"` + LocationY float64 `xml:"Location_Y"` + Scale float64 `xml:"Scale"` + Label string `xml:"Label"` + Title string `xml:"Title"` + Description string `xml:"Description"` + URL string `xml:"Url"` + + //事件相关 + Event EventType `xml:"Event"` + EventKey string `xml:"EventKey"` + Ticket string `xml:"Ticket"` + Latitude string `xml:"Latitude"` + Longitude string `xml:"Longitude"` + Precision string `xml:"Precision"` + MenuID string `xml:"MenuId"` + Status string `xml:"Status"` + SessionFrom string `xml:"SessionFrom"` + TotalCount int64 `xml:"TotalCount"` + FilterCount int64 `xml:"FilterCount"` + SentCount int64 `xml:"SentCount"` + ErrorCount int64 `xml:"ErrorCount"` + + ScanCodeInfo struct { + ScanType string `xml:"ScanType"` + ScanResult string `xml:"ScanResult"` + } `xml:"ScanCodeInfo"` + + SendPicsInfo struct { + Count int32 `xml:"Count"` + PicList []EventPic `xml:"PicList>item"` + } `xml:"SendPicsInfo"` + + SendLocationInfo struct { + LocationX float64 `xml:"Location_X"` + LocationY float64 `xml:"Location_Y"` + Scale float64 `xml:"Scale"` + Label string `xml:"Label"` + Poiname string `xml:"Poiname"` + } + + // 第三方平台相关 + InfoType InfoType `xml:"InfoType"` + AppID string `xml:"AppId"` + ComponentVerifyTicket string `xml:"ComponentVerifyTicket"` + AuthorizerAppid string `xml:"AuthorizerAppid"` + AuthorizationCode string `xml:"AuthorizationCode"` + AuthorizationCodeExpiredTime int64 `xml:"AuthorizationCodeExpiredTime"` + PreAuthCode string `xml:"PreAuthCode"` + + // 卡券相关 + CardID string `xml:"CardId"` + RefuseReason string `xml:"RefuseReason"` + IsGiveByFriend int32 `xml:"IsGiveByFriend"` + FriendUserName string `xml:"FriendUserName"` + UserCardCode string `xml:"UserCardCode"` + OldUserCardCode string `xml:"OldUserCardCode"` + OuterStr string `xml:"OuterStr"` + IsRestoreMemberCard int32 `xml:"IsRestoreMemberCard"` + UnionID string `xml:"UnionId"` + + // 内容审核相关 + IsRisky bool `xml:"isrisky"` + ExtraInfoJSON string `xml:"extra_info_json"` + TraceID string `xml:"trace_id"` + StatusCode int `xml:"status_code"` + + //设备相关 + device.MsgDevice +} + +//EventPic 发图事件推送 +type EventPic struct { + PicMd5Sum string `xml:"PicMd5Sum"` +} + +//EncryptedXMLMsg 安全模式下的消息体 +type EncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + ToUserName string `xml:"ToUserName" json:"ToUserName"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` +} + +//ResponseEncryptedXMLMsg 需要返回的消息体 +type ResponseEncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` + MsgSignature string `xml:"MsgSignature" json:"MsgSignature"` + Timestamp int64 `xml:"TimeStamp" json:"TimeStamp"` + Nonce string `xml:"Nonce" json:"Nonce"` +} + +// CDATA 使用该类型,在序列化为 xml 文本时文本会被解析器忽略 +type CDATA string + +// MarshalXML 实现自己的序列化方法 +func (c CDATA) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return e.EncodeElement(struct { + string `xml:",cdata"` + }{string(c)}, start) +} + +// CommonToken 消息中通用的结构 +type CommonToken struct { + XMLName xml.Name `xml:"xml"` + ToUserName CDATA `xml:"ToUserName"` + FromUserName CDATA `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType MsgType `xml:"MsgType"` +} + +//SetToUserName set ToUserName +func (msg *CommonToken) SetToUserName(toUserName CDATA) { + msg.ToUserName = toUserName +} + +//SetFromUserName set FromUserName +func (msg *CommonToken) SetFromUserName(fromUserName CDATA) { + msg.FromUserName = fromUserName +} + +//SetCreateTime set createTime +func (msg *CommonToken) SetCreateTime(createTime int64) { + msg.CreateTime = createTime +} + +//SetMsgType set MsgType +func (msg *CommonToken) SetMsgType(msgType MsgType) { + msg.MsgType = msgType +} + +//GetOpenID get the FromUserName value +func (msg *CommonToken) GetOpenID() string { + return string(msg.FromUserName) +} diff --git a/work/message/reply.go b/work/message/reply.go new file mode 100644 index 0000000..53592f0 --- /dev/null +++ b/work/message/reply.go @@ -0,0 +1,15 @@ +package message + +import "errors" + +//ErrInvalidReply 无效的回复 +var ErrInvalidReply = errors.New("无效的回复消息") + +//ErrUnsupportReply 不支持的回复类型 +var ErrUnsupportReply = errors.New("不支持的回复消息") + +//Reply 消息回复 +type Reply struct { + MsgType MsgType + MsgData interface{} +} diff --git a/work/oauth/oauth.go b/work/oauth/oauth.go index 296dbaf..a02c10b 100644 --- a/work/oauth/oauth.go +++ b/work/oauth/oauth.go @@ -21,6 +21,8 @@ var ( oauthUserInfoURL = "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo?access_token=%s&code=%s" //oauthQrContentTargetURL 构造独立窗口登录二维码 oauthQrContentTargetURL = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect?appid=%s&agentid=%s&redirect_uri=%s&state=%s" + //code2Session 获取用户信息地址 + code2SessionURL = "https://qyapi.weixin.qq.com/cgi-bin/miniprogram/jscode2session?access_token=%s&js_code=%s&grant_type=authorization_code" ) //NewOauth new init oauth @@ -85,3 +87,24 @@ func (ctr *Oauth) UserFromCode(code string) (result ResUserInfo, err error) { } return } + +func (ctr *Oauth) Code2Session(code string) (result ResUserInfo, err error) { + var accessToken string + accessToken, err = ctr.GetAccessToken() + if err != nil { + return + } + var response []byte + response, err = util.HTTPGet( + fmt.Sprintf(code2SessionURL, accessToken, code), + ) + if err != nil { + return + } + err = json.Unmarshal(response, &result) + if result.ErrCode != 0 { + err = fmt.Errorf("GetUserAccessToken error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} diff --git a/work/server/error.go b/work/server/error.go new file mode 100644 index 0000000..d6dfa10 --- /dev/null +++ b/work/server/error.go @@ -0,0 +1,66 @@ +package server + +import ( + "reflect" + "strings" +) + +// Error 错误 +type Error string + +const ( + SDKValidateSignatureError Error = "签名验证错误" //-40001 + SDKParseJsonError Error = "xml/json解析失败" //-40002 + SDKComputeSignatureError Error = "sha加密生成签名失败" //-40003 + SDKIllegalAesKey Error = "AESKey 非法" //-40004 + SDKValidateCorpidError Error = "ReceiveId 校验错误" //-40005 + SDKEncryptAESError Error = "AES 加密失败" //-40006 + SDKDecryptAESError Error = "AES 解密失败" //-40007 + SDKIllegalBuffer Error = "解密后得到的buffer非法" //-40008 + SDKEncodeBase64Error Error = "base64加密失败" //-40009 + SDKDecodeBase64Error Error = "base64解密失败" //-40010 + SDKGenJsonError Error = "生成xml/json失败" //-40011 + SDKIllegalProtocolType Error = "协议类型非法" //-40012 + SDKUnknownError Error = "未知错误" +) + +//Error 输出错误信息 +func (r Error) Error() string { + return reflect.ValueOf(r).String() +} + +// NewSDKErr 初始化SDK实例错误信息 +func NewSDKErr(code int64, msgList ...string) Error { + switch code { + case 40001: + return SDKValidateSignatureError + case 40002: + return SDKParseJsonError + case 40003: + return SDKComputeSignatureError + case 40004: + return SDKIllegalAesKey + case 40005: + return SDKValidateCorpidError + case 40006: + return SDKEncryptAESError + case 40007: + return SDKDecryptAESError + case 40008: + return SDKIllegalBuffer + case 40009: + return SDKEncodeBase64Error + case 40010: + return SDKDecodeBase64Error + case 40011: + return SDKGenJsonError + case 40012: + return SDKIllegalProtocolType + default: + //返回未知的自定义错误 + if len(msgList) > 0 { + return Error(strings.Join(msgList, ",")) + } + return SDKUnknownError + } +} diff --git a/work/server/server.go b/work/server/server.go new file mode 100644 index 0000000..c6844f4 --- /dev/null +++ b/work/server/server.go @@ -0,0 +1,269 @@ +package server + +import ( + "encoding/xml" + "errors" + "fmt" + "io/ioutil" + "net/http" + "reflect" + "runtime/debug" + "strconv" + + "github.com/silenceper/wechat/v2/work/context" + "github.com/silenceper/wechat/v2/work/message" + log "github.com/sirupsen/logrus" + + "github.com/silenceper/wechat/v2/util" +) + +//Server struct +type Server struct { + *context.Context + Writer http.ResponseWriter + Request *http.Request + + skipValidate bool + + openID string + + messageHandler func(*message.MixMessage) *message.Reply + + RequestRawXMLMsg []byte + RequestMsg *message.MixMessage + ResponseRawXMLMsg []byte + ResponseMsg interface{} + + isSafeMode bool + random []byte + nonce string + timestamp int64 +} + +//NewServer init +func NewServer(context *context.Context) *Server { + srv := new(Server) + srv.Context = context + return srv +} + +func (srv *Server) VerifyURL() (string, error) { + timestamp := srv.Query("timestamp") + nonce := srv.Query("nonce") + signature := srv.Query("msg_signature") + echoStr := srv.Query("echostr") + log.Info("Signature----", util.CalSignature(srv.Token, timestamp, nonce)) + log.Info("Signature----", util.Signature(srv.Token, timestamp, nonce, echoStr)) + log.Info("srv.Token---", srv.Token) + if signature != util.Signature(srv.Token, timestamp, nonce, echoStr) { + return "", NewSDKErr(40001) + } + _, bData, err := util.DecryptMsg(srv.CorpID, echoStr, srv.EncodingAESKey) + if err != nil { + return "", NewSDKErr(40002) + } + + return string(bData), nil +} + +// SkipValidate set skip validate +func (srv *Server) SkipValidate(skip bool) { + srv.skipValidate = skip +} + +//Serve 处理微信的请求消息 +func (srv *Server) Serve() error { + if !srv.Validate() { + log.Error("Validate Signature Failed.") + return fmt.Errorf("请求校验失败") + } + + echostr, exists := srv.GetQuery("echostr") + if exists { + srv.String(echostr) + return nil + } + + response, err := srv.handleRequest() + if err != nil { + return err + } + + //debug print request msg + log.Debugf("request msg =%s", string(srv.RequestRawXMLMsg)) + + return srv.buildResponse(response) +} + +//Validate 校验请求是否合法 +func (srv *Server) Validate() bool { + if srv.skipValidate { + return true + } + timestamp := srv.Query("timestamp") + nonce := srv.Query("nonce") + signature := srv.Query("msg_signature") + log.Debugf("validate signature, timestamp=%s, nonce=%s", timestamp, nonce) + return signature == util.Signature(srv.Token, timestamp, nonce) +} + +//HandleRequest 处理微信的请求 +func (srv *Server) handleRequest() (reply *message.Reply, err error) { + //set isSafeMode + srv.isSafeMode = false + encryptType := srv.Query("encrypt_type") + if encryptType == "aes" { + srv.isSafeMode = true + } + + //set openID + srv.openID = srv.Query("openid") + + var msg interface{} + msg, err = srv.getMessage() + if err != nil { + return + } + mixMessage, success := msg.(*message.MixMessage) + if !success { + err = errors.New("消息类型转换失败") + } + srv.RequestMsg = mixMessage + reply = srv.messageHandler(mixMessage) + return +} + +//GetOpenID return openID +func (srv *Server) GetOpenID() string { + return srv.openID +} + +//getMessage 解析微信返回的消息 +func (srv *Server) getMessage() (interface{}, error) { + var rawXMLMsgBytes []byte + var err error + if srv.isSafeMode { + var encryptedXMLMsg message.EncryptedXMLMsg + if err := xml.NewDecoder(srv.Request.Body).Decode(&encryptedXMLMsg); err != nil { + return nil, fmt.Errorf("从body中解析xml失败,err=%v", err) + } + + //验证消息签名 + timestamp := srv.Query("timestamp") + srv.timestamp, err = strconv.ParseInt(timestamp, 10, 32) + if err != nil { + return nil, err + } + nonce := srv.Query("nonce") + srv.nonce = nonce + msgSignature := srv.Query("msg_signature") + msgSignatureGen := util.Signature(srv.Token, timestamp, nonce, encryptedXMLMsg.EncryptedMsg) + if msgSignature != msgSignatureGen { + return nil, fmt.Errorf("消息不合法,验证签名失败") + } + + //解密 + srv.random, rawXMLMsgBytes, err = util.DecryptMsg(srv.CorpID, encryptedXMLMsg.EncryptedMsg, srv.EncodingAESKey) + if err != nil { + return nil, fmt.Errorf("消息解密失败, err=%v", err) + } + } else { + rawXMLMsgBytes, err = ioutil.ReadAll(srv.Request.Body) + if err != nil { + return nil, fmt.Errorf("从body中解析xml失败, err=%v", err) + } + } + + srv.RequestRawXMLMsg = rawXMLMsgBytes + + return srv.parseRequestMessage(rawXMLMsgBytes) +} + +func (srv *Server) parseRequestMessage(rawXMLMsgBytes []byte) (msg *message.MixMessage, err error) { + msg = &message.MixMessage{} + err = xml.Unmarshal(rawXMLMsgBytes, msg) + return +} + +//SetMessageHandler 设置用户自定义的回调方法 +func (srv *Server) SetMessageHandler(handler func(*message.MixMessage) *message.Reply) { + srv.messageHandler = handler +} + +func (srv *Server) buildResponse(reply *message.Reply) (err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("panic error: %v\n%s", e, debug.Stack()) + } + }() + if reply == nil { + //do nothing + return nil + } + msgType := reply.MsgType + switch msgType { + case message.MsgTypeText: + case message.MsgTypeImage: + case message.MsgTypeVoice: + case message.MsgTypeVideo: + case message.MsgTypeMusic: + case message.MsgTypeNews: + case message.MsgTypeTransfer: + default: + err = message.ErrUnsupportReply + return + } + + msgData := reply.MsgData + value := reflect.ValueOf(msgData) + //msgData must be a ptr + kind := value.Kind().String() + if kind != "ptr" { + return message.ErrUnsupportReply + } + + params := make([]reflect.Value, 1) + params[0] = reflect.ValueOf(srv.RequestMsg.FromUserName) + value.MethodByName("SetToUserName").Call(params) + + params[0] = reflect.ValueOf(srv.RequestMsg.ToUserName) + value.MethodByName("SetFromUserName").Call(params) + + params[0] = reflect.ValueOf(msgType) + value.MethodByName("SetMsgType").Call(params) + + params[0] = reflect.ValueOf(util.GetCurrTS()) + value.MethodByName("SetCreateTime").Call(params) + + srv.ResponseMsg = msgData + srv.ResponseRawXMLMsg, err = xml.Marshal(msgData) + return +} + +//Send 将自定义的消息发送 +func (srv *Server) Send() (err error) { + replyMsg := srv.ResponseMsg + log.Debugf("response msg =%+v", replyMsg) + if srv.isSafeMode { + //安全模式下对消息进行加密 + var encryptedMsg []byte + encryptedMsg, err = util.EncryptMsg(srv.random, srv.ResponseRawXMLMsg, srv.CorpID, srv.EncodingAESKey) + if err != nil { + return + } + //TODO 如果获取不到timestamp nonce 则自己生成 + timestamp := srv.timestamp + timestampStr := strconv.FormatInt(timestamp, 10) + msgSignature := util.Signature(srv.Token, timestampStr, srv.nonce, string(encryptedMsg)) + replyMsg = message.ResponseEncryptedXMLMsg{ + EncryptedMsg: string(encryptedMsg), + MsgSignature: msgSignature, + Timestamp: timestamp, + Nonce: srv.nonce, + } + } + if replyMsg != nil { + srv.XML(replyMsg) + } + return +} diff --git a/work/server/util.go b/work/server/util.go new file mode 100644 index 0000000..6c108fa --- /dev/null +++ b/work/server/util.go @@ -0,0 +1,58 @@ +package server + +import ( + "encoding/xml" + "net/http" +) + +var xmlContentType = []string{"application/xml; charset=utf-8"} +var plainContentType = []string{"text/plain; charset=utf-8"} + +func writeContextType(w http.ResponseWriter, value []string) { + header := w.Header() + if val := header["Content-Type"]; len(val) == 0 { + header["Content-Type"] = value + } +} + +//Render render from bytes +func (srv *Server) Render(bytes []byte) { + //debug + //fmt.Println("response msg = ", string(bytes)) + srv.Writer.WriteHeader(200) + _, err := srv.Writer.Write(bytes) + if err != nil { + panic(err) + } +} + +//String render from string +func (srv *Server) String(str string) { + writeContextType(srv.Writer, plainContentType) + srv.Render([]byte(str)) +} + +//XML render to xml +func (srv *Server) XML(obj interface{}) { + writeContextType(srv.Writer, xmlContentType) + bytes, err := xml.Marshal(obj) + if err != nil { + panic(err) + } + srv.Render(bytes) +} + +// Query returns the keyed url query value if it exists +func (srv *Server) Query(key string) string { + value, _ := srv.GetQuery(key) + return value +} + +// GetQuery is like Query(), it returns the keyed url query value +func (srv *Server) GetQuery(key string) (string, bool) { + req := srv.Request + if values, ok := req.URL.Query()[key]; ok && len(values) > 0 { + return values[0], true + } + return "", false +} diff --git a/work/tools/calendar.go b/work/tools/calendar.go new file mode 100644 index 0000000..f03d2be --- /dev/null +++ b/work/tools/calendar.go @@ -0,0 +1,21 @@ +package tools + +import ( + "github.com/silenceper/wechat/v2/work/context" +) + +const ( + calendarURL = "https://qyapi.weixin.qq.com/cgi-bin/oa/calendar/get?access_token=%s" +) + +//Calendar 日历管理 +type Calendar struct { + *context.Context +} + +//NewCalendar 实例化 +func NewCalendar(context *context.Context) *Calendar { + calendar := new(Calendar) + calendar.Context = context + return calendar +} diff --git a/work/user/user.go b/work/user/user.go new file mode 100644 index 0000000..506e8d8 --- /dev/null +++ b/work/user/user.go @@ -0,0 +1,226 @@ +package user + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/silenceper/wechat/v2/util" + "github.com/silenceper/wechat/v2/work/context" +) + +const ( + userInfoURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=%s&userid=%s" + updateURL = "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=%s&department_id=%s&fetch_child=1" + userListURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get" + launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code" +) + +//User 用户管理 +type User struct { + *context.Context +} + +//NewUser 实例化 +func NewUser(context *context.Context) *User { + user := new(User) + user.Context = context + return user +} + +//Info 用户基本信息 +type Info struct { + util.CommonError + Userid string `json:"userid"` + Name string `json:"name"` + Department []int `json:"department"` + Order []int `json:"order"` + Position string `json:"position"` + Mobile string `json:"mobile"` + Gender string `json:"gender"` + Email string `json:"email"` + IsLeaderInDept []int `json:"is_leader_in_dept"` + Avatar string `json:"avatar"` + ThumbAvatar string `json:"thumb_avatar"` + Telephone string `json:"telephone"` + Alias string `json:"alias"` + Address string `json:"address"` + OpenUserid string `json:"open_userid"` + MainDepartment int `json:"main_department"` + Extattr struct { + Attrs []struct { + Type int `json:"type"` + Name string `json:"name"` + Text struct { + Value string `json:"value"` + } `json:"text,omitempty"` + Web struct { + Url string `json:"url"` + Title string `json:"title"` + } `json:"web,omitempty"` + } `json:"attrs"` + } `json:"extattr"` + Status int `json:"status"` + QrCode string `json:"qr_code"` + ExternalPosition string `json:"external_position"` + ExternalProfile struct { + ExternalCorpName string `json:"external_corp_name"` + WechatChannels struct { + Nickname string `json:"nickname"` + Status int `json:"status"` + } `json:"wechat_channels"` + ExternalAttr []struct { + Type int `json:"type"` + Name string `json:"name"` + Text struct { + Value string `json:"value"` + } `json:"text,omitempty"` + Web struct { + Url string `json:"url"` + Title string `json:"title"` + } `json:"web,omitempty"` + Miniprogram struct { + Appid string `json:"appid"` + Pagepath string `json:"pagepath"` + Title string `json:"title"` + } `json:"miniprogram,omitempty"` + } `json:"external_attr"` + } `json:"external_profile"` +} + +// OpenidList 用户列表 +type OpenidList struct { + util.CommonError + + Total int `json:"total"` + Count int `json:"count"` + Data struct { + OpenIDs []string `json:"openid"` + } `json:"data"` + NextOpenID string `json:"next_openid"` +} + +//GetUserInfo 获取用户基本信息 +func (user *User) GetUserInfo(userID string) (userInfo *Info, err error) { + var accessToken string + accessToken, err = user.GetAccessToken() + if err != nil { + return + } + + uri := fmt.Sprintf(userInfoURL, accessToken, userID) + var response []byte + response, err = util.HTTPGet(uri) + if err != nil { + return + } + userInfo = new(Info) + err = json.Unmarshal(response, userInfo) + if err != nil { + return + } + if userInfo.ErrCode != 0 { + err = fmt.Errorf("GetUserInfo Error , errcode=%d , errmsg=%s", userInfo.ErrCode, userInfo.ErrMsg) + return + } + return +} + +// Update 更新员工资料 +func (user *User) Update(userID, external_position string) (err error) { + var accessToken string + accessToken, err = user.GetAccessToken() + if err != nil { + return + } + + uri := fmt.Sprintf(updateURL, accessToken, userID) + var response []byte + response, err = util.PostJSON(uri, map[string]string{"userid": userID, "external_position": external_position}) + if err != nil { + return + } + + return util.DecodeWithCommonError(response, "updateURL") +} + +// ListUserOpenIDs 返回用户列表 +func (user *User) ListUserOpenIDs(nextOpenid ...string) (*OpenidList, error) { + accessToken, err := user.GetAccessToken() + if err != nil { + return nil, err + } + + uri, _ := url.Parse(userListURL) + q := uri.Query() + q.Set("access_token", accessToken) + if len(nextOpenid) > 0 && nextOpenid[0] != "" { + q.Set("next_openid", nextOpenid[0]) + } + uri.RawQuery = q.Encode() + + response, err := util.HTTPGet(uri.String()) + if err != nil { + return nil, err + } + + userlist := OpenidList{} + + err = util.DecodeWithError(response, &userlist, "ListUserOpenIDs") + if err != nil { + return nil, err + } + + return &userlist, nil +} + +// ListAllUserOpenIDs 返回所有用户OpenID列表 +func (user *User) ListAllUserOpenIDs() ([]string, error) { + nextOpenid := "" + openids := make([]string, 0) + count := 0 + for { + ul, err := user.ListUserOpenIDs(nextOpenid) + if err != nil { + return nil, err + } + openids = append(openids, ul.Data.OpenIDs...) + count += ul.Count + if ul.Total > count { + nextOpenid = ul.NextOpenID + } else { + return openids, nil + } + } +} + +type RespLaunchCode struct { + util.CommonError + LaunchCode string `json:"launch_code"` +} + +//GetLaunchCode 用于打开个人聊天窗口schema +func (user *User) GetLaunchCode(userID, other string) (userInfo *RespLaunchCode, err error) { + var accessToken string + accessToken, err = user.GetAccessToken() + if err != nil { + return + } + + uri := fmt.Sprintf(launchCode, accessToken, userID) + var response []byte + response, err = util.PostJSON(uri, map[string]interface{}{"operator_userid": userID, "single_chat": map[string]string{"userid": other}}) + if err != nil { + return + } + userInfo = new(RespLaunchCode) + err = json.Unmarshal(response, userInfo) + if err != nil { + return + } + if userInfo.ErrCode != 0 { + err = fmt.Errorf("GetUserInfo Error , errcode=%d , errmsg=%s", userInfo.ErrCode, userInfo.ErrMsg) + return + } + return +} diff --git a/work/work.go b/work/work.go index b974c35..451f81a 100644 --- a/work/work.go +++ b/work/work.go @@ -7,6 +7,10 @@ import ( "github.com/silenceper/wechat/v2/work/kf" "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" + "github.com/silenceper/wechat/v2/work/server" + "github.com/silenceper/wechat/v2/work/tools" + "github.com/silenceper/wechat/v2/work/user" + "net/http" ) // Work 企业微信 @@ -29,6 +33,14 @@ func (wk *Work) GetContext() *context.Context { return wk.ctx } +// GetServer 消息管理:接收事件,被动回复消息管理 +func (wk *Work) GetServer(req *http.Request, writer http.ResponseWriter) *server.Server { + srv := server.NewServer(wk.ctx) + srv.Request = req + srv.Writer = writer + return srv +} + //GetOauth get oauth func (wk *Work) GetOauth() *oauth.Oauth { return oauth.NewOauth(wk.ctx) @@ -43,3 +55,13 @@ func (wk *Work) GetMsgAudit() (*msgaudit.Client, error) { func (wk *Work) GetKF() (*kf.Client, error) { return kf.NewClient(wk.ctx.Config) } + +//GetUser get user +func (wk *Work) GetUser() *user.User { + return user.NewUser(wk.ctx) +} + +//GetCalendar get calendar +func (wk *Work) GetCalendar() *tools.Calendar { + return tools.NewCalendar(wk.ctx) +} From 829356eee95fa381a2420279617b5e8bf6f60896 Mon Sep 17 00:00:00 2001 From: hb Date: Thu, 28 Oct 2021 14:26:00 +0800 Subject: [PATCH 02/23] fork github.com/hb1707/wechat/v2 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index a019377..e924fa5 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/silenceper/wechat/v2 +module github.com/hb1707/wechat/v2 go 1.14 From af5115fb4e0e2e7c4187fe2dc2cf157f642f837b Mon Sep 17 00:00:00 2001 From: hb Date: Thu, 28 Oct 2021 14:31:29 +0800 Subject: [PATCH 03/23] fork github.com/silenceper/wechat/v2 --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index e924fa5..a019377 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/hb1707/wechat/v2 +module github.com/silenceper/wechat/v2 go 1.14 From 73adb7dcddf94b655449f4dea5b3d432293be910 Mon Sep 17 00:00:00 2001 From: hb Date: Thu, 28 Oct 2021 16:42:36 +0800 Subject: [PATCH 04/23] =?UTF-8?q?=E5=88=A0=E9=99=A4=E5=86=97=E4=BD=99?= =?UTF-8?q?=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/server/server.go | 123 +++++++++++++++--------------------------- 1 file changed, 42 insertions(+), 81 deletions(-) diff --git a/work/server/server.go b/work/server/server.go index c6844f4..b1a32aa 100644 --- a/work/server/server.go +++ b/work/server/server.go @@ -4,7 +4,6 @@ import ( "encoding/xml" "errors" "fmt" - "io/ioutil" "net/http" "reflect" "runtime/debug" @@ -25,8 +24,6 @@ type Server struct { skipValidate bool - openID string - messageHandler func(*message.MixMessage) *message.Reply RequestRawXMLMsg []byte @@ -34,10 +31,9 @@ type Server struct { ResponseRawXMLMsg []byte ResponseMsg interface{} - isSafeMode bool - random []byte - nonce string - timestamp int64 + random []byte + nonce string + timestamp int64 } //NewServer init @@ -52,9 +48,6 @@ func (srv *Server) VerifyURL() (string, error) { nonce := srv.Query("nonce") signature := srv.Query("msg_signature") echoStr := srv.Query("echostr") - log.Info("Signature----", util.CalSignature(srv.Token, timestamp, nonce)) - log.Info("Signature----", util.Signature(srv.Token, timestamp, nonce, echoStr)) - log.Info("srv.Token---", srv.Token) if signature != util.Signature(srv.Token, timestamp, nonce, echoStr) { return "", NewSDKErr(40001) } @@ -73,17 +66,6 @@ func (srv *Server) SkipValidate(skip bool) { //Serve 处理微信的请求消息 func (srv *Server) Serve() error { - if !srv.Validate() { - log.Error("Validate Signature Failed.") - return fmt.Errorf("请求校验失败") - } - - echostr, exists := srv.GetQuery("echostr") - if exists { - srv.String(echostr) - return nil - } - response, err := srv.handleRequest() if err != nil { return err @@ -91,7 +73,6 @@ func (srv *Server) Serve() error { //debug print request msg log.Debugf("request msg =%s", string(srv.RequestRawXMLMsg)) - return srv.buildResponse(response) } @@ -109,15 +90,6 @@ func (srv *Server) Validate() bool { //HandleRequest 处理微信的请求 func (srv *Server) handleRequest() (reply *message.Reply, err error) { - //set isSafeMode - srv.isSafeMode = false - encryptType := srv.Query("encrypt_type") - if encryptType == "aes" { - srv.isSafeMode = true - } - - //set openID - srv.openID = srv.Query("openid") var msg interface{} msg, err = srv.getMessage() @@ -133,45 +105,34 @@ func (srv *Server) handleRequest() (reply *message.Reply, err error) { return } -//GetOpenID return openID -func (srv *Server) GetOpenID() string { - return srv.openID -} - //getMessage 解析微信返回的消息 func (srv *Server) getMessage() (interface{}, error) { var rawXMLMsgBytes []byte var err error - if srv.isSafeMode { - var encryptedXMLMsg message.EncryptedXMLMsg - if err := xml.NewDecoder(srv.Request.Body).Decode(&encryptedXMLMsg); err != nil { - return nil, fmt.Errorf("从body中解析xml失败,err=%v", err) - } - //验证消息签名 - timestamp := srv.Query("timestamp") - srv.timestamp, err = strconv.ParseInt(timestamp, 10, 32) - if err != nil { - return nil, err - } - nonce := srv.Query("nonce") - srv.nonce = nonce - msgSignature := srv.Query("msg_signature") - msgSignatureGen := util.Signature(srv.Token, timestamp, nonce, encryptedXMLMsg.EncryptedMsg) - if msgSignature != msgSignatureGen { - return nil, fmt.Errorf("消息不合法,验证签名失败") - } + var encryptedXMLMsg message.EncryptedXMLMsg + if err := xml.NewDecoder(srv.Request.Body).Decode(&encryptedXMLMsg); err != nil { + return nil, fmt.Errorf("从body中解析xml失败,err=%v", err) + } - //解密 - srv.random, rawXMLMsgBytes, err = util.DecryptMsg(srv.CorpID, encryptedXMLMsg.EncryptedMsg, srv.EncodingAESKey) - if err != nil { - return nil, fmt.Errorf("消息解密失败, err=%v", err) - } - } else { - rawXMLMsgBytes, err = ioutil.ReadAll(srv.Request.Body) - if err != nil { - return nil, fmt.Errorf("从body中解析xml失败, err=%v", err) - } + //验证消息签名 + timestamp := srv.Query("timestamp") + srv.timestamp, err = strconv.ParseInt(timestamp, 10, 32) + if err != nil { + return nil, err + } + nonce := srv.Query("nonce") + srv.nonce = nonce + msgSignature := srv.Query("msg_signature") + msgSignatureGen := util.Signature(srv.Token, timestamp, nonce, encryptedXMLMsg.EncryptedMsg) + if msgSignature != msgSignatureGen { + return nil, fmt.Errorf("消息不合法,验证签名失败") + } + + //解密 + srv.random, rawXMLMsgBytes, err = util.DecryptMsg(srv.CorpID, encryptedXMLMsg.EncryptedMsg, srv.EncodingAESKey) + if err != nil { + return nil, fmt.Errorf("消息解密失败, err=%v", err) } srv.RequestRawXMLMsg = rawXMLMsgBytes @@ -244,24 +205,24 @@ func (srv *Server) buildResponse(reply *message.Reply) (err error) { func (srv *Server) Send() (err error) { replyMsg := srv.ResponseMsg log.Debugf("response msg =%+v", replyMsg) - if srv.isSafeMode { - //安全模式下对消息进行加密 - var encryptedMsg []byte - encryptedMsg, err = util.EncryptMsg(srv.random, srv.ResponseRawXMLMsg, srv.CorpID, srv.EncodingAESKey) - if err != nil { - return - } - //TODO 如果获取不到timestamp nonce 则自己生成 - timestamp := srv.timestamp - timestampStr := strconv.FormatInt(timestamp, 10) - msgSignature := util.Signature(srv.Token, timestampStr, srv.nonce, string(encryptedMsg)) - replyMsg = message.ResponseEncryptedXMLMsg{ - EncryptedMsg: string(encryptedMsg), - MsgSignature: msgSignature, - Timestamp: timestamp, - Nonce: srv.nonce, - } + + //安全模式下对消息进行加密 + var encryptedMsg []byte + encryptedMsg, err = util.EncryptMsg(srv.random, srv.ResponseRawXMLMsg, srv.CorpID, srv.EncodingAESKey) + if err != nil { + return } + //TODO 如果获取不到timestamp nonce 则自己生成 + timestamp := srv.timestamp + timestampStr := strconv.FormatInt(timestamp, 10) + msgSignature := util.Signature(srv.Token, timestampStr, srv.nonce, string(encryptedMsg)) + replyMsg = message.ResponseEncryptedXMLMsg{ + EncryptedMsg: string(encryptedMsg), + MsgSignature: msgSignature, + Timestamp: timestamp, + Nonce: srv.nonce, + } + if replyMsg != nil { srv.XML(replyMsg) } From 7ae8e08a3ebc5cfd50c5ded1b4406d85e7f2851a Mon Sep 17 00:00:00 2001 From: hb Date: Wed, 24 Nov 2021 14:18:10 +0800 Subject: [PATCH 05/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E4=B8=8E=E6=8E=A5=E6=94=B6=EF=BC=8C?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E5=9B=9E=E8=B0=83=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/app.go | 165 +++++++++++++++++++++++ work/message/image.go | 16 +++ work/message/message.go | 246 +++++++++++++++++++--------------- work/message/news.go | 41 ++++++ work/message/reply.go | 2 +- work/message/template_card.go | 30 +++++ work/message/text.go | 14 ++ work/message/video.go | 20 +++ work/message/voice.go | 16 +++ work/server/server.go | 12 +- 10 files changed, 445 insertions(+), 117 deletions(-) create mode 100644 work/message/app.go create mode 100644 work/message/image.go create mode 100644 work/message/news.go create mode 100644 work/message/template_card.go create mode 100644 work/message/text.go create mode 100644 work/message/video.go create mode 100644 work/message/voice.go diff --git a/work/message/app.go b/work/message/app.go new file mode 100644 index 0000000..3aa59f0 --- /dev/null +++ b/work/message/app.go @@ -0,0 +1,165 @@ +package message + +import ( + "encoding/json" + "fmt" + "github.com/silenceper/wechat/v2/util" + "github.com/silenceper/wechat/v2/work/context" +) + +const ( + messageSendURL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + messageUpdateTemplateCardURL = "https://api.weixin.qq.com/cgi-bin/message/update_template_card" + messageDelURL = "https://api.weixin.qq.com/cgi-bin/message/recall" +) + +//App 应用消息 +type App struct { + *context.Context +} + +//NewApp 实例化 +func NewApp(context *context.Context) *App { + tpl := new(App) + tpl.Context = context + return tpl +} + +//AppMessage 发送的模板消息内容 +type AppMessage struct { + ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 + Toparty string `json:"toparty"` //部门ID列表,当touser为”@all”时忽略本参数 + Totag string `json:"totag"` //标签ID列表,当touser为”@all”时忽略本参数 + Msgtype string `json:"msgtype"` + Agentid int `json:"agentid"` + Safe int `json:"safe"` + EnableIdTrans int `json:"enable_id_trans"` + EnableDuplicateCheck int `json:"enable_duplicate_check"` + DuplicateCheckInterval int `json:"duplicate_check_interval"` + Text *Text + *Image + *Voice + *Video + File *PushFile `json:"file"` + TextCard *PushTextCard `json:"textcard"` + News *News `json:"news"` + MpNews *MpNews `json:"mpnews"` + Markdown *Text `json:"markdown"` + //todo(hb1707) 可能会发生变化的字段直接用interface{}了 + MiniprogramNotice interface{} `json:"miniprogram_notice"` + TemplateCard interface{} `json:"template_card"` +} + +type PushFile struct { + MediaID string `json:"media_id"` +} +type PushTextCard struct { + Title string `json:"title"` + Description string `json:"description"` + Url string `json:"url"` + Btntxt string `json:"btntxt"` +} + +type resTemplateSend struct { + util.CommonError + Invaliduser string `json:"invaliduser"` //不合法的userid,不区分大小写,统一转为小写 + Invalidparty string `json:"invalidparty"` //不合法的partyid + Invalidtag string `json:"invalidtag"` //不合法的标签id + MsgID int64 `json:"msgid"` //消息id,用于撤回应用消息 + ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 +} + +//Send 发送应用消息 +func (tpl *App) Send(msg *AppMessage) (msgID int64, err error) { + var accessToken string + accessToken, err = tpl.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageSendURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, msg) + if err != nil { + return + } + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + return +} + +//TemplateUpdate 更新模版卡片消息内容 +type TemplateUpdate struct { + Userids []string `json:"userids"` + Partyids []int `json:"partyids"` + Tagids []int `json:"tagids"` + Atall int `json:"atall"` + Agentid int `json:"agentid"` + ResponseCode string `json:"response_code"` + *UpdateButton + *TemplateCard +} + +//UpdateTemplate 更新模版卡片消息 +func (tpl *App) UpdateTemplate(msg *TemplateUpdate) (msgID int64, err error) { + var accessToken string + accessToken, err = tpl.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageUpdateTemplateCardURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, msg) + if err != nil { + return + } + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + return +} + +type ReqRecall struct { + MsgID int64 `json:"msgid"` +} + +//Recall 撤回应用消息 +func (tpl *App) Recall(msgID int64) (err error) { + var accessToken string + accessToken, err = tpl.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageDelURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, &ReqRecall{ + MsgID: msgID, + }) + if err != nil { + return + } + var result util.CommonError + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} diff --git a/work/message/image.go b/work/message/image.go new file mode 100644 index 0000000..b877042 --- /dev/null +++ b/work/message/image.go @@ -0,0 +1,16 @@ +package message + +//Image 图片消息 +type Image struct { + CommonToken `json:"-"` + Image struct { + MediaID string `xml:"MediaId" json:"media_id"` + } `xml:"Image" json:"image"` +} + +//NewImage 回复图片消息 +func NewImage(mediaID string) *Image { + image := new(Image) + image.Image.MediaID = mediaID + return image +} diff --git a/work/message/message.go b/work/message/message.go index ecbe12a..82ee26b 100644 --- a/work/message/message.go +++ b/work/message/message.go @@ -6,16 +6,19 @@ import ( "github.com/silenceper/wechat/v2/officialaccount/device" ) -// MsgType 基本消息类型 +// MsgType 企业微信普通消息类型 type MsgType string -// EventType 事件类型 +// EventType 企业微信事件消息类型 type EventType string // InfoType 第三方平台授权事件类型 type InfoType string const ( + //MsgTypeEvent 表示事件推送消息 [限接收] + MsgTypeEvent = "event" + //MsgTypeText 表示文本消息 MsgTypeText MsgType = "text" //MsgTypeImage 表示图片消息 @@ -24,37 +27,48 @@ const ( MsgTypeVoice = "voice" //MsgTypeVideo 表示视频消息 MsgTypeVideo = "video" - //MsgTypeMiniprogrampage 表示小程序卡片消息 - MsgTypeMiniprogrampage = "miniprogrampage" - //MsgTypeShortVideo 表示短视频消息[限接收] - MsgTypeShortVideo = "shortvideo" - //MsgTypeLocation 表示坐标消息[限接收] - MsgTypeLocation = "location" + //MsgTypeNews 表示图文消息[限回复与发送应用消息] + MsgTypeNews = "news" + //MsgTypeLink 表示链接消息[限接收] MsgTypeLink = "link" - //MsgTypeMusic 表示音乐消息[限回复] - MsgTypeMusic = "music" - //MsgTypeNews 表示图文消息[限回复] - MsgTypeNews = "news" - //MsgTypeTransfer 表示消息消息转发到客服 - MsgTypeTransfer = "transfer_customer_service" - //MsgTypeEvent 表示事件推送消息 - MsgTypeEvent = "event" + //MsgTypeLocation 表示坐标消息[限接收] + MsgTypeLocation = "location" + + //MsgTypeUpdateButton 更新点击用户的按钮文案[限回复应用消息] + MsgTypeUpdateButton = "update_button" + //MsgTypeUpdateTemplateCard 更新点击用户的整张卡片[限回复应用消息] + MsgTypeUpdateTemplateCard = "update_template_card" + + //MsgTypeFile 文件消息[限发送应用消息] + MsgTypeFile = "file" + //MsgTypeTextCard 文本卡片消息[限发送应用消息] + MsgTypeTextCard = "textcard" + //MsgTypeMpNews 图文消息[限发送应用消息] 跟普通的图文消息一致,唯一的差异是图文内容存储在企业微信 + MsgTypeMpNews = "mpnews" + //MsgTypeMarkdown markdown消息[限发送应用消息] + MsgTypeMarkdown = "markdown" + //MsgTypeMiniprogramNotice 小程序通知消息[限发送应用消息] + MsgTypeMiniprogramNotice = "miniprogram_notice" + //MsgTypeTemplateCard 模板卡片消息[限发送应用消息] + MsgTypeTemplateCard = "template_card" ) const ( - //EventSubscribe 订阅 + //EventSubscribe 成员关注,成员已经加入企业,管理员添加成员到应用可见范围(或移除可见范围)时 EventSubscribe EventType = "subscribe" - //EventUnsubscribe 取消订阅 + //EventUnsubscribe 成员取消关注,成员已经在应用可见范围,成员加入(或退出)企业时 EventUnsubscribe = "unsubscribe" - //EventScan 用户已经关注公众号,则微信会将带场景值扫描事件推送给开发者 - EventScan = "SCAN" + //EventEnterAgent 本事件在成员进入企业微信的应用时触发 + EventEnterAgent = "enter_agent" //EventLocation 上报地理位置事件 EventLocation = "LOCATION" + //EventBatchJobResult 异步任务完成事件推送 + EventBatchJobResult = "batch_job_result" //EventClick 点击菜单拉取消息时的事件推送 - EventClick = "CLICK" + EventClick = "click" //EventView 点击菜单跳转链接时的事件推送 - EventView = "VIEW" + EventView = "view" //EventScancodePush 扫码推事件的事件推送 EventScancodePush = "scancode_push" //EventScancodeWaitmsg 扫码推事件且弹出“消息接收中”提示框的事件推送 @@ -67,106 +81,119 @@ const ( EventPicWeixin = "pic_weixin" //EventLocationSelect 弹出地理位置选择器的事件推送 EventLocationSelect = "location_select" - //EventTemplateSendJobFinish 发送模板消息推送通知 - EventTemplateSendJobFinish = "TEMPLATESENDJOBFINISH" - //EventMassSendJobFinish 群发消息推送通知 - EventMassSendJobFinish = "MASSSENDJOBFINISH" - //EventWxaMediaCheck 异步校验图片/音频是否含有违法违规内容推送事件 - EventWxaMediaCheck = "wxa_media_check" + + //EventOpenApprovalChange 审批状态通知事件推送 + EventOpenApprovalChange = "open_approval_change" + + //EventShareAgentChange 共享应用事件回调 + EventShareAgentChange = "share_agent_change" + + //EventTemplateCard 模板卡片事件推送 + EventTemplateCard = "template_card_event" + + //EventTemplateCardMenu 通用模板卡片右上角菜单事件推送 + EventTemplateCardMenu = "template_card_menu_event" + + //EventChangeExternalContact 企业客户事件推送 + //add_external_contact 添加 + //edit_external_contact 编辑 + //add_half_external_contact 免验证添加 + //del_external_contact 员工删除客户 + //del_follow_user 客户删除跟进员工 + //transfer_fail 企业将客户分配给新的成员接替后,客户添加失败 + //change_external_chat 客户群创建事件 + EventChangeExternalContact = "change_external_contact" + + //EventChangeExternalChat 企业客户群变更事件推送 + //create 客户群创建 + //update 客户群变更 + //dismiss 客户群解散 + EventChangeExternalChat = "change_external_chat" + + //EventChangeExternalTag 企业客户标签创建事件推送 + //create 创建标签 + //update 变更标签 + //delete 删除标签 + //shuffle 重新排序 + EventChangeExternalTag = "change_external_tag" + + //EventKfMsg 企业微信客服回调事件 + EventKfMsg = "kf_msg_or_event" + //EventLivingStatusChange 直播回调事件 + EventLivingStatusChange = "living_status_change" + + //EventMsgauditNotify 会话内容存档开启后,产生会话回调事件 + EventMsgauditNotify = "msgaudit_notify" ) -const ( - //微信开放平台需要用到 +//todo 第三方应用开发 +/*const ( + //微信开放平台需要用到 - // InfoTypeVerifyTicket 返回ticket - InfoTypeVerifyTicket InfoType = "component_verify_ticket" - // InfoTypeAuthorized 授权 - InfoTypeAuthorized = "authorized" - // InfoTypeUnauthorized 取消授权 - InfoTypeUnauthorized = "unauthorized" - // InfoTypeUpdateAuthorized 更新授权 - InfoTypeUpdateAuthorized = "updateauthorized" -) + // InfoTypeVerifyTicket 返回ticket + InfoTypeVerifyTicket InfoType = "component_verify_ticket" + // InfoTypeAuthorized 授权 + InfoTypeAuthorized = "authorized" + // InfoTypeUnauthorized 取消授权 + InfoTypeUnauthorized = "unauthorized" + // InfoTypeUpdateAuthorized 更新授权 + InfoTypeUpdateAuthorized = "updateauthorized" +)*/ -//MixMessage 存放所有微信发送过来的消息和事件 +//MixMessage 存放所有企业微信官方发送过来的消息和事件 type MixMessage struct { CommonToken - //基本消息 - MsgID int64 `xml:"MsgId"` //其他消息推送过来是MsgId - TemplateMsgID int64 `xml:"MsgID"` //模板消息推送成功的消息是MsgID - Content string `xml:"Content"` - Recognition string `xml:"Recognition"` - PicURL string `xml:"PicUrl"` - MediaID string `xml:"MediaId"` - Format string `xml:"Format"` - ThumbMediaID string `xml:"ThumbMediaId"` - LocationX float64 `xml:"Location_X"` - LocationY float64 `xml:"Location_Y"` - Scale float64 `xml:"Scale"` - Label string `xml:"Label"` - Title string `xml:"Title"` - Description string `xml:"Description"` - URL string `xml:"Url"` + //接收普通消息 + MsgID int64 `xml:"MsgId"` //其他消息推送过来是MsgId + AgentID int `xml:"AgentID"` //企业应用的id,整型。可在应用的设置页面查看 + + Content string `xml:"Content"` //文本消息内容 + Format string `xml:"Format"` //语音消息格式,如amr,speex等 + ThumbMediaID string `xml:"ThumbMediaId"` //视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取数据,仅三天内有效 + + Title string `xml:"Title"` //链接消息,标题 + Description string `xml:"Description"` //链接消息,描述 + URL string `xml:"Url"` //链接消息,链接跳转的url + + PicURL string `xml:"PicUrl"` ////图片消息或者链接消息,封面缩略图的url + MediaID string `xml:"MediaId"` //图片媒体文件id//语音媒体文件id//视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取,仅三天内有效 + + LocationX float64 `xml:"Location_X"` //位置消息,地理位置纬度 + LocationY float64 `xml:"Location_Y"` //位置消息,地理位置经度 + Scale float64 `xml:"Scale"` //位置消息,地图缩放大小 + Label string `xml:"Label"` //位置消息,地理位置信息 + + AppType string `xml:"AppType"` //接收地理位置时存在,app类型,在企业微信固定返回wxwork,在微信不返回该字段 + + //TemplateMsgID int64 `xml:"MsgID"` //模板消息推送成功的消息是MsgID + ///Recognition string `xml:"Recognition"` //事件相关 - Event EventType `xml:"Event"` - EventKey string `xml:"EventKey"` - Ticket string `xml:"Ticket"` - Latitude string `xml:"Latitude"` - Longitude string `xml:"Longitude"` - Precision string `xml:"Precision"` - MenuID string `xml:"MenuId"` - Status string `xml:"Status"` - SessionFrom string `xml:"SessionFrom"` - TotalCount int64 `xml:"TotalCount"` - FilterCount int64 `xml:"FilterCount"` - SentCount int64 `xml:"SentCount"` - ErrorCount int64 `xml:"ErrorCount"` + Event EventType `xml:"Event"` + EventKey string `xml:"EventKey"` - ScanCodeInfo struct { - ScanType string `xml:"ScanType"` - ScanResult string `xml:"ScanResult"` - } `xml:"ScanCodeInfo"` + //仅上报地理位置事件 + Latitude string `xml:"Latitude"` //地理位置纬度 + Longitude string `xml:"Longitude"` //地理位置经度 + Precision string `xml:"Precision"` //地理位置精度 - SendPicsInfo struct { - Count int32 `xml:"Count"` - PicList []EventPic `xml:"PicList>item"` - } `xml:"SendPicsInfo"` + //仅异步任务完成事件 + JobId string `xml:"JobId"` //异步任务id,最大长度为64字符 + JobType string `xml:"JobType"` //异步任务,操作类型,字符串,目前分别有:sync_user(增量更新成员)、 replace_user(全量覆盖成员)、invite_user(邀请成员关注)、replace_party(全量覆盖部门) + ErrCode int `xml:"ErrCode"` //异步任务,返回码 + ErrMsg string `xml:"ErrMsg"` //异步任务,对返回码的文本描述内容 - SendLocationInfo struct { - LocationX float64 `xml:"Location_X"` - LocationY float64 `xml:"Location_Y"` - Scale float64 `xml:"Scale"` - Label string `xml:"Label"` - Poiname string `xml:"Poiname"` - } + //开启通讯录回调通知 https://open.work.weixin.qq.com/api/doc/90000/90135/90967 - // 第三方平台相关 - InfoType InfoType `xml:"InfoType"` - AppID string `xml:"AppId"` - ComponentVerifyTicket string `xml:"ComponentVerifyTicket"` - AuthorizerAppid string `xml:"AuthorizerAppid"` - AuthorizationCode string `xml:"AuthorizationCode"` - AuthorizationCodeExpiredTime int64 `xml:"AuthorizationCodeExpiredTime"` - PreAuthCode string `xml:"PreAuthCode"` - - // 卡券相关 - CardID string `xml:"CardId"` - RefuseReason string `xml:"RefuseReason"` - IsGiveByFriend int32 `xml:"IsGiveByFriend"` - FriendUserName string `xml:"FriendUserName"` - UserCardCode string `xml:"UserCardCode"` - OldUserCardCode string `xml:"OldUserCardCode"` - OuterStr string `xml:"OuterStr"` - IsRestoreMemberCard int32 `xml:"IsRestoreMemberCard"` - UnionID string `xml:"UnionId"` - - // 内容审核相关 - IsRisky bool `xml:"isrisky"` - ExtraInfoJSON string `xml:"extra_info_json"` - TraceID string `xml:"trace_id"` - StatusCode int `xml:"status_code"` + // todo 第三方平台相关 字段名可能不准确 + /*InfoType InfoType `xml:"InfoType"` + AppID string `xml:"AppId"` + ComponentVerifyTicket string `xml:"ComponentVerifyTicket"` + AuthorizerAppid string `xml:"AuthorizerAppid"` + AuthorizationCode string `xml:"AuthorizationCode"``````````````````````````````````````` + AuthorizationCodeExpiredTime int64 `xml:"AuthorizationCodeExpiredTime"` + PreAuthCode string `xml:"PreAuthCode"`*/ //设备相关 device.MsgDevice @@ -181,6 +208,7 @@ type EventPic struct { type EncryptedXMLMsg struct { XMLName struct{} `xml:"xml" json:"-"` ToUserName string `xml:"ToUserName" json:"ToUserName"` + AgentID string `xml:"AgentID" json:"AgentID"` EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` } diff --git a/work/message/news.go b/work/message/news.go new file mode 100644 index 0000000..b4cd16e --- /dev/null +++ b/work/message/news.go @@ -0,0 +1,41 @@ +package message + +//News 图文消息 +type News struct { + CommonToken `json:"-"` + ArticleCount int `xml:"ArticleCount" json:"-"` + Articles []*Article `xml:"Articles>item,omitempty" json:"articles"` +} + +//NewNews 初始化图文消息 +func NewNews(articles []*Article) *News { + news := new(News) + news.ArticleCount = len(articles) + news.Articles = articles + return news +} + +//Article 单篇文章 +type Article struct { + Title string `xml:"Title,omitempty" json:"title"` + Description string `xml:"Description,omitempty" json:"description"` + PicURL string `xml:"PicUrl,omitempty" json:"picurl"` + URL string `xml:"Url,omitempty" json:"url"` + Appid string `xml:"-" json:"appid"` //仅在发送应用消息时需要 + Pagepath string `xml:"-" json:"pagepath"` //仅在发送应用消息时需要 +} + +//MpNews 图文消息 +type MpNews struct { + Articles []*MpNewsArticle `xml:"-" json:"articles"` +} + +//MpNewsArticle mpnews类型的图文消息,跟普通的图文消息一致,唯一的差异是图文内容存储在企业微信 +type MpNewsArticle struct { + Title string `json:"title"` + ThumbMediaId string `json:"thumb_media_id"` + Author string `json:"author"` + ContentSourceUrl string `json:"content_source_url"` + Content string `json:"content"` + Digest string `json:"digest"` +} diff --git a/work/message/reply.go b/work/message/reply.go index 53592f0..8f53903 100644 --- a/work/message/reply.go +++ b/work/message/reply.go @@ -6,7 +6,7 @@ import "errors" var ErrInvalidReply = errors.New("无效的回复消息") //ErrUnsupportReply 不支持的回复类型 -var ErrUnsupportReply = errors.New("不支持的回复消息") +var ErrUnsupportReply = errors.New("无需回复消息") //Reply 消息回复 type Reply struct { diff --git a/work/message/template_card.go b/work/message/template_card.go new file mode 100644 index 0000000..29d3bef --- /dev/null +++ b/work/message/template_card.go @@ -0,0 +1,30 @@ +package message + +//UpdateButton 模板卡片按钮 +type UpdateButton struct { + CommonToken `json:"-"` + Button struct { + ReplaceName string `xml:"ReplaceName" json:"replace_name"` + } `xml:"Button" json:"button"` +} + +//NewUpdateButton 更新点击用户的按钮文案 +func NewUpdateButton(replaceName string) *UpdateButton { + btn := new(UpdateButton) + btn.Button.ReplaceName = replaceName + return btn +} + +//TemplateCard 被动回复模板卡片 +//https://open.work.weixin.qq.com/api/doc/90000/90135/90241 +type TemplateCard struct { + CommonToken `json:"-"` + TemplateCard interface{} `xml:"TemplateCard" json:"template_card"` +} + +// NewTemplateCard 更新点击用户的整张卡片 +func NewTemplateCard(cardXml interface{}) *TemplateCard { + card := new(TemplateCard) + card.TemplateCard = cardXml + return card +} diff --git a/work/message/text.go b/work/message/text.go new file mode 100644 index 0000000..46d3bf4 --- /dev/null +++ b/work/message/text.go @@ -0,0 +1,14 @@ +package message + +//Text 文本消息 +type Text struct { + CommonToken `json:"-"` + Content CDATA `json:"content" xml:"Content"` +} + +//NewText 初始化文本消息 +func NewText(content string) *Text { + text := new(Text) + text.Content = CDATA(content) + return text +} diff --git a/work/message/video.go b/work/message/video.go new file mode 100644 index 0000000..4a60307 --- /dev/null +++ b/work/message/video.go @@ -0,0 +1,20 @@ +package message + +//Video 视频消息 +type Video struct { + CommonToken `json:"-"` + Video struct { + MediaID string `xml:"MediaId" json:"media_id"` + Title string `xml:"Title,omitempty" json:"title"` + Description string `xml:"Description,omitempty" json:"description"` + } `xml:"Video" json:"video"` +} + +//NewVideo 回复图片消息 +func NewVideo(mediaID, title, description string) *Video { + video := new(Video) + video.Video.MediaID = mediaID + video.Video.Title = title + video.Video.Description = description + return video +} diff --git a/work/message/voice.go b/work/message/voice.go new file mode 100644 index 0000000..4a389c0 --- /dev/null +++ b/work/message/voice.go @@ -0,0 +1,16 @@ +package message + +//Voice 语音消息 +type Voice struct { + CommonToken `json:"-"` + Voice struct { + MediaID string `xml:"MediaId" json:"media_id"` + } `xml:"Voice" json:"voice"` +} + +//NewVoice 回复语音消息 +func NewVoice(mediaID string) *Voice { + voice := new(Voice) + voice.Voice.MediaID = mediaID + return voice +} diff --git a/work/server/server.go b/work/server/server.go index b1a32aa..5f0298a 100644 --- a/work/server/server.go +++ b/work/server/server.go @@ -64,7 +64,7 @@ func (srv *Server) SkipValidate(skip bool) { srv.skipValidate = skip } -//Serve 处理微信的请求消息 +//Serve 处理企业微信的请求消息 func (srv *Server) Serve() error { response, err := srv.handleRequest() if err != nil { @@ -88,7 +88,7 @@ func (srv *Server) Validate() bool { return signature == util.Signature(srv.Token, timestamp, nonce) } -//HandleRequest 处理微信的请求 +//HandleRequest 处理企业微信的请求 func (srv *Server) handleRequest() (reply *message.Reply, err error) { var msg interface{} @@ -105,7 +105,7 @@ func (srv *Server) handleRequest() (reply *message.Reply, err error) { return } -//getMessage 解析微信返回的消息 +//getMessage 解析企业微信返回的消息 func (srv *Server) getMessage() (interface{}, error) { var rawXMLMsgBytes []byte var err error @@ -136,7 +136,6 @@ func (srv *Server) getMessage() (interface{}, error) { } srv.RequestRawXMLMsg = rawXMLMsgBytes - return srv.parseRequestMessage(rawXMLMsgBytes) } @@ -167,9 +166,9 @@ func (srv *Server) buildResponse(reply *message.Reply) (err error) { case message.MsgTypeImage: case message.MsgTypeVoice: case message.MsgTypeVideo: - case message.MsgTypeMusic: case message.MsgTypeNews: - case message.MsgTypeTransfer: + case message.MsgTypeUpdateButton: + case message.MsgTypeUpdateTemplateCard: default: err = message.ErrUnsupportReply return @@ -195,7 +194,6 @@ func (srv *Server) buildResponse(reply *message.Reply) (err error) { params[0] = reflect.ValueOf(util.GetCurrTS()) value.MethodByName("SetCreateTime").Call(params) - srv.ResponseMsg = msgData srv.ResponseRawXMLMsg, err = xml.Marshal(msgData) return From 5704abb3b037c9301fa39d62479db041520438ab Mon Sep 17 00:00:00 2001 From: hb Date: Mon, 29 Nov 2021 11:01:20 +0800 Subject: [PATCH 06/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=8E=B7=E5=8F=96=E5=AE=A2=E6=88=B7=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E5=AE=A2=E6=88=B7=E8=AF=A6=E6=83=85=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E7=BE=A4=E5=8F=91=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/externalcontact/add_msg_template.go | 90 ++++++++++ work/externalcontact/client.go | 44 +++++ work/externalcontact/user.go | 211 +++++++++++++++++++++++ work/work.go | 6 + 4 files changed, 351 insertions(+) create mode 100644 work/externalcontact/add_msg_template.go create mode 100644 work/externalcontact/client.go create mode 100644 work/externalcontact/user.go diff --git a/work/externalcontact/add_msg_template.go b/work/externalcontact/add_msg_template.go new file mode 100644 index 0000000..cbb0d3f --- /dev/null +++ b/work/externalcontact/add_msg_template.go @@ -0,0 +1,90 @@ +package externalcontact + +import ( + "encoding/json" + "fmt" + "github.com/silenceper/wechat/v2/util" +) + +const ( + addMsgTemplateUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_msg_template" +) + +type ChatType string + +const ( + ChatTypeSingle ChatType = "single" + ChatTypeGroup ChatType = "group" +) + +//ReqMessage 企业群发参数 +type ReqMessage struct { + ChatType ChatType `json:"chat_type"` //群发任务的类型,默认为single,表示发送给客户,group表示发送给客户群 + ExternalUserid []string `json:"external_userid"` // 客户的外部联系人id列表,仅在chat_type为single时有效,不可与sender同时为空,最多可传入1万个客户 + Sender string `json:"sender"` //发送企业群发消息的成员userid,当类型为发送给客户群时必填 + Text struct { + Content string `json:"content"` + } `json:"text"` + Attachments []struct { + Msgtype string `json:"msgtype"` + Image MsgImage `json:"image"` + Link MsgLink `json:"link"` + Miniprogram MsgMiniprogram `json:"miniprogram"` + Video MsgVideo `json:"video"` + File MsgFile `json:"file"` + } `json:"attachments"` +} +type MsgImage struct { + MediaId string `json:"media_id"` + PicUrl string `json:"pic_url"` +} +type MsgLink struct { + Title string `json:"title"` + Picurl string `json:"picurl"` + Desc string `json:"desc"` + Url string `json:"url"` +} +type MsgMiniprogram struct { + Title string `json:"title"` + PicMediaId string `json:"pic_media_id"` + Appid string `json:"appid"` + Page string `json:"page"` +} +type MsgVideo struct { + MediaId string `json:"media_id"` +} +type MsgFile struct { + MediaId string `json:"media_id"` +} + +type resTemplateSend struct { + util.CommonError + FailList string `json:"fail_list"` + MsgID int64 `json:"msgid"` +} + +//Send 发送应用消息 +func (tpl *Client) Send(msg *ReqMessage) (msgID int64, err error) { + var accessToken string + accessToken, err = tpl.ctx.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", addMsgTemplateUrl, accessToken) + var response []byte + response, err = util.PostJSON(uri, msg) + if err != nil { + return + } + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + return +} diff --git a/work/externalcontact/client.go b/work/externalcontact/client.go new file mode 100644 index 0000000..8574317 --- /dev/null +++ b/work/externalcontact/client.go @@ -0,0 +1,44 @@ +package externalcontact + +import ( + "errors" + "github.com/silenceper/wechat/v2/cache" + "github.com/silenceper/wechat/v2/credential" + "github.com/silenceper/wechat/v2/work/config" + "github.com/silenceper/wechat/v2/work/context" +) + +// Client 企业微信客户联系实例 +type Client struct { + corpID string // 企业ID:企业开通的每个微信客服,都对应唯一的企业ID,企业可在微信客服管理后台的企业信息处查看 + secret string // Secret是微信客服用于校验开发者身份的访问密钥,企业成功注册微信客服后,可在「微信客服管理后台-开发配置」处获取 + token string // 用于生成签名校验回调请求的合法性 + encodingAESKey string // 回调消息加解密参数是AES密钥的Base64编码,用于解密回调消息内容对应的密文 + cache cache.Cache + ctx *context.Context +} + +// NewClient 初始化企业微信客户联系实例 +func NewClient(cfg *config.Config) (client *Client, err error) { + if cfg.Cache == nil { + return nil, errors.New("SDK初始化失败") + } + + //初始化 AccessToken Handle + defaultAkHandle := credential.NewWorkAccessToken(cfg.CorpID, cfg.CorpSecret, credential.CacheKeyWorkPrefix, cfg.Cache) + ctx := &context.Context{ + Config: cfg, + AccessTokenHandle: defaultAkHandle, + } + + client = &Client{ + corpID: cfg.CorpID, + secret: cfg.CorpSecret, + token: cfg.Token, + encodingAESKey: cfg.EncodingAESKey, + cache: cfg.Cache, + ctx: ctx, + } + + return client, nil +} diff --git a/work/externalcontact/user.go b/work/externalcontact/user.go new file mode 100644 index 0000000..c4fd2c1 --- /dev/null +++ b/work/externalcontact/user.go @@ -0,0 +1,211 @@ +package externalcontact + +import ( + "encoding/json" + "fmt" + "github.com/silenceper/wechat/v2/util" +) + +const ( + listUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list" + getUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get" + getByUserBatchUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/batch/get_by_user" +) + +type ReqGetByUser struct { + UseridList []string `json:"userid_list"` + Cursor string `json:"cursor"` + Limit int `json:"limit"` +} +type OneUser struct { + util.CommonError + ExternalContact ExternalContact `json:"external_contact"` + FollowUser []FollowInfo `json:"follow_user"` //注意,仅获取单个客户详情的时候这里返回的是跟进记录列表 + NextCursor string `json:"next_cursor"` +} +type resUserList struct { + util.CommonError + ExternalContactList []UserInfo `json:"external_contact_list"` + NextCursor string `json:"next_cursor"` +} +type resUserids struct { + util.CommonError + ExternalUserid []string `json:"external_userid"` +} + +type UserInfo struct { + ExternalContact ExternalContact `json:"external_contact"` + FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 +} + +type ExternalContact struct { + ExternalUserid string `json:"external_userid"` + Name string `json:"name"` + Position string `json:"position"` + Avatar string `json:"avatar"` + CorpName string `json:"corp_name"` + CorpFullName string `json:"corp_full_name"` + Type int `json:"type"` + Gender int `json:"gender"` + Unionid string `json:"unionid"` + ExternalProfile struct { + ExternalAttr []struct { + Type int `json:"type"` + Name string `json:"name"` + Text struct { + Value string `json:"value"` + } `json:"text,omitempty"` + Web struct { + Url string `json:"url"` + Title string `json:"title"` + } `json:"web,omitempty"` + Miniprogram struct { + Appid string `json:"appid"` + Pagepath string `json:"pagepath"` + Title string `json:"title"` + } `json:"miniprogram,omitempty"` + } `json:"external_attr"` + } `json:"external_profile,omitempty"` +} + +type FollowInfo struct { + Userid string `json:"userid"` + Remark string `json:"remark"` + Description string `json:"description"` + Createtime int `json:"createtime"` + TagId []string `json:"tag_id"` //批量获取时才有 + Tags []struct { + GroupName string `json:"group_name"` + TagName string `json:"tag_name"` + TagId string `json:"tag_id,omitempty"` + Type int `json:"type"` + } `json:"tags,omitempty"` //单独获取时才有 + RemarkCorpName string `json:"remark_corp_name,omitempty"` + RemarkMobiles []string `json:"remark_mobiles,omitempty"` + OperUserid string `json:"oper_userid"` + AddWay int `json:"add_way"` + State string `json:"state,omitempty"` +} + +//GetUseridList 获取我的客户列表 +func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err error) { + var accessToken string + accessToken, err = tpl.ctx.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s&userid=%s", listUrl, accessToken, myUserid) + var response []byte + response, err = util.HTTPGet(uri) + if err != nil { + return + } + var result resUserids + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + externalUserid = result.ExternalUserid + return +} + +//GetUseridList 获取我的全部客户列表及详情 +func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { + var userInfoList []UserInfo + var req ReqGetByUser + req.UseridList = qyUserid + req.Limit = 100 + for { + userInfoPage, resCursor, err := tpl.GetUserInfoListByUserIds(req) + if err != nil { + return userInfoList, err + } + userInfoList = append(userInfoList, userInfoPage...) + if resCursor != "" { + req.Cursor = resCursor + } else { + break + } + } + return userInfoList, nil +} + +//GetUserInfoAndAllFollow 获取客户详情以及全部跟进记录 +func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { + var result, res OneUser + var err error + var cursor string + for { + res, err = tpl.GetUserInfo(userid, cursor) + if err != nil { + return result, err + } + result.FollowUser = append(result.FollowUser, res.FollowUser...) + result.ExternalContact = res.ExternalContact + if res.NextCursor != "" { + cursor = res.NextCursor + } else { + break + } + } + return result, nil +} + +//GetUserInfo 获取客户详情 +func (tpl *Client) GetUserInfo(externalUserid string, cursor ...string) (result OneUser, err error) { + var accessToken string + accessToken, err = tpl.ctx.GetAccessToken() + if err != nil { + return + } + var page = "" + if len(cursor) > 0 { + page = cursor[0] + } + uri := fmt.Sprintf("%s?access_token=%s&external_userid=%s&cursor=%s", getUrl, accessToken, externalUserid, page) + var response []byte + response, err = util.HTTPGet(uri) + if err != nil { + return + } + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} + +//GetUserInfoListByUserId 批量获取客户详情 +func (tpl *Client) GetUserInfoListByUserIds(req ReqGetByUser) (userList []UserInfo, nextCursor string, err error) { + var accessToken string + accessToken, err = tpl.ctx.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", getByUserBatchUrl, accessToken) + var response []byte + response, err = util.PostJSON(uri, req) + if err != nil { + return + } + var result resUserList + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + userList = result.ExternalContactList + nextCursor = result.NextCursor + return +} diff --git a/work/work.go b/work/work.go index 451f81a..782d219 100644 --- a/work/work.go +++ b/work/work.go @@ -4,6 +4,7 @@ import ( "github.com/silenceper/wechat/v2/credential" "github.com/silenceper/wechat/v2/work/config" "github.com/silenceper/wechat/v2/work/context" + "github.com/silenceper/wechat/v2/work/externalcontact" "github.com/silenceper/wechat/v2/work/kf" "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" @@ -65,3 +66,8 @@ func (wk *Work) GetUser() *user.User { func (wk *Work) GetCalendar() *tools.Calendar { return tools.NewCalendar(wk.ctx) } + +//GetExternalContact 客户联系 +func (wk *Work) GetExternalContact() (*externalcontact.Client, error) { + return externalcontact.NewClient(wk.ctx.Config) +} From 1f80c26a1510e86b00cdd20156c08d6f40f6be14 Mon Sep 17 00:00:00 2001 From: hb Date: Wed, 26 Jan 2022 11:34:30 +0800 Subject: [PATCH 07/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9Ejssdk=E6=94=AF=E6=8C=81=E5=92=8COauth=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=BA=AB=E4=BB=BD=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- credential/work_js_ticket.go | 85 ++++++++++++++++++++++++++++++++ work/externalcontact/user.go | 6 +-- work/js/js.go | 93 ++++++++++++++++++++++++++++++++++++ work/user/user.go | 4 +- work/work.go | 6 +++ 5 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 credential/work_js_ticket.go create mode 100644 work/js/js.go diff --git a/credential/work_js_ticket.go b/credential/work_js_ticket.go new file mode 100644 index 0000000..1ce8c11 --- /dev/null +++ b/credential/work_js_ticket.go @@ -0,0 +1,85 @@ +package credential + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/silenceper/wechat/v2/cache" + "github.com/silenceper/wechat/v2/util" +) + +//获取ticket的url https://developer.work.weixin.qq.com/document/path/90506 +const getQyWxTicketURL = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=%s" +const getQyAppTicketURL = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=%s&type=agent_config" + +//WorkJsTicket 默认获取js ticket方法 +type WorkJsTicket struct { + appID string + agentID string + cacheKeyPrefix string + cache cache.Cache + //jsAPITicket 读写锁 同一个AppID一个 + jsAPITicketLock *sync.Mutex +} + +//NewWorkJsTicket new +func NewWorkJsTicket(appID string, agentID string, cacheKeyPrefix string, cache cache.Cache) JsTicketHandle { + return &WorkJsTicket{ + appID: appID, + agentID: agentID, + cache: cache, + cacheKeyPrefix: cacheKeyPrefix, + jsAPITicketLock: new(sync.Mutex), + } +} + +//GetTicket 获取企业微信jsapi_ticket +func (js *WorkJsTicket) GetTicket(accessToken string) (ticketStr string, err error) { + //先从cache中取 + jsAPITicketCacheKey := fmt.Sprintf("%s_jsapi_ticket_%s", js.cacheKeyPrefix, js.appID) + if val := js.cache.Get(jsAPITicketCacheKey); val != nil { + return val.(string), nil + } + + js.jsAPITicketLock.Lock() + defer js.jsAPITicketLock.Unlock() + + // 双检,防止重复从微信服务器获取 + if val := js.cache.Get(jsAPITicketCacheKey); val != nil { + return val.(string), nil + } + + var ticket ResTicket + ticket, err = GetQyWxTicketFromServer(accessToken, js.agentID != "") + if err != nil { + return + } + expires := ticket.ExpiresIn - 1500 + err = js.cache.Set(jsAPITicketCacheKey, ticket.Ticket, time.Duration(expires)*time.Second) + ticketStr = ticket.Ticket + return +} + +//GetQyWxTicketFromServer 从企业微信服务器中获取ticket +func GetQyWxTicketFromServer(accessToken string, isApp bool) (ticket ResTicket, err error) { + var response []byte + url := fmt.Sprintf(getQyWxTicketURL, accessToken) + if isApp { + url = fmt.Sprintf(getQyAppTicketURL, accessToken) + } + response, err = util.HTTPGet(url) + if err != nil { + return + } + err = json.Unmarshal(response, &ticket) + if err != nil { + return + } + if ticket.ErrCode != 0 { + err = fmt.Errorf("getTicket Error : errcode=%d , errmsg=%s", ticket.ErrCode, ticket.ErrMsg) + return + } + return +} diff --git a/work/externalcontact/user.go b/work/externalcontact/user.go index c4fd2c1..34282ed 100644 --- a/work/externalcontact/user.go +++ b/work/externalcontact/user.go @@ -20,7 +20,7 @@ type ReqGetByUser struct { type OneUser struct { util.CommonError ExternalContact ExternalContact `json:"external_contact"` - FollowUser []FollowInfo `json:"follow_user"` //注意,仅获取单个客户详情的时候这里返回的是跟进记录列表 + FollowUser []FollowInfo `json:"follow_user"` //注意,仅获取单个客户详情的时候这里返回的是跟进人列表 NextCursor string `json:"next_cursor"` } type resUserList struct { @@ -35,7 +35,7 @@ type resUserids struct { type UserInfo struct { ExternalContact ExternalContact `json:"external_contact"` - FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 + FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进人信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 } type ExternalContact struct { @@ -134,7 +134,7 @@ func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { return userInfoList, nil } -//GetUserInfoAndAllFollow 获取客户详情以及全部跟进记录 +//GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { var result, res OneUser var err error diff --git a/work/js/js.go b/work/js/js.go new file mode 100644 index 0000000..dc4a8f1 --- /dev/null +++ b/work/js/js.go @@ -0,0 +1,93 @@ +package js + +import ( + "fmt" + + "github.com/silenceper/wechat/v2/credential" + "github.com/silenceper/wechat/v2/util" + "github.com/silenceper/wechat/v2/work/context" +) + +// Js struct +type Js struct { + *context.Context + credential.JsTicketHandle +} + +// Config 返回给用户jssdk配置信息 +type Config struct { + CorpID string `json:"corp_id"` + Agentid string `json:"agentid"` + Timestamp int64 `json:"timestamp"` + NonceStr string `json:"nonce_str"` + Signature string `json:"signature"` +} + +//NewJs init +func NewJs(context *context.Context) *Js { + js := new(Js) + js.Context = context + jsTicketHandle := credential.NewWorkJsTicket(context.CorpID, context.AgentID, credential.CacheKeyWorkPrefix, context.Cache) + js.SetJsTicketHandle(jsTicketHandle) + return js +} + +//SetJsTicketHandle 自定义js ticket取值方式 +func (js *Js) SetJsTicketHandle(ticketHandle credential.JsTicketHandle) { + js.JsTicketHandle = ticketHandle +} + +//GetConfig 获取jssdk需要的配置参数 +//uri 为当前网页地址 +func (js *Js) GetConfig(uri string) (config *Config, err error) { + config = new(Config) + var accessToken string + accessToken, err = js.GetAccessToken() + if err != nil { + return + } + var ticketStr string + ticketStr, err = js.GetTicket(accessToken) + if err != nil { + return + } + + nonceStr := util.RandomStr(16) + timestamp := util.GetCurrTS() + str := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticketStr, nonceStr, timestamp, uri) + sigStr := util.Signature(str) + + config.CorpID = js.CorpID + config.Agentid = js.AgentID + config.NonceStr = nonceStr + config.Timestamp = timestamp + config.Signature = sigStr + return +} + +//GetAgentConfig 获取jssdk需要的配置参数 +//uri 为当前网页地址 +func (js *Js) GetAgentConfig(uri string) (config *Config, err error) { + config = new(Config) + var accessToken string + accessToken, err = js.GetAccessToken() + if err != nil { + return + } + var ticketStr string + ticketStr, err = js.GetTicket(accessToken) + if err != nil { + return + } + + nonceStr := util.RandomStr(16) + timestamp := util.GetCurrTS() + str := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticketStr, nonceStr, timestamp, uri) + sigStr := util.Signature(str) + + config.CorpID = js.CorpID + config.NonceStr = nonceStr + config.Timestamp = timestamp + config.Signature = sigStr + return +} diff --git a/work/user/user.go b/work/user/user.go index 506e8d8..66eda7c 100644 --- a/work/user/user.go +++ b/work/user/user.go @@ -13,7 +13,7 @@ const ( userInfoURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=%s&userid=%s" updateURL = "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=%s&department_id=%s&fetch_child=1" userListURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get" - launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code" + launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code?access_token=%s" ) //User 用户管理 @@ -207,7 +207,7 @@ func (user *User) GetLaunchCode(userID, other string) (userInfo *RespLaunchCode, return } - uri := fmt.Sprintf(launchCode, accessToken, userID) + uri := fmt.Sprintf(launchCode, accessToken) var response []byte response, err = util.PostJSON(uri, map[string]interface{}{"operator_userid": userID, "single_chat": map[string]string{"userid": other}}) if err != nil { diff --git a/work/work.go b/work/work.go index 782d219..72f51de 100644 --- a/work/work.go +++ b/work/work.go @@ -5,6 +5,7 @@ import ( "github.com/silenceper/wechat/v2/work/config" "github.com/silenceper/wechat/v2/work/context" "github.com/silenceper/wechat/v2/work/externalcontact" + "github.com/silenceper/wechat/v2/work/js" "github.com/silenceper/wechat/v2/work/kf" "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" @@ -47,6 +48,11 @@ func (wk *Work) GetOauth() *oauth.Oauth { return oauth.NewOauth(wk.ctx) } +// GetJs js-sdk配置 +func (wk *Work) GetJs() *js.Js { + return js.NewJs(wk.ctx) +} + // GetMsgAudit get msgAudit func (wk *Work) GetMsgAudit() (*msgaudit.Client, error) { return msgaudit.NewClient(wk.ctx.Config) From 88f07bc5fb748828e07ffbfbcfebbdcc562a4f3f Mon Sep 17 00:00:00 2001 From: hb Date: Wed, 26 Jan 2022 11:53:36 +0800 Subject: [PATCH 08/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9Ejssdk=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- credential/work_js_ticket.go | 85 ++++++++++++++++++++++++++++++++ work/externalcontact/user.go | 6 +-- work/js/js.go | 93 ++++++++++++++++++++++++++++++++++++ work/user/user.go | 4 +- work/work.go | 6 +++ 5 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 credential/work_js_ticket.go create mode 100644 work/js/js.go diff --git a/credential/work_js_ticket.go b/credential/work_js_ticket.go new file mode 100644 index 0000000..1ce8c11 --- /dev/null +++ b/credential/work_js_ticket.go @@ -0,0 +1,85 @@ +package credential + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/silenceper/wechat/v2/cache" + "github.com/silenceper/wechat/v2/util" +) + +//获取ticket的url https://developer.work.weixin.qq.com/document/path/90506 +const getQyWxTicketURL = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=%s" +const getQyAppTicketURL = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=%s&type=agent_config" + +//WorkJsTicket 默认获取js ticket方法 +type WorkJsTicket struct { + appID string + agentID string + cacheKeyPrefix string + cache cache.Cache + //jsAPITicket 读写锁 同一个AppID一个 + jsAPITicketLock *sync.Mutex +} + +//NewWorkJsTicket new +func NewWorkJsTicket(appID string, agentID string, cacheKeyPrefix string, cache cache.Cache) JsTicketHandle { + return &WorkJsTicket{ + appID: appID, + agentID: agentID, + cache: cache, + cacheKeyPrefix: cacheKeyPrefix, + jsAPITicketLock: new(sync.Mutex), + } +} + +//GetTicket 获取企业微信jsapi_ticket +func (js *WorkJsTicket) GetTicket(accessToken string) (ticketStr string, err error) { + //先从cache中取 + jsAPITicketCacheKey := fmt.Sprintf("%s_jsapi_ticket_%s", js.cacheKeyPrefix, js.appID) + if val := js.cache.Get(jsAPITicketCacheKey); val != nil { + return val.(string), nil + } + + js.jsAPITicketLock.Lock() + defer js.jsAPITicketLock.Unlock() + + // 双检,防止重复从微信服务器获取 + if val := js.cache.Get(jsAPITicketCacheKey); val != nil { + return val.(string), nil + } + + var ticket ResTicket + ticket, err = GetQyWxTicketFromServer(accessToken, js.agentID != "") + if err != nil { + return + } + expires := ticket.ExpiresIn - 1500 + err = js.cache.Set(jsAPITicketCacheKey, ticket.Ticket, time.Duration(expires)*time.Second) + ticketStr = ticket.Ticket + return +} + +//GetQyWxTicketFromServer 从企业微信服务器中获取ticket +func GetQyWxTicketFromServer(accessToken string, isApp bool) (ticket ResTicket, err error) { + var response []byte + url := fmt.Sprintf(getQyWxTicketURL, accessToken) + if isApp { + url = fmt.Sprintf(getQyAppTicketURL, accessToken) + } + response, err = util.HTTPGet(url) + if err != nil { + return + } + err = json.Unmarshal(response, &ticket) + if err != nil { + return + } + if ticket.ErrCode != 0 { + err = fmt.Errorf("getTicket Error : errcode=%d , errmsg=%s", ticket.ErrCode, ticket.ErrMsg) + return + } + return +} diff --git a/work/externalcontact/user.go b/work/externalcontact/user.go index c4fd2c1..34282ed 100644 --- a/work/externalcontact/user.go +++ b/work/externalcontact/user.go @@ -20,7 +20,7 @@ type ReqGetByUser struct { type OneUser struct { util.CommonError ExternalContact ExternalContact `json:"external_contact"` - FollowUser []FollowInfo `json:"follow_user"` //注意,仅获取单个客户详情的时候这里返回的是跟进记录列表 + FollowUser []FollowInfo `json:"follow_user"` //注意,仅获取单个客户详情的时候这里返回的是跟进人列表 NextCursor string `json:"next_cursor"` } type resUserList struct { @@ -35,7 +35,7 @@ type resUserids struct { type UserInfo struct { ExternalContact ExternalContact `json:"external_contact"` - FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 + FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进人信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 } type ExternalContact struct { @@ -134,7 +134,7 @@ func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { return userInfoList, nil } -//GetUserInfoAndAllFollow 获取客户详情以及全部跟进记录 +//GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { var result, res OneUser var err error diff --git a/work/js/js.go b/work/js/js.go new file mode 100644 index 0000000..dc4a8f1 --- /dev/null +++ b/work/js/js.go @@ -0,0 +1,93 @@ +package js + +import ( + "fmt" + + "github.com/silenceper/wechat/v2/credential" + "github.com/silenceper/wechat/v2/util" + "github.com/silenceper/wechat/v2/work/context" +) + +// Js struct +type Js struct { + *context.Context + credential.JsTicketHandle +} + +// Config 返回给用户jssdk配置信息 +type Config struct { + CorpID string `json:"corp_id"` + Agentid string `json:"agentid"` + Timestamp int64 `json:"timestamp"` + NonceStr string `json:"nonce_str"` + Signature string `json:"signature"` +} + +//NewJs init +func NewJs(context *context.Context) *Js { + js := new(Js) + js.Context = context + jsTicketHandle := credential.NewWorkJsTicket(context.CorpID, context.AgentID, credential.CacheKeyWorkPrefix, context.Cache) + js.SetJsTicketHandle(jsTicketHandle) + return js +} + +//SetJsTicketHandle 自定义js ticket取值方式 +func (js *Js) SetJsTicketHandle(ticketHandle credential.JsTicketHandle) { + js.JsTicketHandle = ticketHandle +} + +//GetConfig 获取jssdk需要的配置参数 +//uri 为当前网页地址 +func (js *Js) GetConfig(uri string) (config *Config, err error) { + config = new(Config) + var accessToken string + accessToken, err = js.GetAccessToken() + if err != nil { + return + } + var ticketStr string + ticketStr, err = js.GetTicket(accessToken) + if err != nil { + return + } + + nonceStr := util.RandomStr(16) + timestamp := util.GetCurrTS() + str := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticketStr, nonceStr, timestamp, uri) + sigStr := util.Signature(str) + + config.CorpID = js.CorpID + config.Agentid = js.AgentID + config.NonceStr = nonceStr + config.Timestamp = timestamp + config.Signature = sigStr + return +} + +//GetAgentConfig 获取jssdk需要的配置参数 +//uri 为当前网页地址 +func (js *Js) GetAgentConfig(uri string) (config *Config, err error) { + config = new(Config) + var accessToken string + accessToken, err = js.GetAccessToken() + if err != nil { + return + } + var ticketStr string + ticketStr, err = js.GetTicket(accessToken) + if err != nil { + return + } + + nonceStr := util.RandomStr(16) + timestamp := util.GetCurrTS() + str := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticketStr, nonceStr, timestamp, uri) + sigStr := util.Signature(str) + + config.CorpID = js.CorpID + config.NonceStr = nonceStr + config.Timestamp = timestamp + config.Signature = sigStr + return +} diff --git a/work/user/user.go b/work/user/user.go index 506e8d8..66eda7c 100644 --- a/work/user/user.go +++ b/work/user/user.go @@ -13,7 +13,7 @@ const ( userInfoURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=%s&userid=%s" updateURL = "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=%s&department_id=%s&fetch_child=1" userListURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get" - launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code" + launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code?access_token=%s" ) //User 用户管理 @@ -207,7 +207,7 @@ func (user *User) GetLaunchCode(userID, other string) (userInfo *RespLaunchCode, return } - uri := fmt.Sprintf(launchCode, accessToken, userID) + uri := fmt.Sprintf(launchCode, accessToken) var response []byte response, err = util.PostJSON(uri, map[string]interface{}{"operator_userid": userID, "single_chat": map[string]string{"userid": other}}) if err != nil { diff --git a/work/work.go b/work/work.go index 782d219..72f51de 100644 --- a/work/work.go +++ b/work/work.go @@ -5,6 +5,7 @@ import ( "github.com/silenceper/wechat/v2/work/config" "github.com/silenceper/wechat/v2/work/context" "github.com/silenceper/wechat/v2/work/externalcontact" + "github.com/silenceper/wechat/v2/work/js" "github.com/silenceper/wechat/v2/work/kf" "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" @@ -47,6 +48,11 @@ func (wk *Work) GetOauth() *oauth.Oauth { return oauth.NewOauth(wk.ctx) } +// GetJs js-sdk配置 +func (wk *Work) GetJs() *js.Js { + return js.NewJs(wk.ctx) +} + // GetMsgAudit get msgAudit func (wk *Work) GetMsgAudit() (*msgaudit.Client, error) { return msgaudit.NewClient(wk.ctx.Config) From 6313e3d58093fa497e62b6bc00327c46b906d1c1 Mon Sep 17 00:00:00 2001 From: hb Date: Wed, 2 Mar 2022 19:20:04 +0800 Subject: [PATCH 09/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E4=BC=81=E4=B8=9A=E5=86=85=E9=83=A8=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/app.go | 46 ++++++++++++++------------ work/message/message.go | 72 ++++++++++++++++++++--------------------- work/work.go | 6 ++++ 3 files changed, 67 insertions(+), 57 deletions(-) diff --git a/work/message/app.go b/work/message/app.go index 3aa59f0..31bd78e 100644 --- a/work/message/app.go +++ b/work/message/app.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/silenceper/wechat/v2/util" "github.com/silenceper/wechat/v2/work/context" + "strconv" ) const ( @@ -27,24 +28,24 @@ func NewApp(context *context.Context) *App { //AppMessage 发送的模板消息内容 type AppMessage struct { - ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 - Toparty string `json:"toparty"` //部门ID列表,当touser为”@all”时忽略本参数 - Totag string `json:"totag"` //标签ID列表,当touser为”@all”时忽略本参数 - Msgtype string `json:"msgtype"` - Agentid int `json:"agentid"` - Safe int `json:"safe"` - EnableIdTrans int `json:"enable_id_trans"` - EnableDuplicateCheck int `json:"enable_duplicate_check"` - DuplicateCheckInterval int `json:"duplicate_check_interval"` - Text *Text - *Image - *Voice - *Video - File *PushFile `json:"file"` - TextCard *PushTextCard `json:"textcard"` - News *News `json:"news"` - MpNews *MpNews `json:"mpnews"` - Markdown *Text `json:"markdown"` + ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 + Toparty string `json:"toparty"` //部门ID列表,当touser为”@all”时忽略本参数 + Totag string `json:"totag"` //标签ID列表,当touser为”@all”时忽略本参数 + Msgtype MsgType `json:"msgtype"` + Agentid int `json:"agentid"` + Safe int `json:"safe"` + EnableIdTrans int `json:"enable_id_trans"` + EnableDuplicateCheck int `json:"enable_duplicate_check"` + DuplicateCheckInterval int `json:"duplicate_check_interval"` + Text *Text `json:"text"` + *Image `json:"image"` + *Voice `json:"voice"` + *Video `json:"video"` + File *PushFile `json:"file"` + TextCard *PushTextCard `json:"textcard"` + News *News `json:"news"` + MpNews *MpNews `json:"mpnews"` + Markdown *Text `json:"markdown"` //todo(hb1707) 可能会发生变化的字段直接用interface{}了 MiniprogramNotice interface{} `json:"miniprogram_notice"` TemplateCard interface{} `json:"template_card"` @@ -65,12 +66,12 @@ type resTemplateSend struct { Invaliduser string `json:"invaliduser"` //不合法的userid,不区分大小写,统一转为小写 Invalidparty string `json:"invalidparty"` //不合法的partyid Invalidtag string `json:"invalidtag"` //不合法的标签id - MsgID int64 `json:"msgid"` //消息id,用于撤回应用消息 + MsgID string `json:"msgid"` //消息id,用于撤回应用消息 ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 } //Send 发送应用消息 -func (tpl *App) Send(msg *AppMessage) (msgID int64, err error) { +func (tpl *App) Send(msg *AppMessage) (msgID string, err error) { var accessToken string accessToken, err = tpl.GetAccessToken() if err != nil { @@ -78,6 +79,9 @@ func (tpl *App) Send(msg *AppMessage) (msgID int64, err error) { } uri := fmt.Sprintf("%s?access_token=%s", messageSendURL, accessToken) var response []byte + if msg.Agentid == 0 { + msg.Agentid, _ = strconv.Atoi(tpl.Context.AgentID) + } response, err = util.PostJSON(uri, msg) if err != nil { return @@ -108,7 +112,7 @@ type TemplateUpdate struct { } //UpdateTemplate 更新模版卡片消息 -func (tpl *App) UpdateTemplate(msg *TemplateUpdate) (msgID int64, err error) { +func (tpl *App) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { var accessToken string accessToken, err = tpl.GetAccessToken() if err != nil { diff --git a/work/message/message.go b/work/message/message.go index 82ee26b..e0b8ffd 100644 --- a/work/message/message.go +++ b/work/message/message.go @@ -22,77 +22,77 @@ const ( //MsgTypeText 表示文本消息 MsgTypeText MsgType = "text" //MsgTypeImage 表示图片消息 - MsgTypeImage = "image" + MsgTypeImage MsgType = "image" //MsgTypeVoice 表示语音消息 - MsgTypeVoice = "voice" + MsgTypeVoice MsgType = "voice" //MsgTypeVideo 表示视频消息 - MsgTypeVideo = "video" + MsgTypeVideo MsgType = "video" //MsgTypeNews 表示图文消息[限回复与发送应用消息] - MsgTypeNews = "news" + MsgTypeNews MsgType = "news" //MsgTypeLink 表示链接消息[限接收] - MsgTypeLink = "link" + MsgTypeLink MsgType = "link" //MsgTypeLocation 表示坐标消息[限接收] - MsgTypeLocation = "location" + MsgTypeLocation MsgType = "location" //MsgTypeUpdateButton 更新点击用户的按钮文案[限回复应用消息] - MsgTypeUpdateButton = "update_button" + MsgTypeUpdateButton MsgType = "update_button" //MsgTypeUpdateTemplateCard 更新点击用户的整张卡片[限回复应用消息] - MsgTypeUpdateTemplateCard = "update_template_card" + MsgTypeUpdateTemplateCard MsgType = "update_template_card" //MsgTypeFile 文件消息[限发送应用消息] - MsgTypeFile = "file" + MsgTypeFile MsgType = "file" //MsgTypeTextCard 文本卡片消息[限发送应用消息] - MsgTypeTextCard = "textcard" + MsgTypeTextCard MsgType = "textcard" //MsgTypeMpNews 图文消息[限发送应用消息] 跟普通的图文消息一致,唯一的差异是图文内容存储在企业微信 - MsgTypeMpNews = "mpnews" + MsgTypeMpNews MsgType = "mpnews" //MsgTypeMarkdown markdown消息[限发送应用消息] - MsgTypeMarkdown = "markdown" + MsgTypeMarkdown MsgType = "markdown" //MsgTypeMiniprogramNotice 小程序通知消息[限发送应用消息] - MsgTypeMiniprogramNotice = "miniprogram_notice" + MsgTypeMiniprogramNotice MsgType = "miniprogram_notice" //MsgTypeTemplateCard 模板卡片消息[限发送应用消息] - MsgTypeTemplateCard = "template_card" + MsgTypeTemplateCard MsgType = "template_card" ) const ( //EventSubscribe 成员关注,成员已经加入企业,管理员添加成员到应用可见范围(或移除可见范围)时 EventSubscribe EventType = "subscribe" //EventUnsubscribe 成员取消关注,成员已经在应用可见范围,成员加入(或退出)企业时 - EventUnsubscribe = "unsubscribe" + EventUnsubscribe EventType = "unsubscribe" //EventEnterAgent 本事件在成员进入企业微信的应用时触发 - EventEnterAgent = "enter_agent" + EventEnterAgent EventType = "enter_agent" //EventLocation 上报地理位置事件 - EventLocation = "LOCATION" + EventLocation EventType = "LOCATION" //EventBatchJobResult 异步任务完成事件推送 - EventBatchJobResult = "batch_job_result" + EventBatchJobResult EventType = "batch_job_result" //EventClick 点击菜单拉取消息时的事件推送 - EventClick = "click" + EventClick EventType = "click" //EventView 点击菜单跳转链接时的事件推送 - EventView = "view" + EventView EventType = "view" //EventScancodePush 扫码推事件的事件推送 - EventScancodePush = "scancode_push" + EventScancodePush EventType = "scancode_push" //EventScancodeWaitmsg 扫码推事件且弹出“消息接收中”提示框的事件推送 - EventScancodeWaitmsg = "scancode_waitmsg" + EventScancodeWaitmsg EventType = "scancode_waitmsg" //EventPicSysphoto 弹出系统拍照发图的事件推送 - EventPicSysphoto = "pic_sysphoto" + EventPicSysphoto EventType = "pic_sysphoto" //EventPicPhotoOrAlbum 弹出拍照或者相册发图的事件推送 - EventPicPhotoOrAlbum = "pic_photo_or_album" + EventPicPhotoOrAlbum EventType = "pic_photo_or_album" //EventPicWeixin 弹出微信相册发图器的事件推送 - EventPicWeixin = "pic_weixin" + EventPicWeixin EventType = "pic_weixin" //EventLocationSelect 弹出地理位置选择器的事件推送 - EventLocationSelect = "location_select" + EventLocationSelect EventType = "location_select" //EventOpenApprovalChange 审批状态通知事件推送 - EventOpenApprovalChange = "open_approval_change" + EventOpenApprovalChange EventType = "open_approval_change" //EventShareAgentChange 共享应用事件回调 - EventShareAgentChange = "share_agent_change" + EventShareAgentChange EventType = "share_agent_change" //EventTemplateCard 模板卡片事件推送 - EventTemplateCard = "template_card_event" + EventTemplateCard EventType = "template_card_event" //EventTemplateCardMenu 通用模板卡片右上角菜单事件推送 - EventTemplateCardMenu = "template_card_menu_event" + EventTemplateCardMenu EventType = "template_card_menu_event" //EventChangeExternalContact 企业客户事件推送 //add_external_contact 添加 @@ -102,28 +102,28 @@ const ( //del_follow_user 客户删除跟进员工 //transfer_fail 企业将客户分配给新的成员接替后,客户添加失败 //change_external_chat 客户群创建事件 - EventChangeExternalContact = "change_external_contact" + EventChangeExternalContact EventType = "change_external_contact" //EventChangeExternalChat 企业客户群变更事件推送 //create 客户群创建 //update 客户群变更 //dismiss 客户群解散 - EventChangeExternalChat = "change_external_chat" + EventChangeExternalChat EventType = "change_external_chat" //EventChangeExternalTag 企业客户标签创建事件推送 //create 创建标签 //update 变更标签 //delete 删除标签 //shuffle 重新排序 - EventChangeExternalTag = "change_external_tag" + EventChangeExternalTag EventType = "change_external_tag" //EventKfMsg 企业微信客服回调事件 - EventKfMsg = "kf_msg_or_event" + EventKfMsg EventType = "kf_msg_or_event" //EventLivingStatusChange 直播回调事件 - EventLivingStatusChange = "living_status_change" + EventLivingStatusChange EventType = "living_status_change" //EventMsgauditNotify 会话内容存档开启后,产生会话回调事件 - EventMsgauditNotify = "msgaudit_notify" + EventMsgauditNotify EventType = "msgaudit_notify" ) //todo 第三方应用开发 diff --git a/work/work.go b/work/work.go index 72f51de..05966b1 100644 --- a/work/work.go +++ b/work/work.go @@ -7,6 +7,7 @@ import ( "github.com/silenceper/wechat/v2/work/externalcontact" "github.com/silenceper/wechat/v2/work/js" "github.com/silenceper/wechat/v2/work/kf" + "github.com/silenceper/wechat/v2/work/message" "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" "github.com/silenceper/wechat/v2/work/server" @@ -77,3 +78,8 @@ func (wk *Work) GetCalendar() *tools.Calendar { func (wk *Work) GetExternalContact() (*externalcontact.Client, error) { return externalcontact.NewClient(wk.ctx.Config) } + +//GetMessageApp 发送应用消息 +func (wk *Work) GetMessageApp() *message.App { + return message.NewApp(wk.ctx) +} From 96e7945d200f4630de690639f0c50320b6b040b7 Mon Sep 17 00:00:00 2001 From: hb Date: Thu, 17 Mar 2022 17:52:24 +0800 Subject: [PATCH 10/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E3=80=8B=E6=8E=A5=E6=94=B6=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=8E=E4=BA=8B=E4=BB=B6=E3=80=8B=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E3=80=8B=E6=A8=A1=E6=9D=BF=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/message.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/work/message/message.go b/work/message/message.go index e0b8ffd..bcf3434 100644 --- a/work/message/message.go +++ b/work/message/message.go @@ -173,6 +173,19 @@ type MixMessage struct { Event EventType `xml:"Event"` EventKey string `xml:"EventKey"` + //模板卡片事件推送 https://developer.work.weixin.qq.com/document/path/90240#%E6%A8%A1%E6%9D%BF%E5%8D%A1%E7%89%87%E4%BA%8B%E4%BB%B6%E6%8E%A8%E9%80%81 + TaskId string `xml:"TaskId"` //与发送模板卡片消息时指定的task_id相同 + CardType string `xml:"CardType"` //通用模板卡片的类型,类型有"text_notice", "news_notice", "button_interaction", "vote_interaction", "multiple_interaction"五种 + ResponseCode string `xml:"ResponseCode"` //用于调用更新卡片接口的ResponseCode,24小时内有效,且只能使用一次 + SelectedItems struct { + SelectedItem struct { + QuestionKey string `xml:"QuestionKey"` //问题的key值 + OptionIds struct { //对应问题的选项列表 + OptionId string `xml:"OptionId"` + } `xml:"OptionIds"` + } `xml:"SelectedItem"` + } `xml:"SelectedItems"` + //仅上报地理位置事件 Latitude string `xml:"Latitude"` //地理位置纬度 Longitude string `xml:"Longitude"` //地理位置经度 From 586a3b058eb8ad24efecc3a469c44e2f90a25130 Mon Sep 17 00:00:00 2001 From: hb Date: Thu, 24 Mar 2022 12:02:41 +0800 Subject: [PATCH 11/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8E=A8=E9=80=81=E3=80=8B=E6=8E=A5=E6=94=B6=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E4=B8=8E=E4=BA=8B=E4=BB=B6=E3=80=8B=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E3=80=8B=E6=A8=A1=E6=9D=BF=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8E=A8=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/app.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/work/message/app.go b/work/message/app.go index 31bd78e..5de623e 100644 --- a/work/message/app.go +++ b/work/message/app.go @@ -71,7 +71,7 @@ type resTemplateSend struct { } //Send 发送应用消息 -func (tpl *App) Send(msg *AppMessage) (msgID string, err error) { +func (tpl *App) Send(msg *AppMessage) (msgID string, responseCode string, err error) { var accessToken string accessToken, err = tpl.GetAccessToken() if err != nil { @@ -96,6 +96,7 @@ func (tpl *App) Send(msg *AppMessage) (msgID string, err error) { return } msgID = result.MsgID + responseCode = result.ResponseCode return } From 502a7818117a037869c46fda45dc55463fdc9356 Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 9 Jan 2023 17:05:53 +0800 Subject: [PATCH 12/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E8=81=94=E7=B3=BB=E3=80=8B=E7=BC=96=E8=BE=91=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E4=BC=81=E4=B8=9A=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/externalcontact/user.go | 43 +++++++++++++++++++++++++++++++----- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/work/externalcontact/user.go b/work/externalcontact/user.go index 34282ed..4d78d68 100644 --- a/work/externalcontact/user.go +++ b/work/externalcontact/user.go @@ -10,6 +10,7 @@ const ( listUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list" getUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get" getByUserBatchUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/batch/get_by_user" + markTag = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/mark_tag" ) type ReqGetByUser struct { @@ -87,7 +88,14 @@ type FollowInfo struct { State string `json:"state,omitempty"` } -//GetUseridList 获取我的客户列表 +type MarkTag struct { + Userid string `json:"userid"` + ExternalUserid string `json:"external_userid"` + AddTag []string `json:"add_tag"` + RemoveTag []string `json:"remove_tag"` +} + +// GetUseridList 获取我的客户列表 func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err error) { var accessToken string accessToken, err = tpl.ctx.GetAccessToken() @@ -113,7 +121,7 @@ func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err return } -//GetUseridList 获取我的全部客户列表及详情 +// GetQyUserInfoList 获取我的全部客户列表及详情 func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { var userInfoList []UserInfo var req ReqGetByUser @@ -134,7 +142,7 @@ func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { return userInfoList, nil } -//GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 +// GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { var result, res OneUser var err error @@ -155,7 +163,7 @@ func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { return result, nil } -//GetUserInfo 获取客户详情 +// GetUserInfo 获取客户详情 func (tpl *Client) GetUserInfo(externalUserid string, cursor ...string) (result OneUser, err error) { var accessToken string accessToken, err = tpl.ctx.GetAccessToken() @@ -183,7 +191,7 @@ func (tpl *Client) GetUserInfo(externalUserid string, cursor ...string) (result return } -//GetUserInfoListByUserId 批量获取客户详情 +// GetUserInfoListByUserIds 批量获取客户详情 func (tpl *Client) GetUserInfoListByUserIds(req ReqGetByUser) (userList []UserInfo, nextCursor string, err error) { var accessToken string accessToken, err = tpl.ctx.GetAccessToken() @@ -209,3 +217,28 @@ func (tpl *Client) GetUserInfoListByUserIds(req ReqGetByUser) (userList []UserIn nextCursor = result.NextCursor return } + +// EditUserTags 编辑客户企业标签 +func (tpl *Client) EditUserTags(req *MarkTag) (result util.CommonError, err error) { + var accessToken string + accessToken, err = tpl.ctx.GetAccessToken() + if err != nil { + return + } + + uri := fmt.Sprintf("%s?access_token=%s", markTag, accessToken) + var response []byte + response, err = util.PostJSON(uri, req) + if err != nil { + return + } + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} From da20182300bb9bbf7af77ab952020fceb6280da8 Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 9 Jan 2023 17:07:05 +0800 Subject: [PATCH 13/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E6=8E=89=E4=B8=80=E4=BA=9B=E5=8F=AF=E8=83=BD=E4=B8=8D?= =?UTF-8?q?=E9=9C=80=E8=A6=81=E7=9A=84=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/message.go | 58 +++++++++++++++++++++++------------------ work/oauth/oauth.go | 1 + 2 files changed, 33 insertions(+), 26 deletions(-) diff --git a/work/message/message.go b/work/message/message.go index bcf3434..a8078c8 100644 --- a/work/message/message.go +++ b/work/message/message.go @@ -148,35 +148,36 @@ type MixMessage struct { MsgID int64 `xml:"MsgId"` //其他消息推送过来是MsgId AgentID int `xml:"AgentID"` //企业应用的id,整型。可在应用的设置页面查看 - Content string `xml:"Content"` //文本消息内容 - Format string `xml:"Format"` //语音消息格式,如amr,speex等 - ThumbMediaID string `xml:"ThumbMediaId"` //视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取数据,仅三天内有效 + Content string `xml:"Content,omitempty"` //文本消息内容 + Format string `xml:"Format,omitempty"` //语音消息格式,如amr,speex等 + ThumbMediaID string `xml:"ThumbMediaId,omitempty"` //视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取数据,仅三天内有效 - Title string `xml:"Title"` //链接消息,标题 - Description string `xml:"Description"` //链接消息,描述 - URL string `xml:"Url"` //链接消息,链接跳转的url + Title string `xml:"Title,omitempty"` //链接消息,标题 + Description string `xml:"Description,omitempty"` //链接消息,描述 + URL string `xml:"Url,omitempty"` //链接消息,链接跳转的url - PicURL string `xml:"PicUrl"` ////图片消息或者链接消息,封面缩略图的url - MediaID string `xml:"MediaId"` //图片媒体文件id//语音媒体文件id//视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取,仅三天内有效 + PicURL string `xml:"PicUrl,omitempty"` ////图片消息或者链接消息,封面缩略图的url + MediaID string `xml:"MediaId,omitempty"` //图片媒体文件id//语音媒体文件id//视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取,仅三天内有效 - LocationX float64 `xml:"Location_X"` //位置消息,地理位置纬度 - LocationY float64 `xml:"Location_Y"` //位置消息,地理位置经度 - Scale float64 `xml:"Scale"` //位置消息,地图缩放大小 - Label string `xml:"Label"` //位置消息,地理位置信息 + LocationX float64 `xml:"Location_X,omitempty"` //位置消息,地理位置纬度 + LocationY float64 `xml:"Location_Y,omitempty"` //位置消息,地理位置经度 + Scale float64 `xml:"Scale,omitempty"` //位置消息,地图缩放大小 + Label string `xml:"Label,omitempty"` //位置消息,地理位置信息 - AppType string `xml:"AppType"` //接收地理位置时存在,app类型,在企业微信固定返回wxwork,在微信不返回该字段 + AppType string `xml:"AppType,omitempty"` //接收地理位置时存在,app类型,在企业微信固定返回wxwork,在微信不返回该字段 //TemplateMsgID int64 `xml:"MsgID"` //模板消息推送成功的消息是MsgID ///Recognition string `xml:"Recognition"` //事件相关 - Event EventType `xml:"Event"` - EventKey string `xml:"EventKey"` + Event EventType `xml:"Event,omitempty"` + EventKey string `xml:"EventKey,omitempty"` + ChangeType string `xml:"ChangeType,omitempty"` //模板卡片事件推送 https://developer.work.weixin.qq.com/document/path/90240#%E6%A8%A1%E6%9D%BF%E5%8D%A1%E7%89%87%E4%BA%8B%E4%BB%B6%E6%8E%A8%E9%80%81 - TaskId string `xml:"TaskId"` //与发送模板卡片消息时指定的task_id相同 - CardType string `xml:"CardType"` //通用模板卡片的类型,类型有"text_notice", "news_notice", "button_interaction", "vote_interaction", "multiple_interaction"五种 - ResponseCode string `xml:"ResponseCode"` //用于调用更新卡片接口的ResponseCode,24小时内有效,且只能使用一次 + TaskId string `xml:"TaskId,omitempty"` //与发送模板卡片消息时指定的task_id相同 + CardType string `xml:"CardType,omitempty"` //通用模板卡片的类型,类型有"text_notice", "news_notice", "button_interaction", "vote_interaction", "multiple_interaction"五种 + ResponseCode string `xml:"ResponseCode,omitempty"` //用于调用更新卡片接口的ResponseCode,24小时内有效,且只能使用一次 SelectedItems struct { SelectedItem struct { QuestionKey string `xml:"QuestionKey"` //问题的key值 @@ -184,20 +185,25 @@ type MixMessage struct { OptionId string `xml:"OptionId"` } `xml:"OptionIds"` } `xml:"SelectedItem"` - } `xml:"SelectedItems"` + } `xml:"SelectedItems,omitempty"` //仅上报地理位置事件 - Latitude string `xml:"Latitude"` //地理位置纬度 - Longitude string `xml:"Longitude"` //地理位置经度 - Precision string `xml:"Precision"` //地理位置精度 + Latitude string `xml:"Latitude,omitempty"` //地理位置纬度 + Longitude string `xml:"Longitude,omitempty"` //地理位置经度 + Precision string `xml:"Precision,omitempty"` //地理位置精度 //仅异步任务完成事件 - JobId string `xml:"JobId"` //异步任务id,最大长度为64字符 - JobType string `xml:"JobType"` //异步任务,操作类型,字符串,目前分别有:sync_user(增量更新成员)、 replace_user(全量覆盖成员)、invite_user(邀请成员关注)、replace_party(全量覆盖部门) - ErrCode int `xml:"ErrCode"` //异步任务,返回码 - ErrMsg string `xml:"ErrMsg"` //异步任务,对返回码的文本描述内容 + JobId string `xml:"JobId,omitempty"` //异步任务id,最大长度为64字符 + JobType string `xml:"JobType,omitempty"` //异步任务,操作类型,字符串,目前分别有:sync_user(增量更新成员)、 replace_user(全量覆盖成员)、invite_user(邀请成员关注)、replace_party(全量覆盖部门) + ErrCode int `xml:"ErrCode,omitempty"` //异步任务,返回码 + ErrMsg string `xml:"ErrMsg,omitempty"` //异步任务,对返回码的文本描述内容 //开启通讯录回调通知 https://open.work.weixin.qq.com/api/doc/90000/90135/90967 + UserID string `xml:"UserID,omitempty"` //用户userid + ExternalUserID string `xml:"ExternalUserID,omitempty"` //外部联系人userid + State string `xml:"State,omitempty"` //添加此用户的「联系我」方式配置的state参数,可用于识别添加此用户的渠道 + WelcomeCode string `xml:"WelcomeCode,omitempty"` //欢迎码,当state为1时,该值有效 + Source string `xml:"Source,omitempty"` //删除客户的操作来源,DELETE_BY_TRANSFER表示此客户是因在职继承自动被转接成员删除 // todo 第三方平台相关 字段名可能不准确 /*InfoType InfoType `xml:"InfoType"` diff --git a/work/oauth/oauth.go b/work/oauth/oauth.go index a02c10b..ff0534b 100644 --- a/work/oauth/oauth.go +++ b/work/oauth/oauth.go @@ -64,6 +64,7 @@ type ResUserInfo struct { DeviceID string `json:"DeviceId"` //非企业成员授权时返回 OpenID string `json:"OpenId"` + UserTicket string `json:"user_ticket"` } //UserFromCode 根据code获取用户信息 From cec81779a5962a541a59b3a249bea3eb30555de6 Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 9 Jan 2023 17:07:44 +0800 Subject: [PATCH 14/23] =?UTF-8?q?=E4=BC=81=E4=B8=9A=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=86=85=E9=83=A8=E5=BC=80=E5=8F=91API=EF=BC=9A=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 work/message/README.md diff --git a/work/message/README.md b/work/message/README.md new file mode 100644 index 0000000..20bc498 --- /dev/null +++ b/work/message/README.md @@ -0,0 +1,3 @@ +### 企业微信消息推送SDK + +相关文档正在梳理中... \ No newline at end of file From 1cd61334203c86870b83f6c92fcbc4d654a203fd Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 9 Jan 2023 17:43:54 +0800 Subject: [PATCH 15/23] =?UTF-8?q?=E4=B8=8Eorigin=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/externalcontact/add_msg_template.go | 8 +- work/externalcontact/client.go | 39 +--- work/externalcontact/user.go | 244 ----------------------- work/work.go | 4 +- 4 files changed, 12 insertions(+), 283 deletions(-) delete mode 100644 work/externalcontact/user.go diff --git a/work/externalcontact/add_msg_template.go b/work/externalcontact/add_msg_template.go index cbb0d3f..0f3b200 100644 --- a/work/externalcontact/add_msg_template.go +++ b/work/externalcontact/add_msg_template.go @@ -17,7 +17,7 @@ const ( ChatTypeGroup ChatType = "group" ) -//ReqMessage 企业群发参数 +// ReqMessage 企业群发参数 type ReqMessage struct { ChatType ChatType `json:"chat_type"` //群发任务的类型,默认为single,表示发送给客户,group表示发送给客户群 ExternalUserid []string `json:"external_userid"` // 客户的外部联系人id列表,仅在chat_type为single时有效,不可与sender同时为空,最多可传入1万个客户 @@ -63,10 +63,10 @@ type resTemplateSend struct { MsgID int64 `json:"msgid"` } -//Send 发送应用消息 -func (tpl *Client) Send(msg *ReqMessage) (msgID int64, err error) { +// Send 发送应用消息 +func (r *Client) Send(msg *ReqMessage) (msgID int64, err error) { var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() + accessToken, err = r.GetAccessToken() if err != nil { return } diff --git a/work/externalcontact/client.go b/work/externalcontact/client.go index 8574317..6399f4d 100644 --- a/work/externalcontact/client.go +++ b/work/externalcontact/client.go @@ -1,44 +1,17 @@ package externalcontact import ( - "errors" - "github.com/silenceper/wechat/v2/cache" - "github.com/silenceper/wechat/v2/credential" - "github.com/silenceper/wechat/v2/work/config" "github.com/silenceper/wechat/v2/work/context" ) -// Client 企业微信客户联系实例 +// Client 外部联系接口实例 type Client struct { - corpID string // 企业ID:企业开通的每个微信客服,都对应唯一的企业ID,企业可在微信客服管理后台的企业信息处查看 - secret string // Secret是微信客服用于校验开发者身份的访问密钥,企业成功注册微信客服后,可在「微信客服管理后台-开发配置」处获取 - token string // 用于生成签名校验回调请求的合法性 - encodingAESKey string // 回调消息加解密参数是AES密钥的Base64编码,用于解密回调消息内容对应的密文 - cache cache.Cache - ctx *context.Context + *context.Context } -// NewClient 初始化企业微信客户联系实例 -func NewClient(cfg *config.Config) (client *Client, err error) { - if cfg.Cache == nil { - return nil, errors.New("SDK初始化失败") +// NewClient 初始化实例 +func NewClient(ctx *context.Context) *Client { + return &Client{ + ctx, } - - //初始化 AccessToken Handle - defaultAkHandle := credential.NewWorkAccessToken(cfg.CorpID, cfg.CorpSecret, credential.CacheKeyWorkPrefix, cfg.Cache) - ctx := &context.Context{ - Config: cfg, - AccessTokenHandle: defaultAkHandle, - } - - client = &Client{ - corpID: cfg.CorpID, - secret: cfg.CorpSecret, - token: cfg.Token, - encodingAESKey: cfg.EncodingAESKey, - cache: cfg.Cache, - ctx: ctx, - } - - return client, nil } diff --git a/work/externalcontact/user.go b/work/externalcontact/user.go deleted file mode 100644 index 4d78d68..0000000 --- a/work/externalcontact/user.go +++ /dev/null @@ -1,244 +0,0 @@ -package externalcontact - -import ( - "encoding/json" - "fmt" - "github.com/silenceper/wechat/v2/util" -) - -const ( - listUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list" - getUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get" - getByUserBatchUrl = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/batch/get_by_user" - markTag = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/mark_tag" -) - -type ReqGetByUser struct { - UseridList []string `json:"userid_list"` - Cursor string `json:"cursor"` - Limit int `json:"limit"` -} -type OneUser struct { - util.CommonError - ExternalContact ExternalContact `json:"external_contact"` - FollowUser []FollowInfo `json:"follow_user"` //注意,仅获取单个客户详情的时候这里返回的是跟进人列表 - NextCursor string `json:"next_cursor"` -} -type resUserList struct { - util.CommonError - ExternalContactList []UserInfo `json:"external_contact_list"` - NextCursor string `json:"next_cursor"` -} -type resUserids struct { - util.CommonError - ExternalUserid []string `json:"external_userid"` -} - -type UserInfo struct { - ExternalContact ExternalContact `json:"external_contact"` - FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进人信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 -} - -type ExternalContact struct { - ExternalUserid string `json:"external_userid"` - Name string `json:"name"` - Position string `json:"position"` - Avatar string `json:"avatar"` - CorpName string `json:"corp_name"` - CorpFullName string `json:"corp_full_name"` - Type int `json:"type"` - Gender int `json:"gender"` - Unionid string `json:"unionid"` - ExternalProfile struct { - ExternalAttr []struct { - Type int `json:"type"` - Name string `json:"name"` - Text struct { - Value string `json:"value"` - } `json:"text,omitempty"` - Web struct { - Url string `json:"url"` - Title string `json:"title"` - } `json:"web,omitempty"` - Miniprogram struct { - Appid string `json:"appid"` - Pagepath string `json:"pagepath"` - Title string `json:"title"` - } `json:"miniprogram,omitempty"` - } `json:"external_attr"` - } `json:"external_profile,omitempty"` -} - -type FollowInfo struct { - Userid string `json:"userid"` - Remark string `json:"remark"` - Description string `json:"description"` - Createtime int `json:"createtime"` - TagId []string `json:"tag_id"` //批量获取时才有 - Tags []struct { - GroupName string `json:"group_name"` - TagName string `json:"tag_name"` - TagId string `json:"tag_id,omitempty"` - Type int `json:"type"` - } `json:"tags,omitempty"` //单独获取时才有 - RemarkCorpName string `json:"remark_corp_name,omitempty"` - RemarkMobiles []string `json:"remark_mobiles,omitempty"` - OperUserid string `json:"oper_userid"` - AddWay int `json:"add_way"` - State string `json:"state,omitempty"` -} - -type MarkTag struct { - Userid string `json:"userid"` - ExternalUserid string `json:"external_userid"` - AddTag []string `json:"add_tag"` - RemoveTag []string `json:"remove_tag"` -} - -// GetUseridList 获取我的客户列表 -func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err error) { - var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() - if err != nil { - return - } - uri := fmt.Sprintf("%s?access_token=%s&userid=%s", listUrl, accessToken, myUserid) - var response []byte - response, err = util.HTTPGet(uri) - if err != nil { - return - } - var result resUserids - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - externalUserid = result.ExternalUserid - return -} - -// GetQyUserInfoList 获取我的全部客户列表及详情 -func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { - var userInfoList []UserInfo - var req ReqGetByUser - req.UseridList = qyUserid - req.Limit = 100 - for { - userInfoPage, resCursor, err := tpl.GetUserInfoListByUserIds(req) - if err != nil { - return userInfoList, err - } - userInfoList = append(userInfoList, userInfoPage...) - if resCursor != "" { - req.Cursor = resCursor - } else { - break - } - } - return userInfoList, nil -} - -// GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 -func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { - var result, res OneUser - var err error - var cursor string - for { - res, err = tpl.GetUserInfo(userid, cursor) - if err != nil { - return result, err - } - result.FollowUser = append(result.FollowUser, res.FollowUser...) - result.ExternalContact = res.ExternalContact - if res.NextCursor != "" { - cursor = res.NextCursor - } else { - break - } - } - return result, nil -} - -// GetUserInfo 获取客户详情 -func (tpl *Client) GetUserInfo(externalUserid string, cursor ...string) (result OneUser, err error) { - var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() - if err != nil { - return - } - var page = "" - if len(cursor) > 0 { - page = cursor[0] - } - uri := fmt.Sprintf("%s?access_token=%s&external_userid=%s&cursor=%s", getUrl, accessToken, externalUserid, page) - var response []byte - response, err = util.HTTPGet(uri) - if err != nil { - return - } - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - return -} - -// GetUserInfoListByUserIds 批量获取客户详情 -func (tpl *Client) GetUserInfoListByUserIds(req ReqGetByUser) (userList []UserInfo, nextCursor string, err error) { - var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() - if err != nil { - return - } - uri := fmt.Sprintf("%s?access_token=%s", getByUserBatchUrl, accessToken) - var response []byte - response, err = util.PostJSON(uri, req) - if err != nil { - return - } - var result resUserList - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - userList = result.ExternalContactList - nextCursor = result.NextCursor - return -} - -// EditUserTags 编辑客户企业标签 -func (tpl *Client) EditUserTags(req *MarkTag) (result util.CommonError, err error) { - var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() - if err != nil { - return - } - - uri := fmt.Sprintf("%s?access_token=%s", markTag, accessToken) - var response []byte - response, err = util.PostJSON(uri, req) - if err != nil { - return - } - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - return -} diff --git a/work/work.go b/work/work.go index da61962..09e9f22 100644 --- a/work/work.go +++ b/work/work.go @@ -78,8 +78,8 @@ func (wk *Work) GetCalendar() *tools.Calendar { } // GetExternalContact 客户联系 -func (wk *Work) GetExternalContact() (*externalcontact.Client, error) { - return externalcontact.NewClient(wk.ctx.Config) +func (wk *Work) GetExternalContact() *externalcontact.Client { + return externalcontact.NewClient(wk.ctx) } // GetMessageApp 发送应用消息 From 589de1925726ca7b45f556a745a7344ca9884b58 Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 9 Jan 2023 17:55:31 +0800 Subject: [PATCH 16/23] =?UTF-8?q?=E4=B8=8Eorigin=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/{app.go => client.go} | 30 +++++++++++++++--------------- work/work.go | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) rename work/message/{app.go => client.go} (89%) diff --git a/work/message/app.go b/work/message/client.go similarity index 89% rename from work/message/app.go rename to work/message/client.go index 5de623e..dad43c2 100644 --- a/work/message/app.go +++ b/work/message/client.go @@ -14,19 +14,19 @@ const ( messageDelURL = "https://api.weixin.qq.com/cgi-bin/message/recall" ) -//App 应用消息 -type App struct { +// Client 应用消息 +type Client struct { *context.Context } -//NewApp 实例化 -func NewApp(context *context.Context) *App { - tpl := new(App) - tpl.Context = context - return tpl +// NewClient 实例化 +func NewClient(ctx *context.Context) *Client { + return &Client{ + ctx, + } } -//AppMessage 发送的模板消息内容 +// AppMessage 发送的模板消息内容 type AppMessage struct { ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 Toparty string `json:"toparty"` //部门ID列表,当touser为”@all”时忽略本参数 @@ -70,8 +70,8 @@ type resTemplateSend struct { ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 } -//Send 发送应用消息 -func (tpl *App) Send(msg *AppMessage) (msgID string, responseCode string, err error) { +// Send 发送应用消息 +func (tpl *Client) Send(msg *AppMessage) (msgID string, responseCode string, err error) { var accessToken string accessToken, err = tpl.GetAccessToken() if err != nil { @@ -100,7 +100,7 @@ func (tpl *App) Send(msg *AppMessage) (msgID string, responseCode string, err er return } -//TemplateUpdate 更新模版卡片消息内容 +// TemplateUpdate 更新模版卡片消息内容 type TemplateUpdate struct { Userids []string `json:"userids"` Partyids []int `json:"partyids"` @@ -112,8 +112,8 @@ type TemplateUpdate struct { *TemplateCard } -//UpdateTemplate 更新模版卡片消息 -func (tpl *App) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { +// UpdateTemplate 更新模版卡片消息 +func (tpl *Client) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { var accessToken string accessToken, err = tpl.GetAccessToken() if err != nil { @@ -142,8 +142,8 @@ type ReqRecall struct { MsgID int64 `json:"msgid"` } -//Recall 撤回应用消息 -func (tpl *App) Recall(msgID int64) (err error) { +// Recall 撤回应用消息 +func (tpl *Client) Recall(msgID int64) (err error) { var accessToken string accessToken, err = tpl.GetAccessToken() if err != nil { diff --git a/work/work.go b/work/work.go index 09e9f22..cef426d 100644 --- a/work/work.go +++ b/work/work.go @@ -83,8 +83,8 @@ func (wk *Work) GetExternalContact() *externalcontact.Client { } // GetMessageApp 发送应用消息 -func (wk *Work) GetMessageApp() *message.App { - return message.NewApp(wk.ctx) +func (wk *Work) GetMessageApp() *message.Client { + return message.NewClient(wk.ctx) } // GetAddressList get address_list From e351d0bc66e92755ac73822eea82dd9c0151c0ea Mon Sep 17 00:00:00 2001 From: wind Date: Mon, 9 Jan 2023 17:59:12 +0800 Subject: [PATCH 17/23] =?UTF-8?q?=E4=B8=8Eorigin=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/client.go | 16 ++++++++-------- work/message/group.go | 1 + 2 files changed, 9 insertions(+), 8 deletions(-) create mode 100644 work/message/group.go diff --git a/work/message/client.go b/work/message/client.go index dad43c2..c348997 100644 --- a/work/message/client.go +++ b/work/message/client.go @@ -46,7 +46,7 @@ type AppMessage struct { News *News `json:"news"` MpNews *MpNews `json:"mpnews"` Markdown *Text `json:"markdown"` - //todo(hb1707) 可能会发生变化的字段直接用interface{}了 + //todo(wind) 可能会发生变化的字段直接用interface{}了 MiniprogramNotice interface{} `json:"miniprogram_notice"` TemplateCard interface{} `json:"template_card"` } @@ -71,16 +71,16 @@ type resTemplateSend struct { } // Send 发送应用消息 -func (tpl *Client) Send(msg *AppMessage) (msgID string, responseCode string, err error) { +func (r *Client) Send(msg *AppMessage) (msgID string, responseCode string, err error) { var accessToken string - accessToken, err = tpl.GetAccessToken() + accessToken, err = r.GetAccessToken() if err != nil { return } uri := fmt.Sprintf("%s?access_token=%s", messageSendURL, accessToken) var response []byte if msg.Agentid == 0 { - msg.Agentid, _ = strconv.Atoi(tpl.Context.AgentID) + msg.Agentid, _ = strconv.Atoi(r.Context.AgentID) } response, err = util.PostJSON(uri, msg) if err != nil { @@ -113,9 +113,9 @@ type TemplateUpdate struct { } // UpdateTemplate 更新模版卡片消息 -func (tpl *Client) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { +func (r *Client) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { var accessToken string - accessToken, err = tpl.GetAccessToken() + accessToken, err = r.GetAccessToken() if err != nil { return } @@ -143,9 +143,9 @@ type ReqRecall struct { } // Recall 撤回应用消息 -func (tpl *Client) Recall(msgID int64) (err error) { +func (r *Client) Recall(msgID int64) (err error) { var accessToken string - accessToken, err = tpl.GetAccessToken() + accessToken, err = r.GetAccessToken() if err != nil { return } diff --git a/work/message/group.go b/work/message/group.go new file mode 100644 index 0000000..ede1b09 --- /dev/null +++ b/work/message/group.go @@ -0,0 +1 @@ +package message From 158fbca872b02d10e8ae1af453da5200ede040ea Mon Sep 17 00:00:00 2001 From: wind Date: Wed, 15 Feb 2023 18:51:51 +0800 Subject: [PATCH 18/23] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/client.go | 153 ------------------- work/message/template_card.go | 270 +++++++++++++++++++++++++++++++++- 2 files changed, 266 insertions(+), 157 deletions(-) diff --git a/work/message/client.go b/work/message/client.go index c348997..2047ebd 100644 --- a/work/message/client.go +++ b/work/message/client.go @@ -1,17 +1,7 @@ package message import ( - "encoding/json" - "fmt" - "github.com/silenceper/wechat/v2/util" "github.com/silenceper/wechat/v2/work/context" - "strconv" -) - -const ( - messageSendURL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" - messageUpdateTemplateCardURL = "https://api.weixin.qq.com/cgi-bin/message/update_template_card" - messageDelURL = "https://api.weixin.qq.com/cgi-bin/message/recall" ) // Client 应用消息 @@ -25,146 +15,3 @@ func NewClient(ctx *context.Context) *Client { ctx, } } - -// AppMessage 发送的模板消息内容 -type AppMessage struct { - ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 - Toparty string `json:"toparty"` //部门ID列表,当touser为”@all”时忽略本参数 - Totag string `json:"totag"` //标签ID列表,当touser为”@all”时忽略本参数 - Msgtype MsgType `json:"msgtype"` - Agentid int `json:"agentid"` - Safe int `json:"safe"` - EnableIdTrans int `json:"enable_id_trans"` - EnableDuplicateCheck int `json:"enable_duplicate_check"` - DuplicateCheckInterval int `json:"duplicate_check_interval"` - Text *Text `json:"text"` - *Image `json:"image"` - *Voice `json:"voice"` - *Video `json:"video"` - File *PushFile `json:"file"` - TextCard *PushTextCard `json:"textcard"` - News *News `json:"news"` - MpNews *MpNews `json:"mpnews"` - Markdown *Text `json:"markdown"` - //todo(wind) 可能会发生变化的字段直接用interface{}了 - MiniprogramNotice interface{} `json:"miniprogram_notice"` - TemplateCard interface{} `json:"template_card"` -} - -type PushFile struct { - MediaID string `json:"media_id"` -} -type PushTextCard struct { - Title string `json:"title"` - Description string `json:"description"` - Url string `json:"url"` - Btntxt string `json:"btntxt"` -} - -type resTemplateSend struct { - util.CommonError - Invaliduser string `json:"invaliduser"` //不合法的userid,不区分大小写,统一转为小写 - Invalidparty string `json:"invalidparty"` //不合法的partyid - Invalidtag string `json:"invalidtag"` //不合法的标签id - MsgID string `json:"msgid"` //消息id,用于撤回应用消息 - ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 -} - -// Send 发送应用消息 -func (r *Client) Send(msg *AppMessage) (msgID string, responseCode string, err error) { - var accessToken string - accessToken, err = r.GetAccessToken() - if err != nil { - return - } - uri := fmt.Sprintf("%s?access_token=%s", messageSendURL, accessToken) - var response []byte - if msg.Agentid == 0 { - msg.Agentid, _ = strconv.Atoi(r.Context.AgentID) - } - response, err = util.PostJSON(uri, msg) - if err != nil { - return - } - var result resTemplateSend - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - msgID = result.MsgID - responseCode = result.ResponseCode - return -} - -// TemplateUpdate 更新模版卡片消息内容 -type TemplateUpdate struct { - Userids []string `json:"userids"` - Partyids []int `json:"partyids"` - Tagids []int `json:"tagids"` - Atall int `json:"atall"` - Agentid int `json:"agentid"` - ResponseCode string `json:"response_code"` - *UpdateButton - *TemplateCard -} - -// UpdateTemplate 更新模版卡片消息 -func (r *Client) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { - var accessToken string - accessToken, err = r.GetAccessToken() - if err != nil { - return - } - uri := fmt.Sprintf("%s?access_token=%s", messageUpdateTemplateCardURL, accessToken) - var response []byte - response, err = util.PostJSON(uri, msg) - if err != nil { - return - } - var result resTemplateSend - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - msgID = result.MsgID - return -} - -type ReqRecall struct { - MsgID int64 `json:"msgid"` -} - -// Recall 撤回应用消息 -func (r *Client) Recall(msgID int64) (err error) { - var accessToken string - accessToken, err = r.GetAccessToken() - if err != nil { - return - } - uri := fmt.Sprintf("%s?access_token=%s", messageDelURL, accessToken) - var response []byte - response, err = util.PostJSON(uri, &ReqRecall{ - MsgID: msgID, - }) - if err != nil { - return - } - var result util.CommonError - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - return -} diff --git a/work/message/template_card.go b/work/message/template_card.go index 29d3bef..c91bdef 100644 --- a/work/message/template_card.go +++ b/work/message/template_card.go @@ -1,6 +1,19 @@ package message -//UpdateButton 模板卡片按钮 +import ( + "encoding/json" + "fmt" + "github.com/silenceper/wechat/v2/util" + "strconv" +) + +const ( + messageSendURL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + messageUpdateTemplateCardURL = "https://api.weixin.qq.com/cgi-bin/message/update_template_card" + messageDelURL = "https://api.weixin.qq.com/cgi-bin/message/recall" +) + +// UpdateButton 模板卡片按钮 type UpdateButton struct { CommonToken `json:"-"` Button struct { @@ -8,15 +21,15 @@ type UpdateButton struct { } `xml:"Button" json:"button"` } -//NewUpdateButton 更新点击用户的按钮文案 +// NewUpdateButton 更新点击用户的按钮文案 func NewUpdateButton(replaceName string) *UpdateButton { btn := new(UpdateButton) btn.Button.ReplaceName = replaceName return btn } -//TemplateCard 被动回复模板卡片 -//https://open.work.weixin.qq.com/api/doc/90000/90135/90241 +// TemplateCard 被动回复模板卡片 +// https://open.work.weixin.qq.com/api/doc/90000/90135/90241 type TemplateCard struct { CommonToken `json:"-"` TemplateCard interface{} `xml:"TemplateCard" json:"template_card"` @@ -28,3 +41,252 @@ func NewTemplateCard(cardXml interface{}) *TemplateCard { card.TemplateCard = cardXml return card } + +// AppMessage 发送的模板消息内容 +type AppMessage struct { + ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 + Toparty string `json:"toparty,omitempty"` //部门ID列表,当touser为”@all”时忽略本参数 + Totag string `json:"totag,omitempty"` //标签ID列表,当touser为”@all”时忽略本参数 + Msgtype MsgType `json:"msgtype"` + Agentid int `json:"agentid"` + Safe int `json:"safe,omitempty"` + EnableIdTrans int `json:"enable_id_trans,omitempty"` + EnableDuplicateCheck int `json:"enable_duplicate_check,omitempty"` + DuplicateCheckInterval int `json:"duplicate_check_interval,omitempty"` + Text *Text `json:"text,omitempty"` + *Image `json:"image,omitempty"` + *Voice `json:"voice,omitempty"` + *Video `json:"video,omitempty"` + File *PushFile `json:"file,omitempty"` + TextCard *PushTextCard `json:"textcard,omitempty"` + News *News `json:"news,omitempty"` + MpNews *MpNews `json:"mpnews,omitempty"` + Markdown *Text `json:"markdown,omitempty"` + //todo(wind) 可能会发生变化的字段直接用interface{}了 + MiniprogramNotice interface{} `json:"miniprogram_notice,omitempty"` + TemplateCard *TemplateCardButton `json:"template_card,omitempty"` +} +type Action struct { + Text string `json:"text"` + Key string `json:"key"` +} +type HorizontalContent struct { + Keyname string `json:"keyname"` + Value string `json:"value,omitempty"` + Type int `json:"type,omitempty"` + Url string `json:"url,omitempty"` + MediaId string `json:"media_id,omitempty"` + Userid string `json:"userid,omitempty"` +} +type VerticalContent struct { + Title string `json:"title"` + Desc string `json:"desc"` +} +type Jump struct { + Type int `json:"type,omitempty"` + Title string `json:"title"` + Url string `json:"url,omitempty"` + Appid string `json:"appid,omitempty"` + Pagepath string `json:"pagepath,omitempty"` +} +type OptionCheckBox struct { + Id string `json:"id"` + Text string `json:"text"` + IsChecked bool `json:"is_checked"` +} +type Option struct { + Id string `json:"id"` + Text string `json:"text"` +} +type Select struct { + QuestionKey string `json:"question_key"` + Title string `json:"title"` + SelectedId string `json:"selected_id"` + OptionList []Option `json:"option_list"` +} +type Button struct { + Text string `json:"text"` + Style int `json:"style,omitempty"` + Key string `json:"key,omitempty"` +} + +type TemplateCardButton struct { + CardType string `json:"card_type"` + Source struct { + IconUrl string `json:"icon_url,omitempty"` + Desc string `json:"desc,omitempty"` + DescColor int `json:"desc_color,omitempty"` + } `json:"source,omitempty"` + + ActionMenu struct { + Desc string `json:"desc,omitempty"` + ActionList []Action `json:"action_list"` + } `json:"action_menu,omitempty"` + MainTitle struct { + Title string `json:"title"` + Desc string `json:"desc,omitempty"` + } `json:"main_title,omitempty"` + QuoteArea struct { + Type int `json:"type,omitempty"` + Url string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + QuoteText string `json:"quote_text,omitempty"` + } `json:"quote_area"` + SubTitleText string `json:"sub_title_text,omitempty"` + HorizontalContentList []HorizontalContent `json:"horizontal_content_list"` + VerticalContentList []VerticalContent `json:"vertical_content_list,omitempty"` + CardAction struct { + Type int `json:"type,omitempty"` + Url string `json:"url,omitempty"` + Appid string `json:"appid,omitempty"` + Pagepath string `json:"pagepath,omitempty"` + } `json:"card_action"` + JumpList []Jump `json:"jump_list,omitempty"` + EmphasisContent struct { + Title string `json:"title,omitempty"` + Desc string `json:"desc,omitempty"` + } `json:"emphasis_content,omitempty"` + ImageTextArea struct { + Type int `json:"type"` + Url string `json:"url"` + Title string `json:"title"` + Desc string `json:"desc"` + ImageUrl string `json:"image_url"` + } `json:"image_text_area,omitempty"` + CardImage struct { + Url string `json:"url"` + AspectRatio float64 `json:"aspect_ratio"` + } `json:"card_image,omitempty"` + Checkbox struct { + QuestionKey string `json:"question_key"` + OptionList []OptionCheckBox `json:"option_list"` + Mode int `json:"mode"` + } `json:"checkbox,omitempty"` + SelectList []Select `json:"select_list"` + TaskId string `json:"task_id"` + ButtonSelection struct { + QuestionKey string `json:"question_key"` + Title string `json:"title,omitempty"` + OptionList []Option `json:"option_list,omitempty"` + SelectedId string `json:"selected_id,omitempty"` + } `json:"button_selection,omitempty"` + ButtonList []Button `json:"button_list"` +} + +type PushFile struct { + MediaID string `json:"media_id"` +} +type PushTextCard struct { + Title string `json:"title"` + Description string `json:"description"` + Url string `json:"url"` + Btntxt string `json:"btntxt"` +} + +type resTemplateSend struct { + util.CommonError + Invaliduser string `json:"invaliduser"` //不合法的userid,不区分大小写,统一转为小写 + Invalidparty string `json:"invalidparty"` //不合法的partyid + Invalidtag string `json:"invalidtag"` //不合法的标签id + MsgID string `json:"msgid"` //消息id,用于撤回应用消息 + ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 +} + +// Send 发送应用消息 +func (r *Client) Send(msg *AppMessage) (msgID string, responseCode string, err error) { + var accessToken string + accessToken, err = r.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageSendURL, accessToken) + var response []byte + if msg.Agentid == 0 { + msg.Agentid, _ = strconv.Atoi(r.Context.AgentID) + } + response, err = util.PostJSON(uri, msg) + if err != nil { + return + } + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + responseCode = result.ResponseCode + return +} + +// TemplateUpdate 更新模版卡片消息内容 +type TemplateUpdate struct { + Userids []string `json:"userids"` + Partyids []int `json:"partyids"` + Tagids []int `json:"tagids"` + Atall int `json:"atall"` + Agentid int `json:"agentid"` + ResponseCode string `json:"response_code"` + *UpdateButton + *TemplateCard +} + +// UpdateTemplate 更新模版卡片消息 +func (r *Client) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { + var accessToken string + accessToken, err = r.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageUpdateTemplateCardURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, msg) + if err != nil { + return + } + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + return +} + +type ReqRecall struct { + MsgID int64 `json:"msgid"` +} + +// Recall 撤回应用消息 +func (r *Client) Recall(msgID int64) (err error) { + var accessToken string + accessToken, err = r.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageDelURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, &ReqRecall{ + MsgID: msgID, + }) + if err != nil { + return + } + var result util.CommonError + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} From 4a4339fc322139109c2be2f55e914d91c81ebf04 Mon Sep 17 00:00:00 2001 From: wind Date: Fri, 27 Sep 2024 15:44:19 +0800 Subject: [PATCH 19/23] =?UTF-8?q?=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- miniprogram/encryptor/encryptor.go | 16 +- work/externalcontact/user.go | 65 +------ work/message/README.md | 3 - work/message/message.go | 2 +- work/message/mix_message.go | 285 +++++++++++++++++++++++++++++ work/message/template_card.go | 169 +---------------- work/oauth/user.go | 64 +++++++ work/tools/calendar.go | 21 --- work/user/user.go | 40 +--- work/work.go | 16 ++ 10 files changed, 394 insertions(+), 287 deletions(-) create mode 100644 work/message/mix_message.go create mode 100644 work/oauth/user.go diff --git a/miniprogram/encryptor/encryptor.go b/miniprogram/encryptor/encryptor.go index af3c4c2..a5dffb4 100644 --- a/miniprogram/encryptor/encryptor.go +++ b/miniprogram/encryptor/encryptor.go @@ -7,8 +7,8 @@ import ( "encoding/json" "errors" "fmt" - "github.com/silenceper/wechat/v2/miniprogram/context" + "strings" ) // Encryptor struct @@ -108,13 +108,23 @@ func GetCipherText(sessionKey, encryptedData, iv string) ([]byte, error) { } // Decrypt 解密数据 -func (encryptor *Encryptor) Decrypt(sessionKey, encryptedData, iv string) (*PlainData, error) { +func (encryptor *Encryptor) Decrypt(sessionKey, encryptedData, appid string) (*PlainData, error) { + ivB := make([]byte, 16) + iv := base64.StdEncoding.EncodeToString(ivB) cipherText, err := GetCipherText(sessionKey, encryptedData, iv) if err != nil { return nil, err } + length := string(cipherText[:20]) + + cipherTextData := strings.TrimPrefix(string(cipherText), string(cipherText[:20])) + cipherTextData = strings.TrimSuffix(cipherTextData, appid) + + if len(length) != len(cipherTextData) { + return nil, fmt.Errorf("length not match, %d != %d", length, len(cipherTextData)) + } var plainData PlainData - err = json.Unmarshal(cipherText, &plainData) + err = json.Unmarshal([]byte(cipherTextData), &plainData) if err != nil { return nil, err } diff --git a/work/externalcontact/user.go b/work/externalcontact/user.go index 34282ed..60b29f8 100644 --- a/work/externalcontact/user.go +++ b/work/externalcontact/user.go @@ -38,59 +38,10 @@ type UserInfo struct { FollowInfo FollowInfo `json:"follow_info"` //企业成员客户跟进人信息,可以参考获取客户详情,但标签信息只会返回企业标签和规则组标签的tag_id,个人标签将不再返回 } -type ExternalContact struct { - ExternalUserid string `json:"external_userid"` - Name string `json:"name"` - Position string `json:"position"` - Avatar string `json:"avatar"` - CorpName string `json:"corp_name"` - CorpFullName string `json:"corp_full_name"` - Type int `json:"type"` - Gender int `json:"gender"` - Unionid string `json:"unionid"` - ExternalProfile struct { - ExternalAttr []struct { - Type int `json:"type"` - Name string `json:"name"` - Text struct { - Value string `json:"value"` - } `json:"text,omitempty"` - Web struct { - Url string `json:"url"` - Title string `json:"title"` - } `json:"web,omitempty"` - Miniprogram struct { - Appid string `json:"appid"` - Pagepath string `json:"pagepath"` - Title string `json:"title"` - } `json:"miniprogram,omitempty"` - } `json:"external_attr"` - } `json:"external_profile,omitempty"` -} - -type FollowInfo struct { - Userid string `json:"userid"` - Remark string `json:"remark"` - Description string `json:"description"` - Createtime int `json:"createtime"` - TagId []string `json:"tag_id"` //批量获取时才有 - Tags []struct { - GroupName string `json:"group_name"` - TagName string `json:"tag_name"` - TagId string `json:"tag_id,omitempty"` - Type int `json:"type"` - } `json:"tags,omitempty"` //单独获取时才有 - RemarkCorpName string `json:"remark_corp_name,omitempty"` - RemarkMobiles []string `json:"remark_mobiles,omitempty"` - OperUserid string `json:"oper_userid"` - AddWay int `json:"add_way"` - State string `json:"state,omitempty"` -} - -//GetUseridList 获取我的客户列表 +// GetUseridList 获取我的客户列表 func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err error) { var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() + accessToken, err = tpl.GetAccessToken() if err != nil { return } @@ -113,7 +64,7 @@ func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err return } -//GetUseridList 获取我的全部客户列表及详情 +// GetUseridList 获取我的全部客户列表及详情 func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { var userInfoList []UserInfo var req ReqGetByUser @@ -134,7 +85,7 @@ func (tpl *Client) GetQyUserInfoList(qyUserid []string) ([]UserInfo, error) { return userInfoList, nil } -//GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 +// GetUserInfoAndAllFollow 获取客户详情以及全部跟进人 func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { var result, res OneUser var err error @@ -155,10 +106,10 @@ func (tpl *Client) GetUserInfoAndAllFollow(userid string) (OneUser, error) { return result, nil } -//GetUserInfo 获取客户详情 +// GetUserInfo 获取客户详情 func (tpl *Client) GetUserInfo(externalUserid string, cursor ...string) (result OneUser, err error) { var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() + accessToken, err = tpl.GetAccessToken() if err != nil { return } @@ -183,10 +134,10 @@ func (tpl *Client) GetUserInfo(externalUserid string, cursor ...string) (result return } -//GetUserInfoListByUserId 批量获取客户详情 +// GetUserInfoListByUserId 批量获取客户详情 func (tpl *Client) GetUserInfoListByUserIds(req ReqGetByUser) (userList []UserInfo, nextCursor string, err error) { var accessToken string - accessToken, err = tpl.ctx.GetAccessToken() + accessToken, err = tpl.GetAccessToken() if err != nil { return } diff --git a/work/message/README.md b/work/message/README.md index 20bc498..e69de29 100644 --- a/work/message/README.md +++ b/work/message/README.md @@ -1,3 +0,0 @@ -### 企业微信消息推送SDK - -相关文档正在梳理中... \ No newline at end of file diff --git a/work/message/message.go b/work/message/message.go index ef6d24d..db4a8aa 100644 --- a/work/message/message.go +++ b/work/message/message.go @@ -24,7 +24,7 @@ type ( // 消息类型,此时固定为:text MsgType string `json:"msgtype"` // 企业应用的id,整型。企业内部开发,可在应用的设置页面查看;第三方服务商,可通过接口 获取企业授权信息 获取该参数值 - AgentID string `json:"agentid"` + AgentID int `json:"agentid"` // 表示是否是保密消息,0表示可对外分享,1表示不能分享且内容显示水印,默认为0 Safe int `json:"safe"` // 表示是否开启id转译,0表示否,1表示是,默认0。仅第三方应用需要用到,企业自建应用可以忽略。 diff --git a/work/message/mix_message.go b/work/message/mix_message.go new file mode 100644 index 0000000..74fc023 --- /dev/null +++ b/work/message/mix_message.go @@ -0,0 +1,285 @@ +package message + +import ( + "encoding/xml" + + "github.com/silenceper/wechat/v2/officialaccount/device" +) + +// MsgType 企业微信普通消息类型 +type MsgType string + +// EventType 企业微信事件消息类型 +type EventType string + +// InfoType 第三方平台授权事件类型 +type InfoType string + +const ( + //MsgTypeEvent 表示事件推送消息 [限接收] + MsgTypeEvent = "event" + + //MsgTypeText 表示文本消息 + MsgTypeText MsgType = "text" + //MsgTypeImage 表示图片消息 + MsgTypeImage MsgType = "image" + //MsgTypeVoice 表示语音消息 + MsgTypeVoice MsgType = "voice" + //MsgTypeVideo 表示视频消息 + MsgTypeVideo MsgType = "video" + //MsgTypeNews 表示图文消息[限回复与发送应用消息] + MsgTypeNews MsgType = "news" + + //MsgTypeLink 表示链接消息[限接收] + MsgTypeLink MsgType = "link" + //MsgTypeLocation 表示坐标消息[限接收] + MsgTypeLocation MsgType = "location" + + //MsgTypeUpdateButton 更新点击用户的按钮文案[限回复应用消息] + MsgTypeUpdateButton MsgType = "update_button" + //MsgTypeUpdateTemplateCard 更新点击用户的整张卡片[限回复应用消息] + MsgTypeUpdateTemplateCard MsgType = "update_template_card" + + //MsgTypeFile 文件消息[限发送应用消息] + MsgTypeFile MsgType = "file" + //MsgTypeTextCard 文本卡片消息[限发送应用消息] + MsgTypeTextCard MsgType = "textcard" + //MsgTypeMpNews 图文消息[限发送应用消息] 跟普通的图文消息一致,唯一的差异是图文内容存储在企业微信 + MsgTypeMpNews MsgType = "mpnews" + //MsgTypeMarkdown markdown消息[限发送应用消息] + MsgTypeMarkdown MsgType = "markdown" + //MsgTypeMiniprogramNotice 小程序通知消息[限发送应用消息] + MsgTypeMiniprogramNotice MsgType = "miniprogram_notice" + //MsgTypeTemplateCard 模板卡片消息[限发送应用消息] + MsgTypeTemplateCard MsgType = "template_card" +) + +const ( + //EventSubscribe 成员关注,成员已经加入企业,管理员添加成员到应用可见范围(或移除可见范围)时 + EventSubscribe EventType = "subscribe" + //EventUnsubscribe 成员取消关注,成员已经在应用可见范围,成员加入(或退出)企业时 + EventUnsubscribe EventType = "unsubscribe" + //EventEnterAgent 本事件在成员进入企业微信的应用时触发 + EventEnterAgent EventType = "enter_agent" + //EventLocation 上报地理位置事件 + EventLocation EventType = "LOCATION" + //EventBatchJobResult 异步任务完成事件推送 + EventBatchJobResult EventType = "batch_job_result" + //EventClick 点击菜单拉取消息时的事件推送 + EventClick EventType = "click" + //EventView 点击菜单跳转链接时的事件推送 + EventView EventType = "view" + //EventScancodePush 扫码推事件的事件推送 + EventScancodePush EventType = "scancode_push" + //EventScancodeWaitmsg 扫码推事件且弹出“消息接收中”提示框的事件推送 + EventScancodeWaitmsg EventType = "scancode_waitmsg" + //EventPicSysphoto 弹出系统拍照发图的事件推送 + EventPicSysphoto EventType = "pic_sysphoto" + //EventPicPhotoOrAlbum 弹出拍照或者相册发图的事件推送 + EventPicPhotoOrAlbum EventType = "pic_photo_or_album" + //EventPicWeixin 弹出微信相册发图器的事件推送 + EventPicWeixin EventType = "pic_weixin" + //EventLocationSelect 弹出地理位置选择器的事件推送 + EventLocationSelect EventType = "location_select" + + //EventOpenApprovalChange 审批状态通知事件推送 + EventOpenApprovalChange EventType = "open_approval_change" + + //EventShareAgentChange 共享应用事件回调 + EventShareAgentChange EventType = "share_agent_change" + + //EventTemplateCard 模板卡片事件推送 + EventTemplateCard EventType = "template_card_event" + + //EventTemplateCardMenu 通用模板卡片右上角菜单事件推送 + EventTemplateCardMenu EventType = "template_card_menu_event" + + //EventChangeExternalContact 企业客户事件推送 + //add_external_contact 添加 + //edit_external_contact 编辑 + //add_half_external_contact 免验证添加 + //del_external_contact 员工删除客户 + //del_follow_user 客户删除跟进员工 + //transfer_fail 企业将客户分配给新的成员接替后,客户添加失败 + //change_external_chat 客户群创建事件 + EventChangeExternalContact EventType = "change_external_contact" + + //EventChangeExternalChat 企业客户群变更事件推送 + //create 客户群创建 + //update 客户群变更 + //dismiss 客户群解散 + EventChangeExternalChat EventType = "change_external_chat" + + //EventChangeExternalTag 企业客户标签创建事件推送 + //create 创建标签 + //update 变更标签 + //delete 删除标签 + //shuffle 重新排序 + EventChangeExternalTag EventType = "change_external_tag" + + //EventKfMsg 企业微信客服回调事件 + EventKfMsg EventType = "kf_msg_or_event" + //EventLivingStatusChange 直播回调事件 + EventLivingStatusChange EventType = "living_status_change" + + //EventMsgauditNotify 会话内容存档开启后,产生会话回调事件 + EventMsgauditNotify EventType = "msgaudit_notify" +) + +//todo 第三方应用开发 +/*const ( + //微信开放平台需要用到 + + // InfoTypeVerifyTicket 返回ticket + InfoTypeVerifyTicket InfoType = "component_verify_ticket" + // InfoTypeAuthorized 授权 + InfoTypeAuthorized = "authorized" + // InfoTypeUnauthorized 取消授权 + InfoTypeUnauthorized = "unauthorized" + // InfoTypeUpdateAuthorized 更新授权 + InfoTypeUpdateAuthorized = "updateauthorized" +)*/ + +// MixMessage 存放所有企业微信官方发送过来的消息和事件 +type MixMessage struct { + CommonToken + + //接收普通消息 + MsgID int64 `xml:"MsgId"` //其他消息推送过来是MsgId + AgentID int `xml:"AgentID"` //企业应用的id,整型。可在应用的设置页面查看 + + Content string `xml:"Content,omitempty"` //文本消息内容 + Format string `xml:"Format,omitempty"` //语音消息格式,如amr,speex等 + ThumbMediaID string `xml:"ThumbMediaId,omitempty"` //视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取数据,仅三天内有效 + + Title string `xml:"Title,omitempty"` //链接消息,标题 + Description string `xml:"Description,omitempty"` //链接消息,描述 + URL string `xml:"Url,omitempty"` //链接消息,链接跳转的url + + PicURL string `xml:"PicUrl,omitempty"` ////图片消息或者链接消息,封面缩略图的url + MediaID string `xml:"MediaId,omitempty"` //图片媒体文件id//语音媒体文件id//视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取,仅三天内有效 + + LocationX float64 `xml:"Location_X,omitempty"` //位置消息,地理位置纬度 + LocationY float64 `xml:"Location_Y,omitempty"` //位置消息,地理位置经度 + Scale float64 `xml:"Scale,omitempty"` //位置消息,地图缩放大小 + Label string `xml:"Label,omitempty"` //位置消息,地理位置信息 + + AppType string `xml:"AppType,omitempty"` //接收地理位置时存在,app类型,在企业微信固定返回wxwork,在微信不返回该字段 + + //TemplateMsgID int64 `xml:"MsgID"` //模板消息推送成功的消息是MsgID + ///Recognition string `xml:"Recognition"` + + //事件相关 + Event EventType `xml:"Event,omitempty"` + EventKey string `xml:"EventKey,omitempty"` + ChangeType string `xml:"ChangeType,omitempty"` + + //模板卡片事件推送 https://developer.work.weixin.qq.com/document/path/90240#%E6%A8%A1%E6%9D%BF%E5%8D%A1%E7%89%87%E4%BA%8B%E4%BB%B6%E6%8E%A8%E9%80%81 + TaskId string `xml:"TaskId,omitempty"` //与发送模板卡片消息时指定的task_id相同 + CardType string `xml:"CardType,omitempty"` //通用模板卡片的类型,类型有"text_notice", "news_notice", "button_interaction", "vote_interaction", "multiple_interaction"五种 + ResponseCode string `xml:"ResponseCode,omitempty"` //用于调用更新卡片接口的ResponseCode,24小时内有效,且只能使用一次 + SelectedItems struct { + SelectedItem struct { + QuestionKey string `xml:"QuestionKey"` //问题的key值 + OptionIds struct { //对应问题的选项列表 + OptionId string `xml:"OptionId"` + } `xml:"OptionIds"` + } `xml:"SelectedItem"` + } `xml:"SelectedItems,omitempty"` + + //仅上报地理位置事件 + Latitude string `xml:"Latitude,omitempty"` //地理位置纬度 + Longitude string `xml:"Longitude,omitempty"` //地理位置经度 + Precision string `xml:"Precision,omitempty"` //地理位置精度 + + //仅异步任务完成事件 + JobId string `xml:"JobId,omitempty"` //异步任务id,最大长度为64字符 + JobType string `xml:"JobType,omitempty"` //异步任务,操作类型,字符串,目前分别有:sync_user(增量更新成员)、 replace_user(全量覆盖成员)、invite_user(邀请成员关注)、replace_party(全量覆盖部门) + ErrCode int `xml:"ErrCode,omitempty"` //异步任务,返回码 + ErrMsg string `xml:"ErrMsg,omitempty"` //异步任务,对返回码的文本描述内容 + + //开启通讯录回调通知 https://open.work.weixin.qq.com/api/doc/90000/90135/90967 + UserID string `xml:"UserID,omitempty"` //用户userid + ExternalUserID string `xml:"ExternalUserID,omitempty"` //外部联系人userid + State string `xml:"State,omitempty"` //添加此用户的「联系我」方式配置的state参数,可用于识别添加此用户的渠道 + WelcomeCode string `xml:"WelcomeCode,omitempty"` //欢迎码,当state为1时,该值有效 + Source string `xml:"Source,omitempty"` //删除客户的操作来源,DELETE_BY_TRANSFER表示此客户是因在职继承自动被转接成员删除 + + // todo 第三方平台相关 字段名可能不准确 + /*InfoType InfoType `xml:"InfoType"` + AppID string `xml:"AppId"` + ComponentVerifyTicket string `xml:"ComponentVerifyTicket"` + AuthorizerAppid string `xml:"AuthorizerAppid"` + AuthorizationCode string `xml:"AuthorizationCode"``````````````````````````````````````` + AuthorizationCodeExpiredTime int64 `xml:"AuthorizationCodeExpiredTime"` + PreAuthCode string `xml:"PreAuthCode"`*/ + + //设备相关 + device.MsgDevice +} + +// EventPic 发图事件推送 +type EventPic struct { + PicMd5Sum string `xml:"PicMd5Sum"` +} + +// EncryptedXMLMsg 安全模式下的消息体 +type EncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + ToUserName string `xml:"ToUserName" json:"ToUserName"` + AgentID string `xml:"AgentID" json:"AgentID"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` +} + +// ResponseEncryptedXMLMsg 需要返回的消息体 +type ResponseEncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` + MsgSignature string `xml:"MsgSignature" json:"MsgSignature"` + Timestamp int64 `xml:"TimeStamp" json:"TimeStamp"` + Nonce string `xml:"Nonce" json:"Nonce"` +} + +// CDATA 使用该类型,在序列化为 xml 文本时文本会被解析器忽略 +type CDATA string + +// MarshalXML 实现自己的序列化方法 +func (c CDATA) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return e.EncodeElement(struct { + string `xml:",cdata"` + }{string(c)}, start) +} + +// CommonToken 消息中通用的结构 +type CommonToken struct { + XMLName xml.Name `xml:"xml"` + ToUserName CDATA `xml:"ToUserName"` + FromUserName CDATA `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType MsgType `xml:"MsgType"` +} + +// SetToUserName set ToUserName +func (msg *CommonToken) SetToUserName(toUserName CDATA) { + msg.ToUserName = toUserName +} + +// SetFromUserName set FromUserName +func (msg *CommonToken) SetFromUserName(fromUserName CDATA) { + msg.FromUserName = fromUserName +} + +// SetCreateTime set createTime +func (msg *CommonToken) SetCreateTime(createTime int64) { + msg.CreateTime = createTime +} + +// SetMsgType set MsgType +func (msg *CommonToken) SetMsgType(msgType MsgType) { + msg.MsgType = msgType +} + +// GetOpenID get the FromUserName value +func (msg *CommonToken) GetOpenID() string { + return string(msg.FromUserName) +} diff --git a/work/message/template_card.go b/work/message/template_card.go index c91bdef..6b0e0de 100644 --- a/work/message/template_card.go +++ b/work/message/template_card.go @@ -4,19 +4,17 @@ import ( "encoding/json" "fmt" "github.com/silenceper/wechat/v2/util" - "strconv" ) const ( - messageSendURL = "https://qyapi.weixin.qq.com/cgi-bin/message/send" messageUpdateTemplateCardURL = "https://api.weixin.qq.com/cgi-bin/message/update_template_card" messageDelURL = "https://api.weixin.qq.com/cgi-bin/message/recall" ) // UpdateButton 模板卡片按钮 type UpdateButton struct { - CommonToken `json:"-"` - Button struct { + //CommonToken `json:"-"` + Button struct { ReplaceName string `xml:"ReplaceName" json:"replace_name"` } `xml:"Button" json:"button"` } @@ -31,7 +29,7 @@ func NewUpdateButton(replaceName string) *UpdateButton { // TemplateCard 被动回复模板卡片 // https://open.work.weixin.qq.com/api/doc/90000/90135/90241 type TemplateCard struct { - CommonToken `json:"-"` + //CommonToken `json:"-"` TemplateCard interface{} `xml:"TemplateCard" json:"template_card"` } @@ -42,137 +40,6 @@ func NewTemplateCard(cardXml interface{}) *TemplateCard { return card } -// AppMessage 发送的模板消息内容 -type AppMessage struct { - ToUser string `json:"touser"` // 必须, 成员ID列表(多个接收者用‘|’分隔,最多支持1000个 ,指定为”@all”,则向该企业应用的全部成员发送 - Toparty string `json:"toparty,omitempty"` //部门ID列表,当touser为”@all”时忽略本参数 - Totag string `json:"totag,omitempty"` //标签ID列表,当touser为”@all”时忽略本参数 - Msgtype MsgType `json:"msgtype"` - Agentid int `json:"agentid"` - Safe int `json:"safe,omitempty"` - EnableIdTrans int `json:"enable_id_trans,omitempty"` - EnableDuplicateCheck int `json:"enable_duplicate_check,omitempty"` - DuplicateCheckInterval int `json:"duplicate_check_interval,omitempty"` - Text *Text `json:"text,omitempty"` - *Image `json:"image,omitempty"` - *Voice `json:"voice,omitempty"` - *Video `json:"video,omitempty"` - File *PushFile `json:"file,omitempty"` - TextCard *PushTextCard `json:"textcard,omitempty"` - News *News `json:"news,omitempty"` - MpNews *MpNews `json:"mpnews,omitempty"` - Markdown *Text `json:"markdown,omitempty"` - //todo(wind) 可能会发生变化的字段直接用interface{}了 - MiniprogramNotice interface{} `json:"miniprogram_notice,omitempty"` - TemplateCard *TemplateCardButton `json:"template_card,omitempty"` -} -type Action struct { - Text string `json:"text"` - Key string `json:"key"` -} -type HorizontalContent struct { - Keyname string `json:"keyname"` - Value string `json:"value,omitempty"` - Type int `json:"type,omitempty"` - Url string `json:"url,omitempty"` - MediaId string `json:"media_id,omitempty"` - Userid string `json:"userid,omitempty"` -} -type VerticalContent struct { - Title string `json:"title"` - Desc string `json:"desc"` -} -type Jump struct { - Type int `json:"type,omitempty"` - Title string `json:"title"` - Url string `json:"url,omitempty"` - Appid string `json:"appid,omitempty"` - Pagepath string `json:"pagepath,omitempty"` -} -type OptionCheckBox struct { - Id string `json:"id"` - Text string `json:"text"` - IsChecked bool `json:"is_checked"` -} -type Option struct { - Id string `json:"id"` - Text string `json:"text"` -} -type Select struct { - QuestionKey string `json:"question_key"` - Title string `json:"title"` - SelectedId string `json:"selected_id"` - OptionList []Option `json:"option_list"` -} -type Button struct { - Text string `json:"text"` - Style int `json:"style,omitempty"` - Key string `json:"key,omitempty"` -} - -type TemplateCardButton struct { - CardType string `json:"card_type"` - Source struct { - IconUrl string `json:"icon_url,omitempty"` - Desc string `json:"desc,omitempty"` - DescColor int `json:"desc_color,omitempty"` - } `json:"source,omitempty"` - - ActionMenu struct { - Desc string `json:"desc,omitempty"` - ActionList []Action `json:"action_list"` - } `json:"action_menu,omitempty"` - MainTitle struct { - Title string `json:"title"` - Desc string `json:"desc,omitempty"` - } `json:"main_title,omitempty"` - QuoteArea struct { - Type int `json:"type,omitempty"` - Url string `json:"url,omitempty"` - Title string `json:"title,omitempty"` - QuoteText string `json:"quote_text,omitempty"` - } `json:"quote_area"` - SubTitleText string `json:"sub_title_text,omitempty"` - HorizontalContentList []HorizontalContent `json:"horizontal_content_list"` - VerticalContentList []VerticalContent `json:"vertical_content_list,omitempty"` - CardAction struct { - Type int `json:"type,omitempty"` - Url string `json:"url,omitempty"` - Appid string `json:"appid,omitempty"` - Pagepath string `json:"pagepath,omitempty"` - } `json:"card_action"` - JumpList []Jump `json:"jump_list,omitempty"` - EmphasisContent struct { - Title string `json:"title,omitempty"` - Desc string `json:"desc,omitempty"` - } `json:"emphasis_content,omitempty"` - ImageTextArea struct { - Type int `json:"type"` - Url string `json:"url"` - Title string `json:"title"` - Desc string `json:"desc"` - ImageUrl string `json:"image_url"` - } `json:"image_text_area,omitempty"` - CardImage struct { - Url string `json:"url"` - AspectRatio float64 `json:"aspect_ratio"` - } `json:"card_image,omitempty"` - Checkbox struct { - QuestionKey string `json:"question_key"` - OptionList []OptionCheckBox `json:"option_list"` - Mode int `json:"mode"` - } `json:"checkbox,omitempty"` - SelectList []Select `json:"select_list"` - TaskId string `json:"task_id"` - ButtonSelection struct { - QuestionKey string `json:"question_key"` - Title string `json:"title,omitempty"` - OptionList []Option `json:"option_list,omitempty"` - SelectedId string `json:"selected_id,omitempty"` - } `json:"button_selection,omitempty"` - ButtonList []Button `json:"button_list"` -} - type PushFile struct { MediaID string `json:"media_id"` } @@ -192,36 +59,6 @@ type resTemplateSend struct { ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 } -// Send 发送应用消息 -func (r *Client) Send(msg *AppMessage) (msgID string, responseCode string, err error) { - var accessToken string - accessToken, err = r.GetAccessToken() - if err != nil { - return - } - uri := fmt.Sprintf("%s?access_token=%s", messageSendURL, accessToken) - var response []byte - if msg.Agentid == 0 { - msg.Agentid, _ = strconv.Atoi(r.Context.AgentID) - } - response, err = util.PostJSON(uri, msg) - if err != nil { - return - } - var result resTemplateSend - err = json.Unmarshal(response, &result) - if err != nil { - return - } - if result.ErrCode != 0 { - err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) - return - } - msgID = result.MsgID - responseCode = result.ResponseCode - return -} - // TemplateUpdate 更新模版卡片消息内容 type TemplateUpdate struct { Userids []string `json:"userids"` diff --git a/work/oauth/user.go b/work/oauth/user.go new file mode 100644 index 0000000..581c758 --- /dev/null +++ b/work/oauth/user.go @@ -0,0 +1,64 @@ +package oauth + +import ( + "encoding/json" + "fmt" + "github.com/silenceper/wechat/v2/util" +) + +const ( + code2SessionURL = "https://qyapi.weixin.qq.com/cgi-bin/miniprogram/jscode2session?access_token=%s&js_code=%s&grant_type=authorization_code" + launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code?access_token=%s" +) + +func (ctr *Oauth) Code2Session(code string) (result ResUserInfo, err error) { + var accessToken string + accessToken, err = ctr.GetAccessToken() + if err != nil { + return + } + var response []byte + response, err = util.HTTPGet( + fmt.Sprintf(code2SessionURL, accessToken, code), + ) + if err != nil { + return + } + err = json.Unmarshal(response, &result) + if result.ErrCode != 0 { + err = fmt.Errorf("GetUserAccessToken error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} + +type RespLaunchCode struct { + util.CommonError + LaunchCode string `json:"launch_code"` +} + +// GetLaunchCode 用于打开个人聊天窗口schema +func (ctr *Oauth) GetLaunchCode(userID, other string) (userInfo *RespLaunchCode, err error) { + var accessToken string + accessToken, err = ctr.GetAccessToken() + if err != nil { + return + } + + uri := fmt.Sprintf(launchCode, accessToken) + var response []byte + response, err = util.PostJSON(uri, map[string]interface{}{"operator_userid": userID, "single_chat": map[string]string{"userid": other}}) + if err != nil { + return + } + userInfo = new(RespLaunchCode) + err = json.Unmarshal(response, userInfo) + if err != nil { + return + } + if userInfo.ErrCode != 0 { + err = fmt.Errorf("GetUserInfo Error , errcode=%d , errmsg=%s", userInfo.ErrCode, userInfo.ErrMsg) + return + } + return +} diff --git a/work/tools/calendar.go b/work/tools/calendar.go index f03d2be..e69de29 100644 --- a/work/tools/calendar.go +++ b/work/tools/calendar.go @@ -1,21 +0,0 @@ -package tools - -import ( - "github.com/silenceper/wechat/v2/work/context" -) - -const ( - calendarURL = "https://qyapi.weixin.qq.com/cgi-bin/oa/calendar/get?access_token=%s" -) - -//Calendar 日历管理 -type Calendar struct { - *context.Context -} - -//NewCalendar 实例化 -func NewCalendar(context *context.Context) *Calendar { - calendar := new(Calendar) - calendar.Context = context - return calendar -} diff --git a/work/user/user.go b/work/user/user.go index 66eda7c..93d9012 100644 --- a/work/user/user.go +++ b/work/user/user.go @@ -13,22 +13,21 @@ const ( userInfoURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=%s&userid=%s" updateURL = "https://qyapi.weixin.qq.com/cgi-bin/user/simplelist?access_token=%s&department_id=%s&fetch_child=1" userListURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get" - launchCode = "https://qyapi.weixin.qq.com/cgi-bin/get_launch_code?access_token=%s" ) -//User 用户管理 +// User 用户管理 type User struct { *context.Context } -//NewUser 实例化 +// NewUser 实例化 func NewUser(context *context.Context) *User { user := new(User) user.Context = context return user } -//Info 用户基本信息 +// Info 用户基本信息 type Info struct { util.CommonError Userid string `json:"userid"` @@ -100,7 +99,7 @@ type OpenidList struct { NextOpenID string `json:"next_openid"` } -//GetUserInfo 获取用户基本信息 +// GetUserInfo 获取用户基本信息 func (user *User) GetUserInfo(userID string) (userInfo *Info, err error) { var accessToken string accessToken, err = user.GetAccessToken() @@ -193,34 +192,3 @@ func (user *User) ListAllUserOpenIDs() ([]string, error) { } } } - -type RespLaunchCode struct { - util.CommonError - LaunchCode string `json:"launch_code"` -} - -//GetLaunchCode 用于打开个人聊天窗口schema -func (user *User) GetLaunchCode(userID, other string) (userInfo *RespLaunchCode, err error) { - var accessToken string - accessToken, err = user.GetAccessToken() - if err != nil { - return - } - - uri := fmt.Sprintf(launchCode, accessToken) - var response []byte - response, err = util.PostJSON(uri, map[string]interface{}{"operator_userid": userID, "single_chat": map[string]string{"userid": other}}) - if err != nil { - return - } - userInfo = new(RespLaunchCode) - err = json.Unmarshal(response, userInfo) - if err != nil { - return - } - if userInfo.ErrCode != 0 { - err = fmt.Errorf("GetUserInfo Error , errcode=%d , errmsg=%s", userInfo.ErrCode, userInfo.ErrMsg) - return - } - return -} diff --git a/work/work.go b/work/work.go index 24c5773..1c79a89 100644 --- a/work/work.go +++ b/work/work.go @@ -9,12 +9,15 @@ import ( "github.com/silenceper/wechat/v2/work/context" "github.com/silenceper/wechat/v2/work/externalcontact" "github.com/silenceper/wechat/v2/work/invoice" + "github.com/silenceper/wechat/v2/work/js" "github.com/silenceper/wechat/v2/work/kf" "github.com/silenceper/wechat/v2/work/material" "github.com/silenceper/wechat/v2/work/message" "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" "github.com/silenceper/wechat/v2/work/robot" + "github.com/silenceper/wechat/v2/work/server" + "net/http" ) // Work 企业微信 @@ -91,3 +94,16 @@ func (wk *Work) GetInvoice() *invoice.Client { func (wk *Work) GetCheckin() *checkin.Client { return checkin.NewClient(wk.ctx) } + +// GetJs js-sdk配置 +func (wk *Work) GetJs() *js.Js { + return js.NewJs(wk.ctx) +} + +// GetServer 消息管理:接收事件,被动回复消息管理 +func (wk *Work) GetServer(req *http.Request, writer http.ResponseWriter) *server.Server { + srv := server.NewServer(wk.ctx) + srv.Request = req + srv.Writer = writer + return srv +} From 61bcd6b0e491ca634d2d839034ac12ba9e504b1e Mon Sep 17 00:00:00 2001 From: wind Date: Fri, 27 Sep 2024 16:05:25 +0800 Subject: [PATCH 20/23] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9EJS-SDK=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=E6=9D=83=E9=99=90=E7=AD=BE=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- credential/work_js_ticket.go | 85 ++++++++++++++++++++++++++++++++ work/js/js.go | 93 ++++++++++++++++++++++++++++++++++++ work/work.go | 6 +++ 3 files changed, 184 insertions(+) create mode 100644 credential/work_js_ticket.go create mode 100644 work/js/js.go diff --git a/credential/work_js_ticket.go b/credential/work_js_ticket.go new file mode 100644 index 0000000..e3906b6 --- /dev/null +++ b/credential/work_js_ticket.go @@ -0,0 +1,85 @@ +package credential + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/silenceper/wechat/v2/cache" + "github.com/silenceper/wechat/v2/util" +) + +// 获取ticket的url https://developer.work.weixin.qq.com/document/path/90506 +const getQyWxTicketURL = "https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket?access_token=%s" +const getQyAppTicketURL = "https://qyapi.weixin.qq.com/cgi-bin/ticket/get?access_token=%s&type=agent_config" + +// WorkJsTicket 默认获取js ticket方法 +type WorkJsTicket struct { + appID string + agentID string + cacheKeyPrefix string + cache cache.Cache + //jsAPITicket 读写锁 同一个AppID一个 + jsAPITicketLock *sync.Mutex +} + +// NewWorkJsTicket new +func NewWorkJsTicket(appID string, agentID string, cacheKeyPrefix string, cache cache.Cache) JsTicketHandle { + return &WorkJsTicket{ + appID: appID, + agentID: agentID, + cache: cache, + cacheKeyPrefix: cacheKeyPrefix, + jsAPITicketLock: new(sync.Mutex), + } +} + +// GetTicket 获取企业微信jsapi_ticket +func (js *WorkJsTicket) GetTicket(accessToken string) (ticketStr string, err error) { + //先从cache中取 + jsAPITicketCacheKey := fmt.Sprintf("%s_jsapi_ticket_%s", js.cacheKeyPrefix, js.appID) + if val := js.cache.Get(jsAPITicketCacheKey); val != nil { + return val.(string), nil + } + + js.jsAPITicketLock.Lock() + defer js.jsAPITicketLock.Unlock() + + // 双检,防止重复从微信服务器获取 + if val := js.cache.Get(jsAPITicketCacheKey); val != nil { + return val.(string), nil + } + + var ticket ResTicket + ticket, err = GetQyWxTicketFromServer(accessToken, js.agentID != "") + if err != nil { + return + } + expires := ticket.ExpiresIn - 1500 + err = js.cache.Set(jsAPITicketCacheKey, ticket.Ticket, time.Duration(expires)*time.Second) + ticketStr = ticket.Ticket + return +} + +// GetQyWxTicketFromServer 从企业微信服务器中获取ticket +func GetQyWxTicketFromServer(accessToken string, isApp bool) (ticket ResTicket, err error) { + var response []byte + url := fmt.Sprintf(getQyWxTicketURL, accessToken) + if isApp { + url = fmt.Sprintf(getQyAppTicketURL, accessToken) + } + response, err = util.HTTPGet(url) + if err != nil { + return + } + err = json.Unmarshal(response, &ticket) + if err != nil { + return + } + if ticket.ErrCode != 0 { + err = fmt.Errorf("getTicket Error : errcode=%d , errmsg=%s", ticket.ErrCode, ticket.ErrMsg) + return + } + return +} diff --git a/work/js/js.go b/work/js/js.go new file mode 100644 index 0000000..4d2a23b --- /dev/null +++ b/work/js/js.go @@ -0,0 +1,93 @@ +package js + +import ( + "fmt" + + "github.com/silenceper/wechat/v2/credential" + "github.com/silenceper/wechat/v2/util" + "github.com/silenceper/wechat/v2/work/context" +) + +// Js struct +type Js struct { + *context.Context + credential.JsTicketHandle +} + +// Config 返回给用户jssdk配置信息 +type Config struct { + CorpID string `json:"corp_id"` + Agentid string `json:"agentid"` + Timestamp int64 `json:"timestamp"` + NonceStr string `json:"nonce_str"` + Signature string `json:"signature"` +} + +// NewJs init +func NewJs(context *context.Context) *Js { + js := new(Js) + js.Context = context + jsTicketHandle := credential.NewWorkJsTicket(context.CorpID, context.AgentID, credential.CacheKeyWorkPrefix, context.Cache) + js.SetJsTicketHandle(jsTicketHandle) + return js +} + +// SetJsTicketHandle 自定义js ticket取值方式 +func (js *Js) SetJsTicketHandle(ticketHandle credential.JsTicketHandle) { + js.JsTicketHandle = ticketHandle +} + +// GetConfig 获取jssdk需要的配置参数 +// uri 为当前网页地址 +func (js *Js) GetConfig(uri string) (config *Config, err error) { + config = new(Config) + var accessToken string + accessToken, err = js.GetAccessToken() + if err != nil { + return + } + var ticketStr string + ticketStr, err = js.GetTicket(accessToken) + if err != nil { + return + } + + nonceStr := util.RandomStr(16) + timestamp := util.GetCurrTS() + str := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticketStr, nonceStr, timestamp, uri) + sigStr := util.Signature(str) + + config.CorpID = js.CorpID + config.Agentid = js.AgentID + config.NonceStr = nonceStr + config.Timestamp = timestamp + config.Signature = sigStr + return +} + +// GetAgentConfig 获取jssdk需要的配置参数 +// uri 为当前网页地址 +func (js *Js) GetAgentConfig(uri string) (config *Config, err error) { + config = new(Config) + var accessToken string + accessToken, err = js.GetAccessToken() + if err != nil { + return + } + var ticketStr string + ticketStr, err = js.GetTicket(accessToken) + if err != nil { + return + } + + nonceStr := util.RandomStr(16) + timestamp := util.GetCurrTS() + str := fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", ticketStr, nonceStr, timestamp, uri) + sigStr := util.Signature(str) + + config.CorpID = js.CorpID + config.NonceStr = nonceStr + config.Timestamp = timestamp + config.Signature = sigStr + return +} diff --git a/work/work.go b/work/work.go index 24c5773..60fbf47 100644 --- a/work/work.go +++ b/work/work.go @@ -9,6 +9,7 @@ import ( "github.com/silenceper/wechat/v2/work/context" "github.com/silenceper/wechat/v2/work/externalcontact" "github.com/silenceper/wechat/v2/work/invoice" + "github.com/silenceper/wechat/v2/work/js" "github.com/silenceper/wechat/v2/work/kf" "github.com/silenceper/wechat/v2/work/material" "github.com/silenceper/wechat/v2/work/message" @@ -91,3 +92,8 @@ func (wk *Work) GetInvoice() *invoice.Client { func (wk *Work) GetCheckin() *checkin.Client { return checkin.NewClient(wk.ctx) } + +// GetJs js-sdk配置 +func (wk *Work) GetJs() *js.Js { + return js.NewJs(wk.ctx) +} From bcdb2fa6ca2bdeca5dcfc11d807872af50b2588a Mon Sep 17 00:00:00 2001 From: wind Date: Fri, 27 Sep 2024 19:56:30 +0800 Subject: [PATCH 21/23] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=B6=88=E6=81=AF=E6=8E=A8=E9=80=81=E6=8E=A5?= =?UTF-8?q?=E6=94=B6=E5=92=8C=E5=8F=91=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- work/message/mix_message.go | 285 ++++++++++++++++++++++++++++++++++ work/message/reply.go | 15 ++ work/message/template_card.go | 129 +++++++++++++++ work/server/error.go | 66 ++++++++ work/server/server.go | 228 +++++++++++++++++++++++++++ work/server/util.go | 58 +++++++ work/work.go | 10 ++ 7 files changed, 791 insertions(+) create mode 100644 work/message/mix_message.go create mode 100644 work/message/reply.go create mode 100644 work/message/template_card.go create mode 100644 work/server/error.go create mode 100644 work/server/server.go create mode 100644 work/server/util.go diff --git a/work/message/mix_message.go b/work/message/mix_message.go new file mode 100644 index 0000000..74fc023 --- /dev/null +++ b/work/message/mix_message.go @@ -0,0 +1,285 @@ +package message + +import ( + "encoding/xml" + + "github.com/silenceper/wechat/v2/officialaccount/device" +) + +// MsgType 企业微信普通消息类型 +type MsgType string + +// EventType 企业微信事件消息类型 +type EventType string + +// InfoType 第三方平台授权事件类型 +type InfoType string + +const ( + //MsgTypeEvent 表示事件推送消息 [限接收] + MsgTypeEvent = "event" + + //MsgTypeText 表示文本消息 + MsgTypeText MsgType = "text" + //MsgTypeImage 表示图片消息 + MsgTypeImage MsgType = "image" + //MsgTypeVoice 表示语音消息 + MsgTypeVoice MsgType = "voice" + //MsgTypeVideo 表示视频消息 + MsgTypeVideo MsgType = "video" + //MsgTypeNews 表示图文消息[限回复与发送应用消息] + MsgTypeNews MsgType = "news" + + //MsgTypeLink 表示链接消息[限接收] + MsgTypeLink MsgType = "link" + //MsgTypeLocation 表示坐标消息[限接收] + MsgTypeLocation MsgType = "location" + + //MsgTypeUpdateButton 更新点击用户的按钮文案[限回复应用消息] + MsgTypeUpdateButton MsgType = "update_button" + //MsgTypeUpdateTemplateCard 更新点击用户的整张卡片[限回复应用消息] + MsgTypeUpdateTemplateCard MsgType = "update_template_card" + + //MsgTypeFile 文件消息[限发送应用消息] + MsgTypeFile MsgType = "file" + //MsgTypeTextCard 文本卡片消息[限发送应用消息] + MsgTypeTextCard MsgType = "textcard" + //MsgTypeMpNews 图文消息[限发送应用消息] 跟普通的图文消息一致,唯一的差异是图文内容存储在企业微信 + MsgTypeMpNews MsgType = "mpnews" + //MsgTypeMarkdown markdown消息[限发送应用消息] + MsgTypeMarkdown MsgType = "markdown" + //MsgTypeMiniprogramNotice 小程序通知消息[限发送应用消息] + MsgTypeMiniprogramNotice MsgType = "miniprogram_notice" + //MsgTypeTemplateCard 模板卡片消息[限发送应用消息] + MsgTypeTemplateCard MsgType = "template_card" +) + +const ( + //EventSubscribe 成员关注,成员已经加入企业,管理员添加成员到应用可见范围(或移除可见范围)时 + EventSubscribe EventType = "subscribe" + //EventUnsubscribe 成员取消关注,成员已经在应用可见范围,成员加入(或退出)企业时 + EventUnsubscribe EventType = "unsubscribe" + //EventEnterAgent 本事件在成员进入企业微信的应用时触发 + EventEnterAgent EventType = "enter_agent" + //EventLocation 上报地理位置事件 + EventLocation EventType = "LOCATION" + //EventBatchJobResult 异步任务完成事件推送 + EventBatchJobResult EventType = "batch_job_result" + //EventClick 点击菜单拉取消息时的事件推送 + EventClick EventType = "click" + //EventView 点击菜单跳转链接时的事件推送 + EventView EventType = "view" + //EventScancodePush 扫码推事件的事件推送 + EventScancodePush EventType = "scancode_push" + //EventScancodeWaitmsg 扫码推事件且弹出“消息接收中”提示框的事件推送 + EventScancodeWaitmsg EventType = "scancode_waitmsg" + //EventPicSysphoto 弹出系统拍照发图的事件推送 + EventPicSysphoto EventType = "pic_sysphoto" + //EventPicPhotoOrAlbum 弹出拍照或者相册发图的事件推送 + EventPicPhotoOrAlbum EventType = "pic_photo_or_album" + //EventPicWeixin 弹出微信相册发图器的事件推送 + EventPicWeixin EventType = "pic_weixin" + //EventLocationSelect 弹出地理位置选择器的事件推送 + EventLocationSelect EventType = "location_select" + + //EventOpenApprovalChange 审批状态通知事件推送 + EventOpenApprovalChange EventType = "open_approval_change" + + //EventShareAgentChange 共享应用事件回调 + EventShareAgentChange EventType = "share_agent_change" + + //EventTemplateCard 模板卡片事件推送 + EventTemplateCard EventType = "template_card_event" + + //EventTemplateCardMenu 通用模板卡片右上角菜单事件推送 + EventTemplateCardMenu EventType = "template_card_menu_event" + + //EventChangeExternalContact 企业客户事件推送 + //add_external_contact 添加 + //edit_external_contact 编辑 + //add_half_external_contact 免验证添加 + //del_external_contact 员工删除客户 + //del_follow_user 客户删除跟进员工 + //transfer_fail 企业将客户分配给新的成员接替后,客户添加失败 + //change_external_chat 客户群创建事件 + EventChangeExternalContact EventType = "change_external_contact" + + //EventChangeExternalChat 企业客户群变更事件推送 + //create 客户群创建 + //update 客户群变更 + //dismiss 客户群解散 + EventChangeExternalChat EventType = "change_external_chat" + + //EventChangeExternalTag 企业客户标签创建事件推送 + //create 创建标签 + //update 变更标签 + //delete 删除标签 + //shuffle 重新排序 + EventChangeExternalTag EventType = "change_external_tag" + + //EventKfMsg 企业微信客服回调事件 + EventKfMsg EventType = "kf_msg_or_event" + //EventLivingStatusChange 直播回调事件 + EventLivingStatusChange EventType = "living_status_change" + + //EventMsgauditNotify 会话内容存档开启后,产生会话回调事件 + EventMsgauditNotify EventType = "msgaudit_notify" +) + +//todo 第三方应用开发 +/*const ( + //微信开放平台需要用到 + + // InfoTypeVerifyTicket 返回ticket + InfoTypeVerifyTicket InfoType = "component_verify_ticket" + // InfoTypeAuthorized 授权 + InfoTypeAuthorized = "authorized" + // InfoTypeUnauthorized 取消授权 + InfoTypeUnauthorized = "unauthorized" + // InfoTypeUpdateAuthorized 更新授权 + InfoTypeUpdateAuthorized = "updateauthorized" +)*/ + +// MixMessage 存放所有企业微信官方发送过来的消息和事件 +type MixMessage struct { + CommonToken + + //接收普通消息 + MsgID int64 `xml:"MsgId"` //其他消息推送过来是MsgId + AgentID int `xml:"AgentID"` //企业应用的id,整型。可在应用的设置页面查看 + + Content string `xml:"Content,omitempty"` //文本消息内容 + Format string `xml:"Format,omitempty"` //语音消息格式,如amr,speex等 + ThumbMediaID string `xml:"ThumbMediaId,omitempty"` //视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取数据,仅三天内有效 + + Title string `xml:"Title,omitempty"` //链接消息,标题 + Description string `xml:"Description,omitempty"` //链接消息,描述 + URL string `xml:"Url,omitempty"` //链接消息,链接跳转的url + + PicURL string `xml:"PicUrl,omitempty"` ////图片消息或者链接消息,封面缩略图的url + MediaID string `xml:"MediaId,omitempty"` //图片媒体文件id//语音媒体文件id//视频消息缩略图的媒体id,可以调用获取媒体文件接口拉取,仅三天内有效 + + LocationX float64 `xml:"Location_X,omitempty"` //位置消息,地理位置纬度 + LocationY float64 `xml:"Location_Y,omitempty"` //位置消息,地理位置经度 + Scale float64 `xml:"Scale,omitempty"` //位置消息,地图缩放大小 + Label string `xml:"Label,omitempty"` //位置消息,地理位置信息 + + AppType string `xml:"AppType,omitempty"` //接收地理位置时存在,app类型,在企业微信固定返回wxwork,在微信不返回该字段 + + //TemplateMsgID int64 `xml:"MsgID"` //模板消息推送成功的消息是MsgID + ///Recognition string `xml:"Recognition"` + + //事件相关 + Event EventType `xml:"Event,omitempty"` + EventKey string `xml:"EventKey,omitempty"` + ChangeType string `xml:"ChangeType,omitempty"` + + //模板卡片事件推送 https://developer.work.weixin.qq.com/document/path/90240#%E6%A8%A1%E6%9D%BF%E5%8D%A1%E7%89%87%E4%BA%8B%E4%BB%B6%E6%8E%A8%E9%80%81 + TaskId string `xml:"TaskId,omitempty"` //与发送模板卡片消息时指定的task_id相同 + CardType string `xml:"CardType,omitempty"` //通用模板卡片的类型,类型有"text_notice", "news_notice", "button_interaction", "vote_interaction", "multiple_interaction"五种 + ResponseCode string `xml:"ResponseCode,omitempty"` //用于调用更新卡片接口的ResponseCode,24小时内有效,且只能使用一次 + SelectedItems struct { + SelectedItem struct { + QuestionKey string `xml:"QuestionKey"` //问题的key值 + OptionIds struct { //对应问题的选项列表 + OptionId string `xml:"OptionId"` + } `xml:"OptionIds"` + } `xml:"SelectedItem"` + } `xml:"SelectedItems,omitempty"` + + //仅上报地理位置事件 + Latitude string `xml:"Latitude,omitempty"` //地理位置纬度 + Longitude string `xml:"Longitude,omitempty"` //地理位置经度 + Precision string `xml:"Precision,omitempty"` //地理位置精度 + + //仅异步任务完成事件 + JobId string `xml:"JobId,omitempty"` //异步任务id,最大长度为64字符 + JobType string `xml:"JobType,omitempty"` //异步任务,操作类型,字符串,目前分别有:sync_user(增量更新成员)、 replace_user(全量覆盖成员)、invite_user(邀请成员关注)、replace_party(全量覆盖部门) + ErrCode int `xml:"ErrCode,omitempty"` //异步任务,返回码 + ErrMsg string `xml:"ErrMsg,omitempty"` //异步任务,对返回码的文本描述内容 + + //开启通讯录回调通知 https://open.work.weixin.qq.com/api/doc/90000/90135/90967 + UserID string `xml:"UserID,omitempty"` //用户userid + ExternalUserID string `xml:"ExternalUserID,omitempty"` //外部联系人userid + State string `xml:"State,omitempty"` //添加此用户的「联系我」方式配置的state参数,可用于识别添加此用户的渠道 + WelcomeCode string `xml:"WelcomeCode,omitempty"` //欢迎码,当state为1时,该值有效 + Source string `xml:"Source,omitempty"` //删除客户的操作来源,DELETE_BY_TRANSFER表示此客户是因在职继承自动被转接成员删除 + + // todo 第三方平台相关 字段名可能不准确 + /*InfoType InfoType `xml:"InfoType"` + AppID string `xml:"AppId"` + ComponentVerifyTicket string `xml:"ComponentVerifyTicket"` + AuthorizerAppid string `xml:"AuthorizerAppid"` + AuthorizationCode string `xml:"AuthorizationCode"``````````````````````````````````````` + AuthorizationCodeExpiredTime int64 `xml:"AuthorizationCodeExpiredTime"` + PreAuthCode string `xml:"PreAuthCode"`*/ + + //设备相关 + device.MsgDevice +} + +// EventPic 发图事件推送 +type EventPic struct { + PicMd5Sum string `xml:"PicMd5Sum"` +} + +// EncryptedXMLMsg 安全模式下的消息体 +type EncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + ToUserName string `xml:"ToUserName" json:"ToUserName"` + AgentID string `xml:"AgentID" json:"AgentID"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` +} + +// ResponseEncryptedXMLMsg 需要返回的消息体 +type ResponseEncryptedXMLMsg struct { + XMLName struct{} `xml:"xml" json:"-"` + EncryptedMsg string `xml:"Encrypt" json:"Encrypt"` + MsgSignature string `xml:"MsgSignature" json:"MsgSignature"` + Timestamp int64 `xml:"TimeStamp" json:"TimeStamp"` + Nonce string `xml:"Nonce" json:"Nonce"` +} + +// CDATA 使用该类型,在序列化为 xml 文本时文本会被解析器忽略 +type CDATA string + +// MarshalXML 实现自己的序列化方法 +func (c CDATA) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + return e.EncodeElement(struct { + string `xml:",cdata"` + }{string(c)}, start) +} + +// CommonToken 消息中通用的结构 +type CommonToken struct { + XMLName xml.Name `xml:"xml"` + ToUserName CDATA `xml:"ToUserName"` + FromUserName CDATA `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType MsgType `xml:"MsgType"` +} + +// SetToUserName set ToUserName +func (msg *CommonToken) SetToUserName(toUserName CDATA) { + msg.ToUserName = toUserName +} + +// SetFromUserName set FromUserName +func (msg *CommonToken) SetFromUserName(fromUserName CDATA) { + msg.FromUserName = fromUserName +} + +// SetCreateTime set createTime +func (msg *CommonToken) SetCreateTime(createTime int64) { + msg.CreateTime = createTime +} + +// SetMsgType set MsgType +func (msg *CommonToken) SetMsgType(msgType MsgType) { + msg.MsgType = msgType +} + +// GetOpenID get the FromUserName value +func (msg *CommonToken) GetOpenID() string { + return string(msg.FromUserName) +} diff --git a/work/message/reply.go b/work/message/reply.go new file mode 100644 index 0000000..a6e4dc7 --- /dev/null +++ b/work/message/reply.go @@ -0,0 +1,15 @@ +package message + +import "errors" + +// ErrInvalidReply 无效的回复 +var ErrInvalidReply = errors.New("无效的回复消息") + +// ErrUnsupportReply 不支持的回复类型 +var ErrUnsupportReply = errors.New("无需回复消息") + +// Reply 消息回复 +type Reply struct { + MsgType MsgType + MsgData interface{} +} diff --git a/work/message/template_card.go b/work/message/template_card.go new file mode 100644 index 0000000..6b0e0de --- /dev/null +++ b/work/message/template_card.go @@ -0,0 +1,129 @@ +package message + +import ( + "encoding/json" + "fmt" + "github.com/silenceper/wechat/v2/util" +) + +const ( + messageUpdateTemplateCardURL = "https://api.weixin.qq.com/cgi-bin/message/update_template_card" + messageDelURL = "https://api.weixin.qq.com/cgi-bin/message/recall" +) + +// UpdateButton 模板卡片按钮 +type UpdateButton struct { + //CommonToken `json:"-"` + Button struct { + ReplaceName string `xml:"ReplaceName" json:"replace_name"` + } `xml:"Button" json:"button"` +} + +// NewUpdateButton 更新点击用户的按钮文案 +func NewUpdateButton(replaceName string) *UpdateButton { + btn := new(UpdateButton) + btn.Button.ReplaceName = replaceName + return btn +} + +// TemplateCard 被动回复模板卡片 +// https://open.work.weixin.qq.com/api/doc/90000/90135/90241 +type TemplateCard struct { + //CommonToken `json:"-"` + TemplateCard interface{} `xml:"TemplateCard" json:"template_card"` +} + +// NewTemplateCard 更新点击用户的整张卡片 +func NewTemplateCard(cardXml interface{}) *TemplateCard { + card := new(TemplateCard) + card.TemplateCard = cardXml + return card +} + +type PushFile struct { + MediaID string `json:"media_id"` +} +type PushTextCard struct { + Title string `json:"title"` + Description string `json:"description"` + Url string `json:"url"` + Btntxt string `json:"btntxt"` +} + +type resTemplateSend struct { + util.CommonError + Invaliduser string `json:"invaliduser"` //不合法的userid,不区分大小写,统一转为小写 + Invalidparty string `json:"invalidparty"` //不合法的partyid + Invalidtag string `json:"invalidtag"` //不合法的标签id + MsgID string `json:"msgid"` //消息id,用于撤回应用消息 + ResponseCode string `json:"response_code"` //仅消息类型为“按钮交互型”,“投票选择型”和“多项选择型”的模板卡片消息返回,应用可使用response_code调用更新模版卡片消息接口,24小时内有效,且只能使用一次 +} + +// TemplateUpdate 更新模版卡片消息内容 +type TemplateUpdate struct { + Userids []string `json:"userids"` + Partyids []int `json:"partyids"` + Tagids []int `json:"tagids"` + Atall int `json:"atall"` + Agentid int `json:"agentid"` + ResponseCode string `json:"response_code"` + *UpdateButton + *TemplateCard +} + +// UpdateTemplate 更新模版卡片消息 +func (r *Client) UpdateTemplate(msg *TemplateUpdate) (msgID string, err error) { + var accessToken string + accessToken, err = r.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageUpdateTemplateCardURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, msg) + if err != nil { + return + } + var result resTemplateSend + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + msgID = result.MsgID + return +} + +type ReqRecall struct { + MsgID int64 `json:"msgid"` +} + +// Recall 撤回应用消息 +func (r *Client) Recall(msgID int64) (err error) { + var accessToken string + accessToken, err = r.GetAccessToken() + if err != nil { + return + } + uri := fmt.Sprintf("%s?access_token=%s", messageDelURL, accessToken) + var response []byte + response, err = util.PostJSON(uri, &ReqRecall{ + MsgID: msgID, + }) + if err != nil { + return + } + var result util.CommonError + err = json.Unmarshal(response, &result) + if err != nil { + return + } + if result.ErrCode != 0 { + err = fmt.Errorf("template msg send error : errcode=%v , errmsg=%v", result.ErrCode, result.ErrMsg) + return + } + return +} diff --git a/work/server/error.go b/work/server/error.go new file mode 100644 index 0000000..6d8100f --- /dev/null +++ b/work/server/error.go @@ -0,0 +1,66 @@ +package server + +import ( + "reflect" + "strings" +) + +// Error 错误 +type Error string + +const ( + SDKValidateSignatureError Error = "签名验证错误" //-40001 + SDKParseJsonError Error = "xml/json解析失败" //-40002 + SDKComputeSignatureError Error = "sha加密生成签名失败" //-40003 + SDKIllegalAesKey Error = "AESKey 非法" //-40004 + SDKValidateCorpidError Error = "ReceiveId 校验错误" //-40005 + SDKEncryptAESError Error = "AES 加密失败" //-40006 + SDKDecryptAESError Error = "AES 解密失败" //-40007 + SDKIllegalBuffer Error = "解密后得到的buffer非法" //-40008 + SDKEncodeBase64Error Error = "base64加密失败" //-40009 + SDKDecodeBase64Error Error = "base64解密失败" //-40010 + SDKGenJsonError Error = "生成xml/json失败" //-40011 + SDKIllegalProtocolType Error = "协议类型非法" //-40012 + SDKUnknownError Error = "未知错误" +) + +// Error 输出错误信息 +func (r Error) Error() string { + return reflect.ValueOf(r).String() +} + +// NewSDKErr 初始化SDK实例错误信息 +func NewSDKErr(code int64, msgList ...string) Error { + switch code { + case 40001: + return SDKValidateSignatureError + case 40002: + return SDKParseJsonError + case 40003: + return SDKComputeSignatureError + case 40004: + return SDKIllegalAesKey + case 40005: + return SDKValidateCorpidError + case 40006: + return SDKEncryptAESError + case 40007: + return SDKDecryptAESError + case 40008: + return SDKIllegalBuffer + case 40009: + return SDKEncodeBase64Error + case 40010: + return SDKDecodeBase64Error + case 40011: + return SDKGenJsonError + case 40012: + return SDKIllegalProtocolType + default: + //返回未知的自定义错误 + if len(msgList) > 0 { + return Error(strings.Join(msgList, ",")) + } + return SDKUnknownError + } +} diff --git a/work/server/server.go b/work/server/server.go new file mode 100644 index 0000000..3380011 --- /dev/null +++ b/work/server/server.go @@ -0,0 +1,228 @@ +package server + +import ( + "encoding/xml" + "errors" + "fmt" + "net/http" + "reflect" + "runtime/debug" + "strconv" + + "github.com/silenceper/wechat/v2/work/context" + "github.com/silenceper/wechat/v2/work/message" + log "github.com/sirupsen/logrus" + + "github.com/silenceper/wechat/v2/util" +) + +// Server struct +type Server struct { + *context.Context + Writer http.ResponseWriter + Request *http.Request + + skipValidate bool + + messageHandler func(*message.MixMessage) *message.Reply + + RequestRawXMLMsg []byte + RequestMsg *message.MixMessage + ResponseRawXMLMsg []byte + ResponseMsg interface{} + + random []byte + nonce string + timestamp int64 +} + +// NewServer 实例化 +func NewServer(context *context.Context) *Server { + srv := new(Server) + srv.Context = context + return srv +} + +func (srv *Server) VerifyURL() (string, error) { + timestamp := srv.Query("timestamp") + nonce := srv.Query("nonce") + signature := srv.Query("msg_signature") + echoStr := srv.Query("echostr") + if signature != util.Signature(srv.Token, timestamp, nonce, echoStr) { + return "", NewSDKErr(40001) + } + _, bData, err := util.DecryptMsg(srv.CorpID, echoStr, srv.EncodingAESKey) + if err != nil { + return "", NewSDKErr(40002) + } + + return string(bData), nil +} + +// SkipValidate set skip validate +func (srv *Server) SkipValidate(skip bool) { + srv.skipValidate = skip +} + +// Serve 处理企业微信的请求消息 +func (srv *Server) Serve() error { + response, err := srv.handleRequest() + if err != nil { + return err + } + + //debug print request msg + log.Debugf("request msg =%s", string(srv.RequestRawXMLMsg)) + return srv.buildResponse(response) +} + +// Validate 校验请求是否合法 +func (srv *Server) Validate() bool { + if srv.skipValidate { + return true + } + timestamp := srv.Query("timestamp") + nonce := srv.Query("nonce") + signature := srv.Query("msg_signature") + log.Debugf("validate signature, timestamp=%s, nonce=%s", timestamp, nonce) + return signature == util.Signature(srv.Token, timestamp, nonce) +} + +// HandleRequest 处理企业微信的请求 +func (srv *Server) handleRequest() (reply *message.Reply, err error) { + + var msg interface{} + msg, err = srv.getMessage() + if err != nil { + return + } + mixMessage, success := msg.(*message.MixMessage) + if !success { + err = errors.New("消息类型转换失败") + } + srv.RequestMsg = mixMessage + reply = srv.messageHandler(mixMessage) + return +} + +// getMessage 解析企业微信返回的消息 +func (srv *Server) getMessage() (interface{}, error) { + var rawXMLMsgBytes []byte + var err error + + var encryptedXMLMsg message.EncryptedXMLMsg + if err := xml.NewDecoder(srv.Request.Body).Decode(&encryptedXMLMsg); err != nil { + return nil, fmt.Errorf("从body中解析xml失败,err=%v", err) + } + + //验证消息签名 + timestamp := srv.Query("timestamp") + srv.timestamp, err = strconv.ParseInt(timestamp, 10, 32) + if err != nil { + return nil, err + } + nonce := srv.Query("nonce") + srv.nonce = nonce + msgSignature := srv.Query("msg_signature") + msgSignatureGen := util.Signature(srv.Token, timestamp, nonce, encryptedXMLMsg.EncryptedMsg) + if msgSignature != msgSignatureGen { + return nil, fmt.Errorf("消息不合法,验证签名失败") + } + + //解密 + srv.random, rawXMLMsgBytes, err = util.DecryptMsg(srv.CorpID, encryptedXMLMsg.EncryptedMsg, srv.EncodingAESKey) + if err != nil { + return nil, fmt.Errorf("消息解密失败, err=%v", err) + } + + srv.RequestRawXMLMsg = rawXMLMsgBytes + return srv.parseRequestMessage(rawXMLMsgBytes) +} + +func (srv *Server) parseRequestMessage(rawXMLMsgBytes []byte) (msg *message.MixMessage, err error) { + msg = &message.MixMessage{} + err = xml.Unmarshal(rawXMLMsgBytes, msg) + return +} + +// SetMessageHandler 设置用户自定义的回调方法 +func (srv *Server) SetMessageHandler(handler func(*message.MixMessage) *message.Reply) { + srv.messageHandler = handler +} + +func (srv *Server) buildResponse(reply *message.Reply) (err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("panic error: %v\n%s", e, debug.Stack()) + } + }() + if reply == nil { + //do nothing + return nil + } + msgType := reply.MsgType + switch msgType { + case message.MsgTypeText: + case message.MsgTypeImage: + case message.MsgTypeVoice: + case message.MsgTypeVideo: + case message.MsgTypeNews: + case message.MsgTypeUpdateButton: + case message.MsgTypeUpdateTemplateCard: + default: + err = message.ErrUnsupportReply + return + } + + msgData := reply.MsgData + value := reflect.ValueOf(msgData) + //msgData must be a ptr + kind := value.Kind().String() + if kind != "ptr" { + return message.ErrUnsupportReply + } + + params := make([]reflect.Value, 1) + params[0] = reflect.ValueOf(srv.RequestMsg.FromUserName) + value.MethodByName("SetToUserName").Call(params) + + params[0] = reflect.ValueOf(srv.RequestMsg.ToUserName) + value.MethodByName("SetFromUserName").Call(params) + + params[0] = reflect.ValueOf(msgType) + value.MethodByName("SetMsgType").Call(params) + + params[0] = reflect.ValueOf(util.GetCurrTS()) + value.MethodByName("SetCreateTime").Call(params) + srv.ResponseMsg = msgData + srv.ResponseRawXMLMsg, err = xml.Marshal(msgData) + return +} + +// Send 将自定义的消息发送 +func (srv *Server) Send() (err error) { + replyMsg := srv.ResponseMsg + log.Debugf("response msg =%+v", replyMsg) + + //安全模式下对消息进行加密 + var encryptedMsg []byte + encryptedMsg, err = util.EncryptMsg(srv.random, srv.ResponseRawXMLMsg, srv.CorpID, srv.EncodingAESKey) + if err != nil { + return + } + //TODO 如果获取不到timestamp nonce 则自己生成 + timestamp := srv.timestamp + timestampStr := strconv.FormatInt(timestamp, 10) + msgSignature := util.Signature(srv.Token, timestampStr, srv.nonce, string(encryptedMsg)) + replyMsg = message.ResponseEncryptedXMLMsg{ + EncryptedMsg: string(encryptedMsg), + MsgSignature: msgSignature, + Timestamp: timestamp, + Nonce: srv.nonce, + } + + if replyMsg != nil { + srv.XML(replyMsg) + } + return +} diff --git a/work/server/util.go b/work/server/util.go new file mode 100644 index 0000000..574d014 --- /dev/null +++ b/work/server/util.go @@ -0,0 +1,58 @@ +package server + +import ( + "encoding/xml" + "net/http" +) + +var xmlContentType = []string{"application/xml; charset=utf-8"} +var plainContentType = []string{"text/plain; charset=utf-8"} + +func writeContextType(w http.ResponseWriter, value []string) { + header := w.Header() + if val := header["Content-Type"]; len(val) == 0 { + header["Content-Type"] = value + } +} + +// Render render from bytes +func (srv *Server) Render(bytes []byte) { + //debug + //fmt.Println("response msg = ", string(bytes)) + srv.Writer.WriteHeader(200) + _, err := srv.Writer.Write(bytes) + if err != nil { + panic(err) + } +} + +// String render from string +func (srv *Server) String(str string) { + writeContextType(srv.Writer, plainContentType) + srv.Render([]byte(str)) +} + +// XML render to xml +func (srv *Server) XML(obj interface{}) { + writeContextType(srv.Writer, xmlContentType) + bytes, err := xml.Marshal(obj) + if err != nil { + panic(err) + } + srv.Render(bytes) +} + +// Query returns the keyed url query value if it exists +func (srv *Server) Query(key string) string { + value, _ := srv.GetQuery(key) + return value +} + +// GetQuery is like Query(), it returns the keyed url query value +func (srv *Server) GetQuery(key string) (string, bool) { + req := srv.Request + if values, ok := req.URL.Query()[key]; ok && len(values) > 0 { + return values[0], true + } + return "", false +} diff --git a/work/work.go b/work/work.go index 60fbf47..1c79a89 100644 --- a/work/work.go +++ b/work/work.go @@ -16,6 +16,8 @@ import ( "github.com/silenceper/wechat/v2/work/msgaudit" "github.com/silenceper/wechat/v2/work/oauth" "github.com/silenceper/wechat/v2/work/robot" + "github.com/silenceper/wechat/v2/work/server" + "net/http" ) // Work 企业微信 @@ -97,3 +99,11 @@ func (wk *Work) GetCheckin() *checkin.Client { func (wk *Work) GetJs() *js.Js { return js.NewJs(wk.ctx) } + +// GetServer 消息管理:接收事件,被动回复消息管理 +func (wk *Work) GetServer(req *http.Request, writer http.ResponseWriter) *server.Server { + srv := server.NewServer(wk.ctx) + srv.Request = req + srv.Writer = writer + return srv +} From 8a044dcd58a454aa3f7f5830157ab886a8c3276e Mon Sep 17 00:00:00 2001 From: wind Date: Fri, 15 Nov 2024 11:25:00 +0800 Subject: [PATCH 22/23] =?UTF-8?q?=E8=87=AA=E7=94=A8=E5=88=86=E6=94=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0180599..2ca1edf 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/silenceper/wechat/v2 +module github.com/hb1707/wechat/v2 go 1.16 From 0f3c9cd77317e1bb37a06b06fc60469e89d0e032 Mon Sep 17 00:00:00 2001 From: wind Date: Fri, 15 Nov 2024 11:51:20 +0800 Subject: [PATCH 23/23] =?UTF-8?q?=E8=BF=98=E5=8E=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 2ca1edf..0180599 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/hb1707/wechat/v2 +module github.com/silenceper/wechat/v2 go 1.16