├── 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)/$*
15 | @echo "<<< ---- $(NAME)-$@"
16 |
17 | release: linux-amd64.zip
18 |
19 | linux-amd64:
20 | mkdir -p $(BUILD_DIR)/$@
21 | GOARCH=amd64 GOOS=linux $(GOBUILD)/$@/$(NAME)
--------------------------------------------------------------------------------
/api/msgMarkdown.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | // markdown消息
4 | func NewMarkdown(opts *MsgOpts) *markdownMsg {
5 | return &markdownMsg{
6 | msgPublic: msgPublic{
7 | ToUser: opts.ToUser,
8 | ToParty: opts.ToParty,
9 | ToTag: opts.ToTag,
10 | AgentId: opts.AgentId,
11 | MsgType: "markdown",
12 | EnableDuplicateCheck: opts.EnableDuplicateCheck,
13 | DuplicateCheckInterval: opts.DuplicateCheckInterval,
14 | },
15 | Markdown: markdownContent{
16 | Content: opts.Content,
17 | },
18 | }
19 | }
20 |
21 | type markdownMsg struct { // 是否必须、说明
22 | msgPublic
23 | Markdown markdownContent `json:"markdown"` // 是
24 | }
25 |
26 | type markdownContent struct {
27 | Content string `json:"content"` // 是 markdown内容,最长不超过2048个字节,必须是utf8编码
28 | }
29 |
--------------------------------------------------------------------------------
/api/msgText.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | // 文本消息
4 | func NewText(opts *MsgOpts) *textMsg {
5 | return &textMsg{
6 | msgPublic: msgPublic{
7 | ToUser: opts.ToUser,
8 | ToParty: opts.ToParty,
9 | ToTag: opts.ToTag,
10 | AgentId: opts.AgentId,
11 | MsgType: "text",
12 | EnableDuplicateCheck: opts.EnableDuplicateCheck,
13 | DuplicateCheckInterval: opts.DuplicateCheckInterval,
14 | },
15 | Text: textContent{
16 | Content: opts.Content,
17 | },
18 | }
19 | }
20 |
21 | type textMsg struct { // 是否必须、说明
22 | msgPublic
23 | Text textContent `json:"text"` // 是
24 | Safe int `json:"safe,omitempty"` // 否 表示是否是保密消息,0表示可对外分享,1表示不能分享且内容显示水印,2表示仅限在企业内分享,默认为0;注意仅mpnews类型的消息支持safe值为2,其他消息类型不支持
25 | }
26 |
27 | type textContent struct {
28 | Content string `json:"content"` // 是 消息内容,最长不超过2048个字节,超过将截断(支持id转译)
29 | }
30 |
--------------------------------------------------------------------------------
/.github/workflows/release-build.yml:
--------------------------------------------------------------------------------
1 | name: release-build
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 |
8 | jobs:
9 |
10 | build:
11 | runs-on: ubuntu-latest
12 | steps:
13 |
14 | - name: Install Go
15 | uses: actions/setup-go@v2
16 | with:
17 | go-version: 1.15
18 |
19 | - name: Checkout code
20 | uses: actions/checkout@v2
21 |
22 | - name: Checkout tag
23 | run: |
24 | git fetch --depth=1 origin +refs/tags/*:refs/tags/*
25 | tag_name="${GITHUB_REF##*/}"
26 | echo Tag $tag_name
27 | git checkout $tag_name
28 |
29 | - name: Build
30 | run: |
31 | make release -j$(nproc)
32 |
33 | - name: Release
34 | uses: softprops/action-gh-release@v1
35 | if: startsWith(github.ref, 'refs/tags/')
36 | with:
37 | files: main-*.zip
38 | env:
39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 since20l8
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "github.com/tencentyun/scf-go-lib/cloudfunction"
6 | "github.com/tencentyun/scf-go-lib/events"
7 | "github.com/zyh94946/wx-msg-push-tencent/api"
8 | "github.com/zyh94946/wx-msg-push-tencent/config"
9 | "log"
10 | )
11 |
12 | var resp = events.APIGatewayResponse{
13 | IsBase64Encoded: false,
14 | StatusCode: 200,
15 | Headers: map[string]string{"Content-Type": "application/json"},
16 | Body: `{"errorCode":0,"errorMessage":""}`,
17 | }
18 |
19 | func process(ctx context.Context, event map[string]interface{}) (events.APIGatewayResponse, error) {
20 |
21 | request, err := config.ParseRequest(event)
22 | if err != nil {
23 | log.Println(err)
24 | return resp, err
25 | }
26 |
27 | at := &api.AccessToken{
28 | CorpId: config.CorpId,
29 | CorpSecret: config.CorpSecret,
30 | }
31 |
32 | var appMsg api.AppMsg
33 | opts := &api.MsgOpts{
34 | ToUser: request.ToUser,
35 | ToParty: request.ToParty,
36 | ToTag: request.ToTag,
37 | Title: request.Title,
38 | Content: request.Content,
39 | AgentId: config.AgentId,
40 | MediaId: config.MediaId,
41 | EnableDuplicateCheck: config.EnableDuplicateCheck,
42 | DuplicateCheckInterval: config.DuplicateCheckInterval,
43 | }
44 |
45 | switch request.MsgType {
46 | case config.MsgTypeMpNews:
47 | appMsg = api.NewMpNews(opts)
48 |
49 | case config.MsgTypeText:
50 | appMsg = api.NewText(opts)
51 |
52 | case config.MsgTypeMarkdown:
53 | appMsg = api.NewMarkdown(opts)
54 | }
55 |
56 | err = api.Send(appMsg, at)
57 | if err != nil {
58 | log.Println(err)
59 | }
60 |
61 | return resp, err
62 | }
63 |
64 | func main() {
65 | // Make the handler available for Remote Procedure Call by Cloud Function
66 | cloudfunction.Start(process)
67 | }
68 |
--------------------------------------------------------------------------------
/api/msgMpNews.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | strip "github.com/grokify/html-strip-tags-go"
5 | "regexp"
6 | )
7 |
8 | // 图文消息(mpnews)
9 | func NewMpNews(opts *MsgOpts) *mpNewsMsg {
10 | // 将内容中的br标签换成换行符,过滤html标签,生成摘要
11 | regBr, _ := regexp.Compile(`<(?i:br)[\S\s]+?>`)
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 |
--------------------------------------------------------------------------------