From bcdb2fa6ca2bdeca5dcfc11d807872af50b2588a Mon Sep 17 00:00:00 2001 From: wind Date: Fri, 27 Sep 2024 19:56:30 +0800 Subject: [PATCH] =?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 +}