├── .editorconfig ├── .gitignore ├── .npmignore ├── .travis.yml ├── AUTHORS ├── Makefile ├── README.md ├── index.js ├── lib ├── wechat.js └── xml.js ├── package.json └── test ├── index.js └── mocha.opts /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.js] 2 | indent_style = space 3 | indent_size = 2 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.swp 3 | .DS_Store 4 | examples 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .travis.yml 2 | test 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "5.1" 5 | - "4" 6 | - "4.2" 7 | - "4.1" 8 | - "4.0" 9 | - "0.12" 10 | - "0.11" 11 | - "0.10" 12 | - "0.8" 13 | - "iojs" 14 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jesse Yang 2 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | clear: 2 | @clear 3 | 4 | authors: 5 | @git log --format='%aN <%aE>' | sort -u > AUTHORS 6 | 7 | test: clear 8 | @export DEBUG= && mocha 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wechat-mp 微信公众平台消息接口中间件 [![Build Status](https://travis-ci.org/node-webot/wechat-mp.png?branch=master)](https://travis-ci.org/node-webot/wechat-mp) 2 | 3 | Utilities for wechat offical account messaging API. 4 | 5 | 校验签名,接受并解析微信消息,处理回复内容为 XML ,并回复给微信。 6 | 7 | 如需使用自定义菜单等高级接口,可使用 [wechat-api](https://www.npmjs.org/package/wechat-api) 模块。 8 | 9 | ## Express Middlewares 10 | 11 | 本模块主要作为 Connect/Express 框架的中间件使用: 12 | 13 | ```javascript 14 | var mp = require('wechat-mp')(process.env.WX_TOKEN) 15 | var app = require('express')() 16 | 17 | app.use('/wechat', mp.start()) 18 | app.post('/wechat', function(req, res, next) { 19 | 20 | console.log(req.body) 21 | 22 | res.body = { 23 | msgType: 'text', 24 | content: 'Hi.' 25 | } 26 | 27 | // or rich media message 28 | res.body = { 29 | msgType: 'music', 30 | content: { 31 | title: 'A beautiful song', 32 | musicUrl: 'http://.....' 33 | }, 34 | } 35 | 36 | next() 37 | }, mp.end()) 38 | ``` 39 | 40 | 如果要在 [koa](http://koajs.com/) 里使用,可尝试 [koa-wechat](https://www.npmjs.org/package/koa-wechat) 模块。 41 | 42 | 43 | ### require('wechat-mp')( *[options]* ) 44 | 45 | `options` can be either the token string or an object. 46 | You can use these options both when initialization(`mp = require('wechat-mp')(options)`) 47 | and `mp.start()`. 48 | 49 | 50 | #### options.token 51 | 52 | The token for wechat to check signature. 53 | 54 | #### options.tokenProp 55 | 56 | Default: 'wx\_token' 57 | 58 | Will try get `req[tokenProp]` as token. Good for dynamically set token. 59 | 60 | #### options.dataProp 61 | 62 | Default: 'body' 63 | 64 | Will put parsed data on `req[dataProp]`. So you can access wechat request message via `req.body` or `req.wx_data`, etc. 65 | 66 | ##### Parsed data properties mapping 67 | 68 | We changed some of the properties' names of Wechat's incoming message, to make it more "JavaScript like", 69 | typically, a request datum would be: 70 | 71 | ```js 72 | { 73 | uid: 'xahfai2oHaf2ka2M41', // FromUserName 74 | sp: 'gh_xmfh2b32tmgkgagsagf', // ToUserName 75 | type: '', // MsgType 76 | createTime: new Date(2014-12..) // CreateTime 77 | text: 'Hi.', // when it's a text message 78 | param: { 79 | lat: '34.193819105', // for a "LOCATION" message's Location_X 80 | lng: '120.2393849201', // Location_Y 81 | } 82 | } 83 | ``` 84 | 85 | For more details, please refer to `lib/xml.js`. 86 | 87 | 88 | #### options.session 89 | 90 | Unless `options.session` is set to `false`, 91 | the `mp.start()` middleware will set `req.sessionID` and `req.sessionId` 92 | to `"wx.#{toUserName}.#{fromUserName}"`. 93 | So you can use `req.session` to save information about one specific user. 94 | 95 | The `sessionId` cannot be changed by any other following middlewares. 96 | 97 | To make this work, `mp.start()` must go before express/connect's session middleware. 98 | 99 | ``` 100 | app.use('/wechat', mp.start()) 101 | app.use(connect.cookieParser()) 102 | app.use(connect.session({ store: ... })) 103 | ``` 104 | 105 | ### mp.start() 106 | 107 | The starting middleware, to parse a Wechat message request, and set `req.body` as a JS object. 108 | 109 | ### mp.end() 110 | 111 | The ending middleware, to response a xml based on `res.body`. 112 | For how to set `res.body` for multi media messages, see source code `lib/xml.js`. 113 | 114 | If your set `res.body` to a string, will reply a text message. When `res.body` is an object, 115 | a `res.body.msgType` is expected, otherwise it will be treated as text message, 116 | and `res.body.content` will be replied as it was a string. 117 | 118 | A typical response: 119 | 120 | ```js 121 | { 122 | msgType: 'news', 123 | content: [{ 124 | title: 'news 1', 125 | url: 'http://...', 126 | picUrl: 'http://...' 127 | }, { 128 | title: 'news 2', 129 | url: 'http://...', 130 | picUrl: 'http://...' 131 | }] 132 | } 133 | ``` 134 | 135 | ## weixin-robot 136 | 137 | 使用 [wexin-robot](https://github.com/node-webot/weixin-robot) 模块,更傻瓜化地定义自动回复功能。 138 | 139 | ## 调试 140 | 141 | 使用 [webot-cli](https://github.com/node-webot/webot-cli) 调试发送测试消息。 142 | 143 | 144 | ## License 145 | 146 | the MIT license. 147 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/wechat'); 2 | -------------------------------------------------------------------------------- /lib/wechat.js: -------------------------------------------------------------------------------- 1 | var crypto = require('crypto') 2 | 3 | var mp_xml = require('./xml') 4 | 5 | function calcSig(token, timestamp, nonce) { 6 | var s = [token, timestamp, nonce].sort().join('') 7 | return crypto.createHash('sha1').update(s).digest('hex') 8 | } 9 | 10 | /** 11 | * Check signature 12 | */ 13 | function checkSig(token, query) { 14 | if (!query) return false 15 | var sig = query.signature 16 | return query.signature === calcSig(token, query.timestamp, query.nonce) 17 | } 18 | 19 | function defaults(a, b) { 20 | for (var k in b) { 21 | if (!(k in a)) { 22 | a[k] = b[k] 23 | } 24 | } 25 | } 26 | 27 | 28 | var DEFAULT_OPTIONS = { 29 | tokenProp: 'wx_token', 30 | dataProp: 'body', 31 | session: true 32 | } 33 | 34 | 35 | /** 36 | * 37 | * New Wechat MP instance, handle default configurations 38 | * 39 | * Options: 40 | * 41 | * `token` - wechat token 42 | * `tokenProp` - will try find token from this property of `req` 43 | * 44 | */ 45 | function Wechat(options) { 46 | if (!(this instanceof Wechat)) return new Wechat(options) 47 | if ('string' == typeof options) { 48 | options = {token: options} 49 | } 50 | this.options = options || {} 51 | defaults(this.options, DEFAULT_OPTIONS) 52 | } 53 | 54 | /** 55 | * To parse wechat xml requests to webot Info realy-to-use Object. 56 | * 57 | * @param {object|String} options/token 58 | * 59 | */ 60 | Wechat.prototype.start = 61 | Wechat.prototype.parser = function bodyParser(opts) { 62 | if ('string' == typeof opts) { 63 | opts = {token: opts} 64 | } 65 | opts = opts || {} 66 | defaults(opts, this.options) 67 | 68 | var self = this 69 | var tokenProp = opts.tokenProp 70 | var dataProp = opts.dataProp 71 | var generateSid 72 | 73 | if (opts.session !== false) { 74 | generateSid = function(data) { 75 | return ['wx', data.sp, data.uid].join('.') 76 | } 77 | } 78 | 79 | return function(req, res, next) { 80 | // use a special property to demine whether this is a wechat message 81 | if (req[dataProp] && req[dataProp].sp) { 82 | // data already set, pass 83 | return next() 84 | } 85 | var token = req[tokenProp] || opts.token 86 | if (!checkSig(token, req.query)) { 87 | return Wechat.block(res) 88 | } 89 | if (req.method == 'GET') { 90 | return res.end(req.query.echostr) 91 | } 92 | if (req.method == 'HEAD') { 93 | return res.end() 94 | } 95 | Wechat.parse(req, function(err, data) { 96 | if (err) { 97 | res.statusCode = 400 98 | return res.end() 99 | } 100 | req[dataProp] = data 101 | if (generateSid) { 102 | var sid = generateSid(data) 103 | // always return the same sessionID for a given service_provider+subscriber 104 | var propdef = { 105 | get: function(){ return sid }, 106 | set: function(){ } 107 | } 108 | Object.defineProperty(req, 'sessionID', propdef) 109 | Object.defineProperty(req, 'sessionId', propdef) 110 | } 111 | next() 112 | }) 113 | } 114 | } 115 | 116 | /** 117 | * to build reply object as xml string 118 | */ 119 | Wechat.prototype.end = 120 | Wechat.prototype.responder = function responder() { 121 | return function(req, res, next) { 122 | res.setHeader('Content-Type', 'application/xml') 123 | res.end(Wechat.dump(Wechat.ensure(res.body, req.body))) 124 | } 125 | } 126 | 127 | /** 128 | * Ensure reply string is a valid reply object, 129 | * get data from request message 130 | */ 131 | Wechat.ensure = function(reply, data) { 132 | reply = reply || { content: '' } 133 | data = data || { } 134 | if ('string' == typeof reply) { 135 | reply = { content: reply, msgType: 'text' } 136 | } 137 | // fill up with default values 138 | reply.uid = reply.uid || data.uid 139 | reply.sp = reply.sp || data.sp 140 | // msgType is always lowercase 141 | reply.msgType = (reply.msgType || reply.type || 'text').toLowerCase() 142 | reply.createTime = reply.createTime || new Date() 143 | return reply 144 | } 145 | 146 | Wechat.parse = function (req, callback) { 147 | var chunks = []; 148 | req.on('data', function (data) { 149 | chunks.push(data); 150 | }); 151 | req.on('end', function () { 152 | req.rawBody = Buffer.concat(chunks).toString(); 153 | try { 154 | var data = Wechat.load(req.rawBody) 155 | callback(null, data) 156 | } catch (e) { 157 | return callback(e) 158 | } 159 | 160 | }); 161 | 162 | } 163 | 164 | /** 165 | * Block unsignatured request 166 | */ 167 | Wechat.block = function endRes(res) { 168 | res.statusCode = 401 169 | res.end('Invalid signature') 170 | } 171 | 172 | /** 173 | * Check signature 174 | */ 175 | Wechat.checkSignature = checkSig 176 | 177 | /** 178 | * parse xml string 179 | */ 180 | Wechat.load = mp_xml.parse 181 | 182 | /** 183 | * dump reply as xml string 184 | * if content in reply is empty should return empty string as response body 185 | * see: https://mp.weixin.qq.com/cgi-bin/announce?action=getannouncement&key=1413446944&version=15&lang=zh_CN 186 | */ 187 | Wechat.dump = function(reply) { 188 | if (reply.content === '') { 189 | return ''; 190 | } 191 | return mp_xml.build(reply); 192 | } 193 | 194 | 195 | module.exports = Wechat 196 | -------------------------------------------------------------------------------- /lib/xml.js: -------------------------------------------------------------------------------- 1 | var lodash_tmpl = require('lodash-template') 2 | var xmllite = require('node-xml-lite') 3 | 4 | var propMap = { 5 | FromUserName: 'uid' 6 | , ToUserName: 'sp' // as 'service provider' 7 | , CreateTime: 'createTime' 8 | , MsgId: 'id' 9 | , MsgType: 'type' 10 | , Content: 'text' 11 | } 12 | 13 | var paramMap = { 14 | Location_X: 'lat' 15 | , Location_Y: 'lng' 16 | // 上报地理位置事件 Event == LOCATION 17 | , Latitude: 'lat' 18 | , Longitude: 'lng' 19 | } 20 | 21 | /** 22 | * convert weixin props into more human readable names 23 | */ 24 | function readable(original, pmap, mmap) { 25 | var param = {} 26 | var data = { 27 | raw: original, 28 | param: param 29 | } 30 | var key, val 31 | for (key in original) { 32 | val = original[key] 33 | if (key in pmap) { 34 | data[pmap[key]] = val 35 | } else if (key in mmap) { 36 | // 名字特殊处理的参数 37 | param[mmap[key]] = val 38 | } else { 39 | // 其他参数都是将首字母转为小写 40 | key = key[0].toLowerCase() + key.slice(1) 41 | if (key === 'recognition') { 42 | data.text = val 43 | } 44 | param[key] = val 45 | } 46 | } 47 | data.createTime = new Date(parseInt(data.createTime, 10) * 1000) 48 | // for compatibility 49 | data.created = data.createTime 50 | return data 51 | } 52 | 53 | function flattern(tree) { 54 | var ret = {} 55 | if (tree.childs) { 56 | tree.childs 57 | .filter(function (child) { 58 | return child !== '\n' && child !== '\n\n' && child !== '\n\n\n' 59 | }).forEach(function (item) { 60 | if (!item.name) { 61 | ret = item 62 | return false 63 | } 64 | var value = flattern(item) 65 | if (item.name in ret) { 66 | ret[item.name] = [ret[item.name], value] 67 | } 68 | ret[item.name] = value 69 | }) 70 | } 71 | return ret 72 | } 73 | 74 | function parseXml(b, options) { 75 | options = options || {} 76 | 77 | var pmap = options.propMap || propMap 78 | var mmap = options.paramMap || paramMap 79 | var tree = xmllite.parseString(b) 80 | var xml = flattern(tree) 81 | 82 | return readable(xml, pmap, mmap) 83 | } 84 | 85 | var renderXml = lodash_tmpl([ 86 | '', 87 | ']]>', 88 | ']]>', 89 | '<%= Math.floor(createTime.valueOf() / 1000) %>', 90 | ']]>', 91 | '<% if (msgType === "transfer_customer_service" && kfAccount) { %>', 92 | '', 93 | '<%- kfAccount %>', 94 | '', 95 | '<% } %>', 96 | '<% if (msgType === "news") { %>', 97 | '<%=content.length%>', 98 | '', 99 | '<% content.forEach(function(item){ %>', 100 | '', 101 | '<![CDATA[<%=item.title%>]]>', 102 | ']]>', 103 | ']]>', 104 | ']]>', 105 | '', 106 | '<% }) %>', 107 | '', 108 | '<% } else if (msgType === "music") { %>', 109 | '', 110 | '<![CDATA[<%=content.title%>]]>', 111 | ']]>', 112 | ']]>', 113 | ']]>', 114 | '', 115 | '<% } else if (msgType === "voice") { %>', 116 | '', 117 | ']]>', 118 | '', 119 | '<% } else if (msgType === "image") { %>', 120 | '', 121 | ']]>', 122 | '', 123 | '<% } else if (msgType === "video") { %>', 124 | '', 130 | '<% } else { %>', 131 | ']]>', 132 | '<% } %>', 133 | '' 134 | ].join('')) 135 | 136 | 137 | module.exports = { 138 | parse: parseXml, 139 | build: renderXml 140 | } 141 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wechat-mp", 3 | "version": "0.2.7", 4 | "repository": "git://github.com/node-webot/wechat-mp.git", 5 | "description": "Wechat open API utilities.", 6 | "author": "Jesse Yang (https://github.com/ktmud)", 7 | "dependencies": { 8 | "node-xml-lite": "~0.0.3", 9 | "lodash-template": "~1.0" 10 | }, 11 | "devDependencies": { 12 | "mocha": "*", 13 | "express": "*", 14 | "supertest": "*", 15 | "should": "^3.3.2" 16 | }, 17 | "engines": { 18 | "node" : ">=0.6.0", 19 | "npm" : ">=1.1.6" 20 | }, 21 | "keywords": [ 22 | "weixin", 23 | "robot", 24 | "webot", 25 | "webot-plugin", 26 | "wechat" 27 | ], 28 | "scripts": { 29 | "test": "./node_modules/mocha/bin/mocha" 30 | }, 31 | "license": "MIT" 32 | } 33 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | var should = require('should') 2 | var supertest = require('supertest') 3 | var express = require('express') 4 | var crypto = require('crypto') 5 | 6 | var Wechat = require('../') 7 | 8 | 9 | function calcSig(token, timestamp, nonce) { 10 | var s = [token, timestamp, nonce].sort().join('') 11 | return crypto.createHash('sha1').update(s).digest('hex') 12 | } 13 | 14 | 15 | describe('wechat-mp', function() { 16 | 17 | var mp, app, req 18 | 19 | supertest.Test.prototype.addsig = function addsig(token) { 20 | var query = { 21 | echostr: 'abc', 22 | timestamp: +new Date(), 23 | nounce: Math.random() 24 | } 25 | query.signature = calcSig(token, query.timestamp, query.nonce) 26 | return this.query(query) 27 | } 28 | 29 | beforeEach(function() { 30 | app = express() 31 | request = supertest(app) 32 | }) 33 | 34 | it('should block', function(done) { 35 | mp = Wechat('abc') 36 | app.use(mp.start()) 37 | request.get('/') 38 | .expect(401, done) 39 | }) 40 | 41 | it('should accept token', function(done) { 42 | mp = Wechat('abc') 43 | app.use(mp.start()) 44 | test_valid_token('abc', done) 45 | }) 46 | 47 | it('should accept token on .start()', function(done) { 48 | app.use(mp.start('token')) 49 | test_valid_token('token', done) 50 | }) 51 | 52 | it('should accept options.tokenProp', function(done) { 53 | app.use(function(req, res, next) { 54 | req.wx = 'tokk' 55 | next() 56 | }) 57 | app.use(mp.start({tokenProp: 'wx'})) 58 | test_valid_token('tokk', done) 59 | }) 60 | 61 | it('should accept options.dataProp', function(done) { 62 | var err = null 63 | app.use(mp.start({token: 'dataprop', dataProp: 'wx_data'})) 64 | app.use(function(req, res, next) { 65 | try { 66 | req.wx_data.type.should.eql('text') 67 | } catch (e) { 68 | err = e 69 | } 70 | next() 71 | }) 72 | app.use(mp.end()) 73 | test_send_message('dataprop', function() { 74 | if (err) { 75 | throw err 76 | } 77 | done() 78 | }) 79 | }) 80 | 81 | it('should end response', function(done) { 82 | app.use(function(req, res, next) { 83 | res.body = { 84 | content: 'abc' 85 | } 86 | next() 87 | }) 88 | app.use(mp.end()) 89 | request.post('/') 90 | .expect(200) 91 | .end(function(req, res) { 92 | res.text.should.containDeep('') 93 | done() 94 | }) 95 | }) 96 | 97 | it('should handle empty response', function(done) { 98 | app.use(mp.end()) 99 | test_send_message('', done) 100 | }) 101 | 102 | it('should handle empty `reply.content`', function(done) { 103 | app.use(function(req, res, next) { 104 | res.body = { 105 | content: '' 106 | }; 107 | next(); 108 | }); 109 | app.use(mp.end()); 110 | request.post('/') 111 | .expect(200) 112 | .end(function(req, res) { 113 | res.body.should.be.empty; 114 | done(); 115 | }); 116 | }) 117 | 118 | 119 | describe('render xml', function() { 120 | 121 | it('should render news and not escape url "&"', function(done) { 122 | app.use(function(req, res, next) { 123 | res.body = { 124 | msgType: 'news', 125 | content: [{ 126 | title: 'abc', 127 | url: 'http://example.com/mpa?abc=c&d=f' 128 | }] 129 | } 130 | next() 131 | }) 132 | app.use(mp.end()) 133 | request.post('/') 134 | .expect(200) 135 | .end(function(req, res) { 136 | res.text.should.containDeep('') 137 | res.text.should.containDeep('http://example.com/mpa?abc=c&d=f') 138 | done() 139 | }) 140 | }) 141 | 142 | it('should accept `reply.type` as msgType', function(done) { 143 | app.use(function(req, res, next) { 144 | res.body = { 145 | type: 'music', 146 | content: { 147 | title: 'abc', 148 | url: 'http://example.com/mpa?abc=c&d=f' 149 | } 150 | } 151 | next() 152 | }) 153 | app.use(mp.end()) 154 | request.post('/') 155 | .expect(200) 156 | .end(function(req, res) { 157 | res.text.should.containDeep('') 158 | res.text.should.containDeep('http://example.com/mpa?abc=c&d=f') 159 | done() 160 | }) 161 | }) 162 | 163 | }) 164 | 165 | function test_valid_token(token, done) { 166 | return request.get('/') 167 | .addsig(token) 168 | .expect(200, done) 169 | } 170 | 171 | function test_send_message(token, done) { 172 | return request.post('/') 173 | .addsig(token) 174 | .send('text') 175 | .expect('Content-Type', 'application/xml') 176 | .expect(200, done) 177 | } 178 | 179 | }) 180 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | --reporter spec 3 | --ui bdd 4 | --growl 5 | --timeout 100 6 | --------------------------------------------------------------------------------