├── test ├── mocha.opts └── wechat.js ├── .gitignore ├── index.js ├── package.json ├── lib ├── interface.js └── client.js └── README.md /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --reporter spec 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/client') 2 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechat-api", 3 | "version": "0.0.5", 4 | "description": "wechat official account advance API SDK. 微信高级API接口工具", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "./node_modules/.bin/mocha" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/node-webot/wechat-api" 12 | }, 13 | "dependencies": { 14 | "superagent": "0.17.x", 15 | "debug": "0.7.x", 16 | "methods": "0.1.0" 17 | }, 18 | "devDependencies": { 19 | "mocha": "*", 20 | "should": "*" 21 | }, 22 | "keywords": [ 23 | "wechat", 24 | "weixin", 25 | "微信", 26 | "api", 27 | "oauth" 28 | ], 29 | "author": "ktmud ", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/node-webot/wechat-api/issues" 33 | }, 34 | "homepage": "https://github.com/node-webot/wechat-api" 35 | } 36 | -------------------------------------------------------------------------------- /lib/interface.js: -------------------------------------------------------------------------------- 1 | var Client = require('./client') 2 | var qs = require('querystring') 3 | 4 | function api(method, url) { 5 | return function(data, fn) { 6 | return this._request(method, url, data, fn) 7 | } 8 | } 9 | 10 | Client.prototype.createMenu = api('POST', '/menu/create') 11 | Client.prototype.getMenu = api('GET', '/menu/get') 12 | Client.prototype.deleteMenu = api('GET', '/menu/delete') 13 | Client.prototype.getUserList = api('GET', '/user/get') 14 | 15 | Client.prototype._getUserInfo = api('GET', '/user/info') 16 | Client.prototype.getUserInfo = function(params, callback) { 17 | if ('string' == typeof params) { 18 | // a shortcut for directly pass openid as params 19 | params = { openid: params } 20 | } 21 | return this._getUserInfo(params, callback) 22 | } 23 | 24 | Client.prototype.createQRCode = api('POST', '/qrcode/create') 25 | Client.prototype.createTempQRCode = function(scene_id, expires_seconds, callback) { 26 | if ('function' == typeof expires_seconds) { 27 | callback = expires_seconds 28 | expires_seconds = null 29 | } 30 | return this.createQRCode({ 31 | expires_seconds: expires_seconds || 1800, 32 | action_name: 'QR_SCENE', 33 | action_info: { 34 | scene: { 35 | scene_id: scene_id 36 | } 37 | } 38 | }, callback) 39 | } 40 | Client.prototype.createPermQRCode = function(scene_id, callback) { 41 | return this.createQRCode({ 42 | action_name: 'QR_LIMIT_SCENE', 43 | action_info: { 44 | scene: { 45 | scene_id: scene_id 46 | } 47 | } 48 | }, callback) 49 | } 50 | 51 | /** 52 | * Upload a media file 53 | * 54 | * @param {String} type - the type of your media (image/voice/video/thumb) 55 | * @param {String} filepath - where to read this file 56 | */ 57 | Client.prototype.uploadMedia = function(type, filepath, callback) { 58 | var req = this._superagent('POST', Client.MEDIA_ROOT + '/post') 59 | req.query({ 60 | access_token: this.token, 61 | type: type 62 | }) 63 | req.attach('media', filepath) 64 | if (callback) { 65 | req.end(callback) 66 | } 67 | return req 68 | } 69 | 70 | /** 71 | * Create a stream to download media 72 | */ 73 | Client.prototype.getMedia = function(media_id, callback) { 74 | var req = this._superagent('GET', this.mediaUrl(media_id)) 75 | return req 76 | } 77 | 78 | /** 79 | * Get a url to download media 80 | */ 81 | Client.prototype.mediaUrl = function(media_id) { 82 | return Client.MEDIA_ROOT + '/get?' + qs.stringify({ 83 | access_token: this.token, 84 | media_id: media_id 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /test/wechat.js: -------------------------------------------------------------------------------- 1 | var should = require('should') 2 | 3 | var KEY = 'wx7440bf7ff5f23a1a' 4 | var SECRET = '972ba827a38121094268724ce0360f67' 5 | var GH_ID = 'gh_b1a083fb1739' 6 | 7 | describe('Wechat API', function() { 8 | var client = require('..')(KEY, SECRET) 9 | 10 | describe('#refreshToken()', function() { 11 | it('should emit refresh event', function(done) { 12 | client.refreshToken() 13 | client.once('refresh', function(token) { 14 | should.exist(token.access_token) 15 | done() 16 | }) 17 | }) 18 | it('should reuse token', function(done) { 19 | var token = client.token 20 | should.exist(token) 21 | should.equal(token, client.access_token) 22 | client.deleteMenu(function(err) { 23 | should.equal(client.token, token) 24 | should.not.exist(err) 25 | done() 26 | }) 27 | }) 28 | it('can re-refresh token', function(done) { 29 | var old = client.access_token 30 | client.refreshToken(function(err, token) { 31 | should.not.exist(err) 32 | should.exist(client.token) 33 | should.notEqual(old, client.token) 34 | done() 35 | }) 36 | }) 37 | }) 38 | 39 | describe('#menu', function() { 40 | var menu = { 41 | button: [{ 42 | type: 'click', 43 | name: '测试1', 44 | sub_button: [], 45 | key: 'test1' 46 | }, { 47 | name: '有子菜单', 48 | sub_button: [ 49 | { 50 | type: 'view', 51 | name: '访问网址', 52 | sub_button: [], 53 | url: 'http://github.com/node-webot/wechat-api' 54 | }, { 55 | type: 'click', 56 | name: '测试2', 57 | sub_button: [], 58 | key: 'test2' 59 | } 60 | ] 61 | }] 62 | } 63 | it('can createMenu', function(done) { 64 | client.createMenu(menu, function(err, result) { 65 | should.not.exist(err) 66 | done() 67 | }) 68 | }) 69 | it('can getMenu', function(done) { 70 | client.getMenu(function(err, result) { 71 | should.not.exist(err) 72 | menu.should.eql(result.menu) 73 | done() 74 | }) 75 | }) 76 | it('can deleteMenu', function(done) { 77 | client.deleteMenu(function(err) { 78 | should.not.exist(err) 79 | client.getMenu(function(err, result) { 80 | should.exist(err) 81 | err.errcode.should.eql(46003) 82 | done() 83 | }) 84 | }) 85 | }) 86 | }) 87 | 88 | describe('only token', function() { 89 | it('should be ok with only token', function(done) { 90 | var client2 = require('..')(null, null, client.access_token) 91 | client.getUserList(function(err, result) { 92 | should.not.exist(err) 93 | result.data.openid.should.be.an.instanceof(Array) 94 | done() 95 | }) 96 | }) 97 | }) 98 | 99 | }) 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wechat-api 微信公共平台 API 工具 2 | 3 | 本模块只负责与 `access_token` 有关的高级接口功能, 4 | 接收和发送消息请使用 [wechat-mp](https://www.npmjs.org/package/wechat-mp) 。 5 | 6 | ## 特点 7 | 8 | - 统一的 API 调用 9 | - 自动管理 `access_token` 过期时间,请求时如遇过期,将自动刷新。 10 | 11 | 12 | ## 使用 13 | 14 | ```javascript 15 | // replace `APP_ID` and `SECRET` with corresponding value 16 | var wechat = require('wechat-api')(APP_ID, SECRET) 17 | 18 | // 查询自定义菜单信息 19 | wechat.getMenu(function(err, result) { 20 | // 21 | // the `result` will be the json response 22 | // 23 | // when error happens: 24 | // 25 | // err == {"errcode":40013,"errmsg":"invalid appid"} 26 | // result == null 27 | // 28 | }) 29 | 30 | // 根据 OpenID 获取用户信息 31 | wechat.getUserInfo({ 32 | openid: 'o6_bmjrPTlm6_2sgVt7hMZOPfL2M', 33 | lang: 'zh_CN' 34 | }, function(err, result) { 35 | // 36 | // result === { 37 | // "subscribe": 1, 38 | // "openid": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M", 39 | // "nickname": "Band", 40 | // "sex": 1, 41 | // "language": "zh_CN", 42 | // "city": "广州", 43 | // ... 44 | // } 45 | // 46 | }) 47 | 48 | ``` 49 | 50 | ### 全部 API 51 | 52 | - createMenu(menu, callback) 53 | [创建菜单](http://mp.weixin.qq.com/wiki/index.php?title=%E8%87%AA%E5%AE%9A%E4%B9%89%E8%8F%9C%E5%8D%95%E5%88%9B%E5%BB%BA%E6%8E%A5%E5%8F%A3) 54 | - getMenu(callback) 55 | [查询自定义菜单](http://mp.weixin.qq.com/wiki/index.php?title=%E8%87%AA%E5%AE%9A%E4%B9%89%E8%8F%9C%E5%8D%95%E6%9F%A5%E8%AF%A2%E6%8E%A5%E5%8F%A3) 56 | - deleteMenu(callback) 57 | [删除自定义菜单](http://mp.weixin.qq.com/wiki/index.php?title=%E8%87%AA%E5%AE%9A%E4%B9%89%E8%8F%9C%E5%8D%95%E5%88%A0%E9%99%A4%E6%8E%A5%E5%8F%A3) 58 | - getUserInfo(args, callback) 59 | [获取用户信息](http://mp.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E7%94%A8%E6%88%B7%E5%9F%BA%E6%9C%AC%E4%BF%A1%E6%81%AF) 60 | 可直接传入 openid 字符串 61 | - getUserList(args, callback) 62 | [获取关注者列表](http://mp.weixin.qq.com/wiki/index.php?title=%E8%8E%B7%E5%8F%96%E5%85%B3%E6%B3%A8%E8%80%85%E5%88%97%E8%A1%A8) 63 | - createQRCode(args, callback) 64 | [创建二维码](http://mp.weixin.qq.com/wiki/index.php?title=%E7%94%9F%E6%88%90%E5%B8%A6%E5%8F%82%E6%95%B0%E7%9A%84%E4%BA%8C%E7%BB%B4%E7%A0%81) 65 | - createTempQRCode(scene_id, [expires_seconds,] callback) 66 | 创建临时二维码 67 | - createPermQRCode(scene_id, callback) 68 | 创建永久二维码 69 | - uploadMedia(type, filepath, callback) 70 | [上传多媒体文件](http://mp.weixin.qq.com/wiki/index.php?title=%E4%B8%8A%E4%BC%A0%E4%B8%8B%E8%BD%BD%E5%A4%9A%E5%AA%92%E4%BD%93%E6%96%87%E4%BB%B6) 71 | - mediaUrl(media_id) 72 | 获取文件下载地址 73 | - getMedia(media_id) 74 | 下载多媒体文件,返回一个 readable stream ,可直接 pipe 到本地文件存储 75 | 76 | 77 | ### Events 78 | 79 | - **ready** 已获取 access_token,可以开始请求,如果你在初始化时传入了之前存储好的 token ,`ready` 事件会即时触发 80 | - **refresh** 已获取新的 access_token 81 | 82 | ```javascript 83 | var client = require('wechat-api')('appid', 'secret', 'the-stored-token') 84 | 85 | client.on('ready', function() { 86 | }) 87 | client.on('refresh', function(token) { 88 | // token.expire_date ~= +new Date() + 7200 * 10e3 89 | }) 90 | ``` 91 | 92 | 93 | ## TODO 94 | 95 | - 接口频率限制 96 | 97 | 98 | ## License 99 | 100 | The MIT license. 101 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | var debug = require('debug')('wechat:api:debug') 2 | var util = require('util') 3 | 4 | module.exports = Client 5 | 6 | 7 | function Client(appid, secret, access_token) { 8 | if (!(this instanceof Client)) { 9 | return new Client(appid, secret, access_token) 10 | } 11 | this.appid = appid 12 | this.secret = secret 13 | this.token = access_token 14 | this._refreshing = null 15 | this.init() 16 | } 17 | util.inherits(Client, require('events').EventEmitter) 18 | 19 | // export the code 20 | // so it is possible to: 21 | // 22 | // wx.refreshToken(function(err) { 23 | // if (err === wx.INVALID_TOKEN) { 24 | // // do stuff 25 | // } 26 | // }) 27 | Client.INVALID_TOKEN = Client.prototype.INVALID_TOKEN = 40001 28 | Client.INVALID_APPID = Client.prototype.INVALID_APPID = 40013 29 | 30 | Client.prototype.init = function() { 31 | aliasProperty(this, 'access_token', 'token') 32 | aliasProperty(this, 'key', 'appid') 33 | 34 | // in case you have some batch operation waiting 35 | this.setMaxListeners(50) 36 | 37 | if (!this.token) { 38 | debug('No token provided for init, try get one...') 39 | this.refreshToken() 40 | this.once('refresh', function() { 41 | this.emit('ready') 42 | }) 43 | } else { 44 | this.emit('ready') 45 | } 46 | } 47 | 48 | Client.prototype._request = function(method, url, data, callback, refresh_tries) { 49 | var self = this 50 | 51 | if ('function' === typeof data) { 52 | refresh_tries = callback 53 | callback = data 54 | data = null 55 | } 56 | if (refresh_tries === undefined) { 57 | refresh_tries = 3 58 | } 59 | if (!callback) { 60 | callback = function(err, res) { 61 | if (err) { 62 | debug('Wechat API error: %s, %j', err, res) 63 | } 64 | } 65 | } 66 | 67 | function refresh(done) { 68 | debug('Token invalid, refreshing..') 69 | self.refreshToken(function(err, token) { 70 | if (err) { 71 | return callback.call(self, err) 72 | } 73 | done() 74 | }) 75 | } 76 | 77 | function run() { 78 | var req = self._superagent(method, url) 79 | 80 | req.query({ 81 | access_token: self.token 82 | }) 83 | 84 | if (data) { 85 | if (method == 'GET') { 86 | req.query(data) 87 | } else { 88 | req.send(data) 89 | } 90 | } 91 | 92 | debug('%s -> %s', method, req.url) 93 | 94 | req.end(function(err, res) { 95 | var result = res.body 96 | 97 | err = err || res.error || null 98 | 99 | if (err || !result) { 100 | return callback.call(self, err, result, req, res) 101 | } 102 | if (result && result.errcode == self.INVALID_TOKEN && refresh_tries) { 103 | refresh_tries -= 1 104 | return refresh(run) 105 | } 106 | if (result && result.errcode) { 107 | err = result 108 | result = null 109 | } 110 | if (err) { 111 | self.last_error = err 112 | } 113 | callback.call({ req: req, res: res }, err, result) 114 | }) 115 | } 116 | 117 | if (!self.token || self.hasExpired()) { 118 | refresh(run) 119 | } else { 120 | run() 121 | } 122 | } 123 | 124 | Client.prototype.hasExpired = function() { 125 | return this.expire_date < new Date() 126 | } 127 | 128 | Client.prototype.loadToken = function(info) { 129 | if ('string' == typeof info) { 130 | info = { 131 | access_token: info 132 | } 133 | } 134 | var expire = info.expires_in || 7200 135 | 136 | this.token = info.access_token 137 | this.expire_date = info.expire_date || new Date(+new Date() + expire * 1000) 138 | } 139 | 140 | /** 141 | * Get a new access_token 142 | */ 143 | Client.prototype.refreshToken = function(callback) { 144 | var self = this 145 | var req = self._superagent('/token') 146 | 147 | if (callback) { 148 | self.on('refresh', function(token) { 149 | callback(null, token) 150 | }) 151 | self.on('refresh_error', function(err) { 152 | callback(err) 153 | }) 154 | } 155 | 156 | // to prevent multiple refresh request 157 | if (this._refreshing) { 158 | debug('Refreshing in progress, queue callback') 159 | return this._refreshing 160 | } 161 | 162 | debug('Refreshing token...') 163 | 164 | req.query({ 165 | grant_type: 'client_credential', 166 | appid: self.appid, 167 | secret: self.secret, 168 | }) 169 | req.end(function(err, res) { 170 | var result = res.body 171 | 172 | // empty `res.error` will be a `false`, we want it as `null 173 | err = err || res.error || null 174 | 175 | if (result && result.access_token) { 176 | debug('Got new token: %j', result) 177 | result.expire_date = new Date(+new Date() + result.expires_in * 1000) 178 | self.loadToken(result) 179 | self.emit('refresh', result) 180 | } else { 181 | debug('Error when getting access_token: %j', result) 182 | } 183 | if (result && result.errcode) { 184 | err = result 185 | } 186 | if (err) { 187 | self.emit('refresh_error', err) 188 | self.last_error = err 189 | } 190 | self._refreshing = null 191 | }) 192 | 193 | this._refreshing = req 194 | 195 | return req 196 | } 197 | 198 | Client.MEDIA_ROOT = 'http://file.api.weixin.qq.com/cgi-bin/media' 199 | Client.API_ROOT = 'https://api.weixin.qq.com/cgi-bin' 200 | 201 | Client.setApiRoot = function(root) { 202 | root = root || Client.API_ROOT 203 | Client.API_ROOT = root 204 | Client.prototype._superagent = prefixedRequest(root) 205 | } 206 | Client.setApiRoot() 207 | 208 | 209 | 210 | require('./interface') 211 | 212 | 213 | 214 | function prefixedRequest(prefix) { 215 | var request = require('superagent') 216 | return function() { 217 | var req = request.apply(this, arguments) 218 | if (req.url[0] === '/') { 219 | req.url = prefix + req.url 220 | } 221 | return req 222 | } 223 | } 224 | 225 | function aliasProperty(obj, name, alias) { 226 | Object.defineProperty(obj, name, { 227 | get: function() { 228 | return this[alias] 229 | }, 230 | set: function(value) { 231 | this[alias] = value 232 | } 233 | }) 234 | } 235 | --------------------------------------------------------------------------------