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/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/util/signature.go b/util/signature.go index 2deb8e2..80ac8f0 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/externalcontact/add_msg_template.go b/work/externalcontact/add_msg_template.go new file mode 100644 index 0000000..0f3b200 --- /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 (r *Client) Send(msg *ReqMessage) (msgID int64, err error) { + var accessToken string + accessToken, err = r.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/user.go b/work/externalcontact/user.go new file mode 100644 index 0000000..60b29f8 --- /dev/null +++ b/work/externalcontact/user.go @@ -0,0 +1,162 @@ +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,个人标签将不再返回 +} + +// GetUseridList 获取我的客户列表 +func (tpl *Client) GetUseridList(myUserid string) (externalUserid []string, err error) { + var accessToken string + accessToken, err = tpl.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.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.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/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/message/README.md b/work/message/README.md new file mode 100644 index 0000000..e69de29 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 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 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/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 new file mode 100644 index 0000000..8f53903 --- /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/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/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/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..5f0298a --- /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 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") + 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..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..e69de29 diff --git a/work/user/user.go b/work/user/user.go new file mode 100644 index 0000000..93d9012 --- /dev/null +++ b/work/user/user.go @@ -0,0 +1,194 @@ +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" +) + +// 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 + } + } +} diff --git a/work/work.go b/work/work.go index 69a2bae..2368263 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 +}