├── .gitignore ├── entry ├── error.go ├── access.go ├── subscriber.go ├── request.go ├── menu.go └── response.go ├── client_test.go ├── conf └── conf.go ├── client.subscriber.go ├── client.menu.go ├── client.go ├── demo └── demo.go ├── server.go ├── README.md └── app.go /.gitignore: -------------------------------------------------------------------------------- 1 | demo/demo 2 | -------------------------------------------------------------------------------- /entry/error.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import "fmt" 4 | 5 | type ApiError struct { 6 | Code int64 `json:"errcode"` 7 | Msg string `json:"errmsg"` 8 | } 9 | 10 | func (e ApiError) Error() string { 11 | return fmt.Sprintf("code: %d message: %s", e.Code, e.Msg) 12 | } 13 | -------------------------------------------------------------------------------- /entry/access.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import "time" 4 | 5 | type Token struct { 6 | ApiError 7 | Secret string `json:"access_token"` 8 | ExpireIn int64 `json:"expires_in"` 9 | CreateAt time.Time `json:"-"` 10 | } 11 | 12 | type IPList struct { 13 | ApiError 14 | items []string `json:"ip_list"` 15 | } 16 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "log" 5 | "testing" 6 | 7 | "github.com/liujianping/wechat/entry" 8 | ) 9 | 10 | func TestGetUserInfo(t *testing.T) { 11 | 12 | client := NewClient("wx02da1455ece52e5a", "9340ce4b0ab01f33e66dcf9650103fb3").Debug(true) 13 | 14 | var user entry.UserInfo 15 | if err := client.GetUserInfo("o9_Ejs2eLQasNUVFvZtAs2cogCn4", "zh_CN", &user); err != nil { 16 | t.Errorf("client.GetUserInfo failed: %s", err.Error()) 17 | } 18 | 19 | log.Println("api.GetUserInfo ", user.OpenID, user.NickName, user.HeadImageURL) 20 | } 21 | -------------------------------------------------------------------------------- /conf/conf.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const HOST = "api.weixin.qq.com" 8 | 9 | var URIs map[string]string 10 | 11 | func MakeURL(key string) string { 12 | if uri, ok := URIs[key]; ok { 13 | return fmt.Sprintf("https://%s%s", HOST, uri) 14 | } 15 | return fmt.Sprintf("https://%s", HOST) 16 | } 17 | 18 | func init() { 19 | URIs = make(map[string]string) 20 | URIs["access.token"] = "/cgi-bin/token" 21 | URIs["callback.ip"] = "/cgi-bin/getcallbackip" 22 | URIs["menu.create"] = "/cgi-bin/menu/create" 23 | URIs["menu.get"] = "/cgi-bin/menu/get" 24 | URIs["menu.delete"] = "/cgi-bin/menu/delete" 25 | URIs["user.info"] = "/cgi-bin/user/info" 26 | } 27 | -------------------------------------------------------------------------------- /client.subscriber.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "github.com/liujianping/api" 5 | "github.com/liujianping/wechat/conf" 6 | "github.com/liujianping/wechat/entry" 7 | ) 8 | 9 | func (c *Client) GetUserInfo(opendid, lang string, user_info *entry.UserInfo) error { 10 | if err := c.Access(nil); err != nil { 11 | return err 12 | } 13 | 14 | agent := api.Post(conf.MakeURL("user.info")).Debug(c.debug) 15 | agent.QuerySet("access_token", c.token.Secret) 16 | agent.QuerySet("openid", opendid) 17 | agent.QuerySet("lang", lang) 18 | 19 | if _, _, err := agent.JSON(user_info); err != nil { 20 | return err 21 | } 22 | 23 | if user_info.ApiError.Code != 0 { 24 | return user_info.ApiError 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /client.menu.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "github.com/liujianping/api" 5 | "github.com/liujianping/wechat/conf" 6 | "github.com/liujianping/wechat/entry" 7 | ) 8 | 9 | func (c *Client) CreateMenu(m *entry.Menu) error { 10 | if err := c.Access(nil); err != nil { 11 | return err 12 | } 13 | 14 | agent := api.Post(conf.MakeURL("menu.create")).Debug(c.debug) 15 | agent.QuerySet("access_token", c.token.Secret) 16 | agent.JSONData(m, true) 17 | 18 | var e entry.ApiError 19 | if _, _, err := agent.JSON(&e); err != nil { 20 | return err 21 | } 22 | 23 | if e.Code != 0 { 24 | return e 25 | } 26 | return nil 27 | } 28 | 29 | func (c *Client) DeleteMenu() error { 30 | if err := c.Access(nil); err != nil { 31 | return err 32 | } 33 | 34 | agent := api.Get(conf.MakeURL("menu.delete")).Debug(c.debug) 35 | agent.QuerySet("access_token", c.token.Secret) 36 | 37 | var e entry.ApiError 38 | if _, _, err := agent.JSON(&e); err != nil { 39 | return err 40 | } 41 | 42 | if e.Code != 0 { 43 | return e 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/liujianping/api" 7 | "github.com/liujianping/wechat/conf" 8 | "github.com/liujianping/wechat/entry" 9 | ) 10 | 11 | type Client struct { 12 | appid string 13 | secret string 14 | token *entry.Token 15 | debug bool 16 | } 17 | 18 | func NewClient(appid, secret string) *Client { 19 | return &Client{ 20 | appid: appid, 21 | secret: secret, 22 | token: nil, 23 | } 24 | } 25 | 26 | func (c *Client) Debug(flag bool) *Client { 27 | c.debug = flag 28 | return c 29 | } 30 | 31 | func (c *Client) Access(tk *entry.Token) error { 32 | if c.token != nil { 33 | expire := c.token.CreateAt.Add(time.Duration(c.token.ExpireIn) * time.Second) 34 | if expire.After(time.Now()) { 35 | if tk != nil { 36 | tk = c.token 37 | } 38 | return nil 39 | } 40 | } 41 | 42 | agent := api.Get(conf.MakeURL("access.token")).Debug(c.debug) 43 | agent.QuerySet("grant_type", "client_credential") 44 | agent.QuerySet("appid", c.appid) 45 | agent.QuerySet("secret", c.secret) 46 | var token entry.Token 47 | if _, _, err := agent.JSON(&token); err != nil { 48 | return err 49 | } 50 | token.CreateAt = time.Now() 51 | c.token = &token 52 | if tk != nil { 53 | tk = &token 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /entry/subscriber.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | /* 4 | { 5 | "subscribe": 1, 6 | "openid": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M", 7 | "nickname": "Band", 8 | "sex": 1, 9 | "language": "zh_CN", 10 | "city": "广州", 11 | "province": "广东", 12 | "country": "中国", 13 | "headimgurl": "http://wx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0", 14 | "subscribe_time": 1382694957, 15 | "unionid": " o6_bmasdasdsad6_2sgVt7hMZOPfL" 16 | "remark": "", 17 | "groupid": 0 18 | } 19 | */ 20 | const ( 21 | LangZhCN = "zh_CN" // 简体中文 22 | LangZhTW = "zh_TW" // 繁体中文 23 | LangEN = "en" // 英文 24 | ) 25 | 26 | const ( 27 | SexUnknown = 0 // 未知 28 | SexMale = 1 // 男性 29 | SexFemale = 2 // 女性 30 | ) 31 | 32 | type UserInfo struct { 33 | ApiError 34 | IsSubscriber int64 `json:"subscribe"` // 用户是否订阅该公众号标识, 值为0时, 代表此用户没有关注该公众号, 拉取不到其余信息 35 | OpenID string `json:"openid"` // 用户的标识, 对当前公众号唯一 36 | NickName string `json:"nickname"` // 用户的昵称 37 | Sex int64 `json:"sex"` // 用户的性别, 值为1时是男性, 值为2时是女性, 值为0时是未知 38 | Language string `json:"language"` // 用户的语言, zh_CN, zh_TW, en 39 | City string `json:"city"` // 用户所在城市 40 | Province string `json:"province"` // 用户所在省份 41 | Country string `json:"country"` // 用户所在国家 42 | HeadImageURL string `json:"headimgurl"` // 用户头像, 最后一个数值代表正方形头像大小(有0, 46, 64, 96, 132数值可选, 0代表640*640正方形头像), 用户没有头像时该项为空 43 | SubscribeTime int64 `json:"subscribe_time"` // 用户关注时间, 为时间戳. 如果用户曾多次关注, 则取最后关注时间 44 | UnionID string `json:"unionid,omitempty"` // 只有在用户将公众号绑定到微信开放平台帐号后, 才会出现该字段. 45 | Remark string `json:"remark"` // 公众号运营者对粉丝的备注, 公众号运营者可在微信公众平台用户管理界面对粉丝添加备注 46 | GroupID int64 `json:"groupid"` // 用户所在的分组ID 47 | Tags []string `json:"tagid_list"` 48 | } 49 | -------------------------------------------------------------------------------- /demo/demo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | "github.com/liujianping/wechat" 10 | "github.com/liujianping/wechat/entry" 11 | ) 12 | 13 | func DemoHandle(app *wechat.Application, request *entry.Request) (interface{}, error) { 14 | switch strings.ToLower(request.MsgType) { 15 | case entry.MsgTypeEvent: 16 | switch strings.ToLower(request.Event) { 17 | case entry.EventSubscribe: 18 | log.Printf("user (%s) subscribed", request.FromUserName) 19 | 20 | var user_info entry.UserInfo 21 | if err := app.Api().GetUserInfo(request.FromUserName, entry.LangZhCN, &user_info); err != nil { 22 | return nil, err 23 | } 24 | 25 | text := entry.NewText(request.FromUserName, 26 | request.ToUserName, 27 | time.Now().Unix(), 28 | fmt.Sprintf("亲爱的(%s), 谢谢您的关注!", user_info.NickName)) 29 | return text, nil 30 | 31 | case entry.EventUnSubscribe: 32 | log.Printf("user (%s) unsubscribed", request.FromUserName) 33 | case entry.EventScan: 34 | log.Printf("user (%s) scan", request.FromUserName) 35 | case entry.EventLocation: 36 | log.Printf("user (%s) location", request.FromUserName) 37 | case entry.EventClick: 38 | log.Printf("user (%s) menu click (%s)", request.FromUserName, request.EventKey) 39 | case entry.EventView: 40 | log.Printf("user (%s) menu view (%s)", request.FromUserName, request.EventKey) 41 | } 42 | case entry.MsgTypeText: 43 | text := entry.NewText(request.FromUserName, request.ToUserName, time.Now().Unix(), request.TextContent) 44 | return text, nil 45 | case entry.MsgTypeImage: 46 | case entry.MsgTypeVoice: 47 | case entry.MsgTypeVideo: 48 | case entry.MsgTypeMusic: 49 | case entry.MsgTypeNews: 50 | } 51 | return nil, nil 52 | } 53 | 54 | func main() { 55 | app := wechat.NewApplication("/demo", "demo_secret", "wx02da1455ece52e5a", "9340ce4b0ab01f33e66dcf9650103fb3", false) 56 | 57 | btn1 := entry.NewButton("链接菜单").URL("https://github.com/liujianping/wechat") 58 | btn2 := entry.NewButton("点击菜单").Event("EVENT_100") 59 | btn3 := entry.NewButton("更多").SubButton(btn1, btn2) 60 | app.Menu(entry.NewMenu(btn1, btn2, btn3)) 61 | 62 | serv := wechat.NewServer(":8080").Debug(true) 63 | serv.Application(app, DemoHandle) 64 | 65 | serv.Start() 66 | } 67 | -------------------------------------------------------------------------------- /entry/request.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | import "encoding/xml" 4 | 5 | const ( 6 | MsgTypeEvent = "event" // 文本消息 7 | MsgTypeText = "text" // 文本消息 8 | MsgTypeImage = "image" // 图片消息 9 | MsgTypeVoice = "voice" // 语音消息 10 | MsgTypeVideo = "video" // 视频消息 11 | MsgTypeMusic = "music" // 音乐消息 12 | MsgTypeNews = "news" // 图文消息 13 | MsgTypeTransferCustomerService = "transfer_customer_service" // 将消息转发到多客服 14 | ) 15 | 16 | const ( 17 | EventSubscribe = "subscribe" // 订阅事件 18 | EventUnSubscribe = "unsubscribe" // 取消订阅事件 19 | EventScan = "scan" // 扫描 20 | EventLocation = "location" // 定位 21 | EventClick = "click" // 自定义菜单点击事件 22 | EventView = "view" // 自定义菜单跳转事件 23 | ) 24 | 25 | //! msg request referrence: http://mp.weixin.qq.com/wiki/17/fc9a27730e07b9126144d9c96eaf51f9.html 26 | //! evt request referrence: http://mp.weixin.qq.com/wiki/14/f79bdec63116f376113937e173652ba2.html 27 | type Request struct { 28 | XMLName xml.Name `xml:"xml"` 29 | ToUserName string `xml:"ToUserName"` 30 | FromUserName string `xml:"FromUserName"` 31 | CreateTime int64 `xml:"CreateTime"` 32 | MsgType string `xml:"MsgType"` 33 | MsgID string `xml:"MsgId"` 34 | MediaID string `xml:"MediaId"` 35 | TextContent string `xml:"Content"` 36 | PictureURL string `xml:"PicUrl"` 37 | VoiceFormat string `xml:"Format"` 38 | VoiceRecognition string `xml:"Recognition"` 39 | VideoThumbMediaID string `xml:"ThumbMediaId"` 40 | LocationLabel string `xml:"Label"` 41 | LocationX float64 `xml:"Location_X"` 42 | LocationY float64 `xml:"Location_Y"` 43 | LocationScale float64 `xml:"Scale"` 44 | LinkTitle string `xml:"Title"` 45 | LinkDescription string `xml:"Description"` 46 | LinkURL string `xml:"Url"` 47 | Event string `xml:"Event"` 48 | EventKey string `xml:"EventKey"` 49 | EventTicket string `xml:"Ticket"` 50 | EventLatitude float64 `xml:"Latitude"` 51 | EventLongitude float64 `xml:"Longitude"` 52 | EventPrecision float64 `xml:"Precision"` 53 | } 54 | -------------------------------------------------------------------------------- /entry/menu.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | const ( 4 | // 下面6个类型(包括view类型)的按钮是在公众平台官网发布的菜单按钮类型 5 | ButtonTypeText = "text" 6 | ButtonTypeImage = "img" 7 | ButtonTypePhoto = "photo" 8 | ButtonTypeVideo = "video" 9 | ButtonTypeVoice = "voice" 10 | 11 | // 上面5个类型的按钮不能通过API设置 12 | 13 | ButtonTypeView = "view" // 跳转URL 14 | ButtonTypeClick = "click" // 点击推事件 15 | 16 | // 下面的按钮类型仅支持微信 iPhone5.4.1 以上版本, 和 Android5.4 以上版本的微信用户, 17 | // 旧版本微信用户点击后将没有回应, 开发者也不能正常接收到事件推送. 18 | ButtonTypeScanCodePush = "scancode_push" // 扫码推事件 19 | ButtonTypeScanCodeWaitMsg = "scancode_waitmsg" // 扫码带提示 20 | ButtonTypePicSysPhoto = "pic_sysphoto" // 系统拍照发图 21 | ButtonTypePicPhotoOrAlbum = "pic_photo_or_album" // 拍照或者相册发图 22 | ButtonTypePicWeixin = "pic_weixin" // 微信相册发图 23 | ButtonTypeLocationSelect = "location_select" // 发送位置 24 | 25 | // 下面的按钮类型专门给第三方平台旗下未微信认证(具体而言, 是资质认证未通过)的订阅号准备的事件类型, 26 | // 它们是没有事件推送的, 能力相对受限, 其他类型的公众号不必使用. 27 | ButtonTypeMediaId = "media_id" // 下发消息 28 | ButtonTypeViewLimited = "view_limited" // 跳转图文消息URL 29 | ) 30 | 31 | type Menu struct { 32 | Buttons []*Button `json:"button,omitempty"` 33 | MenuId int64 `json:"menuid,omitempty"` // 有个性化菜单时查询接口返回值包含这个字段 34 | } 35 | 36 | func NewMenu(btns ...*Button) *Menu { 37 | var buttons []*Button 38 | for i, btn := range btns { 39 | if i < 3 { 40 | buttons = append(buttons, btn) 41 | } 42 | } 43 | return &Menu{Buttons: buttons} 44 | } 45 | 46 | type Button struct { 47 | Type string `json:"type,omitempty"` // 非必须; 菜单的响应动作类型 48 | Name string `json:"name,omitempty"` // 必须; 菜单标题 49 | Key string `json:"key,omitempty"` // 非必须; 菜单KEY值, 用于消息接口推送 50 | Url string `json:"url,omitempty"` // 非必须; 网页链接, 用户点击菜单可打开链接 51 | MediaId string `json:"media_id,omitempty"` // 非必须; 调用新增永久素材接口返回的合法media_id 52 | SubButtons []*Button `json:"sub_button,omitempty"` // 非必须; 二级菜单数组 53 | } 54 | 55 | func NewButton(caption string) *Button { 56 | return &Button{ 57 | Name: caption, 58 | } 59 | } 60 | 61 | func (btn *Button) SubButton(btns ...*Button) *Button { 62 | btn.SubButtons = append(btn.SubButtons, btns...) 63 | return btn 64 | } 65 | 66 | func (btn *Button) Event(key string) *Button { 67 | btn.Type = ButtonTypeClick 68 | btn.Key = key 69 | return btn 70 | } 71 | 72 | func (btn *Button) URL(url string) *Button { 73 | btn.Type = ButtonTypeView 74 | btn.Url = url 75 | return btn 76 | } 77 | 78 | func (btn *Button) ScanCodePush(key string) *Button { 79 | btn.Type = ButtonTypeScanCodePush 80 | btn.Key = key 81 | return btn 82 | } 83 | 84 | func (btn *Button) ScanCodeWaitMsg(key string) *Button { 85 | btn.Type = ButtonTypeScanCodeWaitMsg 86 | btn.Key = key 87 | return btn 88 | } 89 | 90 | func (btn *Button) PicSysPhoto(key string) *Button { 91 | btn.Type = ButtonTypePicSysPhoto 92 | btn.Key = key 93 | return btn 94 | } 95 | 96 | func (btn *Button) PicPhotoOrAlbum(key string) *Button { 97 | btn.Type = ButtonTypePicPhotoOrAlbum 98 | btn.Key = key 99 | return btn 100 | } 101 | 102 | func (btn *Button) PicWeixin(key string) *Button { 103 | btn.Type = ButtonTypePicWeixin 104 | btn.Key = key 105 | return btn 106 | } 107 | 108 | func (btn *Button) LocationSelect(key string) *Button { 109 | btn.Type = ButtonTypeLocationSelect 110 | btn.Key = key 111 | return btn 112 | } 113 | 114 | func (btn *Button) MediaID(mediaId string) *Button { 115 | btn.Type = ButtonTypeMediaId 116 | btn.MediaId = mediaId 117 | return btn 118 | } 119 | 120 | func (btn *Button) ViewLimited(mediaId string) *Button { 121 | btn.Type = ButtonTypeViewLimited 122 | btn.MediaId = mediaId 123 | return btn 124 | } 125 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "crypto/sha1" 5 | "fmt" 6 | "log" 7 | "sort" 8 | "time" 9 | 10 | "github.com/kataras/iris" 11 | "github.com/liujianping/wechat/entry" 12 | ) 13 | 14 | type Application struct { 15 | uri string 16 | token string 17 | appid string 18 | secret string 19 | customer bool 20 | menu *entry.Menu 21 | api *Client 22 | } 23 | 24 | type Handle func(app *Application, request *entry.Request) (response interface{}, err error) 25 | 26 | func NewApplication(uri, token, appid, secret string, customer bool) *Application { 27 | return &Application{ 28 | uri: uri, 29 | token: token, 30 | appid: appid, 31 | secret: secret, 32 | customer: customer, 33 | } 34 | } 35 | 36 | func (app *Application) Menu(menu *entry.Menu) *Application { 37 | app.menu = menu 38 | return app 39 | } 40 | 41 | func (app *Application) Api() *Client { 42 | if app.api == nil { 43 | app.api = NewClient(app.appid, app.secret) 44 | } 45 | return app.api 46 | } 47 | 48 | func (app *Application) signature(signature, timestamp, nonce string) bool { 49 | strs := sort.StringSlice{app.token, timestamp, nonce} 50 | sort.Strings(strs) 51 | str := "" 52 | 53 | for _, s := range strs { 54 | str += s 55 | } 56 | 57 | h := sha1.New() 58 | h.Write([]byte(str)) 59 | 60 | signature_now := fmt.Sprintf("%x", h.Sum(nil)) 61 | if signature == signature_now { 62 | return true 63 | } 64 | return false 65 | } 66 | 67 | type Server struct { 68 | address string 69 | applications map[string]*Application 70 | handles map[string]Handle 71 | debug bool 72 | } 73 | 74 | func NewServer(address string) *Server { 75 | return &Server{ 76 | address: address, 77 | applications: make(map[string]*Application), 78 | handles: make(map[string]Handle), 79 | } 80 | } 81 | 82 | func (srv *Server) Application(app *Application, handle Handle) *Server { 83 | srv.applications[app.uri] = app 84 | srv.handles[app.uri] = handle 85 | return srv 86 | } 87 | 88 | func (srv *Server) Debug(flag bool) *Server { 89 | srv.debug = flag 90 | return srv 91 | } 92 | 93 | func (srv *Server) Start() { 94 | for uri, app := range srv.applications { 95 | if app.menu != nil { 96 | app.Api().Debug(srv.debug) 97 | if err := app.Api().DeleteMenu(); err != nil { 98 | log.Printf("wechat server started faild: %s\n", err.Error()) 99 | return 100 | } 101 | if err := app.Api().CreateMenu(app.menu); err != nil { 102 | log.Printf("wechat server started faild: %s\n", err.Error()) 103 | return 104 | } 105 | } 106 | iris.Get(uri, srv.Get) 107 | iris.Post(uri, srv.Post) 108 | } 109 | iris.Listen(srv.address) 110 | } 111 | 112 | func (srv *Server) Get(c *iris.Context) { 113 | if app, ok := srv.applications[c.PathString()]; ok { 114 | signature := c.URLParam("signature") 115 | timestamp := c.URLParam("timestamp") 116 | nonce := c.URLParam("nonce") 117 | echostr := c.URLParam("echostr") 118 | 119 | if app.signature(signature, timestamp, nonce) == true { 120 | c.Write(echostr) 121 | return 122 | } 123 | } 124 | c.NotFound() 125 | return 126 | } 127 | 128 | func (srv *Server) Post(c *iris.Context) { 129 | if app, ok := srv.applications[c.PathString()]; ok { 130 | var request entry.Request 131 | if err := c.ReadXML(&request); err == nil { 132 | if handle, ok := srv.handles[c.PathString()]; ok { 133 | //! customer service 134 | if app.customer { 135 | go handle(app, &request) 136 | resp := entry.NewTransferToCustomerService(request.FromUserName, request.ToUserName, time.Now().Unix(), "") 137 | c.XML(200, resp) 138 | return 139 | } else { 140 | if resp, err := handle(app, &request); err == nil { 141 | if resp != nil { 142 | c.XML(200, resp) 143 | return 144 | } 145 | } 146 | } 147 | } 148 | } 149 | } 150 | c.Write("success") 151 | return 152 | } 153 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## wechat 开发框架 2 | 3 | [![GoDoc](http://godoc.org/github.com/liujianping/wechat?status.png)](http://godoc.org/github.com/liujianping/wechat) 4 | 5 | 该框架仅为抛砖之作, 没有实现的微信公众号的全部api接口, 仅实现部分接口包括: 6 | 7 | - [获取接口调用凭据](http://mp.weixin.qq.com/wiki/2/88b2bf1265a707c031e51f26ca5e6512.html) 8 | - [接收消息](http://mp.weixin.qq.com/wiki/17/fc9a27730e07b9126144d9c96eaf51f9.html) 9 | - [发送消息](http://mp.weixin.qq.com/wiki/18/c66a9f0b5aa952346e46dc39de20f672.html) 10 | - [自定义菜单](http://mp.weixin.qq.com/wiki/6/95cade7d98b6c1e1040cde5d9a2f9c26.html) 11 | - [用户管理](http://mp.weixin.qq.com/wiki/17/c807ee0f10ce36226637cebf428a0f6d.html) 12 | 13 | ### 项目依赖 14 | 15 | - [api](http://github.com/liujianping/api) 16 | - [iris](http://github.com/kataras/iris) 17 | 18 | ### 快速开始 19 | 20 | 客户端快速开发指南: 21 | 22 | ````go 23 | 24 | import "github.com/liujianping/wechat" 25 | import "github.com/liujianping/wechat/entry" 26 | 27 | api := wechat.NewClient("appid", "appsecret") 28 | 29 | // 获取令牌 30 | var token entry.Token 31 | if err := api.Access(&token); err != nil { 32 | 33 | } 34 | 35 | // 获取用户信息 36 | var user_info entry.UserInfo 37 | if err := api.GetUserInfo("open_id", "zh_CN", &user_info); err != nil { 38 | 39 | } 40 | 41 | // 更多接口(参考其它微信项目的对象定义即可轻松实现) 42 | ... 43 | 44 | ```` 45 | 46 | 服务端(支持多应用)快速开发指南: 47 | 48 | ````go 49 | 50 | package main 51 | 52 | import ( 53 | "log" 54 | 55 | "github.com/liujianping/wechat" 56 | "github.com/liujianping/wechat/entry" 57 | ) 58 | 59 | func DemoHandle(app *wechat.Application, request *entry.Request) (interface{}, error) { 60 | log.Printf("demo msg (%v)\n", request) 61 | return nil, nil 62 | } 63 | 64 | func EchoHandle(app *wechat.Application, request *entry.Request) (interface{}, error) { 65 | switch strings.ToLower(request.MsgType) { 66 | case entry.MsgTypeEvent: 67 | switch strings.ToLower(request.Event) { 68 | case entry.EventSubscribe: 69 | log.Printf("user (%s) subscribed", request.FromUserName) 70 | 71 | var user_info entry.UserInfo 72 | if err := app.Api().GetUserInfo(request.FromUserName, entry.LangZhCN, &user_info); err != nil { 73 | return nil, err 74 | } 75 | 76 | text := entry.NewText(request.FromUserName, 77 | request.ToUserName, 78 | time.Now().Unix(), 79 | fmt.Sprintf("亲爱的(%s), 谢谢您的关注!", user_info.NickName)) 80 | return text, nil 81 | 82 | case entry.EventUnSubscribe: 83 | log.Printf("user (%s) unsubscribed", request.FromUserName) 84 | case entry.EventScan: 85 | log.Printf("user (%s) scan", request.FromUserName) 86 | case entry.EventLocation: 87 | log.Printf("user (%s) location", request.FromUserName) 88 | case entry.EventClick: 89 | log.Printf("user (%s) menu click (%s)", request.FromUserName, request.EventKey) 90 | case entry.EventView: 91 | log.Printf("user (%s) menu view (%s)", request.FromUserName, request.EventKey) 92 | } 93 | case entry.MsgTypeText: 94 | //! echo 95 | text := entry.NewText(request.FromUserName, request.ToUserName, time.Now().Unix(), request.TextContent) 96 | return text, nil 97 | case entry.MsgTypeImage: 98 | case entry.MsgTypeVoice: 99 | case entry.MsgTypeVideo: 100 | case entry.MsgTypeMusic: 101 | case entry.MsgTypeNews: 102 | } 103 | return nil, nil 104 | } 105 | 106 | func main() { 107 | demo := wechat.NewApplication("/demo", "demo_secret", "appid", "secret", false) 108 | 109 | btn11 := entry.NewButton("link").URL("http://github.com/liujianping/wechat") 110 | btn12 := entry.NewButton("click").Event("event_click") 111 | demo.Menu(entry.NewMenu(btn11, btn12)) 112 | 113 | 114 | echo := wechat.NewApplication("/echo", "echo_secret", "appid", "secret", false) 115 | 116 | btn21 := entry.NewButton("link").URL("http://github.com/liujianping/wechat") 117 | btn22 := entry.NewButton("click").Event("event_click") 118 | echo.Menu(entry.NewMenu(btn21, btn22)) 119 | 120 | serv := wechat.NewServer(":8080") 121 | 122 | serv.Application(demo, DemoHandle) 123 | serv.Application(echo, EchoHandle) 124 | 125 | serv.Start() 126 | } 127 | 128 | ```` 129 | ### 微信测试号 130 | 131 | [申请](https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login) 132 | 133 | ### 例子 134 | 135 | 参考 [demo](https://github.com/liujianping/wechat/blob/master/demo/demo.go) 实现. 136 | 137 | ### 老版本 138 | 139 | [README](https://github.com/liujianping/wechat/blob/v0.1/README.md) 140 | 141 | 142 | 143 | -------------------------------------------------------------------------------- /entry/response.go: -------------------------------------------------------------------------------- 1 | package entry 2 | 3 | //! xml response: http://mp.weixin.qq.com/wiki/18/c66a9f0b5aa952346e46dc39de20f672.html 4 | 5 | type MsgHead struct { 6 | ToUserName string `xml:"ToUserName" json:"ToUserName"` 7 | FromUserName string `xml:"FromUserName" json:"FromUserName"` 8 | CreateTime int64 `xml:"CreateTime" json:"CreateTime"` 9 | MsgType string `xml:"MsgType" json:"MsgType"` 10 | } 11 | 12 | // 文本消息 13 | type Text struct { 14 | XMLName struct{} `xml:"xml" json:"-"` 15 | MsgHead 16 | Content string `xml:"Content" json:"Content"` // 回复的消息内容(换行: 在content中能够换行, 微信客户端支持换行显示) 17 | } 18 | 19 | func NewText(to, from string, timestamp int64, content string) (text *Text) { 20 | return &Text{ 21 | MsgHead: MsgHead{ 22 | ToUserName: to, 23 | FromUserName: from, 24 | CreateTime: timestamp, 25 | MsgType: MsgTypeText, 26 | }, 27 | Content: content, 28 | } 29 | } 30 | 31 | // 图片消息 32 | type Image struct { 33 | XMLName struct{} `xml:"xml" json:"-"` 34 | MsgHead 35 | Image struct { 36 | MediaId string `xml:"MediaId" json:"MediaId"` // 通过素材管理接口上传多媒体文件得到 MediaId 37 | } `xml:"Image" json:"Image"` 38 | } 39 | 40 | func NewImage(to, from string, timestamp int64, mediaId string) (image *Image) { 41 | image = &Image{ 42 | MsgHead: MsgHead{ 43 | ToUserName: to, 44 | FromUserName: from, 45 | CreateTime: timestamp, 46 | MsgType: MsgTypeImage, 47 | }, 48 | } 49 | image.Image.MediaId = mediaId 50 | return 51 | } 52 | 53 | // 语音消息 54 | type Voice struct { 55 | XMLName struct{} `xml:"xml" json:"-"` 56 | MsgHead 57 | Voice struct { 58 | MediaId string `xml:"MediaId" json:"MediaId"` // 通过素材管理接口上传多媒体文件得到 MediaId 59 | } `xml:"Voice" json:"Voice"` 60 | } 61 | 62 | func NewVoice(to, from string, timestamp int64, mediaId string) (voice *Voice) { 63 | voice = &Voice{ 64 | MsgHead: MsgHead{ 65 | ToUserName: to, 66 | FromUserName: from, 67 | CreateTime: timestamp, 68 | MsgType: MsgTypeVoice, 69 | }, 70 | } 71 | voice.Voice.MediaId = mediaId 72 | return 73 | } 74 | 75 | // 视频消息 76 | type Video struct { 77 | XMLName struct{} `xml:"xml" json:"-"` 78 | MsgHead 79 | Video struct { 80 | MediaId string `xml:"MediaId" json:"MediaId"` // 通过素材管理接口上传多媒体文件得到 MediaId 81 | Title string `xml:"Title,omitempty" json:"Title,omitempty"` // 视频消息的标题, 可以为空 82 | Description string `xml:"Description,omitempty" json:"Description,omitempty"` // 视频消息的描述, 可以为空 83 | } `xml:"Video" json:"Video"` 84 | } 85 | 86 | func NewVideo(to, from string, timestamp int64, mediaId, title, description string) (video *Video) { 87 | video = &Video{ 88 | MsgHead: MsgHead{ 89 | ToUserName: to, 90 | FromUserName: from, 91 | CreateTime: timestamp, 92 | MsgType: MsgTypeVideo, 93 | }, 94 | } 95 | video.Video.MediaId = mediaId 96 | video.Video.Title = title 97 | video.Video.Description = description 98 | return 99 | } 100 | 101 | // 音乐消息 102 | type Music struct { 103 | XMLName struct{} `xml:"xml" json:"-"` 104 | MsgHead 105 | Music struct { 106 | Title string `xml:"Title,omitempty" json:"Title,omitempty"` // 音乐标题 107 | Description string `xml:"Description,omitempty" json:"Description,omitempty"` // 音乐描述 108 | MusicURL string `xml:"MusicUrl" json:"MusicUrl"` // 音乐链接 109 | HQMusicURL string `xml:"HQMusicUrl" json:"HQMusicUrl"` // 高质量音乐链接, WIFI环境优先使用该链接播放音乐 110 | ThumbMediaId string `xml:"ThumbMediaId" json:"ThumbMediaId"` // 通过素材管理接口上传多媒体文件得到 ThumbMediaId 111 | } `xml:"Music" json:"Music"` 112 | } 113 | 114 | func NewMusic(to, from string, timestamp int64, thumbMediaId, musicURL, HQMusicURL, title, description string) (music *Music) { 115 | music = &Music{ 116 | MsgHead: MsgHead{ 117 | ToUserName: to, 118 | FromUserName: from, 119 | CreateTime: timestamp, 120 | MsgType: MsgTypeMusic, 121 | }, 122 | } 123 | music.Music.Title = title 124 | music.Music.Description = description 125 | music.Music.MusicURL = musicURL 126 | music.Music.HQMusicURL = HQMusicURL 127 | music.Music.ThumbMediaId = thumbMediaId 128 | return 129 | } 130 | 131 | // 图文消息里的 Article 132 | type Article struct { 133 | Title string `xml:"Title,omitempty" json:"Title,omitempty"` // 图文消息标题 134 | Description string `xml:"Description,omitempty" json:"Description,omitempty"` // 图文消息描述 135 | PicURL string `xml:"PicUrl,omitempty" json:"PicUrl,omitempty"` // 图片链接, 支持JPG, PNG格式, 较好的效果为大图360*200, 小图200*200 136 | URL string `xml:"Url,omitempty" json:"Url,omitempty"` // 点击图文消息跳转链接 137 | } 138 | 139 | // 图文消息 140 | type News struct { 141 | XMLName struct{} `xml:"xml" json:"-"` 142 | MsgHead 143 | ArticleCount int `xml:"ArticleCount" json:"ArticleCount"` // 图文消息个数, 限制为10条以内 144 | Articles []Article `xml:"Articles>item,omitempty" json:"Articles,omitempty"` // 多条图文消息信息, 默认第一个item为大图, 注意, 如果图文数超过10, 则将会无响应 145 | } 146 | 147 | func NewNews(to, from string, timestamp int64, articles []Article) (news *News) { 148 | news = &News{ 149 | MsgHead: MsgHead{ 150 | ToUserName: to, 151 | FromUserName: from, 152 | CreateTime: timestamp, 153 | MsgType: MsgTypeNews, 154 | }, 155 | } 156 | news.ArticleCount = len(articles) 157 | news.Articles = articles 158 | return 159 | } 160 | 161 | // 将消息转发到多客服, 参见多客服模块 162 | type TransferToCustomerService struct { 163 | XMLName struct{} `xml:"xml" json:"-"` 164 | MsgHead 165 | TransInfo *TransInfo `xml:"TransInfo,omitempty" json:"TransInfo,omitempty"` 166 | } 167 | 168 | type TransInfo struct { 169 | KfAccount string `xml:"KfAccount" json:"KfAccount"` 170 | } 171 | 172 | // 如果不指定客服则 kfAccount 留空. 173 | func NewTransferToCustomerService(to, from string, timestamp int64, kfAccount string) (msg *TransferToCustomerService) { 174 | msg = &TransferToCustomerService{ 175 | MsgHead: MsgHead{ 176 | ToUserName: to, 177 | FromUserName: from, 178 | CreateTime: timestamp, 179 | MsgType: MsgTypeTransferCustomerService, 180 | }, 181 | } 182 | if kfAccount != "" { 183 | msg.TransInfo = &TransInfo{ 184 | KfAccount: kfAccount, 185 | } 186 | } 187 | return 188 | } 189 | -------------------------------------------------------------------------------- /app.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | // import ( 4 | // "fmt" 5 | // "time" 6 | // "encoding/xml" 7 | // "io/ioutil" 8 | // "github.com/astaxie/beego/config" 9 | // "net/http" 10 | // "os" 11 | // "path/filepath" 12 | // "sync" 13 | // "errors" 14 | // "github.com/liujianping/wechat/entry" 15 | // ) 16 | 17 | // type CallbackInterface interface { 18 | // Initialize(app *WeChatApp, api *ApiClient) 19 | // MsgText(txt *entry.TextRequest, back chan interface{}) 20 | // MsgImage(img *entry.ImageRequest, back chan interface{}) 21 | // MsgVoice(voice *entry.VoiceRequest, back chan interface{}) 22 | // MsgVideo(video *entry.VideoRequest, back chan interface{}) 23 | // MsgLink(link *entry.LinkRequest, back chan interface{}) 24 | // Location(location *entry.LocationRequest, back chan interface{}) 25 | // EventSubscribe(appoid string, oid string, back chan interface{}) 26 | // EventUnsubscribe(appoid string, oid string, back chan interface{}) 27 | // EventMenu(appoid string, oid string, key string, back chan interface{}) 28 | // } 29 | 30 | // type Handle func (app *WeChatApp, data []byte, back chan []byte) 31 | 32 | // type WeChatApp struct{ 33 | // AppHost string 34 | // AppPort int 35 | // AppURI string 36 | // AppToken string 37 | // AppId string 38 | // AppSecret string 39 | // AppPath string 40 | // Config config.ConfigContainer 41 | // menu *entry.Menu 42 | // cb CallbackInterface 43 | // handle Handle 44 | // api *ApiClient 45 | // once sync.Once 46 | // } 47 | 48 | // func NewWeChatApp() *WeChatApp { 49 | // app := new(WeChatApp) 50 | // app.AppPath, _ = filepath.Abs(filepath.Dir(os.Args[0])) 51 | // os.Chdir(app.AppPath) 52 | 53 | // app.AppHost = "" 54 | // app.AppPort = 80 55 | // app.AppURI = "/" 56 | 57 | // app.AppToken = "" 58 | // app.AppId = "" 59 | // app.AppSecret = "" 60 | // return app 61 | // } 62 | 63 | // func (app *WeChatApp) SetConfig(adapter, file string) error { 64 | // var err error 65 | // app.Config, err = config.NewConfig(adapter, file) 66 | // if err != nil { 67 | // return err 68 | // } else { 69 | // app.AppHost = app.Config.String("AppHost") 70 | 71 | // if v, err := app.Config.Int("AppPort"); err == nil { 72 | // app.AppPort = v 73 | // } 74 | 75 | // if v := app.Config.String("AppURI"); v != "" { 76 | // app.AppURI = v 77 | // } 78 | // if v := app.Config.String("AppToken"); v != "" { 79 | // app.AppToken = v 80 | // } 81 | // if v := app.Config.String("AppId"); v != "" { 82 | // app.AppId = v 83 | // } 84 | // if v := app.Config.String("AppSecret"); v != "" { 85 | // app.AppSecret = v 86 | // } 87 | // } 88 | // return nil 89 | // } 90 | 91 | // func (app *WeChatApp) SetMenu(menu *entry.Menu){ 92 | // app.menu = menu 93 | // } 94 | 95 | // func (app *WeChatApp) SetCallback(callback CallbackInterface) { 96 | // app.cb = callback 97 | // } 98 | 99 | // func (app *WeChatApp) SetHandle(handle Handle) { 100 | // app.handle = handle 101 | // } 102 | 103 | // func (app *WeChatApp) Run() { 104 | // defer func(){ 105 | // if x := recover(); x != nil { 106 | // fmt.Println("wechat : app panic for <", x, ">") 107 | // } 108 | // }() 109 | 110 | // if err := app.initialize(); err != nil { 111 | // panic(err) 112 | // } 113 | 114 | // http.HandleFunc(app.AppURI, app.uri) 115 | // http.ListenAndServe(fmt.Sprintf("%s:%d", app.AppHost, app.AppPort), nil) 116 | // } 117 | 118 | // func (app *WeChatApp) initialize() error{ 119 | // if app.AppId == "" || app.AppToken == "" || app.AppSecret == "" { 120 | // return errors.New("wechat: app id or token or secret not setting!") 121 | // } 122 | 123 | // if app.cb == nil && app.handle == nil { 124 | // return errors.New("wechat: handle & callback both unset") 125 | // } 126 | 127 | // app.api = NewApiClient(app.AppToken, app.AppId, app.AppSecret) 128 | 129 | // if app.cb != nil { 130 | // app.cb.Initialize(app, app.api) 131 | // } 132 | 133 | // return nil 134 | // } 135 | 136 | // func (app *WeChatApp) buildMenu(){ 137 | // if app.menu != nil { 138 | // if err := app.api.RemoveMenu(); err != nil { 139 | // panic(err) 140 | // } 141 | // if err := app.api.CreateMenu(app.menu); err != nil { 142 | // panic(err) 143 | // } 144 | // } 145 | // } 146 | 147 | // func (app *WeChatApp) uri(wr http.ResponseWriter, req *http.Request) { 148 | // if req.Method == "GET" { 149 | // signature := req.FormValue("signature") 150 | // timestamp := req.FormValue("timestamp") 151 | // nonce := req.FormValue("nonce") 152 | // echostr := req.FormValue("echostr") 153 | 154 | // if app.api.Signature(signature, timestamp, nonce) == true { 155 | // wr.Write([]byte(echostr)) 156 | // app.once.Do(app.buildMenu) 157 | // } else { 158 | // wr.Write([]byte("")) 159 | // } 160 | // } else { 161 | // if app.handle != nil { 162 | 163 | // } 164 | // if err := app.execute(wr, req); err != nil { 165 | // Warn("wechat:", err) 166 | // } 167 | // } 168 | // } 169 | 170 | // func (app *WeChatApp) execute(wr http.ResponseWriter, req *http.Request) error { 171 | // data, err := ioutil.ReadAll(req.Body) 172 | // if err != nil { 173 | // return err 174 | // } 175 | 176 | // Debug("wechat: data \n", string(data)) 177 | // raw := make(chan []byte) 178 | // defer close(raw) 179 | 180 | // ch := make(chan interface{}) 181 | // defer close(ch) 182 | 183 | // timeout := make(chan bool, 1) 184 | // defer close(timeout) 185 | 186 | // go func(c chan bool) { 187 | // defer func(){ 188 | // if x := recover(); x != nil { 189 | // Debug("wechat: ", x) 190 | // } 191 | // }() 192 | 193 | // time.Sleep(3e9) // 等待3秒钟 194 | // c <- true 195 | // }(timeout) 196 | 197 | // if app.handle != nil { 198 | // go app.handle(app, data, raw) 199 | // } 200 | 201 | // if app.cb != nil { 202 | // request := &entry.Request{} 203 | // err = xml.Unmarshal(data, request) 204 | // if err != nil { 205 | // return err 206 | // } 207 | 208 | // event := request.Event 209 | // msgType := request.MsgType 210 | 211 | // if "event" == msgType { 212 | // //! event 213 | // switch (event){ 214 | // case "subscribe": 215 | // go app.cb.EventSubscribe(request.ToUserName, request.FromUserName, ch) 216 | // case "unsubscribe": 217 | // go app.cb.EventUnsubscribe(request.ToUserName, request.FromUserName, ch) 218 | // case "CLICK": 219 | // go app.cb.EventMenu(request.ToUserName, request.FromUserName, request.EventKey, ch) 220 | // case "LOCATION": 221 | // location := &entry.LocationRequest{} 222 | // err = xml.Unmarshal(data, location) 223 | // if err != nil { 224 | // return err 225 | // } 226 | // go app.cb.Location(location, ch) 227 | // default: 228 | // return errors.New("unknown event ") 229 | // } 230 | // } else { 231 | // //! other msg 232 | // switch (msgType){ 233 | // case "text": 234 | // text := &entry.TextRequest{} 235 | // err = xml.Unmarshal(data, text) 236 | // if err != nil { 237 | // return err 238 | // } 239 | // go app.cb.MsgText(text, ch) 240 | // case "image": 241 | // image := &entry.ImageRequest{} 242 | // err = xml.Unmarshal(data, image) 243 | // if err != nil { 244 | // return err 245 | // } 246 | // go app.cb.MsgImage(image, ch) 247 | // case "voice": 248 | // voice := &entry.VoiceRequest{} 249 | // err = xml.Unmarshal(data, voice) 250 | // if err != nil { 251 | // return err 252 | // } 253 | // go app.cb.MsgVoice(voice, ch) 254 | // case "video": 255 | // video := &entry.VideoRequest{} 256 | // err = xml.Unmarshal(data, video) 257 | // if err != nil { 258 | // return err 259 | // } 260 | // go app.cb.MsgVideo(video, ch) 261 | // case "location": 262 | // location := &entry.LocationRequest{} 263 | // err = xml.Unmarshal(data, location) 264 | // if err != nil { 265 | // return err 266 | // } 267 | // go app.cb.Location(location, ch) 268 | // case "link": 269 | // link := &entry.LinkRequest{} 270 | // err = xml.Unmarshal(data, link) 271 | // if err != nil { 272 | // return err 273 | // } 274 | // go app.cb.MsgLink(link, ch) 275 | // } 276 | // } 277 | // } 278 | 279 | // select{ 280 | // case r := <-raw: 281 | // Debug("wechat: get response \n", string(r)) 282 | // wr.Write(r) 283 | // case b := <-ch: 284 | // response,_ := xml.Marshal(b) 285 | // Debug("wechat: get response \n", string(response)) 286 | // wr.Write(response) 287 | // case <-timeout: 288 | // Warn("wechat: timeout for null response") 289 | // wr.Write([]byte("")) 290 | // } 291 | 292 | // return nil 293 | // } 294 | --------------------------------------------------------------------------------