├── .travis.yml
├── .gitignore
├── LICENSE
├── README.md
└── weixin.go
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: go
2 |
3 | go:
4 | - 1.2
5 | - tip
6 |
7 | # whitelist
8 | branches:
9 | only:
10 | - master
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled source #
2 | ###################
3 | *.com
4 | *.class
5 | *.dll
6 | *.exe
7 | *.o
8 | *.so
9 |
10 | # Packages #
11 | ############
12 | # it's better to unpack these files and commit the raw source
13 | # git has its own built in compression methods
14 | *.7z
15 | *.dmg
16 | *.gz
17 | *.iso
18 | *.jar
19 | *.rar
20 | *.tar
21 | *.zip
22 |
23 | # Logs and databases #
24 | ######################
25 | *.log
26 | *.sql
27 | *.sqlite
28 |
29 | # OS generated files #
30 | ######################
31 | .DS_Store
32 | .DS_Store?
33 | ._*
34 | .Spotlight-V100
35 | .Trashes
36 | ehthumbs.db
37 | Thumbs.db
38 |
39 | # Generated By Go #
40 | ###################
41 | *.8
42 | *.6
43 | *.out
44 | /_obj
45 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2013 wizjin
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 微信公众平台库 – Go语言版本
2 |
3 | ## 简介
4 |
5 | 这是一个使用Go语言对微信公众平台的封装。参考了微信公众平台API文档
6 |
7 | [](https://travis-ci.org/wizjin/weixin)
8 | [](http://godoc.org/github.com/wizjin/weixin)
9 |
10 | ## 入门
11 |
12 | ### 安装
13 |
14 | 通过执行下列语句就可以完成安装
15 |
16 | go get github.com/wizjin/weixin
17 |
18 | ### 注册微信公众平台
19 |
20 | 注册微信公众平台,填写验证微信公众平台的Token
21 |
22 | ### 示例
23 |
24 | ```Go
25 | package main
26 |
27 | import (
28 | "github.com/wizjin/weixin"
29 | "net/http"
30 | )
31 |
32 | // 文本消息的处理函数
33 | func Echo(w weixin.ResponseWriter, r *weixin.Request) {
34 | txt := r.Content // 获取用户发送的消息
35 | w.ReplyText(txt) // 回复一条文本消息
36 | w.PostText("Post:" + txt) // 发送一条文本消息
37 | }
38 |
39 | // 关注事件的处理函数
40 | func Subscribe(w weixin.ResponseWriter, r *weixin.Request) {
41 | w.ReplyText("欢迎关注") // 有新人关注,返回欢迎消息
42 | }
43 |
44 | func main() {
45 | // my-token 验证微信公众平台的Token
46 | // app-id, app-secret用于高级API调用。
47 | // 如果仅使用接收/回复消息,则可以不填写,使用下面语句
48 | // mux := weixin.New("my-token", "", "")
49 | mux := weixin.New("my-token", "app-id", "app-secret")
50 | // 设置AES密钥,如果不使用AES可以省略这行代码
51 | mux.SetEncodingAESKey("encoding-AES-key")
52 | // 注册文本消息的处理函数
53 | mux.HandleFunc(weixin.MsgTypeText, Echo)
54 | // 注册关注事件的处理函数
55 | mux.HandleFunc(weixin.MsgTypeEventSubscribe, Subscribe)
56 | http.Handle("/", mux) // 注册接收微信服务器数据的接口URI
57 | http.ListenAndServe(":80", nil) // 启动接收微信数据服务器
58 | }
59 | ```
60 |
61 | 微信公众平台要求在收到消息后5秒内回复消息(Reply接口)
62 | 如果时间操作很长,则可以使用Post接口发送消息
63 | 如果只使用Post接口发送消息,则需要先调用ReplyOK来告知微信不用等待回复。
64 |
65 | ### 处理函数
66 |
67 | 处理函数的定义可以使用下面的形式
68 |
69 | ```Go
70 | func Func(w weixin.ResponseWriter, r *weixin.Request) {
71 | ...
72 | }
73 | ```
74 |
75 | 可以注册的处理函数类型有以下几种
76 |
77 | - `weixin.MsgTypeText` 接收文本消息
78 | - `weixin.MsgTypeImage` 接收图片消息
79 | - `weixin.MsgTypeVoice` 接收语音消息
80 | - `weixin.MsgTypeVideo` 接收视频消息
81 | - `weixin.MsgTypeShortVideo` 接收小视频消息
82 | - `weixin.MsgTypeLocation` 接收地理位置消息
83 | - `weixin.MsgTypeLink` 接收链接消息
84 | - `weixin.MsgTypeEventSubscribe` 接收关注事件
85 | - `weixin.MsgTypeEventUnsubscribe` 接收取消关注事件
86 | - `weixin.MsgTypeEventScan` 接收扫描二维码事件
87 | - `weixin.MsgTypeEventView` 接收点击菜单跳转链接时的事件
88 | - `weixin.MsgTypeEventClick` 接收自定义菜单事件
89 | - `weixin.MsgTypeEventLocation` 接收上报地理位置事件
90 | - `weixin.MsgTypeEventTemplateSent` 接收模版消息发送结果
91 |
92 | ### 发送被动响应消息
93 |
94 | 需要发送被动响应消息,可通过`weixin.ResponseWriter`的下列方法完成
95 |
96 | - `ReplyOK()` 无同步消息回复
97 | - `ReplyText(text)` 回复文本消息
98 | - `ReplyImage(mediaId)` 回复图片消息
99 | - `ReplyVoice(mediaId)` 回复语音消息
100 | - `ReplyVideo(mediaId, title, description)` 回复视频消息
101 | - `ReplyMusic(music)` 回复音乐消息
102 | - `ReplyNews(articles)` 回复图文消息
103 |
104 | ### 发送客服消息
105 |
106 | - `PostText(text)` 发送文本消息
107 | - `PostImage(mediaId)` 发送图片消息
108 | - `PostVoice(mediaId)` 发送语音消息
109 | - `PostVideo(mediaId, title, description)` 发送视频消息
110 | - `PostMusic(music)` 发送音乐消息
111 | - `PostNews(articles)` 发送图文消息
112 |
113 | ### 发送模版消息
114 |
115 | 如需要发送模版消息,需要先获取模版ID,之后再根据ID发送。
116 |
117 | ```Go
118 | func GetTemplateId(wx *weixin.Weixin) {
119 | tid, err := wx.AddTemplate("TM00015")
120 | if err != nil {
121 | fmt.Println(err)
122 | } else {
123 | fmt.Println(ips) // 模版ID
124 | }
125 | }
126 | ```
127 |
128 | 随后可以发送模版消息了。
129 |
130 | ```Go
131 | func SendTemplateMessage(w weixin.ResponseWriter, r *weixin.Request) {
132 | templateId := ...
133 | url := ...
134 | msgid, err := w.PostTemplateMessage(templateId, url,
135 | weixin.TmplData{ "first": weixin.TmplItem{"Hello World!", "#173177"}})
136 | if err != nil {
137 | fmt.Println(err)
138 | } else {
139 | fmt.Println(msgid)
140 | }
141 | }
142 | ```
143 |
144 | 在模版消息发送成功后,还会通过类型为`MsgTypeEventTemplateSent`的消息推送获得发送结果。
145 |
146 | - `TemplateSentStatusSuccess` 发送成功
147 | - `TemplateSentStatusUserBlock` 发送失败,用户拒绝接收
148 | - `TemplateSentStatusSystemFailed` 发送失败,系统原因
149 |
150 |
151 | ### 上传/下载多媒体文件
152 |
153 | 使用如下函数可以用来上传多媒体文件:
154 |
155 | `UploadMediaFromFile(mediaType string, filepath string)`
156 |
157 | 示例 (用一张本地图片来返回图片消息):
158 |
159 | ```Go
160 | func ReciveMessage(w weixin.ResponseWriter, r *weixin.Request) {
161 | // 上传本地文件并获取MediaID
162 | mediaId, err := w.UploadMediaFromFile(weixin.MediaTypeImage, "/my-file-path")
163 | if err != nil {
164 | w.ReplyText("上传图片失败")
165 | } else {
166 | w.ReplyImage(mediaId) // 利用获取的MediaId来返回图片消息
167 | }
168 | }
169 | ```
170 |
171 | 使用如下函数可以用来下载多媒体文件:
172 |
173 | `DownloadMediaToFile(mediaId string, filepath string)`
174 |
175 | 示例 (收到一条图片消息,然后保存图片到本地文件):
176 |
177 | ```Go
178 | func ReciveImageMessage(w weixin.ResponseWriter, r *weixin.Request) {
179 | // 下载文件并保存到本地
180 | err := w.DownloadMediaToFile(r.MediaId, "/my-file-path")
181 | if err != nil {
182 | w.ReplyText("保存图片失败")
183 | } else {
184 | w.ReplyText("保存图片成功")
185 | }
186 | }
187 | ```
188 |
189 | ### 获取微信服务器IP地址
190 |
191 | 示例,获取微信服务器IP地址列表
192 |
193 | ```Go
194 | func GetIpList(wx *weixin.Weixin) {
195 | ips, err := wx.GetIpList()
196 | if err != nil {
197 | fmt.Println(err)
198 | } else {
199 | fmt.Println(ips) // Ip地址列表
200 | }
201 | }
202 | ```
203 |
204 | ### 获取AccessToken
205 |
206 | 示例,获取AccessToken
207 |
208 | ```Go
209 | func GetAccessToken(wx *weixin.Weixin) {
210 | a := wx.GetAccessToken
211 | if time.Until(token.Expires).Seconds() > 0 {
212 | fmt.Println(a.Token) // AccessToken
213 | } else {
214 | fmt.Println("Timeout") // 超时
215 | }
216 | }
217 | ```
218 |
219 | ### 创建/换取二维码
220 |
221 | 示例,创建临时二维码
222 |
223 | ```Go
224 | func CreateQRScene(wx *weixin.Weixin) {
225 | // 二维码ID - 1000
226 | // 过期时间 - 1800秒
227 | qr, err := wx.CreateQRScene(1000, 1800)
228 | if err != nil {
229 | fmt.Println(err)
230 | } else {
231 | url := qr.ToURL() // 获取二维码的URL
232 | fmt.Println(url)
233 | }
234 | }
235 | ```
236 |
237 | 示例,创建永久二维码
238 |
239 | ```Go
240 | func CreateQRScene(wx *weixin.Weixin) {
241 | // 二维码ID - 1001
242 | qr, err := wx.CreateQRLimitScene(1001)
243 | if err != nil {
244 | fmt.Println(err)
245 | } else {
246 | url := qr.ToURL() // 获取二维码的URL
247 | fmt.Println(url)
248 | }
249 | }
250 | ```
251 |
252 | ### 长链接转短链接接口
253 |
254 | ```Go
255 | func ShortURL(wx *weixin.Weixin) {
256 | // 长链接转短链接
257 | url, err := wx.ShortURL("http://mp.weixin.qq.com/wiki/10/165c9b15eddcfbd8699ac12b0bd89ae6.html")
258 | if err != nil {
259 | fmt.Println(err)
260 | } else {
261 | fmt.Println(url)
262 | }
263 | }
264 | ```
265 |
266 | ### 自定义菜单
267 |
268 | 示例,创建自定义菜单
269 |
270 | ```Go
271 | func CreateMenu(wx *weixin.Weixin) {
272 | menu := &weixin.Menu{make([]weixin.MenuButton, 2)}
273 | menu.Buttons[0].Name = "我的菜单"
274 | menu.Buttons[0].Type = weixin.MenuButtonTypeUrl
275 | menu.Buttons[0].Url = "https://mp.weixin.qq.com"
276 | menu.Buttons[1].Name = "我的子菜单"
277 | menu.Buttons[1].SubButtons = make([]weixin.MenuButton, 1)
278 | menu.Buttons[1].SubButtons[0].Name = "测试"
279 | menu.Buttons[1].SubButtons[0].Type = weixin.MenuButtonTypeKey
280 | menu.Buttons[1].SubButtons[0].Key = "MyKey001"
281 | err := wx.CreateMenu(menu)
282 | if err != nil {
283 | fmt.Println(err)
284 | }
285 | }
286 | ```
287 |
288 | 自定义菜单的类型有如下几种
289 |
290 | - `weixin.MenuButtonTypeKey` 点击推事件
291 | - `weixin.MenuButtonTypeUrl` 跳转URL
292 | - `weixin.MenuButtonTypeScancodePush` 扫码推事件
293 | - `weixin.MenuButtonTypeScancodeWaitmsg` 扫码推事件且弹出“消息接收中”提示框
294 | - `weixin.MenuButtonTypePicSysphoto` 弹出系统拍照发图
295 | - `weixin.MenuButtonTypePicPhotoOrAlbum` 弹出拍照或者相册发图
296 | - `weixin.MenuButtonTypePicWeixin` 弹出微信相册发图器
297 | - `weixin.MenuButtonTypeLocationSelect` 弹出地理位置选择器
298 | - `weixin.MenuButtonTypeMediaId` 下发消息(除文本消息)
299 | - `weixin.MenuButtonTypeViewLimited` 跳转图文消息URL
300 |
301 | 示例,获取自定义菜单
302 |
303 | ```Go
304 | func DeleteMenu(wx *weixin.Weixin) {
305 | menu, err := wx.GetMenu()
306 | if err != nil {
307 | fmt.Println(err)
308 | } else {
309 | fmt.Println(menu)
310 | }
311 | }
312 | ```
313 |
314 | 示例,删除自定义菜单
315 |
316 | ```Go
317 | func DeleteMenu(wx *weixin.Weixin) {
318 | err := wx.DeleteMenu()
319 | if err != nil {
320 | fmt.Println(err)
321 | }
322 | }
323 | ```
324 |
325 | ### JSSDK签名
326 |
327 | 示例,生成JSSDK签名
328 | ```Go
329 | func SignJSSDK(wx *weixin.Weixin, url string) {
330 | timestamp := time.Now().Unix()
331 | noncestr := fmt.Sprintf("%d", c.randreader.Int())
332 | sign, err := wx.JsSignature(url, timestamp, noncestr)
333 | if err != nil {
334 | fmt.Println(err)
335 | } else {
336 | fmt.Println(sign)
337 | }
338 | }
339 | ```
340 |
341 | ### 重定向URL生成
342 |
343 | 示例,生成重定向URL
344 | ```Go
345 | func CreateRedirect(wx *weixin.Weixin, url string) {
346 | redirect := wx.CreateRedirectURL(url, weixin.RedirectURLScopeBasic, "")
347 | }
348 | ```
349 |
350 | URL的类型有如下几种:
351 |
352 | - `RedirectURLScopeBasic` 基本授权,仅用来获取OpenId或UnionId
353 | - `RedirectURLScopeUserInfo` 高级授权,可以用于获取用户基本信息,需要用户同意
354 |
355 | ### 用户OpenId和UnionId获取
356 |
357 | 示例,获取用户OpenId和UnionId
358 | ```Go
359 | func GetUserId(wx *weixin.Weixin, code string) {
360 | user, err := wx.GetUserAccessToken(code)
361 | if err != nil {
362 | fmt.Println(err)
363 | } else {
364 | fmt.Println(user.OpenId)
365 | fmt.Println(user.UnionId)
366 | }
367 | }
368 | ```
369 |
370 | ### 用户信息获取
371 |
372 | 示例,获取用户信息
373 | ```Go
374 | func GetUserInfo(wx *weixin.Weixin, openid string) {
375 | user, err := wx.GetUserInfo(openid)
376 | if err != nil {
377 | fmt.Println(err)
378 | } else {
379 | fmt.Println(user.Nickname)
380 | fmt.Println(user.Sex)
381 | fmt.Println(user.City)
382 | fmt.Println(user.Country)
383 | fmt.Println(user.Province)
384 | fmt.Println(user.HeadImageUrl)
385 | fmt.Println(user.SubscribeTime)
386 | fmt.Println(user.Remark)
387 | }
388 | }
389 | ```
390 |
391 |
392 | ## 参考连接
393 |
394 | * [Wiki](https://github.com/wizjin/weixin/wiki)
395 | * [API文档](http://godoc.org/github.com/wizjin/weixin)
396 | * [微信公众平台](https://mp.weixin.qq.com)
397 | * [微信公众平台API文档](http://mp.weixin.qq.com/wiki/index.php)
398 |
399 | ## 版权声明
400 |
401 | This project is licensed under the MIT license, see [LICENSE](LICENSE).
402 |
403 | ## 更新日志
404 |
405 | ### Version 0.5.4 - upcoming
406 |
407 | - 用户管理
408 | - 支持AES
409 |
410 | ### Version 0.5.3 - 2016/01/05
411 |
412 | - 添加模版消息送
413 |
414 | ### Version 0.5.2 - 2015/12/05
415 |
416 | - 添加JSSDK签名生成
417 | - 添加重定向URL生成
418 | - 添加获取用户OpenId和UnionId
419 | - 添加获取授权用户信息
420 |
421 | ### Version 0.5.1 - 2015/06/26
422 |
423 | - 获取微信服务器IP地址
424 | - 接收小视频消息
425 |
426 | ### Version 0.5.0 - 2015/06/25
427 |
428 | - 自定义菜单
429 | - 长链接转短链接
430 |
431 | ### Version 0.4.1 - 2014/09/07
432 |
433 | - 添加将消息转发到多客服功能
434 |
435 | ### Version 0.4.0 - 2014/02/07
436 |
437 | - 创建/换取二维码
438 |
439 | ### Version 0.3.0 - 2014/01/07
440 |
441 | - 多媒体文件处理:上传/下载多媒体文件
442 |
443 | ### Version 0.2.0 - 2013/12/19
444 |
445 | - 发送客服消息:文本消息,图片消息,语音消息,视频消息,音乐消息,图文消息
446 |
447 | ### Version 0.1.0 – 2013/12/17
448 |
449 | - Token验证URL有效性
450 | - 接收普通消息:文本消息,图片消息,语音消息,视频消息,地理位置消息,链接消息
451 | - 接收事件推送:关注/取消关注,扫描二维码事件,上报地理位置,自定义菜单
452 | - 发送被动响应消息:文本消息,图片消息,语音消息,视频消息,音乐消息,图文消息
453 |
--------------------------------------------------------------------------------
/weixin.go:
--------------------------------------------------------------------------------
1 | // Package weixin MP SDK (Golang)
2 | package weixin
3 |
4 | import (
5 | "bytes"
6 | "crypto/aes"
7 | "crypto/cipher"
8 | "crypto/sha1"
9 | "encoding/base64"
10 | "encoding/binary"
11 | "encoding/json"
12 | "encoding/xml"
13 | "errors"
14 | "fmt"
15 | "io"
16 | "io/ioutil"
17 | "log"
18 | "mime/multipart"
19 | "net/http"
20 | "net/url"
21 | "os"
22 | "path/filepath"
23 | "regexp"
24 | "sort"
25 | "strings"
26 | "sync/atomic"
27 | "time"
28 | )
29 |
30 | // nolint
31 | const (
32 | // Event type
33 | msgEvent = "event"
34 |
35 | EventSubscribe = "subscribe"
36 | EventUnsubscribe = "unsubscribe"
37 | EventScan = "SCAN"
38 | EventView = "VIEW"
39 | EventClick = "CLICK"
40 | EventLocation = "LOCATION"
41 | EventTemplateSent = "TEMPLATESENDJOBFINISH"
42 |
43 | // Message type
44 | MsgTypeDefault = ".*"
45 | MsgTypeText = "text"
46 | MsgTypeImage = "image"
47 | MsgTypeVoice = "voice"
48 | MsgTypeVideo = "video"
49 | MsgTypeShortVideo = "shortvideo"
50 | MsgTypeLocation = "location"
51 | MsgTypeLink = "link"
52 | MsgTypeEvent = msgEvent + ".*"
53 | MsgTypeEventSubscribe = msgEvent + "\\." + EventSubscribe
54 | MsgTypeEventUnsubscribe = msgEvent + "\\." + EventUnsubscribe
55 | MsgTypeEventScan = msgEvent + "\\." + EventScan
56 | MsgTypeEventView = msgEvent + "\\." + EventView
57 | MsgTypeEventClick = msgEvent + "\\." + EventClick
58 | MsgTypeEventLocation = msgEvent + "\\." + EventLocation
59 | MsgTypeEventTemplateSent = msgEvent + "\\." + EventTemplateSent
60 |
61 | // Media type
62 | MediaTypeImage = "image"
63 | MediaTypeVoice = "voice"
64 | MediaTypeVideo = "video"
65 | MediaTypeThumb = "thumb"
66 | // Button type
67 | MenuButtonTypeKey = "click"
68 | MenuButtonTypeUrl = "view"
69 | MenuButtonTypeScancodePush = "scancode_push"
70 | MenuButtonTypeScancodeWaitmsg = "scancode_waitmsg"
71 | MenuButtonTypePicSysphoto = "pic_sysphoto"
72 | MenuButtonTypePicPhotoOrAlbum = "pic_photo_or_album"
73 | MenuButtonTypePicWeixin = "pic_weixin"
74 | MenuButtonTypeLocationSelect = "location_select"
75 | MenuButtonTypeMediaId = "media_id"
76 | MenuButtonTypeViewLimited = "view_limited"
77 | MenuButtonTypeMiniProgram = "miniprogram"
78 | // Template Status
79 | TemplateSentStatusSuccess = "success"
80 | TemplateSentStatusUserBlock = "failed:user block"
81 | TemplateSentStatusSystemFailed = "failed:system failed"
82 | // Redirect Scope
83 | RedirectURLScopeBasic = "snsapi_base"
84 | RedirectURLScopeUserInfo = "snsapi_userinfo"
85 | // Weixin host URL
86 | weixinHost = "https://api.weixin.qq.com/cgi-bin"
87 | weixinQRScene = "https://api.weixin.qq.com/cgi-bin/qrcode"
88 | weixinShowQRScene = "https://mp.weixin.qq.com/cgi-bin/showqrcode"
89 | weixinMaterialURL = "https://api.weixin.qq.com/cgi-bin/material"
90 | weixinShortURL = "https://api.weixin.qq.com/cgi-bin/shorturl"
91 | weixinUserInfo = "https://api.weixin.qq.com/cgi-bin/user/info"
92 | weixinFileURL = "http://file.api.weixin.qq.com/cgi-bin/media"
93 | weixinTemplate = "https://api.weixin.qq.com/cgi-bin/template"
94 | weixinRedirectURL = "https://open.weixin.qq.com/connect/oauth2/authorize?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect"
95 | weixinUserAccessTokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code"
96 | weixinJsApiTicketURL = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
97 | // Max retry count
98 | retryMaxN = 3
99 | // Reply format
100 | replyText = "%s"
101 | replyImage = "%s"
102 | replyVoice = "%s"
103 | replyVideo = "%s"
104 | replyMusic = "%s"
105 | replyNews = "%s%d%s"
106 | replyHeader = "%d"
107 | replyArticle = "-
"
108 | transferCustomerService = "" + replyHeader + ""
109 |
110 | // Material request
111 | requestMaterial = `{"type":"%s","offset":%d,"count":%d}`
112 | // QR scene request
113 | requestQRScene = `{"expire_seconds":%d,"action_name":"QR_SCENE","action_info":{"scene":{"scene_id":%d}}}`
114 | requestQRSceneStr = `{"expire_seconds":%d,"action_name":"QR_STR_SCENE","action_info":{"scene":{"scene_str":"%s"}}}`
115 | requestQRLimitScene = `{"action_name":"QR_LIMIT_SCENE","action_info":{"scene":{"scene_id":%d}}}`
116 | requestQRLimitSceneStr = `{"action_name":"QR_LIMIT_STR_SCENE","action_info":{"scene":{"scene_str":"%s"}}}`
117 | )
118 |
119 | // MessageHeader is the header of common message.
120 | type MessageHeader struct {
121 | ToUserName string
122 | FromUserName string
123 | CreateTime int
124 | MsgType string
125 | Encrypt string
126 | }
127 |
128 | // Request is weixin event request.
129 | type Request struct {
130 | MessageHeader
131 | MsgId int64 // nolint
132 | Content string
133 | PicUrl string // nolint
134 | MediaId string // nolint
135 | Format string
136 | ThumbMediaId string // nolint
137 | LocationX float32 `xml:"Location_X"`
138 | LocationY float32 `xml:"Location_Y"`
139 | Scale float32
140 | Label string
141 | Title string
142 | Description string
143 | Url string // nolint
144 | Event string
145 | EventKey string
146 | Ticket string
147 | Latitude float32
148 | Longitude float32
149 | Precision float32
150 | Recognition string
151 | Status string
152 | }
153 |
154 | // Music is the response of music message.
155 | type Music struct {
156 | Title string `json:"title"`
157 | Description string `json:"description"`
158 | MusicUrl string `json:"musicurl"` // nolint
159 | HQMusicUrl string `json:"hqmusicurl"` // nolint
160 | ThumbMediaId string `json:"thumb_media_id"` // nolint
161 | }
162 |
163 | // Article is the response of news message.
164 | type Article struct {
165 | Title string `json:"title"`
166 | Description string `json:"description"`
167 | PicUrl string `json:"picurl"` // nolint
168 | Url string `json:"url"` // nolint
169 | }
170 |
171 | // QRScene is the QR code.
172 | type QRScene struct {
173 | Ticket string `json:"ticket"`
174 | ExpireSeconds int `json:"expire_seconds"`
175 | Url string `json:"url,omitempty"` // nolint
176 | }
177 |
178 | // Menu is custom menu.
179 | type Menu struct {
180 | Buttons []MenuButton `json:"button,omitempty"`
181 | }
182 |
183 | // MenuButton is the button of custom menu.
184 | type MenuButton struct {
185 | Name string `json:"name"`
186 | Type string `json:"type,omitempty"`
187 | Key string `json:"key,omitempty"`
188 | Url string `json:"url,omitempty"` // nolint
189 | MediaId string `json:"media_id,omitempty"` // nolint
190 | SubButtons []MenuButton `json:"sub_button,omitempty"`
191 | AppId string `json:"appid,omitempty"` // nolint
192 | PagePath string `json:"pagepath,omitempty"`
193 | }
194 |
195 | // UserAccessToken access token for user.
196 | type UserAccessToken struct {
197 | AccessToken string `json:"access_token"`
198 | RefreshToken string `json:"refresh_token"`
199 | ExpireSeconds int `json:"expires_in"`
200 | OpenId string `json:"openid"` // nolint
201 | Scope string `json:"scope"`
202 | UnionId string `json:"unionid,omitempty"` // nolint
203 | }
204 |
205 | // UserInfo store user information.
206 | type UserInfo struct {
207 | Subscribe int `json:"subscribe,omitempty"`
208 | Language string `json:"language,omitempty"`
209 | OpenId string `json:"openid,omitempty"` // nolint
210 | UnionId string `json:"unionid,omitempty"` // nolint
211 | Nickname string `json:"nickname,omitempty"`
212 | Sex int `json:"sex,omitempty"`
213 | City string `json:"city,omitempty"`
214 | Country string `json:"country,omitempty"`
215 | Province string `json:"province,omitempty"`
216 | HeadImageUrl string `json:"headimgurl,omitempty"` // nolint
217 | SubscribeTime int64 `json:"subscribe_time,omitempty"`
218 | Remark string `json:"remark,omitempty"`
219 | GroupId int `json:"groupid,omitempty"` // nolint
220 | }
221 |
222 | // Material data.
223 | type Material struct {
224 | MediaId string `json:"media_id,omitempty"` // nolint
225 | Name string `json:"name,omitempty"`
226 | UpdateTime int64 `json:"update_time,omitempty"`
227 | CreateTime int64 `json:"create_time,omitempty"`
228 | Url string `json:"url,omitempty"` // nolint
229 | Content struct {
230 | NewsItem []struct {
231 | Title string `json:"title,omitempty"`
232 | ThumbMediaId string `json:"thumb_media_id,omitempty"` // nolint
233 | ShowCoverPic int `json:"show_cover_pic,omitempty"`
234 | Author string `json:"author,omitempty"`
235 | Digest string `json:"digest,omitempty"`
236 | Content string `json:"content,omitempty"`
237 | Url string `json:"url,omitempty"` // nolint
238 | ContentSourceUrl string `json:"content_source_url,omitempty"` // nolint
239 | } `json:"news_item,omitempty"`
240 | } `json:"content,omitempty"`
241 | }
242 |
243 | // Materials is the list of material
244 | type Materials struct {
245 | TotalCount int `json:"total_count,omitempty"`
246 | ItemCount int `json:"item_count,omitempty"`
247 | Items []Material `json:"item,omitempty"`
248 | }
249 |
250 | // TmplData for mini program
251 | type TmplData map[string]TmplItem
252 |
253 | // TmplItem for mini program
254 | type TmplItem struct {
255 | Value string `json:"value,omitempty"`
256 | Color string `json:"color,omitempty"`
257 | }
258 |
259 | // TmplMiniProgram for mini program
260 | type TmplMiniProgram struct {
261 | AppId string `json:"appid,omitempty"` // nolint
262 | PagePath string `json:"pagepath,omitempty"`
263 | }
264 |
265 | // TmplMsg for mini program
266 | type TmplMsg struct {
267 | ToUser string `json:"touser"`
268 | TemplateId string `json:"template_id"` // nolint
269 | Url string `json:"url,omitempty"` // nolint 若填写跳转小程序 则此为版本过低的替代跳转url
270 | MiniProgram *TmplMiniProgram `json:"miniprogram,omitempty"` // 跳转小程序 选填
271 | Data TmplData `json:"data,omitempty"`
272 | Color string `json:"color,omitempty"` // 全局颜色
273 | }
274 |
275 | // ResponseWriter is used to output reply
276 | // nolint
277 | type ResponseWriter interface {
278 | // Get weixin
279 | GetWeixin() *Weixin
280 | GetUserData() interface{}
281 | // Reply message
282 | replyMsg(msg string)
283 | ReplyOK()
284 | ReplyText(text string)
285 | ReplyImage(mediaId string)
286 | ReplyVoice(mediaId string)
287 | ReplyVideo(mediaId string, title string, description string)
288 | ReplyMusic(music *Music)
289 | ReplyNews(articles []Article)
290 | TransferCustomerService(serviceId string)
291 | // Post message
292 | PostText(text string) error
293 | PostImage(mediaId string) error
294 | PostVoice(mediaId string) error
295 | PostVideo(mediaId string, title string, description string) error
296 | PostMusic(music *Music) error
297 | PostNews(articles []Article) error
298 | PostTemplateMessage(templateid string, url string, data TmplData) (int32, error)
299 | // Media operator
300 | UploadMediaFromFile(mediaType string, filepath string) (string, error)
301 | DownloadMediaToFile(mediaId string, filepath string) error
302 | UploadMedia(mediaType string, filename string, reader io.Reader) (string, error)
303 | DownloadMedia(mediaId string, writer io.Writer) error
304 | }
305 |
306 | type responseWriter struct {
307 | wx *Weixin
308 | writer http.ResponseWriter
309 | toUserName string
310 | fromUserName string
311 | }
312 |
313 | type response struct {
314 | ErrorCode int `json:"errcode,omitempty"`
315 | ErrorMessage string `json:"errmsg,omitempty"`
316 | }
317 |
318 | // HandlerFunc is callback function handler
319 | type HandlerFunc func(ResponseWriter, *Request)
320 |
321 | type route struct {
322 | regex *regexp.Regexp
323 | handler HandlerFunc
324 | }
325 |
326 | // AccessToken define weixin access token.
327 | type AccessToken struct {
328 | Token string
329 | Expires time.Time
330 | }
331 |
332 | type jsAPITicket struct {
333 | ticket string
334 | expires time.Time
335 | }
336 |
337 | // Weixin instance
338 | type Weixin struct {
339 | token string
340 | routes []*route
341 | tokenChan chan AccessToken
342 | ticketChan chan jsAPITicket
343 | userData interface{}
344 | appID string
345 | appSecret string
346 | refreshToken int32
347 | encodingAESKey []byte
348 | }
349 |
350 | // ToURL convert qr scene to url.
351 | func (qr *QRScene) ToURL() string {
352 | return (weixinShowQRScene + "?ticket=" + qr.Ticket)
353 | }
354 |
355 | // New create a Weixin instance.
356 | func New(token string, appid string, secret string) *Weixin {
357 | wx := &Weixin{}
358 | wx.token = token
359 | wx.appID = appid
360 | wx.appSecret = secret
361 | wx.refreshToken = 0
362 | wx.encodingAESKey = []byte{}
363 | if len(appid) > 0 && len(secret) > 0 {
364 | wx.tokenChan = make(chan AccessToken)
365 | go wx.createAccessToken(wx.tokenChan, appid, secret)
366 | wx.ticketChan = make(chan jsAPITicket)
367 | go createJsAPITicket(wx.tokenChan, wx.ticketChan)
368 | }
369 | return wx
370 | }
371 |
372 | // NewWithUserData create data with userdata.
373 | func NewWithUserData(token string, appid string, secret string, userData interface{}) *Weixin {
374 | wx := New(token, appid, secret)
375 | wx.userData = userData
376 | return wx
377 | }
378 |
379 | // SetEncodingAESKey set AES key
380 | func (wx *Weixin) SetEncodingAESKey(key string) error {
381 | k, err := base64.StdEncoding.DecodeString(key + "=")
382 | if err != nil {
383 | return err
384 | }
385 | wx.encodingAESKey = k
386 | return nil
387 | }
388 |
389 | // GetAppId retrun app id.
390 | func (wx *Weixin) GetAppId() string { // nolint
391 | return wx.appID
392 | }
393 |
394 | // GetAppSecret return app secret.
395 | func (wx *Weixin) GetAppSecret() string {
396 | return wx.appSecret
397 | }
398 |
399 | // RefreshAccessToken update access token.
400 | func (wx *Weixin) RefreshAccessToken() {
401 | atomic.StoreInt32(&wx.refreshToken, 1)
402 | <-wx.tokenChan
403 | }
404 |
405 | // GetAccessToken read access token.
406 | func (wx *Weixin) GetAccessToken() AccessToken {
407 | for i := 0; i < retryMaxN; i++ {
408 | token := <-wx.tokenChan
409 | if time.Since(token.Expires).Seconds() < 0 {
410 | return token
411 | }
412 | }
413 | return AccessToken{}
414 | }
415 |
416 | // HandleFunc used to register request callback.
417 | func (wx *Weixin) HandleFunc(pattern string, handler HandlerFunc) {
418 | regex, err := regexp.Compile(pattern)
419 | if err != nil {
420 | panic(err)
421 | }
422 | route := &route{regex, handler}
423 | wx.routes = append(wx.routes, route)
424 | }
425 |
426 | // PostText used to post text message.
427 | func (wx *Weixin) PostText(touser string, text string) error {
428 | var msg struct {
429 | ToUser string `json:"touser"`
430 | MsgType string `json:"msgtype"`
431 | Text struct {
432 | Content string `json:"content"`
433 | } `json:"text"`
434 | }
435 | msg.ToUser = touser
436 | msg.MsgType = "text"
437 | msg.Text.Content = text
438 | return postMessage(wx.tokenChan, &msg)
439 | }
440 |
441 | // PostImage used to post image message.
442 | func (wx *Weixin) PostImage(touser string, mediaID string) error {
443 | var msg struct {
444 | ToUser string `json:"touser"`
445 | MsgType string `json:"msgtype"`
446 | Image struct {
447 | MediaID string `json:"media_id"`
448 | } `json:"image"`
449 | }
450 | msg.ToUser = touser
451 | msg.MsgType = "image"
452 | msg.Image.MediaID = mediaID
453 | return postMessage(wx.tokenChan, &msg)
454 | }
455 |
456 | // PostVoice used to post voice message.
457 | func (wx *Weixin) PostVoice(touser string, mediaID string) error {
458 | var msg struct {
459 | ToUser string `json:"touser"`
460 | MsgType string `json:"msgtype"`
461 | Voice struct {
462 | MediaID string `json:"media_id"`
463 | } `json:"voice"`
464 | }
465 | msg.ToUser = touser
466 | msg.MsgType = "voice"
467 | msg.Voice.MediaID = mediaID
468 | return postMessage(wx.tokenChan, &msg)
469 | }
470 |
471 | // PostVideo used to post video message.
472 | func (wx *Weixin) PostVideo(touser string, m string, t string, d string) error {
473 | var msg struct {
474 | ToUser string `json:"touser"`
475 | MsgType string `json:"msgtype"`
476 | Video struct {
477 | MediaID string `json:"media_id"`
478 | Title string `json:"title"`
479 | Description string `json:"description"`
480 | } `json:"video"`
481 | }
482 | msg.ToUser = touser
483 | msg.MsgType = "video"
484 | msg.Video.MediaID = m
485 | msg.Video.Title = t
486 | msg.Video.Description = d
487 | return postMessage(wx.tokenChan, &msg)
488 | }
489 |
490 | // PostMusic used to post music message.
491 | func (wx *Weixin) PostMusic(touser string, music *Music) error {
492 | var msg struct {
493 | ToUser string `json:"touser"`
494 | MsgType string `json:"msgtype"`
495 | Music *Music `json:"music"`
496 | }
497 | msg.ToUser = touser
498 | msg.MsgType = "video"
499 | msg.Music = music
500 | return postMessage(wx.tokenChan, &msg)
501 | }
502 |
503 | // PostNews used to post news message.
504 | func (wx *Weixin) PostNews(touser string, articles []Article) error {
505 | var msg struct {
506 | ToUser string `json:"touser"`
507 | MsgType string `json:"msgtype"`
508 | News struct {
509 | Articles []Article `json:"articles"`
510 | } `json:"news"`
511 | }
512 | msg.ToUser = touser
513 | msg.MsgType = "news"
514 | msg.News.Articles = articles
515 | return postMessage(wx.tokenChan, &msg)
516 | }
517 |
518 | // UploadMediaFromFile used to upload media from local file.
519 | func (wx *Weixin) UploadMediaFromFile(mediaType string, fp string) (string, error) {
520 | file, err := os.Open(fp)
521 | if err != nil {
522 | return "", err
523 | }
524 | defer file.Close()
525 | return wx.UploadMedia(mediaType, filepath.Base(fp), file)
526 | }
527 |
528 | // DownloadMediaToFile used to download media and save to local file.
529 | func (wx *Weixin) DownloadMediaToFile(mediaID string, fp string) error {
530 | file, err := os.Create(fp)
531 | if err != nil {
532 | return err
533 | }
534 | defer file.Close()
535 | return wx.DownloadMedia(mediaID, file)
536 | }
537 |
538 | // UploadMedia used to upload media with media.
539 | func (wx *Weixin) UploadMedia(mediaType string, filename string, reader io.Reader) (string, error) {
540 | return uploadMedia(wx.tokenChan, mediaType, filename, reader)
541 | }
542 |
543 | // DownloadMedia used to download media with media.
544 | func (wx *Weixin) DownloadMedia(mediaID string, writer io.Writer) error {
545 | return downloadMedia(wx.tokenChan, mediaID, writer)
546 | }
547 |
548 | // BatchGetMaterial used to batch get Material.
549 | func (wx *Weixin) BatchGetMaterial(materialType string, offset int, count int) (*Materials, error) {
550 | reply, err := postRequest(weixinMaterialURL+"/batchget_material?access_token=", wx.tokenChan,
551 | []byte(fmt.Sprintf(requestMaterial, materialType, offset, count)))
552 | if err != nil {
553 | return nil, err
554 | }
555 | var materials Materials
556 | if err := json.Unmarshal(reply, &materials); err != nil {
557 | return nil, err
558 | }
559 | return &materials, nil
560 | }
561 |
562 | // GetIpList used to get ip list.
563 | func (wx *Weixin) GetIpList() ([]string, error) { // nolint
564 | reply, err := sendGetRequest(weixinHost+"/getcallbackip?access_token=", wx.tokenChan)
565 | if err != nil {
566 | return nil, err
567 | }
568 | var result struct {
569 | IPList []string `json:"ip_list"`
570 | }
571 | if err := json.Unmarshal(reply, &result); err != nil {
572 | return nil, err
573 | }
574 | return result.IPList, nil
575 | }
576 |
577 | // CreateQRScene used to create QR scene.
578 | func (wx *Weixin) CreateQRScene(sceneID int, expires int) (*QRScene, error) {
579 | reply, err := postRequest(weixinQRScene+"/create?access_token=", wx.tokenChan, []byte(fmt.Sprintf(requestQRScene, expires, sceneID)))
580 | if err != nil {
581 | return nil, err
582 | }
583 | var qr QRScene
584 | if err := json.Unmarshal(reply, &qr); err != nil {
585 | return nil, err
586 | }
587 | return &qr, nil
588 | }
589 |
590 | // CreateQRSceneByString used to create QR scene by str.
591 | func (wx *Weixin) CreateQRSceneByString(sceneStr string, expires int) (*QRScene, error) {
592 | reply, err := postRequest(weixinQRScene+"/create?access_token=", wx.tokenChan, []byte(fmt.Sprintf(requestQRSceneStr, expires, sceneStr)))
593 | if err != nil {
594 | return nil, err
595 | }
596 | var qr QRScene
597 | if err := json.Unmarshal(reply, &qr); err != nil {
598 | return nil, err
599 | }
600 | return &qr, nil
601 | }
602 |
603 | // CreateQRLimitScene used to create QR limit scene.
604 | func (wx *Weixin) CreateQRLimitScene(sceneID int) (*QRScene, error) {
605 | reply, err := postRequest(weixinQRScene+"/create?access_token=", wx.tokenChan, []byte(fmt.Sprintf(requestQRLimitScene, sceneID)))
606 | if err != nil {
607 | return nil, err
608 | }
609 | var qr QRScene
610 | if err := json.Unmarshal(reply, &qr); err != nil {
611 | return nil, err
612 | }
613 | return &qr, nil
614 | }
615 |
616 | // CreateQRLimitSceneByString used to create QR limit scene by str.
617 | func (wx *Weixin) CreateQRLimitSceneByString(sceneStr string) (*QRScene, error) {
618 | reply, err := postRequest(weixinQRScene+"/create?access_token=", wx.tokenChan, []byte(fmt.Sprintf(requestQRLimitSceneStr, sceneStr)))
619 | if err != nil {
620 | return nil, err
621 | }
622 | var qr QRScene
623 | if err := json.Unmarshal(reply, &qr); err != nil {
624 | return nil, err
625 | }
626 | return &qr, nil
627 | }
628 |
629 | // ShortURL used to convert long url to short url
630 | func (wx *Weixin) ShortURL(url string) (string, error) {
631 | var request struct {
632 | Action string `json:"action"`
633 | LongURL string `json:"long_url"`
634 | }
635 | request.Action = "long2short"
636 | request.LongURL = url
637 | data, err := marshal(request)
638 | if err != nil {
639 | return "", err
640 | }
641 | reply, err := postRequest(weixinShortURL+"?access_token=", wx.tokenChan, data)
642 | if err != nil {
643 | return "", err
644 | }
645 | var shortURL struct {
646 | URL string `json:"short_url"`
647 | }
648 | if err := json.Unmarshal(reply, &shortURL); err != nil {
649 | return "", err
650 | }
651 | return shortURL.URL, nil
652 | }
653 |
654 | // CreateMenu used to create custom menu.
655 | func (wx *Weixin) CreateMenu(menu *Menu) error {
656 | data, err := marshal(menu)
657 | if err != nil {
658 | return err
659 | }
660 | _, err = postRequest(weixinHost+"/menu/create?access_token=", wx.tokenChan, data)
661 | return err
662 | }
663 |
664 | // GetMenu used to get menu.
665 | func (wx *Weixin) GetMenu() (*Menu, error) {
666 | reply, err := sendGetRequest(weixinHost+"/menu/get?access_token=", wx.tokenChan)
667 | if err != nil {
668 | return nil, err
669 | }
670 | var result struct {
671 | MenuCtx *Menu `json:"menu"`
672 | }
673 | if err := json.Unmarshal(reply, &result); err != nil {
674 | return nil, err
675 | }
676 | return result.MenuCtx, nil
677 | }
678 |
679 | // DeleteMenu used to delete menu.
680 | func (wx *Weixin) DeleteMenu() error {
681 | _, err := sendGetRequest(weixinHost+"/menu/delete?access_token=", wx.tokenChan)
682 | return err
683 | }
684 |
685 | // SetTemplateIndustry used to set template industry.
686 | func (wx *Weixin) SetTemplateIndustry(id1 string, id2 string) error {
687 | var industry struct {
688 | ID1 string `json:"industry_id1,omitempty"`
689 | ID2 string `json:"industry_id2,omitempty"`
690 | }
691 | industry.ID1 = id1
692 | industry.ID2 = id2
693 | data, err := marshal(industry)
694 | if err != nil {
695 | return err
696 | }
697 | _, err = postRequest(weixinTemplate+"/api_set_industry?access_token=", wx.tokenChan, data)
698 | return err
699 | }
700 |
701 | // AddTemplate used to add template.
702 | func (wx *Weixin) AddTemplate(shortid string) (string, error) {
703 | var request struct {
704 | Shortid string `json:"template_id_short,omitempty"`
705 | }
706 | request.Shortid = shortid
707 | data, err := marshal(request)
708 | if err != nil {
709 | return "", err
710 | }
711 | reply, err := postRequest(weixinTemplate+"/api_set_industry?access_token=", wx.tokenChan, data)
712 | if err != nil {
713 | return "", err
714 | }
715 | var templateID struct {
716 | ID string `json:"template_id,omitempty"`
717 | }
718 | if err := json.Unmarshal(reply, &templateID); err != nil {
719 | return "", err
720 | }
721 | return templateID.ID, nil
722 | }
723 |
724 | // PostTemplateMessage used to post template message.
725 | func (wx *Weixin) PostTemplateMessage(touser string, templateid string, url string, data TmplData) (int32, error) {
726 | var msg struct {
727 | ToUser string `json:"touser"`
728 | TemplateID string `json:"template_id"`
729 | URL string `json:"url,omitempty"`
730 | Data TmplData `json:"data,omitempty"`
731 | }
732 | msg.ToUser = touser
733 | msg.TemplateID = templateid
734 | msg.URL = url
735 | msg.Data = data
736 | msgStr, err := marshal(msg)
737 | if err != nil {
738 | return 0, err
739 | }
740 | reply, err := postRequest(weixinHost+"/message/template/send?access_token=", wx.tokenChan, msgStr)
741 | if err != nil {
742 | return 0, err
743 | }
744 | var resp struct {
745 | MsgID int32 `json:"msgid,omitempty"`
746 | }
747 | if err := json.Unmarshal(reply, &resp); err != nil {
748 | return 0, err
749 | }
750 | return resp.MsgID, nil
751 | }
752 |
753 | // PostTemplateMessageMiniProgram 兼容模板消息跳转小程序
754 | func (wx *Weixin) PostTemplateMessageMiniProgram(msg *TmplMsg) (int64, error) {
755 | msgStr, err := marshal(msg)
756 | if err != nil {
757 | return 0, err
758 | }
759 | reply, err := postRequest(weixinHost+"/message/template/send?access_token=", wx.tokenChan, msgStr)
760 | if err != nil {
761 | return 0, err
762 | }
763 | var resp struct {
764 | MsgID int64 `json:"msgid,omitempty"`
765 | }
766 | if err := json.Unmarshal(reply, &resp); err != nil {
767 | return 0, err
768 | }
769 | return resp.MsgID, nil
770 | }
771 |
772 | // CreateRedirectURL used to create redirect url
773 | func (wx *Weixin) CreateRedirectURL(urlStr string, scope string, state string) string {
774 | return fmt.Sprintf(weixinRedirectURL, wx.appID, url.QueryEscape(urlStr), scope, state)
775 | }
776 |
777 | // GetUserAccessToken used to get open id
778 | func (wx *Weixin) GetUserAccessToken(code string) (*UserAccessToken, error) {
779 | resp, err := http.Get(fmt.Sprintf(weixinUserAccessTokenURL, wx.appID, wx.appSecret, code))
780 | if err != nil {
781 | return nil, err
782 | }
783 | defer resp.Body.Close()
784 | body, err := ioutil.ReadAll(resp.Body)
785 | if err != nil {
786 | return nil, err
787 | }
788 | var res UserAccessToken
789 | if err := json.Unmarshal(body, &res); err != nil {
790 | return nil, err
791 | }
792 | return &res, nil
793 | }
794 |
795 | // GetUserInfo used to get user info
796 | func (wx *Weixin) GetUserInfo(openid string) (*UserInfo, error) {
797 | reply, err := sendGetRequest(fmt.Sprintf("%s?openid=%s&lang=zh_CN&access_token=", weixinUserInfo, openid), wx.tokenChan)
798 | if err != nil {
799 | return nil, err
800 | }
801 | var result UserInfo
802 | if err := json.Unmarshal(reply, &result); err != nil {
803 | return nil, err
804 | }
805 | return &result, nil
806 | }
807 |
808 | // GetJsAPITicket used to get js api ticket.
809 | func (wx *Weixin) GetJsAPITicket() (string, error) {
810 | for i := 0; i < retryMaxN; i++ {
811 | ticket := <-wx.ticketChan
812 | if time.Since(ticket.expires).Seconds() < 0 {
813 | return ticket.ticket, nil
814 | }
815 | }
816 | return "", errors.New("Get JsApi Ticket Timeout")
817 | }
818 |
819 | // JsSignature used to sign js url.
820 | func (wx *Weixin) JsSignature(url string, timestamp int64, noncestr string) (string, error) {
821 | ticket, err := wx.GetJsAPITicket()
822 | if err != nil {
823 | return "", err
824 | }
825 | h := sha1.New()
826 | h.Write([]byte(fmt.Sprintf("jsapi_ticket=%s&noncestr=%s×tamp=%d&url=%s", // nolint
827 | ticket, noncestr, timestamp, url)))
828 | return fmt.Sprintf("%x", h.Sum(nil)), nil
829 | }
830 |
831 | // CreateHandlerFunc used to create handler function.
832 | func (wx *Weixin) CreateHandlerFunc(w http.ResponseWriter, r *http.Request) http.HandlerFunc {
833 | return func(w http.ResponseWriter, r *http.Request) {
834 | wx.ServeHTTP(w, r)
835 | }
836 | }
837 |
838 | // ServeHTTP used to process weixin request and send response.
839 | func (wx *Weixin) ServeHTTP(w http.ResponseWriter, r *http.Request) {
840 | if !checkSignature(wx.token, w, r) {
841 | http.Error(w, "", http.StatusUnauthorized)
842 | return
843 | }
844 | // Verify request
845 | if r.Method == "GET" {
846 | fmt.Fprintf(w, r.FormValue("echostr")) // nolint
847 | return
848 | }
849 | // Process message
850 | data, err := ioutil.ReadAll(r.Body)
851 | if err != nil {
852 | log.Println("Weixin receive message failed:", err)
853 | http.Error(w, "", http.StatusBadRequest)
854 | } else {
855 | var msg Request
856 | if err := xml.Unmarshal(data, &msg); err != nil {
857 | log.Println("Weixin parse message failed:", err)
858 | http.Error(w, "", http.StatusBadRequest)
859 | return
860 | }
861 | if len(wx.encodingAESKey) > 0 && len(msg.Encrypt) > 0 {
862 | // check encrypt
863 | d, err := base64.StdEncoding.DecodeString(msg.Encrypt)
864 | if err != nil {
865 | log.Println("Weixin decode base64 message failed:", err)
866 | http.Error(w, "", http.StatusBadRequest)
867 | return
868 | }
869 | if len(d) <= 20 {
870 | log.Println("Weixin invalid aes message:", err)
871 | http.Error(w, "", http.StatusBadRequest)
872 | return
873 | }
874 | // valid
875 | strs := sort.StringSlice{wx.token, r.FormValue("timestamp"), r.FormValue("nonce"), msg.Encrypt}
876 | sort.Strings(strs)
877 | if fmt.Sprintf("%x", sha1.Sum([]byte(strings.Join(strs, "")))) != r.FormValue("msg_signature") {
878 | log.Println("Weixin check message sign failed!")
879 | http.Error(w, "", http.StatusBadRequest)
880 | return
881 | }
882 | // decode
883 | key := wx.encodingAESKey
884 | b, err := aes.NewCipher(key)
885 | if err != nil {
886 | log.Println("Weixin create cipher failed:", err)
887 | http.Error(w, "", http.StatusBadRequest)
888 | return
889 | }
890 | bs := b.BlockSize()
891 | bm := cipher.NewCBCDecrypter(b, key[:bs])
892 | data = make([]byte, len(d))
893 | bm.CryptBlocks(data, d)
894 | data = fixPKCS7UnPadding(data)
895 | len := binary.BigEndian.Uint32(data[16:20])
896 | if err := xml.Unmarshal(data[20:(20+len)], &msg); err != nil {
897 | log.Println("Weixin parse aes message failed:", err)
898 | http.Error(w, "", http.StatusBadRequest)
899 | return
900 | }
901 | }
902 | wx.routeRequest(w, &msg)
903 | }
904 | return
905 | }
906 |
907 | func (wx *Weixin) routeRequest(w http.ResponseWriter, r *Request) {
908 | requestPath := r.MsgType
909 | if requestPath == msgEvent {
910 | requestPath += "." + r.Event
911 | }
912 | for _, route := range wx.routes {
913 | if !route.regex.MatchString(requestPath) {
914 | continue
915 | }
916 | writer := responseWriter{}
917 | writer.wx = wx
918 | writer.writer = w
919 | writer.toUserName = r.FromUserName
920 | writer.fromUserName = r.ToUserName
921 | route.handler(writer, r)
922 | return
923 | }
924 | http.Error(w, "", http.StatusNotFound)
925 | return
926 | }
927 |
928 | func marshal(v interface{}) ([]byte, error) {
929 | data, err := json.Marshal(v)
930 | if err == nil {
931 | data = bytes.Replace(data, []byte("\\u003c"), []byte("<"), -1)
932 | data = bytes.Replace(data, []byte("\\u003e"), []byte(">"), -1)
933 | data = bytes.Replace(data, []byte("\\u0026"), []byte("&"), -1)
934 | }
935 | return data, err
936 | }
937 |
938 | func fixPKCS7UnPadding(data []byte) []byte {
939 | length := len(data)
940 | unpadding := int(data[length-1])
941 | return data[:(length - unpadding)]
942 | }
943 |
944 | func checkSignature(t string, w http.ResponseWriter, r *http.Request) bool {
945 | r.ParseForm() // nolint
946 | signature := r.FormValue("signature")
947 | timestamp := r.FormValue("timestamp")
948 | nonce := r.FormValue("nonce")
949 | strs := sort.StringSlice{t, timestamp, nonce}
950 | sort.Strings(strs)
951 | var str string
952 | for _, s := range strs {
953 | str += s
954 | }
955 | h := sha1.New()
956 | h.Write([]byte(str)) // nolint
957 | return fmt.Sprintf("%x", h.Sum(nil)) == signature
958 | }
959 |
960 | func authAccessToken(appid string, secret string) (string, time.Duration) {
961 | resp, err := http.Get(weixinHost + "/token?grant_type=client_credential&appid=" + appid + "&secret=" + secret)
962 | if err != nil {
963 | log.Println("Get access token failed: ", err)
964 | } else {
965 | defer resp.Body.Close()
966 | body, err := ioutil.ReadAll(resp.Body)
967 | if err != nil {
968 | log.Println("Read access token failed: ", err)
969 | } else {
970 | var res struct {
971 | AccessToken string `json:"access_token"`
972 | ExpiresIn int64 `json:"expires_in"`
973 | }
974 | if err := json.Unmarshal(body, &res); err != nil {
975 | log.Println("Parse access token failed: ", err)
976 | } else {
977 | //log.Printf("AuthAccessToken token=%s expires_in=%d", res.AccessToken, res.ExpiresIn)
978 | return res.AccessToken, time.Duration(res.ExpiresIn * 1000 * 1000 * 1000)
979 | }
980 | }
981 | }
982 | return "", 0
983 | }
984 |
985 | func getJsAPITicket(c chan AccessToken) (*jsAPITicket, error) {
986 | reply, err := sendGetRequest(weixinJsApiTicketURL+"?type=jsapi&access_token=", c)
987 | if err != nil {
988 | return nil, err
989 | }
990 | var res struct {
991 | Ticket string `json:"ticket"`
992 | ExpiresIn int64 `json:"expires_in"`
993 | }
994 | if err := json.Unmarshal(reply, &res); err != nil {
995 | return nil, err
996 | }
997 | var ticket jsAPITicket
998 | ticket.ticket = res.Ticket
999 | ticket.expires = time.Now().Add(time.Duration(res.ExpiresIn * 1000 * 1000 * 1000))
1000 | return &ticket, nil
1001 |
1002 | }
1003 |
1004 | func (wx *Weixin) createAccessToken(c chan AccessToken, appid string, secret string) {
1005 | token := AccessToken{"", time.Now()}
1006 | c <- token
1007 | for {
1008 | swapped := atomic.CompareAndSwapInt32(&wx.refreshToken, 1, 0)
1009 | if swapped || time.Since(token.Expires).Seconds() >= 0 {
1010 | var expires time.Duration
1011 | token.Token, expires = authAccessToken(appid, secret)
1012 | token.Expires = time.Now().Add(expires)
1013 | }
1014 | c <- token
1015 | }
1016 | }
1017 |
1018 | func createJsAPITicket(cin chan AccessToken, c chan jsAPITicket) {
1019 | ticket := jsAPITicket{"", time.Now()}
1020 | c <- ticket
1021 | for {
1022 | if time.Since(ticket.expires).Seconds() >= 0 {
1023 | t, err := getJsAPITicket(cin)
1024 | if err == nil {
1025 | ticket = *t
1026 | }
1027 | }
1028 | c <- ticket
1029 | }
1030 | }
1031 |
1032 | func sendGetRequest(reqURL string, c chan AccessToken) ([]byte, error) {
1033 | for i := 0; i < retryMaxN; i++ {
1034 | token := <-c
1035 | if time.Since(token.Expires).Seconds() < 0 {
1036 | r, err := http.Get(reqURL + token.Token)
1037 | if err != nil {
1038 | return nil, err
1039 | }
1040 | defer r.Body.Close()
1041 | reply, err := ioutil.ReadAll(r.Body)
1042 | if err != nil {
1043 | return nil, err
1044 | }
1045 | var result response
1046 | if err := json.Unmarshal(reply, &result); err != nil {
1047 | return nil, err
1048 | }
1049 | switch result.ErrorCode {
1050 | case 0:
1051 | return reply, nil
1052 | case 42001: // access_token timeout and retry
1053 | continue
1054 | default:
1055 | return nil, fmt.Errorf("WeiXin send get request reply[%d]: %s", result.ErrorCode, result.ErrorMessage)
1056 | }
1057 | }
1058 | }
1059 | return nil, errors.New("WeiXin post request too many times:" + reqURL)
1060 | }
1061 |
1062 | func postRequest(reqURL string, c chan AccessToken, data []byte) ([]byte, error) {
1063 | for i := 0; i < retryMaxN; i++ {
1064 | token := <-c
1065 | if time.Since(token.Expires).Seconds() < 0 {
1066 | r, err := http.Post(reqURL+token.Token, "application/json; charset=utf-8", bytes.NewReader(data))
1067 | if err != nil {
1068 | return nil, err
1069 | }
1070 | defer r.Body.Close()
1071 | reply, err := ioutil.ReadAll(r.Body)
1072 | if err != nil {
1073 | return nil, err
1074 | }
1075 | var result response
1076 | if err := json.Unmarshal(reply, &result); err != nil {
1077 | return nil, err
1078 | }
1079 | switch result.ErrorCode {
1080 | case 0:
1081 | return reply, nil
1082 | case 42001: // access_token timeout and retry
1083 | continue
1084 | default:
1085 | return nil, fmt.Errorf("WeiXin send post request reply[%d]: %s", result.ErrorCode, result.ErrorMessage)
1086 | }
1087 | }
1088 | }
1089 | return nil, errors.New("WeiXin post request too many times:" + reqURL)
1090 | }
1091 |
1092 | func postMessage(c chan AccessToken, msg interface{}) error {
1093 | data, err := marshal(msg)
1094 | if err != nil {
1095 | return err
1096 | }
1097 | _, err = postRequest(weixinHost+"/message/custom/send?access_token=", c, data)
1098 | return err
1099 | }
1100 |
1101 | // nolint: gocyclo
1102 | func uploadMedia(c chan AccessToken, mediaType string, filename string, reader io.Reader) (string, error) {
1103 | reqURL := weixinFileURL + "/upload?type=" + mediaType + "&access_token="
1104 | for i := 0; i < retryMaxN; i++ {
1105 | token := <-c
1106 | if time.Since(token.Expires).Seconds() < 0 {
1107 | bodyBuf := &bytes.Buffer{}
1108 | bodyWriter := multipart.NewWriter(bodyBuf)
1109 | fileWriter, err := bodyWriter.CreateFormFile("filename", filename)
1110 | if err != nil {
1111 | return "", err
1112 | }
1113 | if _, err = io.Copy(fileWriter, reader); err != nil {
1114 | return "", err
1115 | }
1116 | contentType := bodyWriter.FormDataContentType()
1117 | bodyWriter.Close() // nolint
1118 | r, err := http.Post(reqURL+token.Token, contentType, bodyBuf)
1119 | if err != nil {
1120 | return "", err
1121 | }
1122 | defer r.Body.Close()
1123 | reply, err := ioutil.ReadAll(r.Body)
1124 | if err != nil {
1125 | return "", err
1126 | }
1127 | var result struct {
1128 | response
1129 | Type string `json:"type"`
1130 | MediaID string `json:"media_id"`
1131 | CreatedAt int64 `json:"created_at"`
1132 | }
1133 | err = json.Unmarshal(reply, &result)
1134 | if err != nil {
1135 | return "", err
1136 | }
1137 | switch result.ErrorCode {
1138 | case 0:
1139 | return result.MediaID, nil
1140 | case 42001: // access_token timeout and retry
1141 | continue
1142 | default:
1143 | return "", fmt.Errorf("WeiXin upload[%d]: %s", result.ErrorCode, result.ErrorMessage)
1144 | }
1145 | }
1146 | }
1147 | return "", errors.New("WeiXin upload media too many times")
1148 | }
1149 |
1150 | func downloadMedia(c chan AccessToken, mediaID string, writer io.Writer) error {
1151 | reqURL := weixinFileURL + "/get?media_id=" + mediaID + "&access_token="
1152 | for i := 0; i < retryMaxN; i++ {
1153 | token := <-c
1154 | if time.Since(token.Expires).Seconds() < 0 {
1155 | r, err := http.Get(reqURL + token.Token)
1156 | if err != nil {
1157 | return err
1158 | }
1159 | defer r.Body.Close()
1160 | if r.Header.Get("Content-Type") != "text/plain" {
1161 | _, err = io.Copy(writer, r.Body)
1162 | return err
1163 | }
1164 | reply, err := ioutil.ReadAll(r.Body)
1165 | if err != nil {
1166 | return err
1167 | }
1168 | var result response
1169 | if err := json.Unmarshal(reply, &result); err != nil {
1170 | return err
1171 | }
1172 | switch result.ErrorCode {
1173 | case 0:
1174 | return nil
1175 | case 42001: // access_token timeout and retry
1176 | continue
1177 | default:
1178 | return fmt.Errorf("WeiXin download[%d]: %s", result.ErrorCode, result.ErrorMessage)
1179 | }
1180 | }
1181 | }
1182 | return errors.New("WeiXin download media too many times")
1183 | }
1184 |
1185 | // Format reply message header.
1186 | func (w responseWriter) replyHeader() string {
1187 | return fmt.Sprintf(replyHeader, w.toUserName, w.fromUserName, time.Now().Unix())
1188 | }
1189 |
1190 | // Return weixin instance.
1191 | func (w responseWriter) GetWeixin() *Weixin {
1192 | return w.wx
1193 | }
1194 |
1195 | // Return user data.
1196 | func (w responseWriter) GetUserData() interface{} {
1197 | return w.wx.userData
1198 | }
1199 |
1200 | func (w responseWriter) replyMsg(msg string) {
1201 | w.writer.Write([]byte(msg))
1202 | }
1203 |
1204 | // ReplyOK used to reply empty message.
1205 | func (w responseWriter) ReplyOK() {
1206 | w.replyMsg("success")
1207 | }
1208 |
1209 | // ReplyText used to reply text message.
1210 | func (w responseWriter) ReplyText(text string) {
1211 | w.replyMsg(fmt.Sprintf(replyText, w.replyHeader(), text))
1212 | }
1213 |
1214 | // ReplyImage used to reply image message.
1215 | func (w responseWriter) ReplyImage(mediaID string) {
1216 | w.replyMsg(fmt.Sprintf(replyImage, w.replyHeader(), mediaID))
1217 | }
1218 |
1219 | // ReplyVoice used to reply voice message.
1220 | func (w responseWriter) ReplyVoice(mediaID string) {
1221 | w.replyMsg(fmt.Sprintf(replyVoice, w.replyHeader(), mediaID))
1222 | }
1223 |
1224 | // ReplyVideo used to reply video message
1225 | func (w responseWriter) ReplyVideo(mediaID string, title string, description string) {
1226 | w.replyMsg(fmt.Sprintf(replyVideo, w.replyHeader(), mediaID, title, description))
1227 | }
1228 |
1229 | // ReplyMusic used to reply music message
1230 | func (w responseWriter) ReplyMusic(m *Music) {
1231 | msg := fmt.Sprintf(replyMusic, w.replyHeader(), m.Title, m.Description, m.MusicUrl, m.HQMusicUrl, m.ThumbMediaId)
1232 | w.replyMsg(msg)
1233 | }
1234 |
1235 | // ReplyNews used to reply news message (max 10 news)
1236 | func (w responseWriter) ReplyNews(articles []Article) {
1237 | var ctx string
1238 | for _, article := range articles {
1239 | ctx += fmt.Sprintf(replyArticle, article.Title, article.Description, article.PicUrl, article.Url)
1240 | }
1241 | msg := fmt.Sprintf(replyNews, w.replyHeader(), len(articles), ctx)
1242 | w.replyMsg(msg)
1243 | }
1244 |
1245 | // TransferCustomerService used to tTransfer customer service
1246 | func (w responseWriter) TransferCustomerService(serviceID string) {
1247 | msg := fmt.Sprintf(transferCustomerService, serviceID, w.fromUserName, time.Now().Unix())
1248 | w.replyMsg(msg)
1249 | }
1250 |
1251 | // PostText used to Post text message
1252 | func (w responseWriter) PostText(text string) error {
1253 | return w.wx.PostText(w.toUserName, text)
1254 | }
1255 |
1256 | // Post image message
1257 | func (w responseWriter) PostImage(mediaID string) error {
1258 | return w.wx.PostImage(w.toUserName, mediaID)
1259 | }
1260 |
1261 | // Post voice message
1262 | func (w responseWriter) PostVoice(mediaID string) error {
1263 | return w.wx.PostVoice(w.toUserName, mediaID)
1264 | }
1265 |
1266 | // Post video message
1267 | func (w responseWriter) PostVideo(mediaID string, title string, desc string) error {
1268 | return w.wx.PostVideo(w.toUserName, mediaID, title, desc)
1269 | }
1270 |
1271 | // Post music message
1272 | func (w responseWriter) PostMusic(music *Music) error {
1273 | return w.wx.PostMusic(w.toUserName, music)
1274 | }
1275 |
1276 | // Post news message
1277 | func (w responseWriter) PostNews(articles []Article) error {
1278 | return w.wx.PostNews(w.toUserName, articles)
1279 | }
1280 |
1281 | // Post template message
1282 | func (w responseWriter) PostTemplateMessage(templateid string, url string, data TmplData) (int32, error) {
1283 | return w.wx.PostTemplateMessage(w.toUserName, templateid, url, data)
1284 | }
1285 |
1286 | // Upload media from local file
1287 | func (w responseWriter) UploadMediaFromFile(mediaType string, filepath string) (string, error) {
1288 | return w.wx.UploadMediaFromFile(mediaType, filepath)
1289 | }
1290 |
1291 | // Download media and save to local file
1292 | func (w responseWriter) DownloadMediaToFile(mediaID string, filepath string) error {
1293 | return w.wx.DownloadMediaToFile(mediaID, filepath)
1294 | }
1295 |
1296 | // Upload media with reader
1297 | func (w responseWriter) UploadMedia(mediaType string, filename string, reader io.Reader) (string, error) {
1298 | return w.wx.UploadMedia(mediaType, filename, reader)
1299 | }
1300 |
1301 | // Download media with writer
1302 | func (w responseWriter) DownloadMedia(mediaID string, writer io.Writer) error {
1303 | return w.wx.DownloadMedia(mediaID, writer)
1304 | }
1305 |
--------------------------------------------------------------------------------