├── .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 | [![Build Status](https://travis-ci.org/wizjin/weixin.png?branch=master)](https://travis-ci.org/wizjin/weixin) 8 | [![GoDoc](http://godoc.org/github.com/wizjin/weixin?status.png)](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<![CDATA[%s]]>" 105 | replyNews = "%s%d%s" 106 | replyHeader = "%d" 107 | replyArticle = "<![CDATA[%s]]> " 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 | --------------------------------------------------------------------------------