├── demo ├── cf1.png ├── api1.png ├── demo.gif ├── demo.png ├── info.png ├── media.png └── demo_text.png ├── go.mod ├── go.sum ├── Makefile ├── api ├── msgMarkdown.go ├── msgText.go ├── msgMpNews.go ├── getToken.go └── sendMsg.go ├── .github └── workflows │ └── release-build.yml ├── LICENSE ├── main.go ├── config └── config.go └── README.md /demo/cf1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/cf1.png -------------------------------------------------------------------------------- /demo/api1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/api1.png -------------------------------------------------------------------------------- /demo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/demo.gif -------------------------------------------------------------------------------- /demo/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/demo.png -------------------------------------------------------------------------------- /demo/info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/info.png -------------------------------------------------------------------------------- /demo/media.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/media.png -------------------------------------------------------------------------------- /demo/demo_text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zyh94946/wx-msg-push-tencent/HEAD/demo/demo_text.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zyh94946/wx-msg-push-tencent 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/grokify/html-strip-tags-go v0.0.1 7 | github.com/tencentyun/scf-go-lib v0.0.0-20200624065115-ba679e2ec9c9 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/grokify/html-strip-tags-go v0.0.1 h1:0fThFwLbW7P/kOiTBs03FsJSV9RM2M/Q/MOnCQxKMo0= 2 | github.com/grokify/html-strip-tags-go v0.0.1/go.mod h1:2Su6romC5/1VXOQMaWL2yb618ARB8iVo6/DR99A6d78= 3 | github.com/tencentyun/scf-go-lib v0.0.0-20200624065115-ba679e2ec9c9 h1:JdeXp/XPi7lBmpQNSUxElMAvwppMlFSiamTtXYRFuUc= 4 | github.com/tencentyun/scf-go-lib v0.0.0-20200624065115-ba679e2ec9c9/go.mod h1:K3DbqPpP2WE/9MWokWWzgFZcbgtMb9Wd5CYk9AAbEN8= 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | NAME := main 2 | BUILD_DIR := build 3 | GOBUILD = CGO_ENABLED=0 $(GO_DIR)go build -ldflags="-s -w -buildid=" -o $(BUILD_DIR) 4 | 5 | .PHONY: build 6 | 7 | build: 8 | @echo "Building ..." 9 | mkdir -p $(BUILD_DIR) 10 | $(GOBUILD)/$(NAME) 11 | @echo "Build success" 12 | 13 | %.zip: % 14 | @zip -du $(NAME)-$@ -j $(BUILD_DIR)/$`) 12 | digest := regBr.ReplaceAllString(opts.Content, "\n") 13 | digest = strip.StripTags(digest) 14 | 15 | return &mpNewsMsg{ 16 | msgPublic: msgPublic{ 17 | ToUser: opts.ToUser, 18 | ToParty: opts.ToParty, 19 | ToTag: opts.ToTag, 20 | AgentId: opts.AgentId, 21 | MsgType: "mpnews", 22 | EnableDuplicateCheck: opts.EnableDuplicateCheck, 23 | DuplicateCheckInterval: opts.DuplicateCheckInterval, 24 | }, 25 | MpNews: &mpNewsArticles{Articles: []*mpNewsArticleItem{{ 26 | Title: opts.Title, 27 | ThumbMediaId: opts.MediaId, 28 | Content: opts.Content, 29 | Digest: digest, 30 | }}}, 31 | } 32 | } 33 | 34 | type mpNewsMsg struct { // 是否必须、说明 35 | msgPublic 36 | MpNews *mpNewsArticles `json:"mpnews"` // 是 图文消息,一个图文消息支持1到8条图文 37 | Safe int `json:"safe,omitempty"` // 否 表示是否是保密消息,0表示可对外分享,1表示不能分享且内容显示水印,2表示仅限在企业内分享,默认为0;注意仅mpnews类型的消息支持safe值为2,其他消息类型不支持 38 | 39 | isRetry bool 40 | } 41 | 42 | type mpNewsArticles struct { 43 | Articles []*mpNewsArticleItem `json:"articles"` 44 | } 45 | 46 | type mpNewsArticleItem struct { 47 | Title string `json:"title"` // 是 标题,不超过128个字节,超过会自动截断(支持id转译) 48 | ThumbMediaId string `json:"thumb_media_id"` // 是 图文消息缩略图的media_id, 可以通过素材管理接口获得。此处thumb_media_id即上传接口返回的media_id 49 | Author string `json:"author,omitempty"` // 否 图文消息的作者,不超过64个字节 50 | ContentSourceUrl string `json:"content_source_url,omitempty"` // 否 图文消息点击“阅读原文”之后的页面链接 51 | Content string `json:"content"` // 是 图文消息的内容,支持html标签,不超过666 K个字节(支持id转译) 52 | Digest string `json:"digest,omitempty"` // 否 图文消息的描述,不超过512个字节,超过会自动截断(支持id转译) 53 | } 54 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log" 7 | "strconv" 8 | ) 9 | import "os" 10 | 11 | var CorpId = os.Getenv("CORP_ID") 12 | var CorpSecret = os.Getenv("CORP_SECRET") 13 | var AgentId, _ = strconv.ParseInt(os.Getenv("AGENT_ID"), 10, 64) 14 | var MediaId = os.Getenv("MEDIA_ID") 15 | var EnableDuplicateCheck = 1 // 是否开启重复消息检查,0表示否,1表示是 16 | var DuplicateCheckInterval = 300 // 重复消息检查的时间间隔,单位秒,最大不超过4小时 17 | 18 | var GetTokenUrl = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET" 19 | var SendMsgUrl = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=ACCESS_TOKEN" 20 | var MsgTypeMpNews = "mpnews" 21 | var MsgTypeText = "text" 22 | var MsgTypeMarkdown = "markdown" 23 | 24 | type ApiRequest struct { 25 | HttpMethod string `json:"httpMethod"` 26 | QueryString map[string]string `json:"queryString"` 27 | Body string `json:"body"` 28 | PathParameters map[string]string `json:"pathParameters"` 29 | } 30 | 31 | type SimpleRequest struct { 32 | Title string `json:"title"` 33 | Content string `json:"content"` 34 | MsgType string `json:"type"` 35 | ToUser string `json:"touser"` 36 | ToParty string `json:"toparty"` 37 | ToTag string `json:"totag"` 38 | } 39 | 40 | func ParseRequest(event map[string]interface{}) (*SimpleRequest, error) { 41 | requestPars := ApiRequest{} 42 | eventJson, _ := json.Marshal(event) 43 | err := json.Unmarshal(eventJson, &requestPars) 44 | log.Println("event:", event) 45 | log.Println("request:", requestPars, "json error:", err) 46 | 47 | request := &SimpleRequest{} 48 | if requestPars.PathParameters["SECRET"] != CorpSecret { 49 | return request, errors.New("Request check fail ") 50 | } 51 | 52 | request.Title = requestPars.QueryString["title"] 53 | request.Content = requestPars.QueryString["content"] 54 | request.MsgType = requestPars.QueryString["type"] 55 | request.ToUser = requestPars.QueryString["touser"] 56 | request.ToParty = requestPars.QueryString["toparty"] 57 | request.ToTag = requestPars.QueryString["totag"] 58 | 59 | if requestPars.HttpMethod == "POST" { 60 | if err := json.Unmarshal([]byte(requestPars.Body), &request); err != nil { 61 | return request, err 62 | } 63 | } 64 | 65 | if request.Content == "" { 66 | return request, errors.New("Request params fail ") 67 | } 68 | 69 | if request.MsgType == "" { 70 | request.MsgType = MsgTypeMpNews 71 | } 72 | 73 | if request.ToUser == "" && request.ToParty == "" && request.ToTag == "" { 74 | request.ToUser = "@all" 75 | } 76 | 77 | return request, nil 78 | } 79 | -------------------------------------------------------------------------------- /api/getToken.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "github.com/zyh94946/wx-msg-push-tencent/config" 10 | "io" 11 | "io/ioutil" 12 | "net/http" 13 | "strconv" 14 | "strings" 15 | "sync" 16 | "time" 17 | ) 18 | 19 | type AccessToken struct { 20 | CorpId string 21 | CorpSecret string 22 | cacheKey string 23 | } 24 | 25 | var instToken = sync.Map{} 26 | 27 | func (at *AccessToken) GetToken(isGetNew bool) (string, error) { 28 | 29 | if isGetNew { 30 | at.expireToken() 31 | } 32 | 33 | if tokenVal := at.getTokenCache(); tokenVal != "" { 34 | return tokenVal, nil 35 | } 36 | 37 | url := config.GetTokenUrl 38 | url = strings.ReplaceAll(url, "ID", at.CorpId) 39 | url = strings.ReplaceAll(url, "SECRET", at.CorpSecret) 40 | 41 | hpClient := http.Client{ 42 | Timeout: 2 * time.Second, 43 | } 44 | 45 | resp, err := hpClient.Get(url) 46 | if err != nil { 47 | return "", errors.New("Get token return error: " + err.Error()) 48 | } 49 | 50 | defer func() { 51 | _ = resp.Body.Close() 52 | }() 53 | 54 | if resp.StatusCode != 200 { 55 | return "", errors.New("Get token return error, http: " + resp.Status) 56 | } 57 | 58 | type tokenResp struct { 59 | ErrCode int64 `json:"errcode"` 60 | ErrMsg string `json:"errmsg"` 61 | AccessToken string `json:"access_token"` 62 | ExpiresIn int64 `json:"expires_in"` 63 | } 64 | tokenReturn := tokenResp{} 65 | body, err := ioutil.ReadAll(resp.Body) 66 | if err := json.NewDecoder(bytes.NewBuffer(body)).Decode(&tokenReturn); err != nil { 67 | return "", errors.New("Token json decode error, body: " + string(body) + " error: " + err.Error()) 68 | } 69 | 70 | if tokenReturn.ErrCode != 0 { 71 | return "", errors.New("Get token return errcode, errcode: " + strconv.FormatInt(tokenReturn.ErrCode, 10) + " errmsg: " + tokenReturn.ErrMsg) 72 | } 73 | 74 | at.setTokenCache(tokenReturn.AccessToken, tokenReturn.ExpiresIn) 75 | 76 | return tokenReturn.AccessToken, nil 77 | 78 | } 79 | 80 | func (at *AccessToken) getTokenCache() string { 81 | 82 | if tokenVal, isOk := instToken.Load(at.getTokenKey()); isOk { 83 | return tokenVal.(string) 84 | } 85 | 86 | return "" 87 | } 88 | 89 | func (at *AccessToken) setTokenCache(tokenVal string, expire int64) { 90 | instToken.Store(at.getTokenKey(), tokenVal) 91 | time.AfterFunc(time.Duration(expire)*time.Second, at.expireToken) 92 | } 93 | 94 | func (at *AccessToken) expireToken() { 95 | instToken.Store(at.getTokenKey(), "") 96 | } 97 | 98 | func (at *AccessToken) getTokenKey() string { 99 | if at.cacheKey != "" { 100 | return at.cacheKey 101 | } 102 | 103 | h := md5.New() 104 | _, _ = io.WriteString(h, at.CorpId+"_"+at.CorpSecret) 105 | at.cacheKey = fmt.Sprintf("%x", h.Sum(nil)) 106 | return at.cacheKey 107 | } 108 | -------------------------------------------------------------------------------- /api/sendMsg.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "github.com/zyh94946/wx-msg-push-tencent/config" 8 | "io/ioutil" 9 | "net/http" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | type AppMsg interface { 16 | sendMsg(msgJson []byte, token string) (err error, msgErrCode int64) 17 | } 18 | 19 | func Send(msg AppMsg, at *AccessToken) error { 20 | 21 | token, err := at.GetToken(false) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | msgJson, err := json.Marshal(msg) 27 | if err != nil { 28 | return errors.New("msg to json err:" + err.Error()) 29 | } 30 | 31 | err, errCode := msg.sendMsg(msgJson, token) 32 | 33 | switch errCode { 34 | case -1, 40014, 42001: // retry 35 | token, err = at.GetToken(true) 36 | if err != nil { 37 | return err 38 | } 39 | err, _ = msg.sendMsg(msgJson, token) 40 | return err 41 | 42 | } 43 | 44 | return err 45 | } 46 | 47 | type msgPublic struct { 48 | ToUser string `json:"touser,omitempty"` // 否 成员ID列表(消息接收者,多个接收者用‘|’分隔,最多支持1000个)。特殊情况:指定为@all,则向关注该企业应用的全部成员发送 49 | ToParty string `json:"toparty,omitempty"` // 否 部门ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数 50 | ToTag string `json:"totag,omitempty"` // 否 标签ID列表,多个接收者用‘|’分隔,最多支持100个。当touser为@all时忽略本参数 51 | MsgType string `json:"msgtype"` // 是 消息类型 52 | AgentId int64 `json:"agentid"` // 是 企业应用的id,整型。企业内部开发,可在应用的设置页面查看;第三方服务商,可通过接口 获取企业授权信息 获取该参数值 53 | EnableIdTrans int `json:"enable_id_trans,omitempty"` // 否 表示是否开启id转译,0表示否,1表示是,默认0 54 | EnableDuplicateCheck int `json:"enable_duplicate_check,omitempty"` // 否 表示是否开启重复消息检查,0表示否,1表示是,默认0 55 | DuplicateCheckInterval int `json:"duplicate_check_interval,omitempty"` // 否 表示是否重复消息检查的时间间隔,默认1800s,最大不超过4小时 56 | } 57 | 58 | type MsgOpts struct { 59 | ToUser string 60 | ToParty string 61 | ToTag string 62 | Title string 63 | Content string 64 | AgentId int64 65 | MediaId string 66 | EnableDuplicateCheck int 67 | DuplicateCheckInterval int 68 | } 69 | 70 | func (mn *msgPublic) sendMsg(msgJson []byte, token string) (error, int64) { 71 | 72 | sendUrl := config.SendMsgUrl 73 | sendUrl = strings.ReplaceAll(sendUrl, "ACCESS_TOKEN", token) 74 | 75 | hpClient := http.Client{ 76 | Timeout: 2 * time.Second, 77 | } 78 | 79 | //log.Println(string(msgJson)) 80 | 81 | bodyType := "application/json;charset=utf-8" 82 | resp, err := hpClient.Post(sendUrl, bodyType, bytes.NewBuffer(msgJson)) 83 | if err != nil { 84 | return errors.New("Send msg return error: " + err.Error()), 0 85 | } 86 | 87 | defer func() { 88 | _ = resp.Body.Close() 89 | }() 90 | 91 | if resp.StatusCode != 200 { 92 | return errors.New("Send msg return error, http: " + resp.Status), 0 93 | } 94 | 95 | type sendMsgResp struct { 96 | ErrCode int64 `json:"errcode"` 97 | ErrMsg string `json:"errmsg"` 98 | } 99 | sendMsgReturn := sendMsgResp{} 100 | body, err := ioutil.ReadAll(resp.Body) 101 | if err := json.NewDecoder(bytes.NewBuffer(body)).Decode(&sendMsgReturn); err != nil { 102 | return errors.New("Send msg json decode error, body: " + string(body) + " error: " + err.Error()), 0 103 | } 104 | 105 | switch sendMsgReturn.ErrCode { 106 | case 0: 107 | return nil, 0 108 | } 109 | 110 | return errors.New("Send msg return errcode, errcode: " + strconv.FormatInt(sendMsgReturn.ErrCode, 10) + " errmsg: " + sendMsgReturn.ErrMsg), sendMsgReturn.ErrCode 111 | 112 | } 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # 基于腾讯云Serverless实现的企业微信应用消息推送服务 3 | 4 | ## Serverless 云函数 计费模式和免费额度于 2022 年 6 月 1 日进行调整,请参考[免费额度](https://cloud.tencent.com/document/product/583/12282) 5 | 6 | ~~Serverless 云函数目前每月有免费资源使用量40万GBs、免费调用次数100万次~~ 7 | 8 | API网关目前开通即送时长12个月100万次免费额度 9 | 10 | 个人或者低频率使用完全够了,可以通过 GET、POST 方式调用发消息。 11 | 12 | 对于有服务器、域名资源,~~通过简单修改~~也可以直接部署到服务器上。 13 | 14 | **独立部署版见 [wx-msg-push](https://github.com/zyh94946/wx-msg-push)** 15 | 16 | ## 消息效果 17 | 18 |
19 | 点击展开 20 | 21 |
22 | 23 | 不用安装企业微信App,直接通过微信App关注微信插件即可实现在微信App中接收应用消息,还可以选择消息免打扰。 24 | 25 | ## 消息限制 26 | 27 | 目前支持发送的应用消息类型为: 28 | 29 | 1. 图文消息(mpnews),消息内容支持html标签,不超过666K个字节,会自动生成摘要替换br标签为换行符,过滤html标签。 30 | 2. 文本消息(text),消息内容最长不超过2048个字节,超过将截断。 31 | 3. markdown消息(markdown),markdown内容,最长不超过2048个字节,必须是utf8编码。([支持的markdown语法](https://work.weixin.qq.com/api/doc/90000/90135/90236#%E6%94%AF%E6%8C%81%E7%9A%84markdown%E8%AF%AD%E6%B3%95)) 32 | 33 | 每企业消息发送不可超过帐号上限数*30人次/天(注:若调用api一次发给1000人,算1000人次;若企业帐号上限是500人,则每天可发送15000人次的消息) 34 | 35 | 每应用对同一个成员不可超过30条/分,超过部分会被丢弃不下发 36 | 37 | 默认已启用重复消息推送检查5分钟内同样内容的消息,不会重复收到,可修改 `EnableDuplicateCheck` `DuplicateCheckInterval` 调整是否开启与时间间隔。 38 | 39 | ## 部署方式 40 | 41 | 首先注册[企业微信](https://work.weixin.qq.com/) 42 | 43 | ### 创建应用 44 | 45 | 登录[企业微信web管理](https://work.weixin.qq.com/) 46 | 47 | 进入 `应用管理` , `创建应用` ,完成后复制下 `AgentId` `Secret` 。 48 | 49 | (如果仅使用文本消息可跳过此步) 进入 `管理工具` , `素材库` , `图片` , `添加图片` (这个图片是图文消息的展示图),上传成功后在图片下载按钮上复制下载地址 50 | 51 | 52 | 53 | (如果仅使用文本消息可跳过此步) 把url的 `media_id` 值复制下备用 54 | 55 | 进入 `我的企业` ,把 `企业ID` 复制下,进入 `微信插件` ,用微信APP扫 `邀请关注` 的二维码码即可在微信App中查看企业微信消息。 56 | 57 | 58 | 59 | ### 注册腾讯云账号 60 | 61 | [腾讯云](https://cloud.tencent.com/) 62 | 63 | ### 新建云函数 64 | 65 | [云函数](https://console.cloud.tencent.com/scf/index) 66 | 67 | 如果想要绑定域名的话可以选择香港地区,免备案。 68 | 69 | 从 releases 中下载 `linux-amd64` 的预编译 zip 包。 70 | 71 | 选择 `自定义创建` , `运行环境` `Go1` , `提交方法` 选择 `本地上传zip包` 选择刚才的 zip 包。 72 | 73 | 74 | 75 | `高级配置` 中增加 `环境变量` 76 | 77 | - CORP_ID 企业微信 企业id 78 | - CORP_SECRET 企业微信 应用Secret 79 | - AGENT_ID 企业微信 应用AgentId 80 | - MEDIA_ID 企业微信 图片素材的media_id(如果仅使用文本消息可随意填写) 81 | 82 | `触发器` 配置选择 `自定义配置` ,触发方式选择 `API网关触发` 83 | 84 | 点击 `完成` 开始创建 85 | 86 | ### 设置API网关 87 | 88 | [Api网关](https://console.cloud.tencent.com/apigateway/service) 89 | 90 | 进入 `API网关` 服务列表,选择 `配置管理` ,然后 `管理API` 点 `编辑` 91 | 92 | 93 | 94 | 增加 `参数配置` ,参数名 `SECRET` ,参数位置 `path`,类型 `string` 95 | 96 | 路径修改为 `/你的云函数名称/{SECRET}` 97 | 98 | 然后点 `立即完成` 发布服务 99 | 100 | 在 `基础配置` 中复制 `公网访问地址` ,想要绑定域名可以在 `自定义域名` 中绑定,CNAME指到API网关的二级域名,自定义路径映射 `/` 路径 到 `发布` 环境。 101 | 102 | ## 使用方法 103 | 104 | 消息类型值:`text` 代表文本消息,`mpnews` 代表图文消息,`markdown` 代表 markdown 消息。为兼容旧版本,不传默认为图文消息。 105 | 106 | 支持推送消息至指定的 `touser`, `toparty`, `totag`。不传默认设置 `touser=@all` 107 | 108 | GET方式 109 | 110 | `https://你的Api网关域名/你的云函数名称/CORP_SECRET?title=消息标题&content=消息内容&type=消息类型` 111 | 112 | POST方式 113 | 114 | ```bash 115 | $ curl --location --request POST 'https://你的Api网关域名/你的云函数名称/CORP_SECRET' \ 116 | --header 'Content-Type: application/json;charset=utf-8' \ 117 | --data-raw '{"title":"消息标题","content":"消息内容","type":"消息类型"}' 118 | ``` 119 | 120 | 发送成功状态码返回200,`"Content-Type":"application/json"` body `{"errorCode":0,"errorMessage":""}` 。 121 | 122 | ## 其它 123 | 124 | 发送失败问题排查: 125 | 126 | - 请检查云函数环境变量是否正确 127 | - 请检查发送url中CORP_SECRET是否正确 128 | - 请检查Api网关SECRET参数是否设置 129 | - 进入云函数后台查看请求日志的具体错误原因 130 | 131 | ## 更新记录 132 | 133 | - 2021-04-11 支持文本消息,优化代码结构方便新增其它消息类型。 134 | - 2021-04-29 支持推送消息至指定的`touser`, `toparty`, `totag`。 135 | - 2021-09-13 支持markdown消息类型。 136 | --------------------------------------------------------------------------------