├── test ├── mocha.opts └── trap │ ├── a0.jpg │ ├── trapHandle.test.js │ ├── config.js │ ├── signature.test.js │ ├── device.test.js │ ├── template.test.js │ ├── shakearoundUserShake.test.js │ ├── helper.js │ ├── attrNameProcessors.test.js │ ├── request.js │ ├── cardMessage.test.js │ ├── click.test.js │ ├── generalMessage.test.js │ └── eventMessage.test.js ├── .gitignore ├── .travis.yml ├── Makefile ├── package.json ├── lib ├── api │ └── oauth.js ├── api.js ├── util.js └── trap.js ├── index.js └── README.md /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require should 2 | --reporter list --growl 3 | --ui bdd -------------------------------------------------------------------------------- /test/trap/a0.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/liuxiaodong/weixin-trap/HEAD/test/trap/a0.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | .coveralls.yml 4 | coverage.html 5 | coverage 6 | lib-cov -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.10" 4 | - "4.2.1" 5 | - "v5.3.0" 6 | sudo: required 7 | after_script: 8 | npm run cov -------------------------------------------------------------------------------- /test/trap/trapHandle.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | describe('trapHandle', function(){ 8 | context('not a function', function(){ 9 | it('throw error', function(){ 10 | (function(){ 11 | new Helper({trapHandle: 'aaa'}); 12 | }).should.throw(); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/trap/config.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | "id": "gh_956b2584a111", 4 | "token": "swechatcardtest", 5 | "app_id": "wx309678780eb63111", 6 | "app_secret": "f65a2f757b92787f137c359fa5699111", 7 | "encrypt_key": "5iteleZLwN1UplKO08L7Fa57H5EuwPaTqnjvO85u111" 8 | }, 9 | { 10 | "id": "gh_ff831e3e9222", 11 | "token": "swechatcardtest", 12 | "app_id": "wxd1fbffa91579f222", 13 | "app_secret": "f11dcaf01dab462589cdeb43aeade222", 14 | "encrypt_key": "5iteleZLwN1UplKO08L7Fa57H5EuwPaTqnjvO85u222" 15 | } 16 | ]; -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TESTS = test/**/*.js 2 | REPORTER = spec 3 | TIMEOUT = 20000 4 | ISTANBUL = ./node_modules/.bin/istanbul 5 | MOCHA = ./node_modules/.bin/_mocha 6 | COVERALLS = ./node_modules/.bin/coveralls 7 | 8 | test: 9 | @NODE_ENV=test $(MOCHA) -R $(REPORTER) -t $(TIMEOUT) \ 10 | $(MOCHA_OPTS) \ 11 | $(TESTS) 12 | 13 | test-cov: 14 | @$(ISTANBUL) cover --report html $(MOCHA) -- -t $(TIMEOUT) -R spec $(TESTS) 15 | 16 | test-coveralls: 17 | @$(ISTANBUL) cover --report lcovonly $(MOCHA) -- -t $(TIMEOUT) -R spec $(TESTS) 18 | @echo TRAVIS_JOB_ID $(TRAVIS_JOB_ID) 19 | @cat ./coverage/lcov.info | $(COVERALLS) && rm -rf ./coverage 20 | 21 | test-all: test test-coveralls 22 | 23 | clean: 24 | rm -rf coverage 25 | 26 | .PHONY: test -------------------------------------------------------------------------------- /test/trap/signature.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var config = require('./config'); 3 | var Helper = require('./helper'); 4 | 5 | 6 | 7 | describe('Signature', function(){ 8 | 9 | it('verify', function(done){ 10 | var helper = new Helper(); 11 | var p = helper.requestWrapper('get', config[0].token, 'echostr', function(err, ret){ 12 | should.not.exist(err); 13 | ret.should.equal('echostr'); 14 | }); 15 | helper.doneWapper(p, done); 16 | }); 17 | 18 | it('without token', function(done){ 19 | var helper = new Helper(); 20 | var p = helper.requestWrapper('get', '', 'echostr', function(err, ret){ 21 | should.not.exist(err); 22 | ret.should.equal('echostr'); 23 | }); 24 | helper.doneWapper(p, done); 25 | }); 26 | 27 | }); 28 | -------------------------------------------------------------------------------- /test/trap/device.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | var helper; 8 | before(function(){ 9 | helper = new Helper(); 10 | }); 11 | 12 | describe('Device Message', function(){ 13 | 14 | // 设备消息 15 | it('text', function(done){ 16 | var msg = '001122334455'; 17 | 18 | var p1 = helper.trapWrapper('device', function(req, res){ 19 | req.body.DeviceType.should.equal(config[0].id); 20 | req.body.DeviceID.should.equal('123456'); 21 | req.body.Content.should.equal('device_text_test'); 22 | res.should.have.property('device'); 23 | res.device('reply device text'); 24 | }); 25 | 26 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 27 | should.not.exist(err); 28 | ret.content.should.equal('cmVwbHkgZGV2aWNlIHRleHQ='); // ‘reply device text’ base64 编码后的内容 29 | }); 30 | 31 | helper.doneWapper(p1, p2, done); 32 | }); 33 | 34 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weixin-trap", 3 | "version": "1.0.5", 4 | "description": "托管多个公众号的 express 中间件", 5 | "main": "index.js", 6 | "directories": { 7 | "test": "test" 8 | }, 9 | "dependencies": { 10 | "async": "^1.3.0", 11 | "debug": "^2.2.0", 12 | "express": "^4.13.0", 13 | "raw-body": "^2.1.2", 14 | "underscore": "^1.8.3", 15 | "wechat-api": "^1.32.0", 16 | "wechat-crypto": "^0.0.2", 17 | "xml2js": "^0.4.9" 18 | }, 19 | "devDependencies": { 20 | "coveralls": "^2.11.2", 21 | "istanbul": "^0.3.17", 22 | "mocha": "^2.2.5", 23 | "mocha-lcov-reporter": "0.0.2", 24 | "q": "^1.4.1", 25 | "should": "^7.0.2", 26 | "sinon": "^1.17.2", 27 | "supertest": "^1.0.1" 28 | }, 29 | "scripts": { 30 | "test": "make test-cov", 31 | "cov": "make test-coveralls" 32 | }, 33 | "repository": { 34 | "type": "git", 35 | "url": "https://github.com/liuxiaodong/weixin.git" 36 | }, 37 | "keywords": [ 38 | "weixin", 39 | "wechat", 40 | "wx" 41 | ], 42 | "author": "leaf ", 43 | "license": "ISC", 44 | "bugs": { 45 | "url": "https://github.com/liuxiaodong/weixin/issues" 46 | }, 47 | "homepage": "https://github.com/liuxiaodong/weixin" 48 | } 49 | -------------------------------------------------------------------------------- /test/trap/template.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | var helper; 8 | before(function(){ 9 | helper = new Helper(); 10 | }); 11 | 12 | describe('Shakearoundusershake Message', function(){ 13 | 14 | // 模板消息,未监听 15 | it('shake', function(done){ 16 | var msg = '200163840 '; 17 | 18 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 19 | console.log(err, ret); 20 | ret.should.equal(''); 21 | }); 22 | 23 | helper.doneWapper(p2, done); 24 | }); 25 | 26 | 27 | // 模板消息 28 | it('shake', function(done){ 29 | var msg = '200163840 '; 30 | 31 | var p1 = helper.trapWrapper('template', function(req, res){ 32 | req.body.MsgID.should.equal('200163840'); 33 | res.text('templatesendjobfinish'); 34 | }); 35 | 36 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 37 | should.not.exist(err); 38 | ret.content.should.equal('templatesendjobfinish'); 39 | }); 40 | 41 | helper.doneWapper(p1, p2, done); 42 | }); 43 | 44 | }); -------------------------------------------------------------------------------- /test/trap/shakearoundUserShake.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | var helper; 8 | before(function(){ 9 | helper = new Helper(); 10 | }); 11 | 12 | describe('Shakearoundusershake Message', function(){ 13 | 14 | // 摇周边事件推送 15 | it('shake', function(done){ 16 | var msg = '111111110.05722222222166.8163333333315.013'; 17 | 18 | var p1 = helper.trapWrapper('shakeAround', function(req, res){ 19 | req.body.ChosenBeacon.Uuid.should.equal('121212121212'); 20 | req.body.ChosenBeacon.Major.should.equal('1111'); 21 | req.body.ChosenBeacon.Minor.should.equal('1111'); 22 | req.body.ChosenBeacon.Distance.should.equal('0.057'); 23 | req.body.AroundBeacons.AroundBeacon.should.have.lengthOf(2); 24 | res.text('shakearoundusershake'); 25 | }); 26 | 27 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 28 | should.not.exist(err); 29 | ret.content.should.equal('shakearoundusershake'); 30 | }); 31 | 32 | helper.doneWapper(p1, p2, done); 33 | }); 34 | 35 | }); -------------------------------------------------------------------------------- /lib/api/oauth.js: -------------------------------------------------------------------------------- 1 | /**! 2 | * weixin 3 | * Copyright(c) 2015-2015 leaf 4 | * MIT Licensed 5 | */ 6 | 7 | /** 8 | * oauth 授权部分 9 | */ 10 | 11 | 'use strict'; 12 | var util = require('wechat-api/lib/util'); 13 | var wrapper = util.wrapper; 14 | var postJSON = util.postJSON; 15 | var make = util.make; 16 | 17 | /** 18 | * 通过 code 获取网页授权的 accessToken 19 | */ 20 | make(exports, 'getOauthAccessToken', function(code, callback){ 21 | var url = 'https://api.weixin.qq.com/sns/oauth2/access_token?appid=' + this.appid + '&secret=' + this.appsecret + '&code=' + code + '&grant_type=authorization_code'; 22 | var that = this; 23 | this.request(url, {dataType: 'json'}, wrapper(function(err, data){ 24 | if (err) { 25 | return callback(err); 26 | } 27 | var token = { 28 | accessToken: data.access_token, 29 | expireTime: data.expires_in, 30 | refreshToken: data.refresh_token, 31 | openid: data.openid, 32 | scope: data.scope, 33 | unionid: data.unionid 34 | }; 35 | that.saveOauthToken(that.appid, token, function (err) { 36 | if (err) { 37 | return callback(err); 38 | } 39 | callback(err, token); 40 | }); 41 | })); 42 | }); 43 | 44 | /** 45 | * 通过 refreshToken 重新获取 accessToken 46 | */ 47 | make(exports, 'refreshOauthAccessToken', function(refresh_token, callback){ 48 | var url = 'https://api.weixin.qq.com/sns/oauth2/refresh_token?appid=' + this.appid + '&grant_type=refresh_token&refresh_token=' + refresh_token; 49 | var that = this; 50 | this.request(url, {dataType: 'json'}, wrapper(function(err, data){ 51 | if (err) { 52 | return callback(err); 53 | } 54 | var token = { 55 | accessToken: data.access_token, 56 | expireTime: data.expires_in, 57 | refreshToken: data.refresh_token, 58 | openid: data.openid, 59 | scope: data.scope 60 | }; 61 | that.saveOauthToken(that.appid, token, function (err) { 62 | if (err) { 63 | return callback(err); 64 | } 65 | callback(err, token); 66 | }); 67 | })); 68 | }); 69 | 70 | /** 71 | * 通过网页收取后获取到的accessToken 去获取用户信息 72 | */ 73 | make(exports, 'getSnsUserinfo', function(openid, callback){ 74 | var that = this; 75 | this.getOauthToken(wrapper(function(err, oauthToken){ 76 | if(err) return callback(err); 77 | if(!oauthToken) return callback({message: 'Get error'}); 78 | var url = 'https://api.weixin.qq.com/sns/userinfo?access_token=' + oauthToken.accessToken + '&openid=' + openid + '&lang=zh_CN'; 79 | that.request(url, {dataType: 'json'}, wrapper(callback)); 80 | })); 81 | }); 82 | -------------------------------------------------------------------------------- /test/trap/helper.js: -------------------------------------------------------------------------------- 1 | var Q = require('q'); 2 | var _ = require('underscore'); 3 | var config =require('./config'); 4 | var util = require('../../lib/util'); 5 | var Weixin = require('../../'); 6 | var Request = require('./request'); 7 | 8 | var __slice = [].slice; 9 | var Helper = module.exports = function(options){ 10 | var defaultOptions = { 11 | getBody: true, 12 | parseXml: true, 13 | decrypt: true, 14 | attrNameProcessors: 'keep', 15 | populate_user: false, 16 | trapHandle: util.trapHandle, 17 | config: config 18 | }; 19 | options = options || {}; 20 | _.extend(defaultOptions, options); 21 | var app = require('express')(); 22 | 23 | this.request = new Request(app); 24 | var weixin = new Weixin(defaultOptions); 25 | this.trap = weixin.trap; 26 | this.weixin = weixin; 27 | app.use('/wechat', this.trap); 28 | }; 29 | 30 | Helper.prototype.trapWrapper = function(fname, arg1, cb){ 31 | if(typeof arg1 === 'function' && typeof cb !== 'function'){ 32 | cb = arg1; 33 | arg1 = undefined; 34 | } 35 | var deferred = Q.defer(); 36 | var callbackWrapper = function(){ 37 | try{ 38 | cb.apply({}, arguments); 39 | }catch(e){ 40 | return deferred.reject(e); 41 | } 42 | return deferred.resolve(); 43 | }; 44 | 45 | if(arg1){ 46 | if (arguments.length === 3) { 47 | this.trap[fname](arg1, callbackWrapper); 48 | } else { 49 | var handlers = _.map(__slice.call(arguments, 2), function(f){ 50 | return function(){ 51 | try{ 52 | this.f.apply({}, arguments); 53 | }catch(e){ 54 | return deferred.reject(e); 55 | } 56 | return deferred.resolve(); 57 | }.bind({f: f}); 58 | }); 59 | handlers.unshift(arg1); 60 | this.trap[fname].apply(this, handlers); 61 | } 62 | }else { 63 | this.trap[fname](callbackWrapper); 64 | } 65 | return deferred.promise; 66 | }; 67 | 68 | Helper.prototype.requestWrapper = function(){ 69 | var deferred = Q.defer(); 70 | var args = __slice.call(arguments); 71 | var method = args.shift(); 72 | var cb = args.pop(); 73 | 74 | var callbackWrapper = function(){ 75 | try{ 76 | cb.apply({}, arguments); 77 | }catch(e){ 78 | return deferred.reject(e); 79 | } 80 | return deferred.resolve(); 81 | }; 82 | 83 | args.push(callbackWrapper); 84 | this.request[method].apply(this.request, args); 85 | return deferred.promise; 86 | }; 87 | 88 | Helper.prototype.doneWapper = function(){ 89 | var args = __slice.call(arguments); 90 | var done = args.pop(); 91 | Q.all(args).then(function(){ 92 | done(); 93 | }, function(err){ 94 | done(err); 95 | }); 96 | }; -------------------------------------------------------------------------------- /test/trap/attrNameProcessors.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | describe('attrNameProcessors', function(){ 8 | context('with function', function(){ 9 | it('success', function(done){ 10 | var helper = new Helper({attrNameProcessors: function(attr){ return 'attr-test-' + attr;} }); 11 | 12 | var msg = '111111110.05722222222166.8163333333315.013'; 13 | 14 | var p1 = helper.trapWrapper('shakeAround', function(req, res){ 15 | req.body['attr-test-ChosenBeacon']['attr-test-Uuid'].should.equal('121212121212'); 16 | res.text('shakearoundusershake'); 17 | }); 18 | 19 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 20 | should.not.exist(err); 21 | ret.content.should.equal('shakearoundusershake'); 22 | }); 23 | 24 | helper.doneWapper(p1, p2, done); 25 | 26 | }); 27 | }); 28 | 29 | context('underscored', function(){ 30 | it('success', function(done){ 31 | var helper = new Helper({attrNameProcessors: 'underscored' }); 32 | 33 | var msg = '111111110.05722222222166.8163333333315.013'; 34 | 35 | var p1 = helper.trapWrapper('shakeAround', function(req, res){ 36 | req.body['chosen_beacon']['uuid'].should.equal('121212121212'); 37 | res.text('shakearoundusershake'); 38 | }); 39 | 40 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 41 | should.not.exist(err); 42 | ret.content.should.equal('shakearoundusershake'); 43 | }); 44 | 45 | helper.doneWapper(p1, p2, done); 46 | 47 | }); 48 | }); 49 | 50 | context('invalid', function(){ 51 | it('success', function(done){ 52 | var helper = new Helper({attrNameProcessors: 'underscored11' }); 53 | 54 | var msg = '111111110.05722222222166.8163333333315.013'; 55 | 56 | var p1 = helper.trapWrapper('shakeAround', function(req, res){ 57 | req.body.ChosenBeacon.Uuid.should.equal('121212121212'); 58 | res.text('shakearoundusershake'); 59 | }); 60 | 61 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 62 | should.not.exist(err); 63 | ret.content.should.equal('shakearoundusershake'); 64 | }); 65 | 66 | helper.doneWapper(p1, p2, done); 67 | 68 | }); 69 | }); 70 | 71 | }); 72 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * weixin 3 | * Copyright(c) 2015-2015 leaf 4 | * MIT Licensed 5 | */ 6 | 7 | 'use strict'; 8 | 9 | /** 10 | * Module dependencies. 11 | */ 12 | var api = require('./lib/api'); 13 | var _ = require('underscore'); 14 | var util = require('./lib/util'); 15 | var trap = require('./lib/trap'); 16 | var debug = require('debug')('weixin'); 17 | 18 | /** 19 | * 微信对外接口封装 20 | * api 微信 API 接口 21 | * util 工具集,一般用不上 22 | * trap 路由事件处理,对接微信的回调 url 处理微信推送到服务器的消息 23 | * options: { 24 | * populate_user: false, # 是否扩展出用户信息,默认 false 25 | * saveToken: Funtion, # 保存 accessToken 默认保存在内存(不推荐这样做) 26 | * getToken: Funtion, # 获取accessToken 的方法,若定义了 saveToken 则最好也同事定义 getToken 函数,不然默认方法可能获取不到对应的 accessToken 27 | * config: Array # 微信相关配置数据 28 | * } 29 | * 30 | * 微信相关配置数据 31 | * config: { 32 | * id: '' # 公众号id 33 | * appid: '' # 公众号的 app_id 34 | * app_secret: '' # 公众号的 app_secret 35 | * token: '' # 公众号配置的token 36 | * encryptkey: '' # 公众号配置的加密秘钥 37 | * } 38 | */ 39 | function Weixin(options){ 40 | if (!(this instanceof Weixin)) { 41 | return new Weixin(options); 42 | } 43 | 44 | this.api = {__proto__: api, weixin: this}; 45 | this.util = {__proto__: util, weixin: this}; 46 | 47 | var defaultOptions = { 48 | getBody: true, 49 | parseXml: true, 50 | decrypt: true, 51 | attrNameProcessors: 'keep', 52 | populate_user: false, 53 | trapHandle: this.util.trapHandle 54 | }; 55 | options = options || {}; 56 | _.extend(defaultOptions, options); 57 | 58 | 59 | this.trap = trap.call({ 60 | // 微信实例 61 | weixin: this, 62 | // 微信事件的默认是处理函数 63 | trapHandle: defaultOptions.trapHandle, 64 | // 是否需要从 req 中获取数据流 65 | getBody: defaultOptions.getBody, 66 | // 是否需要解析 xml 67 | parseXml: defaultOptions.parseXml, 68 | // 是否需要解密数据 69 | decrypt: defaultOptions.decrypt, 70 | // 微信数据格式化 71 | attrNameProcessors: defaultOptions.attrNameProcessors, 72 | // 微信推送时间过来是,如果带有 openid,是否扩展出用户信息 73 | populate_user: defaultOptions.populate_user 74 | }); 75 | 76 | // accessToken 的管理默认用 util 中的函数 77 | this.api.saveToken = this.util.saveToken; 78 | this.api.getToken = this.util.getToken; 79 | 80 | // oauth2 授权后获取到的 accessToken 等信息的管理 的默认函数 81 | this.api.saveOauthToken = this.util.saveOauthToken; 82 | this.api.getOauthToken = this.util.getOauthToken; 83 | 84 | // ticket 的默认管理函数 85 | this.api.getTicketToken = this.util.getTicketToken; 86 | this.api.saveTicketToken = this.util.saveTicketToken; 87 | 88 | 89 | //this.trap.trapHandle = this.util.trapHandle; 90 | 91 | // 用户自定义 config 管理函数 92 | if (typeof defaultOptions.saveConfig === 'function') this.util.saveConfig = defaultOptions.saveConfig; 93 | if (typeof defaultOptions.getConfig === 'function') this.util.getConfig = defaultOptions.getConfig; 94 | if (typeof defaultOptions.setConfig === 'function') this.util.setConfig = defaultOptions.setConfig; 95 | 96 | // 用户可以自定义 accessToken 管理的函数 97 | if (typeof defaultOptions.saveToken === 'function') this.api.saveToken = defaultOptions.saveToken; 98 | if (typeof defaultOptions.getToken === 'function') this.api.getToken = defaultOptions.getToken; 99 | 100 | // 用户自定义 oauth2 授权的取得的 accessToken 等信息管理函数 101 | if (typeof defaultOptions.saveOauthToken === 'function') this.api.saveOauthToken = defaultOptions.saveOauthToken; 102 | if (typeof defaultOptions.getOauthToken === 'function') this.api.getOauthToken = defaultOptions.getOauthToken; 103 | 104 | // 用户自定义 ticket 管理函数 105 | if (typeof defaultOptions.getTicketToken === 'function') this.api.getTicketToken = defaultOptions.getTicketToken; 106 | if (typeof defaultOptions.saveTicketToken === 'function') this.api.saveTicketToken = defaultOptions.saveTicketToken; 107 | 108 | if (defaultOptions.config) this.util.saveConfig(defaultOptions.config); 109 | } 110 | 111 | module.exports = Weixin; 112 | -------------------------------------------------------------------------------- /test/trap/request.js: -------------------------------------------------------------------------------- 1 | var request = require('supertest'); 2 | var WXBizMsgCrypt = require('wechat-crypto'); 3 | var crypto = require('crypto'); 4 | var xml2js = require('xml2js'); 5 | 6 | 7 | /* 8 | * 生成随机字符串 9 | */ 10 | var createNonceStr = function () { 11 | return Math.random().toString(36).substr(2, 15); 12 | }; 13 | 14 | /*! 15 | * 生成时间戳 16 | */ 17 | var createTimestamp = function () { 18 | return parseInt(new Date().getTime() / 1000, 0) + ''; 19 | }; 20 | 21 | var createXml = function (config, openid, msg, timestamp, need_encrypt){ 22 | var xml = '' + timestamp + '' + msg + ''; 23 | if(!need_encrypt) return xml; 24 | var crypter = new WXBizMsgCrypt(config.token, config.encrypt_key, config.app_id); 25 | var encrypt = crypter.encrypt(xml); 26 | xml = ''; 27 | return xml; 28 | }; 29 | 30 | var createSign = function(token, timestamp, nonce){ 31 | var str = [token, timestamp, nonce].sort().join(''); 32 | return crypto.createHash('sha1').update(str).digest('hex'); 33 | }; 34 | 35 | var options = { 36 | async: true, 37 | explicitArray: true, 38 | normalize: true, 39 | trim: true 40 | }; 41 | 42 | var formatStr = function(str){ 43 | return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase(); 44 | }; 45 | 46 | var _format = function(data){ 47 | if(data){ 48 | for(var p in data){ 49 | var prot = p; 50 | if(typeof prot === 'string') prot = formatStr(prot); 51 | if(prot !== p){ 52 | data[prot] = data[p]; 53 | delete data[p]; 54 | } 55 | if(typeof data[prot] === 'object') { 56 | if((Object.prototype.toString.call(data[prot]) === '[object Array]') && data[prot].length === 1){ 57 | data[prot] = data[prot][0]; 58 | } 59 | _format(data[prot]); 60 | } 61 | } 62 | } 63 | }; 64 | 65 | var get = function (token, echostr, callback){ 66 | if(typeof echostr === 'callback') { 67 | callback = echostr; 68 | echostr = createNonceStr(); 69 | } 70 | var timestamp = createTimestamp(); 71 | var nonce = createNonceStr(); 72 | var signature = createSign(token, timestamp, nonce); 73 | this.request.get('/wechat?signature=' + signature + '×tamp=' + timestamp + '&nonce=' + nonce + '&echostr=' +echostr) 74 | .end(function(err, res){ 75 | if(err) return callback(err); 76 | if(res.text !== echostr) return callback('echostr error'); 77 | return callback(null, res.text); 78 | }); 79 | }; 80 | 81 | var post = function (config, openid, msg, need_encrypt, callback){ 82 | if(typeof need_encrypt === 'function'){ 83 | callback = need_encrypt; 84 | need_encrypt = false; 85 | } 86 | var timestamp = createTimestamp(); 87 | var nonce = createNonceStr(); 88 | var signature = createSign(config.token, timestamp, nonce); 89 | var xml = createXml(config, openid, msg, timestamp, need_encrypt); 90 | var url = '/wechat?signature=' + signature + '×tamp=' + timestamp + '&nonce=' + nonce; 91 | if(need_encrypt) url += '&encrypt_type=aes'; 92 | this.request.post(url) 93 | .set('Content-Type', 'application/xml') 94 | .send(xml) 95 | .end(function(err, res){ 96 | if(err) return callback(err); 97 | var xml = res.text; 98 | xml2js.parseString(xml, options, function(err, ret){ 99 | if(err || !ret || !ret.xml) return callback(null, xml); 100 | var result = ret.xml; 101 | _format(result); 102 | if(!result.encrypt) return callback(null, result); 103 | if(result.encrypt){ 104 | var crypter = new WXBizMsgCrypt(config.token, config.encrypt_key, config.app_id); 105 | var message = crypter.decrypt(result.encrypt).message; 106 | if(!message) return callback(result); 107 | xml2js.parseString(message, options, function(err, ret){ 108 | if(err || !ret || !ret.xml) return callback(null, result); 109 | var data = ret.xml; 110 | _format(data); 111 | return callback(null, data); 112 | }); 113 | } 114 | }); 115 | }); 116 | }; 117 | 118 | 119 | var Request = module.exports = function(base){ 120 | if (!(this instanceof Request)) { 121 | return new Request(base); 122 | } 123 | 124 | this.request = request(base); 125 | this.post = post; 126 | this.get = get; 127 | }; 128 | 129 | -------------------------------------------------------------------------------- /test/trap/cardMessage.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | var helper; 8 | before(function(){ 9 | helper = new Helper(); 10 | }); 11 | 12 | describe('Card Message', function(){ 13 | 14 | // 卡券审核同通过 15 | it('card_pass_check', function(done){ 16 | var msg = ''; 17 | 18 | var p1 = helper.trapWrapper('card', function(req, res){ 19 | req.body.CardId.should.equal('123456789'); 20 | res.text('card_pass_check success'); 21 | }); 22 | 23 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 24 | should.not.exist(err); 25 | ret.content.should.equal('card_pass_check success'); 26 | }); 27 | helper.doneWapper(p1, p2, done); 28 | }); 29 | 30 | // 审核未通过 31 | it('card_not_pass_check', function(done){ 32 | var msg = ''; 33 | 34 | var p1 = helper.trapWrapper('card', function(req, res){ 35 | req.body.CardId.should.equal('123456789'); 36 | res.text('card_not_pass_check'); 37 | }); 38 | 39 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 40 | should.not.exist(err); 41 | ret.content.should.equal('card_not_pass_check'); 42 | }); 43 | helper.doneWapper(p1, p2, done); 44 | }); 45 | 46 | // 用户领取卡券 47 | it('user_get_card', function(done){ 48 | var msg = '10'; 49 | 50 | var p1 = helper.trapWrapper('card', function(req, res){ 51 | req.body.CardId.should.equal('123456789'); 52 | req.body.UserCardCode.should.equal('12312312'); 53 | res.text('user_get_card'); 54 | }); 55 | 56 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 57 | should.not.exist(err); 58 | ret.content.should.equal('user_get_card'); 59 | }); 60 | helper.doneWapper(p1, p2, done); 61 | }); 62 | 63 | // 删除卡券 64 | it('user_del_card', function(done){ 65 | var msg = ''; 66 | 67 | var p1 = helper.trapWrapper('card', function(req, res){ 68 | req.body.CardId.should.equal('123456789'); 69 | req.body.UserCardCode.should.equal('12312312'); 70 | res.text('user_del_card'); 71 | }); 72 | 73 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 74 | should.not.exist(err); 75 | ret.content.should.equal('user_del_card'); 76 | }); 77 | helper.doneWapper(p1, p2, done); 78 | }); 79 | 80 | // 用户核销卡券 81 | it('user_consume_card', function(done){ 82 | var msg = ''; 83 | 84 | var p1 = helper.trapWrapper('card', function(req, res){ 85 | req.body.CardId.should.equal('123456789'); 86 | req.body.UserCardCode.should.equal('12312312'); 87 | req.body.ConsumeSource.should.equal('FROM_API'); 88 | res.text('user_consume_card'); 89 | }); 90 | 91 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 92 | should.not.exist(err); 93 | ret.content.should.equal('user_consume_card'); 94 | }); 95 | helper.doneWapper(p1, p2, done); 96 | }); 97 | 98 | // 进入会员卡事件推送 99 | it('user_view_card', function(done){ 100 | var msg = ''; 101 | 102 | var p1 = helper.trapWrapper('card', function(req, res){ 103 | req.body.CardId.should.equal('123456789'); 104 | req.body.UserCardCode.should.equal('12312312'); 105 | res.text('user_view_card'); 106 | }); 107 | 108 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 109 | should.not.exist(err); 110 | ret.content.should.equal('user_view_card'); 111 | }); 112 | helper.doneWapper(p1, p2, done); 113 | }); 114 | 115 | // 从卡券进入公众号会话事件推送 116 | it('user_enter_session_from_card', function(done){ 117 | var msg = ''; 118 | 119 | var p1 = helper.trapWrapper('card', function(req, res){ 120 | req.body.CardId.should.equal('123456789'); 121 | req.body.UserCardCode.should.equal('12312312'); 122 | res.text('user_enter_session_from_card'); 123 | }); 124 | 125 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 126 | should.not.exist(err); 127 | ret.content.should.equal('user_enter_session_from_card'); 128 | }); 129 | helper.doneWapper(p1, p2, done); 130 | }); 131 | 132 | }); -------------------------------------------------------------------------------- /test/trap/click.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | 7 | var helper; 8 | before(function(){ 9 | helper = new Helper(); 10 | }); 11 | 12 | describe('Menu', function(){ 13 | 14 | // 点击菜单事件,未监听此事件 15 | it('click not listen', function(done){ 16 | var msg = ''; 17 | 18 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 19 | should.not.exist(err); 20 | ret.should.equal(''); 21 | }); 22 | 23 | helper.doneWapper(p2, done); 24 | }); 25 | 26 | 27 | // 点击菜单事件,未监听此事件 28 | it('click listen incorrect', function(done){ 29 | var msg = ''; 30 | 31 | var funInvoke = false; 32 | var fun = function(req, res){ 33 | funInvoke = true; 34 | req.body.EventKey.should.equal('EVENTKEY'); 35 | res.text('click'); 36 | }; 37 | var p1 = helper.trapWrapper('click', 'EVENTKEY111', fun); 38 | 39 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 40 | should.not.exist(err); 41 | funInvoke.should.equal(false); 42 | ret.should.equal(''); 43 | }); 44 | 45 | helper.doneWapper(p2, done); 46 | }); 47 | 48 | 49 | // 点击菜单事件 50 | it('click', function(done){ 51 | var msg = ''; 52 | 53 | var p1 = helper.trapWrapper('click', 'EVENTKEY', function(req, res){ 54 | req.body.EventKey.should.equal('EVENTKEY'); 55 | res.text('click'); 56 | }); 57 | 58 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 59 | should.not.exist(err); 60 | ret.content.should.equal('click'); 61 | }); 62 | 63 | helper.doneWapper(p1, p2, done); 64 | }); 65 | 66 | // 点击菜单事件 67 | it('click with two function listen', function(done){ 68 | var msg = ''; 69 | 70 | var p1Invoke = false, p2Invoke = false; 71 | var p1 = helper.trapWrapper('click', 'EVENTKEY', function(req, res, next){ 72 | req.body.EventKey.should.equal('EVENTKEY'); 73 | p1Invoke = true; 74 | next(); 75 | }, function(req, res, next){ 76 | req.body.EventKey.should.equal('EVENTKEY'); 77 | p2Invoke = true; 78 | next(); 79 | }); 80 | 81 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 82 | p1Invoke.should.equal(true); 83 | p2Invoke.should.equal(true); 84 | should.not.exist(err); 85 | ret.should.equal(''); 86 | }); 87 | 88 | helper.doneWapper(p1, p2, done); 89 | }); 90 | 91 | // 其他事件 92 | it('other event', function(done){ 93 | var msg = ''; 94 | 95 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 96 | should.not.exist(err); 97 | ret.should.equal(''); 98 | }); 99 | 100 | helper.doneWapper(p2, done); 101 | }); 102 | 103 | // 其他事件 104 | it('other msgType', function(done){ 105 | var msg = ''; 106 | 107 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 108 | should.not.exist(err); 109 | ret.should.equal(''); 110 | }); 111 | 112 | helper.doneWapper(p2, done); 113 | }); 114 | 115 | // 点击菜单事件 116 | it('click with decrypt=false', function(done){ 117 | helper = new Helper({decrypt: false}); 118 | var msg = ''; 119 | 120 | var p1Invoke = false; 121 | var p1 = helper.trapWrapper('click', 'EVENTKEY', function(req, res){ 122 | p1Invoke = true; 123 | req.body.EventKey.should.equal('EVENTKEY'); 124 | res.text('click'); 125 | }); 126 | 127 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 128 | p1Invoke.should.equal(false); 129 | }); 130 | 131 | helper.doneWapper(p2, done); 132 | }); 133 | 134 | // 点击菜单事件 135 | it('click without crypter', function(done){ 136 | helper = new Helper({config: {}}); 137 | var msg = ''; 138 | 139 | var p1Invoke = false; 140 | var p1 = helper.trapWrapper('click', 'EVENTKEY', function(req, res){ 141 | p1Invoke = true; 142 | req.body.EventKey.should.equal('EVENTKEY'); 143 | res.text('click'); 144 | }); 145 | 146 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 147 | p1Invoke.should.equal(false); 148 | }); 149 | 150 | helper.doneWapper(p2, done); 151 | }); 152 | 153 | // 点击菜单事件 154 | it('click populate_user=true', function(done){ 155 | helper = new Helper({populate_user: true}); 156 | var msg = ''; 157 | 158 | var p1 = helper.trapWrapper('click', 'EVENTKEY', function(req, res){ 159 | req.body.EventKey.should.equal('EVENTKEY'); 160 | res.text('click'); 161 | }); 162 | 163 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 164 | console.log(err, ret); 165 | }); 166 | 167 | helper.doneWapper(p1, p2, done); 168 | }); 169 | 170 | }); -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | /**! 2 | * weixin 3 | * Copyright(c) 2015-2015 leaf 4 | * MIT Licensed 5 | */ 6 | 7 | /** 8 | * 公众号的 api 部分 9 | * 使用了开源模块 wechat-api 10 | * 并重写了其中一部分代码 11 | * 对所有 api 进行了包装,使能够满足多账号托管的需求 12 | */ 13 | 14 | 'use strict'; 15 | var WechatAPI = require('wechat-api'); 16 | var _ = require('underscore'); 17 | var debug = require('debug')('weixin'); 18 | var util = require('wechat-api/lib/util'); 19 | var wrapper = util.wrapper; 20 | var postJSON = util.postJSON; 21 | 22 | /*! 23 | * 需要access token的接口调用如果采用preRequest进行封装后,就可以直接调用。 24 | * 无需依赖getAccessToken为前置调用。 25 | * 应用开发者无需直接调用此API。 26 | * 27 | * Examples: 28 | * ``` 29 | * api.preRequest(method, arguments); 30 | * ``` 31 | * @param {Function} method 需要封装的方法 32 | * @param {Array} args 方法需要的参数 33 | * 这里不校验 token 是否过期 34 | */ 35 | WechatAPI.prototype.preRequest = function (method, args, retryed) { 36 | var that = this; 37 | var callback = args[args.length - 1]; 38 | // 调用用户传入的获取token的异步方法,获得token之后使用(并缓存它)。 39 | 40 | if(typeof callback !== 'function') callback = function(){}; 41 | that.getToken(function (err, token) { 42 | if (err) { 43 | return callback(err); 44 | } 45 | 46 | // 有token并且token有效直接调用 47 | if (token) { 48 | // 暂时保存token 49 | that.token = token; 50 | if (!retryed) { 51 | var retryHandle = function (err, data, res) { 52 | // 40001 重试 53 | if (data && data.errcode && (data.errcode === 40001 || 54 | data.errcode === 40014 || data.errcode === 42001)) { 55 | return that.getAccessToken(function (err) { 56 | if (err) { 57 | return callback(err); 58 | } 59 | return that.preRequest(method, args, true); 60 | }); 61 | } 62 | callback(err, data, res); 63 | }; 64 | // 替换callback 65 | var newargs = Array.prototype.slice.call(args, 0, -1); 66 | newargs.push(retryHandle); 67 | method.apply(that, newargs); 68 | } else { 69 | method.apply(that, args); 70 | } 71 | } else { 72 | // 使用appid/appsecret获取token 73 | that.getAccessToken(function (err, token) { 74 | // 如遇错误,通过回调函数传出 75 | if (err) { 76 | return callback(err); 77 | } 78 | // 暂时保存token 79 | that.token = token; 80 | method.apply(that, args); 81 | }); 82 | } 83 | }); 84 | }; 85 | 86 | /** 87 | * 重写了 getAccessToken 接口 88 | * 源代码对返回的 token 做了处理 89 | * 此处不作处理直接传给 saveToken 函数 90 | */ 91 | WechatAPI.prototype.getAccessToken = function (callback) { 92 | var that = this; 93 | var url = this.endpoint + '/cgi-bin/token?grant_type=client_credential&appid=' + this.appid + '&secret=' + this.appsecret; 94 | this.request(url, {dataType: 'json'}, wrapper(function (err, data) { 95 | if (err) { 96 | return callback(err); 97 | } 98 | var token = {accessToken: data.access_token, expireTime: data.expires_in}; 99 | that.saveToken(token, function (err) { 100 | if (err) { 101 | return callback(err); 102 | } 103 | callback(err, token); 104 | }); 105 | })); 106 | return this; 107 | }; 108 | 109 | /** 110 | * Overwrite 111 | * 获取最新的token 112 | * 113 | * Examples: 114 | * ``` 115 | * api.getLatestToken(callback); 116 | * ``` 117 | * Callback: 118 | * 119 | * - `err`, 获取access token出现异常时的异常对象 120 | * - `token`, 获取的token 121 | * 122 | * @param {Function} method 需要封装的方法 123 | * @param {Array} args 方法需要的参数 124 | */ 125 | WechatAPI.prototype.getLatestToken = function (callback) { 126 | var that = this; 127 | // 调用用户传入的获取token的异步方法,获得token之后使用(并缓存它)。 128 | that.getToken(function (err, token) { 129 | if (err) { 130 | return callback(err); 131 | } 132 | // 有token并且token有效直接调用 133 | if (token) { 134 | callback(null, token); 135 | } else { 136 | // 使用appid/appsecret获取token 137 | that.getAccessToken(callback); 138 | } 139 | }); 140 | }; 141 | 142 | /** 143 | * 重写获取ticket函数 144 | * 在调用存储 ticket 的方法时加上 appid 参数 145 | */ 146 | WechatAPI.prototype._getTicket = function (type, callback) { 147 | if (typeof type === 'function') { 148 | callback = type; 149 | type = 'jsapi'; 150 | } 151 | var that = this; 152 | var url = this.endpoint + '/cgi-bin/ticket/getticket?access_token=' + this.token.accessToken + '&type=' + type; 153 | this.request(url, {dataType: 'json'}, wrapper(function(err, data) { 154 | if (err) { 155 | return callback(err, data); 156 | } 157 | if (!data) { 158 | return callback({message: 'Not get ticket'}); 159 | } 160 | var ticket = {ticket: data.ticket, expireTime: data.expires_in}; 161 | that.saveTicketToken(that.appid, type, ticket, function (err) { 162 | if (err) { 163 | return callback(err); 164 | } 165 | callback(err, ticket); 166 | }); 167 | })); 168 | }; 169 | 170 | 171 | WechatAPI.mixin(require('./api/oauth')); 172 | 173 | var api = module.exports = new WechatAPI(); 174 | 175 | /** 176 | * 返回 api 执行需要的执行环境 context 177 | */ 178 | api.contextWrapper = function(config) { 179 | var context = function(){}; 180 | 181 | _.extend(context.__proto__, this.__proto__); 182 | _.extend(context, this); 183 | 184 | if (_.isObject(config) && !_.isArray(config)) { 185 | _.extend(context, config); 186 | } 187 | 188 | return context; 189 | }; 190 | 191 | /** 192 | * wechat-api 接口包装,支持多系统处理多个公众号的 API 调用情况 193 | */ 194 | var apiWrapper = function(fn) { 195 | return function(){ 196 | var args = [].slice.call(arguments, 0), id = args[0], config, argLen = args.length, fnLen = fn.length; 197 | var callback = _.last(args); 198 | var that = this; 199 | if (typeof id === 'string' && !httpReg.test(id)) { 200 | this.weixin.util.getConfig(id, function(err, config) { 201 | if(config){ 202 | debug('get config by id: ' + id); 203 | args.shift(); 204 | }else if(argLen > fnLen){ 205 | debug('Not found config by id: ' + id); 206 | args.shift(); 207 | return callback({message: 'No permission'}); 208 | } 209 | var context = that.contextWrapper(config); 210 | fn.apply(context, args); 211 | }); 212 | } else { 213 | var context = that.contextWrapper(); 214 | fn.apply(context, args); 215 | } 216 | }; 217 | }; 218 | 219 | /** 220 | * 对 wechat-api 所有接口进行的包装 221 | * 调用 api 方法的第一个参数必须是公众号的 id 222 | * 通过公众号id获取到该公众号的相关配置 223 | * 然后再调用 wechat-api 的方法 224 | */ 225 | var httpReg = /http(s)?\/\//; 226 | _.each(WechatAPI.prototype, function(fn, n) { 227 | if (n.indexOf('_') === 0) return; 228 | api[n] = apiWrapper(fn); 229 | }); 230 | 231 | api.wrapper = wrapper; 232 | api.postJSON = postJSON; 233 | api.apiWrapper = apiWrapper; 234 | 235 | /** 236 | * API 重写或扩展时可以使用此方法 237 | * 238 | * Example: 239 | * weixinTrap.api.make(weixinTrap.api, 'test', function(arg1, arg2, callback){ 240 | * var url = 'https://api.weixin.qq.com/card/location/batchadd?access_token=' + this.token.accessToken; 241 | * var data = {}; 242 | * this.request(url, this.postJSON(data), this.wrapper(callback)); 243 | * }) 244 | */ 245 | api.make = function(host, name, fn) { 246 | host[name] = apiWrapper(function () { 247 | this.preRequest(this['_' + name], arguments); 248 | }); 249 | host['_' + name] = fn; 250 | }; 251 | 252 | 253 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | weixin-trap 2 | ========== 3 | [![Build Status](https://travis-ci.org/liuxiaodong/weixin-trap.png)](https://travis-ci.org/liuxiaodong/weixin-trap) 4 | [![Coverage Status](https://coveralls.io/repos/liuxiaodong/weixin-trap/badge.svg?branch=master&service=github)](https://coveralls.io/github/liuxiaodong/weixin-trap?branch=master) 5 | 6 | 托管多个公众号 7 | 8 | ## Installation 9 | 10 | ``` 11 | $ npm install weixin-trap --save 12 | ``` 13 | 14 | ## Usage 15 | 16 | ``` 17 | var options = { 18 | attrNameProcessors: 'underscored', 19 | getConfig: function(id, callback){ // id 可能是 appid 或者是 wechat_id, 也可能是其他任何值 20 | getConfigFun(id, function(err, ret){ 21 | config = {}; 22 | config.id = ret.wechat_id; 23 | config.appid = ret.appid; 24 | config.encryptkey = ret.encrypt_key; 25 | config.appsecret = ret.appsecret; 26 | config.token = ret.token; 27 | callback(null, config); 28 | }); 29 | }, 30 | saveToken: function(token, callback){ 31 | var exp = token.expireTime - 120; 32 | var appid = this.appid; 33 | db.setex('ACCESS_TOKEN:' + appid, exp, token.accessToken, function(err){ 34 | callback(err, token); 35 | }); 36 | } 37 | getToken: function(){ 38 | var appid = this.appid; 39 | db.get('ACCESS_TOKEN:' + appid, function(err, accessToken){ 40 | if(err) return callback(err); 41 | if(!accessToken) return callback(); 42 | var token = { 43 | accessToken: accessToken 44 | }; 45 | db.ttl('ACCESS_TOKEN:' + appid, function(err, exp){ 46 | if(err) return callback(err); 47 | token.expireTime = exp; 48 | callback(err, token); 49 | }); 50 | }); 51 | } 52 | }; 53 | 54 | var weixin = require('weixin-trap')(options); 55 | var trap = weixin.trap; 56 | 57 | app.use('/wechat', trap); 58 | 59 | trap.text('hi', function(req, res){ 60 | res.text('hello'); 61 | }); 62 | 63 | 64 | ``` 65 | 66 | ## Options 67 | 68 | `getBody:` 是否 req 中的数据流,default true 69 | 70 | `parseXml:` 是否解析 xml 为 json 数据,defalut true 71 | 72 | `decrypt:` 若数据加密,是否解密数据,default true 73 | 74 | `attrNameProcessors:` 数据的格式化,例:{AppId:'123'} -> {app_id: '123'},default 'keep' 75 | 76 | * keep 保持不变 {AppId: '123'} -> {AppId: '123'} 77 | * lowerCase 转小写 {AppId: '123'} -> {appid: '123'} 78 | * underscored 转小写并以下划线间隔 {AppId: '123'} -> {app_id: '123'} 79 | 80 | `populate_user:` 微信推送来的消息是否自动扩张出用户信息,默认 false 81 | 82 | * 只能获取关注用户的用户信息 83 | 84 | `trapHandle:` 对微信推送事件消息的默认处理函数,默认回复空字符串 85 | 86 | `saveToken:` 存储 accessToken 的函数,默认存储在内存中。 87 | 88 | `getToken:` 获取 accessToken 函数。若配置了 saveToken 则必须要配置此函数,不然会找不到 accessToken 89 | 90 | `getConfig:` 获取微信公众号的配置信息的函数 91 | 92 | * getConfig 第一个参数为 appid 或公众号 id 或其他任何值,需要函数自己判断是否需要返回 config 信息。 93 | 94 | * 设置此函数则可不用配置 config 参数,但获取到的数据格式为如下第一种样式 95 | 96 | `config:` 微信公众号的配置 json 数据 97 | 98 | ``` 99 | { 100 | "id": "gh_956b2584a111", 101 | "token": "swechatcardtest", 102 | "appid": "wx309678780eb63111", 103 | "appsecret": "f65a2f757b92787f137c359fa5699111", 104 | "encryptkey": "5iteleZLwN1UplKO08L7Fa57H5EuwPaTqnjvO85u111" 105 | } 106 | 107 | 或者 108 | 109 | [ 110 | { 111 | "id": "gh_956b2584a111", 112 | "token": "swechatcardtest", 113 | "appid": "wx309678780eb63111", 114 | "appsecret": "f65a2f757b92787f137c359fa5699111", 115 | "encryptkey": "5iteleZLwN1UplKO08L7Fa57H5EuwPaTqnjvO85u111" 116 | }, 117 | { 118 | "id": "gh_ff831e3e9222", 119 | "token": "swechatcardtest", 120 | "appid": "wxd1fbffa91579f222", 121 | "appsecret": "f11dcaf01dab462589cdeb43aeade222", 122 | "encryptkey": "5iteleZLwN1UplKO08L7Fa57H5EuwPaTqnjvO85u222" 123 | } 124 | ] 125 | ``` 126 | 127 | 128 | 129 | ##消息处理 130 | ===== 131 | 132 | 133 | #### 文本消息 134 | 135 | ``` 136 | trap.text('hi', function(req, res){ 137 | res.text('hello'); 138 | }); 139 | ``` 140 | 141 | #### 图片 142 | 143 | ``` 144 | trap.image(function(req, res){ 145 | res.image(media_id); 146 | }); 147 | ``` 148 | 149 | #### 录音 150 | 151 | ``` 152 | trap.voice(function(req, res){ 153 | res.voice(media_id); 154 | }); 155 | ``` 156 | 157 | #### 视屏 158 | 159 | ``` 160 | trap.video(function(req, res){ 161 | res.video({title:'video title', video: media_id, description: 'video description'}); 162 | }); 163 | ``` 164 | 165 | #### 小视屏 166 | 167 | ``` 168 | trap.shortvideo(function(req, res){ 169 | res.text('shortvideo'); 170 | }); 171 | ``` 172 | 173 | #### 地理位置 174 | 175 | ``` 176 | trap.location(function(req, res){ 177 | res.text('location'); 178 | }); 179 | ``` 180 | 181 | #### 连接 182 | 183 | ``` 184 | trap.link(function(req, res){ 185 | res.text('link'); 186 | }); 187 | ``` 188 | 189 | #### 关注公众号 或 用户未关注时,扫描带参数二维码事件 190 | 191 | ``` 192 | trap.subscribe(function(req, res){ 193 | res.text('subscribe'); 194 | }); 195 | ``` 196 | 197 | #### 取消关注公众号 198 | 199 | ``` 200 | trap.unsubscribe(function(req, res){ 201 | res.text('unsubscribe'); 202 | }); 203 | ``` 204 | 205 | #### 用户以关注公众号,扫描带参数二维码事件 206 | 207 | ``` 208 | trap.scan(function(req, res){ 209 | res.text('subscribe'); 210 | }); 211 | ``` 212 | 213 | #### 上报地理位置 214 | 215 | ``` 216 | trap.reportedLocation(function(req, res){ 217 | res.text('reportedLocation'); 218 | }); 219 | ``` 220 | 221 | #### 菜单栏点击事件 222 | 223 | ``` 224 | trap.click('buttonA', function(req, res){ 225 | res.text('click'); 226 | }); 227 | ``` 228 | 229 | #### 菜单栏页面跳转 230 | 231 | ``` 232 | trap.view(function(req, res){ 233 | res.text('click'); 234 | }); 235 | ``` 236 | 237 | #### 卡券事件 238 | 239 | ``` 240 | trap.card(function(req, res){ 241 | switch(req.Event){ 242 | case 'card_pass_check': 243 | return handle(); 244 | case 'card_not_pass_check': 245 | return handle(); 246 | defalut: 247 | res.text('no handle') 248 | } 249 | }); 250 | ``` 251 | 252 | #### IOT 设备事件 253 | 254 | ``` 255 | trap.device(function(req, res){ 256 | res.device('commmand'); 257 | }); 258 | ``` 259 | 260 | #### 摇周边 261 | 262 | ``` 263 | trap.shakeAround(function(req, res){ 264 | handle(); 265 | }); 266 | ``` 267 | 268 | ##消息回复 269 | ===== 270 | 271 | #### 回复文本 272 | 273 | ``` 274 | res.text('text'); 275 | ``` 276 | 277 | #### 回复图片 278 | 279 | ``` 280 | res.image(media_id); 281 | ``` 282 | 283 | #### 回复录音 284 | 285 | ``` 286 | res.voice(media_id); 287 | ``` 288 | 289 | #### 回复视屏 290 | 291 | ``` 292 | res.video({title:'video title', video: media_id, description: 'video description'}) 293 | ``` 294 | 295 | #### 回复音乐 296 | 297 | ``` 298 | var data = { 299 | title: 'music title', 300 | description: 'music description', 301 | music_url: 'music url', 302 | hq_music_url: 'hq music url', 303 | thumb_media: media_id 304 | }; 305 | 306 | res.music(data); 307 | ``` 308 | 309 | #### 回复图文消息 310 | 311 | ``` 312 | var news = [ 313 | { 314 | title: 'news title', 315 | description: 'news description', 316 | pic_url: 'news pic url', 317 | url: 'news url' 318 | }, 319 | { 320 | title: 'news title 1', 321 | description: 'news description 1', 322 | pic_url: 'news pic url 1', 323 | url: 'news url 1' 324 | } 325 | ]; 326 | 327 | res.news(news); 328 | ``` 329 | 330 | #### 回复 IOT 设备消息 331 | 332 | ``` 333 | res.device('command text'); 334 | ``` 335 | ## API 336 | 337 | * api 部分对 wechat-api 进行了包装,在调用任何 API 时第一个参数可以传一个 appid 或微信公众号 id。 实现托管多个公众号时对微信 API 的调用 338 | * api 部分没有测试,如果遇到任何问题,请提 issues 或 PR 339 | 340 | `例子:` 341 | 342 | ``` 343 | // 给用户发消息 344 | 345 | weixin.api.sendText(appid, openid, text, callback); 346 | 347 | ``` 348 | 349 | * 若某个微信 API wechat-api 没有提供,自己可以通过 weixin.api.make 函数扩展 350 | 351 | `例子:` 352 | 353 | ``` 354 | // 批量获取用户基本信息 355 | 356 | weixin.api.make(weixin.api, 'batchGetUsers', function (openids, callback) { 357 | var url = 'https://api.weixin.qq.com/cgi-bin/user/info/batchget?access_token=' + this.token.accessToken; 358 | var data = {}; 359 | data.user_list = []; 360 | var openidsLength = openids.length; 361 | for(var i = 0; i < openidsLength; i++){ 362 | data.user_list.push({openid: openids[i], language: 'zh-CN'}); 363 | } 364 | this.request(url, this.postJSON(data), this.wrapper(callback)); 365 | }); 366 | 367 | * 此方法经过测试,若微信用户数据有特殊字符会导致 urllib 的 JSON.parse 抛出异常。 368 | * 可以接收返回 String 类型数据然后在自己处理 369 | 370 | 371 | * 调用此接口 weixin.api.batchGetUsers(appid, openids, callback); 372 | ``` 373 | -------------------------------------------------------------------------------- /lib/util.js: -------------------------------------------------------------------------------- 1 | /**! 2 | * weixin 3 | * Copyright(c) 2015-2015 leaf 4 | * MIT Licensed 5 | */ 6 | 7 | /**! 8 | * weixin 工具集 9 | */ 10 | 11 | var _ = require('underscore'); 12 | var crypto = require('crypto'); 13 | var debug = require('debug')('weixin'); 14 | 15 | 'use strict'; 16 | 17 | /** 18 | * 对传入的微信配置数据做处理 19 | * 统一转换成 appid appsecret 这种形式,去除_ ,转为小写 20 | */ 21 | var parseConfig = function(config) { 22 | return _.compact(_.map(config, function(c) { 23 | var o = {}; 24 | if (c) { 25 | _.each(c, function(v, k){ 26 | k = k.replace(/[-|_|\.]+/ig, ''); 27 | k = k ? k.toLowerCase() : null; 28 | if (!k) { 29 | o = null; 30 | } else if(o) { 31 | o[k] = v; 32 | } 33 | }); 34 | return o; 35 | } else { 36 | return null; 37 | } 38 | })); 39 | }; 40 | 41 | /*! 42 | * 生成随机字符串 43 | */ 44 | var createNonceStr = function () { 45 | return Math.random().toString(36).substr(2, 15); 46 | }; 47 | 48 | /*! 49 | * 生成时间戳 50 | */ 51 | var createTimestamp = function () { 52 | return parseInt(new Date().getTime() / 1000, 0) + ''; 53 | }; 54 | 55 | /*! 56 | * 排序查询字符串 57 | */ 58 | var raw = function (args) { 59 | var keys = Object.keys(args); 60 | keys = keys.sort(); 61 | var newArgs = {}; 62 | keys.forEach(function (key) { 63 | newArgs[key.toLowerCase()] = args[key]; 64 | }); 65 | 66 | var string = ''; 67 | for(var k in newArgs) { 68 | string += '&' + k + '=' + newArgs[k]; 69 | } 70 | return string.substr(1); 71 | }; 72 | 73 | var signCardExt = function(api_ticket, card_id, timestamp, nonceStr, code, openid) { 74 | var values = [api_ticket, card_id, timestamp, nonceStr, code || '', openid || '']; 75 | values.sort(); 76 | 77 | var string = values.join(''); 78 | var shasum = crypto.createHash('sha1'); 79 | shasum.update(string); 80 | return shasum.digest('hex'); 81 | }; 82 | 83 | /** 84 | * 存储微信的配置参数到内存 85 | * TODO 自定义 saveConfig 86 | */ 87 | exports.saveConfig = function(config) { 88 | if (!config) return; 89 | if (!Array.isArray(config)) { 90 | config = [config]; 91 | } 92 | this.config = parseConfig(config); 93 | }; 94 | 95 | /** 96 | * 设置微信的配置数据 97 | */ 98 | exports.setConfig = function(config) { 99 | if (!this.config) this.config = []; 100 | if (!config) return null; 101 | if (!Array.isArray(config)) { 102 | config = [config]; 103 | } 104 | _.each(parseConfig(config), function(c) { 105 | if (!c.appid || !c.appsecret) { 106 | debug('losing appid or appsecret'); 107 | } else { 108 | if (!_.find(this.config , function(c1){ return c1.appid === c.appid; })) { 109 | this.config.push(c); 110 | } 111 | } 112 | }, this); 113 | }; 114 | 115 | /** 116 | * 获取某公众号的配置 117 | * id 可以为 appid(type === 'appid') 也可以为 公众号ID (type === 'id') 118 | */ 119 | exports.getConfig = function(id, callback) { 120 | if (typeof id !== 'string') callback(); 121 | var c = _.find(this.config, function(c) { return c.id === id; }); 122 | if (!c) c = _.find(this.config, function(c) { return c.appid === id; }); 123 | callback(null, c); 124 | }; 125 | 126 | /** 127 | * 存储 accessToken 的默认函数 128 | */ 129 | exports.saveToken = function(token, callback){ 130 | if (!this.tokenStore) this.tokenStore = {}; 131 | this.tokenStore[this.appid] = { 132 | accessToken: token.accessToken, 133 | expireTime: (new Date().getTime()) + (token.expireTime - 10) * 1000 // 过期时间,因网络延迟等,将实际过期时间提前10秒,以防止临界点 134 | }; 135 | if (process.env.NODE_ENV === 'production') { 136 | debug('Dont save accessToken in memory, when cluster or multi-computer!'); 137 | } 138 | if (typeof callback === 'function') callback(null, this.tokenStore[this.appid]); 139 | return this.tokenStore[this.appid]; 140 | }; 141 | 142 | /** 143 | * 获取 accessToken 的默认函数 144 | */ 145 | exports.getToken = function(callback){ 146 | var token = this.tokenStore ? this.tokenStore[this.appid] : null; 147 | if (token) { 148 | if ((new Date().getTime()) < token.expireTime) { 149 | callback(null, token.accessToken); 150 | } else { 151 | this.getAccessToken(callback); 152 | } 153 | } else { 154 | this.getAccessToken(callback); 155 | } 156 | }; 157 | 158 | /** 159 | * 存储微信 oauth2 授权后通过 code 获取到的信息 160 | * accessToken 接口凭证 161 | * expireTime 接口凭证过期时间 162 | * refreshToken 刷新接口凭证参数 163 | * openid 用户唯一标识,请注意,在未关注公众号时,用户访问公众号的网页,也会产生一个用户和公众号唯一的OpenID 164 | * scope 用户授权的作用域,使用逗号(,)分隔 165 | * unionid 只有在用户将公众号绑定到微信开放平台帐号后,才会出现该字段 166 | * 167 | */ 168 | exports.saveOauthToken = function(appid, token, callback){ 169 | if (!this.oauthTokenStore) this.oauthTokenStore = {}; 170 | this.oauthTokenStore[appid] = { 171 | accessToken: token.accessToken, 172 | refreshToken: token.refreshToken, 173 | openid: token.openid, 174 | scope: token.scope, 175 | unionid: token.unionid, 176 | expireTime: (new Date().getTime()) + (token.expireTime - 10) * 1000 // 过期时间,因网络延迟等,将实际过期时间提前10秒,以防止临界点 177 | }; 178 | if (process.env.NODE_ENV === 'production') { 179 | debug('Dont save oauth accessToken in memory, when cluster or multi-computer!'); 180 | } 181 | if (typeof callback === 'function') callback(null, this.oauthTokenStore[appid]); 182 | return this.oauthTokenStore[appid]; 183 | }; 184 | 185 | /** 186 | * 获取微信 oauth2 授权后通过 code 获取到的信息 187 | */ 188 | exports.getOauthToken = function(appid, callback){ 189 | var token = this.oauthTokenStore ? this.oauthTokenStore[appid] : null; 190 | if (token){ 191 | if ((new Date().getTime()) < token.expireTime) { 192 | callback(null, token); 193 | } else { 194 | this.refreshOauthAccessToken(appid, token.refreshToken, callback); 195 | } 196 | } else { 197 | callback({errMsg: 'refreshToken missing'}); 198 | } 199 | }; 200 | 201 | /** 202 | * 存储 ticket 函数 203 | */ 204 | exports.saveTicketToken = function(appid, type, ticketToken, callback) { 205 | if (typeof ticketToken === 'function') { 206 | callback = ticketToken; 207 | ticketToken = type; 208 | type = 'jsapi'; 209 | } 210 | 211 | if (!this.ticketStore) this.ticketStore = {}; 212 | if (!this.ticketStore[appid]) this.ticketStore[appid] = {}; 213 | 214 | this.ticketStore[this.appid][type] = { 215 | ticket: ticketToken.ticket, 216 | expireTime: (new Date().getTime()) + (ticketToken.expireTime - 10) * 1000 // 过期时间,因网络延迟等,将实际过期时间提前10秒,以防止临界点 217 | }; 218 | if (process.env.NODE_ENV === 'production') { 219 | debug('Dont save ticket in memory, when cluster or multi-computer!'); 220 | } 221 | callback(null, this.ticketStore[appid][type]); 222 | return this.ticketStore[appid][type]; 223 | }; 224 | 225 | /** 226 | * 获取 ticket 227 | */ 228 | exports.getTicketToken = function(appid, type, callback) { 229 | if (typeof type === 'function') { 230 | callback = type; 231 | type = 'jsapi'; 232 | } 233 | var ticketToken = (this.ticketToken && this.ticketStore[appid]) ? this.ticketStore[appid][type] : null; 234 | if (_.isEmpty(ticketToken)) { 235 | if ((new Date().getTime()) < ticketToken.expireTime) { 236 | callback(null, ticketToken); 237 | } else { 238 | this.getTicket(type, callback); 239 | } 240 | } else { 241 | this.getTicket(type, callback); 242 | } 243 | }; 244 | 245 | /** 246 | * jssdk 签名 247 | */ 248 | exports.getJsConfig = function(ticket, url) { 249 | var ret = { 250 | jsapi_ticket: ticket, 251 | nonceStr: createNonceStr(), 252 | timestamp: createTimestamp(), 253 | url: url 254 | }; 255 | var string = raw(ret); 256 | var shasum = crypto.createHash('sha1'); 257 | shasum.update(string); 258 | ret.signature = shasum.digest('hex'); 259 | delete ret.jsapi_ticket; 260 | return ret; 261 | }; 262 | 263 | /** 264 | * 卡券签名 265 | */ 266 | exports.getCardExt = function(ticket, card_id, card_code, openid) { 267 | var timestamp = createTimestamp(); 268 | var nonceStr = createNonceStr(); 269 | var signature = signCardExt(ticket, card_id, timestamp, nonceStr, card_code, openid); 270 | var result = { 271 | timestamp: timestamp, 272 | nonceStr: nonceStr, 273 | signature: signature, 274 | card_id: card_id 275 | }; 276 | 277 | if (card_code) result.card_code = card_code; 278 | if (openid) result.openid = openid; 279 | return result; 280 | }; 281 | 282 | /** 283 | * 微信事件的默认处理函数 284 | */ 285 | exports.trapHandle = function(req, res){ 286 | res.ok(); 287 | }; 288 | 289 | 290 | /** 291 | * 字符串格式化 292 | * keep: 保持不变 293 | * lowerCase: 转为消息 294 | * underscored: 转为下划线形式 295 | */ 296 | var defaultFormatStr = { 297 | keep: function(str){ 298 | return str.trim(); 299 | }, 300 | lowerCase: function(str){ 301 | return str.trim().toLowerCase(); 302 | }, 303 | underscored: function(str){ 304 | return str.trim().replace(/([a-z\d])([A-Z]+)/g, '$1_$2').replace(/[-\s]+/g, '_').toLowerCase(); 305 | } 306 | }; 307 | exports.formatStr = function(type){ 308 | return defaultFormatStr[type]; 309 | }; 310 | 311 | /** 312 | * 错误包装 313 | */ 314 | exports.error = function(msg){ 315 | var err = new Error(msg); 316 | err.name = 'WeixinError'; 317 | err.code = '-2'; 318 | return err; 319 | }; 320 | -------------------------------------------------------------------------------- /test/trap/generalMessage.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var Helper = require('./helper'); 3 | var config = require('./config'); 4 | 5 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 6 | var media_id = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_-a'; 7 | var video = {video: media_id, title:'video title', description: 'video description'}; 8 | var music = {title: 'music title', description: 'music description', music_url: 'music url', hq_music_url: 'hq music url', thumb_media: media_id}; 9 | var news = [ 10 | { 11 | title: 'news title', 12 | description: 'news description', 13 | pic_url: 'news pic url', 14 | url: 'news url' 15 | }, 16 | { 17 | title: 'news title 1', 18 | description: 'news description 1', 19 | pic_url: 'news pic url 1', 20 | url: 'news url 1' 21 | } 22 | ]; 23 | 24 | 25 | var helper; 26 | before(function(){ 27 | helper = new Helper(); 28 | }); 29 | 30 | describe('General Message', function(){ 31 | 32 | // 文本消息,未监听,返回控制 33 | it('text msg and not listen', function(done){ 34 | helper = new Helper(); 35 | var msg = '1234567890123456'; 36 | 37 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 38 | should.not.exist(err); 39 | ret.should.equal(''); 40 | }); 41 | helper.doneWapper(p2, done); 42 | 43 | }); 44 | 45 | // 文本消息 46 | it('text msg and textPattern is Reg', function(done){ 47 | helper = new Helper(); 48 | var msg = '1234567890123456'; 49 | 50 | var textReg = /.*/ig; 51 | var p1 = helper.trapWrapper('text', textReg, function(req, res){ 52 | req.body.Content.should.equal('this is a test'); 53 | res.should.have.property('text'); 54 | res.text('text'); 55 | }); 56 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 57 | should.not.exist(err); 58 | ret.content.should.equal('text'); 59 | }); 60 | helper.doneWapper(p1, p2, done); 61 | 62 | }); 63 | 64 | // 文本消息,文本匹配 65 | it('text msg and textPattern is string', function(done){ 66 | helper = new Helper(); 67 | var msg = '1234567890123456'; 68 | 69 | var textReg = 'this'; 70 | var funInvoke = false; 71 | var p1 = helper.trapWrapper('text', textReg, function(req, res){ 72 | funInvoke = true; 73 | req.body.Content.should.equal('this is a test'); 74 | res.should.have.property('text'); 75 | res.text('text'); 76 | }); 77 | 78 | var p2 = helper.trapWrapper('text', textReg, function(req, res){ 79 | req.body.Content.should.equal('this is a test'); 80 | res.should.have.property('text'); 81 | res.text('text'); 82 | }); 83 | var p3 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 84 | should.not.exist(err); 85 | ret.content.should.equal('text'); 86 | funInvoke.should.equal(false); 87 | }); 88 | helper.doneWapper(p2, p3, done); 89 | 90 | }); 91 | 92 | // 文本消息,未匹配上 93 | it('text msg and textPattern is string not match', function(done){ 94 | helper = new Helper(); 95 | var msg = '1234567890123456'; 96 | 97 | var textReg = 'lalalla'; 98 | var p1Invoke = false; 99 | var p1 = helper.trapWrapper('text', textReg, function(req, res){ 100 | p1Invoke = true; 101 | req.body.Content.should.equal('this is a test'); 102 | res.should.have.property('text'); 103 | res.text('text'); 104 | }); 105 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 106 | p1Invoke.should.equal(false); 107 | should.not.exist(err); 108 | ret.should.equal(''); 109 | }); 110 | helper.doneWapper(p2, done); 111 | 112 | }); 113 | 114 | // 文本消息,文本匹配 115 | it('text msg and with default textPattern is function', function(done){ 116 | helper = new Helper(); 117 | var msg = '1234567890123456'; 118 | 119 | var p1 = helper.trapWrapper('text', function(req, res){ 120 | req.body.Content.should.equal('this is a test'); 121 | res.should.have.property('text'); 122 | res.text('text'); 123 | }); 124 | 125 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 126 | should.not.exist(err); 127 | ret.content.should.equal('text'); 128 | }); 129 | helper.doneWapper(p1, p2, done); 130 | 131 | }); 132 | 133 | // 图片消息 134 | it('image msg', function(done){ 135 | var msg = '1234567890123456'; 136 | 137 | var p1 = helper.trapWrapper('image', function(req, res){ 138 | req.body.PicUrl.should.equal('http://www.pic.com/url'); 139 | req.body.MediaId.should.equal('123456'); 140 | res.should.have.property('image'); 141 | res.image(media_id); 142 | }); 143 | 144 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 145 | should.not.exist(err); 146 | ret.image.media_id.should.equal(media_id); 147 | }); 148 | 149 | helper.doneWapper(p1, p2, done); 150 | 151 | }); 152 | 153 | // 录音 154 | it('voice msg', function(done){ 155 | var helper = new Helper(); 156 | 157 | var msg = '1234567890123456'; 158 | 159 | var p1 = helper.trapWrapper('voice', function(req, res){ 160 | req.body.MediaId.should.equal('123456'); 161 | res.should.have.property('voice'); 162 | res.voice(media_id); 163 | }); 164 | 165 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 166 | should.not.exist(err); 167 | ret.voice.media_id.should.equal(media_id); 168 | }); 169 | 170 | helper.doneWapper(p1, p2, done); 171 | }); 172 | 173 | // 视频 174 | it('video msg', function(done){ 175 | var helper = new Helper(); 176 | 177 | var msg = '1234567890123456'; 178 | 179 | var p1 = helper.trapWrapper('video', function(req, res){ 180 | req.body.MediaId.should.equal('123457'); 181 | res.should.have.property('video'); 182 | res.video(video); 183 | }); 184 | 185 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 186 | should.not.exist(err); 187 | ret.video.media_id.should.equal(video.video); 188 | ret.video.title.should.equal(video.title); 189 | ret.video.description.should.equal(video.description); 190 | }); 191 | 192 | helper.doneWapper(p1, p2, done); 193 | }); 194 | 195 | // 小视屏 196 | it('shortvideo msg', function(done){ 197 | var helper = new Helper(); 198 | 199 | var msg = '1234567890123456'; 200 | 201 | var p1 = helper.trapWrapper('shortvideo', function(req, res){ 202 | req.body.MediaId.should.equal('123456'); 203 | res.should.have.property('music'); 204 | res.music(music); 205 | }); 206 | 207 | var p2 = helper.requestWrapper('post', config[0], openid, msg, function(err, ret){ 208 | should.not.exist(err); 209 | ret.music.title.should.equal(music.title); 210 | ret.music.description.should.equal(music.description); 211 | ret.music.music_url.should.equal(music.music_url); 212 | ret.music.hqmusic_url.should.equal(music.hq_music_url); 213 | ret.music.thumb_media_id.should.equal(music.thumb_media); 214 | }); 215 | 216 | helper.doneWapper(p1, p2, done); 217 | }); 218 | 219 | // 地理位置 220 | it('location msg', function(done){ 221 | var helper = new Helper(); 222 | 223 | var msg = '23.134521113.358803201234567890123456'; 224 | 225 | var p1 = helper.trapWrapper('location', function(req, res){ 226 | req.body.Label.should.equal('位置信息'); 227 | res.should.have.property('news'); 228 | res.news(news); 229 | }); 230 | 231 | var p2 = helper.requestWrapper('post', config[0], openid, msg, function(err, ret){ 232 | should.not.exist(err); 233 | ret.articles.item.should.have.lengthOf(2); 234 | }); 235 | 236 | helper.doneWapper(p1, p2, done); 237 | }); 238 | 239 | // 连接 240 | it('link msg', function(done){ 241 | var helper = new Helper(); 242 | 243 | var msg = '<![CDATA[公众平台官网链接]]>1234567890123456'; 244 | 245 | var p1 = helper.trapWrapper('link', function(req, res){ 246 | req.body.Title.should.equal('公众平台官网链接'); 247 | res.text('link'); 248 | }); 249 | 250 | var p2 = helper.requestWrapper('post', config[0], openid, msg, function(err, ret){ 251 | should.not.exist(err); 252 | ret.content.should.equal('link'); 253 | }); 254 | 255 | helper.doneWapper(p1, p2, done); 256 | }); 257 | 258 | }); -------------------------------------------------------------------------------- /test/trap/eventMessage.test.js: -------------------------------------------------------------------------------- 1 | var should = require('should'); 2 | var sinon = require('sinon'); 3 | var Helper = require('./helper'); 4 | var config = require('./config'); 5 | 6 | var openid = 'ovKXbsxcjA05QLUcShoQkAMfkECE'; 7 | 8 | var helper; 9 | before(function(){ 10 | helper = new Helper(); 11 | }); 12 | 13 | describe('Event Message', function(){ 14 | 15 | // 关注公众号 16 | it('subscribe', function(done){ 17 | var msg = ''; 18 | 19 | var p1 = helper.trapWrapper('subscribe', function(req, res){ 20 | req.body.Event.should.equal('subscribe'); 21 | req.body.FromUserName.should.equal(openid); 22 | res.text('subscribe success'); 23 | }); 24 | 25 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 26 | should.not.exist(err); 27 | ret.content.should.equal('subscribe success'); 28 | }); 29 | helper.doneWapper(p1, p2, done); 30 | }); 31 | 32 | // 取消关注 33 | it('unsubscribe', function(done){ 34 | var msg = ''; 35 | 36 | var p1 = helper.trapWrapper('unsubscribe', function(req, res){ 37 | req.body.Event.should.equal('unsubscribe'); 38 | req.body.FromUserName.should.equal(openid); 39 | res.text('unsubscribe success'); 40 | }); 41 | 42 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 43 | should.not.exist(err); 44 | ret.content.should.equal('unsubscribe success'); 45 | }); 46 | helper.doneWapper(p1, p2, done); 47 | }); 48 | 49 | // 用户未关注时,扫描带参数二维码事件 50 | it('scan and subscribe', function(done){ 51 | var msg = ''; 52 | 53 | var p1 = helper.trapWrapper('subscribe', function(req, res){ 54 | req.body.Event.should.equal('subscribe'); 55 | req.body.FromUserName.should.equal(openid); 56 | req.body.EventKey.should.equal('qrscene_123123'); 57 | res.text('scan and subscribe success'); 58 | }); 59 | 60 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 61 | should.not.exist(err); 62 | ret.content.should.equal('scan and subscribe success'); 63 | }); 64 | helper.doneWapper(p1, p2, done); 65 | }); 66 | 67 | // 扫描带参数二维码事件 68 | it('scan', function(done){ 69 | var msg = ''; 70 | 71 | var p1 = helper.trapWrapper('scan', function(req, res){ 72 | req.body.Event.should.equal('SCAN'); 73 | req.body.FromUserName.should.equal(openid); 74 | req.body.EventKey.should.equal('SCENE_VALUE'); 75 | res.text('scan success'); 76 | }); 77 | 78 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 79 | should.not.exist(err); 80 | ret.content.should.equal('scan success'); 81 | }); 82 | helper.doneWapper(p1, p2, done); 83 | }); 84 | 85 | // 上报地理位置, 经纬度 86 | it('reported location', function(done){ 87 | var msg = '23.137466113.352425119.385040'; 88 | 89 | var p1 = helper.trapWrapper('reportedLocation', function(req, res){ 90 | req.body.Event.should.equal('LOCATION'); 91 | req.body.Latitude.should.equal('23.137466'); 92 | res.text('reported location'); 93 | }); 94 | 95 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 96 | should.not.exist(err); 97 | ret.content.should.equal('reported location'); 98 | }); 99 | 100 | helper.doneWapper(p1, p2, done); 101 | }); 102 | 103 | // 菜单栏点击事件 104 | it('menu click', function(done){ 105 | var msg = ''; 106 | 107 | var p1 = helper.trapWrapper('click', 'click_test', function(req, res){ 108 | req.body.Event.should.equal('CLICK'); 109 | req.body.EventKey.should.equal('click_test'); 110 | res.text('click'); 111 | }); 112 | 113 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 114 | should.not.exist(err); 115 | ret.content.should.equal('click'); 116 | }); 117 | 118 | helper.doneWapper(p1, p2, done); 119 | }); 120 | 121 | // 菜单栏页面跳转 122 | it('menu view', function(done){ 123 | var msg = ''; 124 | 125 | var p1 = helper.trapWrapper('view', function(req, res){ 126 | req.body.Event.should.equal('VIEW'); 127 | req.body.EventKey.should.equal('http://www.example.com'); 128 | res.text('view'); 129 | }); 130 | 131 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 132 | should.not.exist(err); 133 | ret.content.should.equal('view'); 134 | }); 135 | 136 | helper.doneWapper(p1, p2, done); 137 | }); 138 | 139 | // 菜单栏页面跳转,公众号给用户推送一个图片消息 140 | it('apply image but get access_token failure', function(done){ 141 | var msg = ''; 142 | 143 | var p1 = helper.trapWrapper('view', function(req, res){ 144 | req.body.Event.should.equal('VIEW'); 145 | req.body.EventKey.should.equal('http://www.example.com'); 146 | res.image('http://www.asdf.com/a.jpg'); 147 | }); 148 | 149 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 150 | ret = ret || ''; 151 | ret.should.equal(''); 152 | }); 153 | 154 | helper.doneWapper(p1, p2, done); 155 | }); 156 | 157 | // 菜单栏页面跳转,公众号给用户推送一个图片消息 158 | it('apply image but image file not exits', function(done){ 159 | var msg = ''; 160 | 161 | helper = new Helper({ 162 | getToken: function(callback){ 163 | callback(null, {accessToken: '1234568'}); 164 | } 165 | }); 166 | var p1 = helper.trapWrapper('view', function(req, res){ 167 | req.body.Event.should.equal('VIEW'); 168 | req.body.EventKey.should.equal('http://www.example.com'); 169 | res.image('http://www.asdf.com/a.jpg'); 170 | }); 171 | 172 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 173 | ret = ret || ''; 174 | ret.should.equal(''); 175 | }); 176 | 177 | helper.doneWapper(p1, p2, done); 178 | }); 179 | 180 | // 菜单栏页面跳转,公众号给用户推送一个图片消息 181 | it('apply image but upload image failure', function(done){ 182 | var msg = ''; 183 | 184 | helper = new Helper({ 185 | getToken: function(callback){ 186 | callback(null, {accessToken: '1234568'}); 187 | } 188 | }); 189 | var p1 = helper.trapWrapper('view', function(req, res){ 190 | req.body.Event.should.equal('VIEW'); 191 | req.body.EventKey.should.equal('http://www.example.com'); 192 | var file = __dirname + '/a0.jpg'; 193 | console.log(file); 194 | res.image(file); 195 | }); 196 | 197 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 198 | ret = ret || ''; 199 | ret.should.equal(''); 200 | }); 201 | 202 | helper.doneWapper(p1, p2, done); 203 | }); 204 | 205 | // 菜单栏页面跳转,公众号给用户推送一个音频消息 206 | it('apply voice but upload voice failure', function(done){ 207 | var msg = ''; 208 | 209 | helper = new Helper({ 210 | getToken: function(callback){ 211 | callback(null, {accessToken: '1234568'}); 212 | } 213 | }); 214 | var p1 = helper.trapWrapper('view', function(req, res){ 215 | req.body.Event.should.equal('VIEW'); 216 | req.body.EventKey.should.equal('http://www.example.com'); 217 | res.voice('asdfasdf'); 218 | }); 219 | 220 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 221 | ret = ret || ''; 222 | ret.should.equal(''); 223 | }); 224 | 225 | helper.doneWapper(p1, p2, done); 226 | }); 227 | 228 | // 菜单栏页面跳转,公众号给用户推送一个视频消息 229 | it('apply video but upload video failure', function(done){ 230 | var msg = ''; 231 | 232 | helper = new Helper({ 233 | getToken: function(callback){ 234 | callback(null, {accessToken: '1234568'}); 235 | } 236 | }); 237 | var video = {video: 'asdfklajsfd', title:'video title', description: 'video description'}; 238 | var p1 = helper.trapWrapper('view', function(req, res){ 239 | req.body.Event.should.equal('VIEW'); 240 | req.body.EventKey.should.equal('http://www.example.com'); 241 | res.video(video); 242 | }); 243 | 244 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 245 | ret = ret || ''; 246 | ret.should.equal(''); 247 | }); 248 | 249 | helper.doneWapper(p1, p2, done); 250 | }); 251 | 252 | // 菜单栏页面跳转,公众号给用户推送一断音乐 253 | it('apply music but upload music failure', function(done){ 254 | var msg = ''; 255 | 256 | helper = new Helper({ 257 | getToken: function(callback){ 258 | callback(null, {accessToken: '1234568'}); 259 | } 260 | }); 261 | var music = {title: 'music title', description: 'music description', music_url: 'music url', hq_music_url: 'hq music url', thumb_media: 'asdfklajsfd'}; 262 | var p1 = helper.trapWrapper('view', function(req, res){ 263 | req.body.Event.should.equal('VIEW'); 264 | req.body.EventKey.should.equal('http://www.example.com'); 265 | res.music(music); 266 | }); 267 | 268 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 269 | ret = ret || ''; 270 | ret.should.equal(''); 271 | }); 272 | 273 | helper.doneWapper(p1, p2, done); 274 | }); 275 | 276 | // 菜单栏页面跳转,转客服 277 | it('transfer failure', function(done){ 278 | var msg = ''; 279 | 280 | helper = new Helper({ 281 | getToken: function(callback){ 282 | callback(null, {accessToken: '1234568'}); 283 | } 284 | }); 285 | var music = {title: 'music title', description: 'music description', music_url: 'music url', hq_music_url: 'hq music url', thumb_media: 'asdfklajsfd'}; 286 | var p1 = helper.trapWrapper('view', function(req, res){ 287 | req.body.Event.should.equal('VIEW'); 288 | req.body.EventKey.should.equal('http://www.example.com'); 289 | res.transfer(); 290 | }); 291 | 292 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 293 | ret.msg_type.should.equal('transfer_customer_service'); 294 | }); 295 | 296 | helper.doneWapper(p1, p2, done); 297 | }); 298 | 299 | // 菜单栏页面跳转,空回复 300 | it('empty apply', function(done){ 301 | var msg = ''; 302 | 303 | helper = new Helper({ 304 | getToken: function(callback){ 305 | callback(null, {accessToken: '1234568'}); 306 | } 307 | }); 308 | var music = {title: 'music title', description: 'music description', music_url: 'music url', hq_music_url: 'hq music url', thumb_media: 'asdfklajsfd'}; 309 | var p1 = helper.trapWrapper('view', function(req, res){ 310 | req.body.Event.should.equal('VIEW'); 311 | req.body.EventKey.should.equal('http://www.example.com'); 312 | res.ok(); 313 | }); 314 | 315 | var p2 = helper.requestWrapper('post', config[0], openid, msg, true, function(err, ret){ 316 | ret.should.equal(''); 317 | }); 318 | 319 | helper.doneWapper(p1, p2, done); 320 | }); 321 | 322 | }); -------------------------------------------------------------------------------- /lib/trap.js: -------------------------------------------------------------------------------- 1 | /**! 2 | * weixin 3 | * Copyright(c) 2015-2015 leaf 4 | * MIT Licensed 5 | */ 6 | 7 | /** 8 | * 对微信推送消息,时间的处理 9 | * 借鉴于 wx 开源库 10 | * 能进行多账号的托管 11 | */ 12 | 13 | 14 | 'use strict'; 15 | 16 | var express = require('express'); 17 | var crypto = require('crypto'); 18 | var getBody = require('raw-body'); 19 | var xml2js = require('xml2js'); 20 | var _ = require('underscore'); 21 | var async = require('async'); 22 | var WXBizMsgCrypt = require('wechat-crypto'); 23 | var debug = require('debug')('weixin'); 24 | 25 | var mime = function (req) { 26 | var str = req.headers['content-type'] || ''; 27 | return str.split(';')[0]; 28 | }; 29 | 30 | var Trap = module.exports = function(){ 31 | var router = express.Router(); 32 | var self = this; 33 | var text_handlers = [], click_handlers = {}, regex_media_id = /^[\w\_\-]{40,70}$/, 34 | __slice = [].slice, attrNameProcessors; 35 | var parserOptions = { 36 | async: true, 37 | explicitArray: true, 38 | normalize: true, 39 | trim: true 40 | }; 41 | 42 | /** 43 | * 默认消息处理函数 44 | */ 45 | if(typeof this.trapHandle !== 'function'){ 46 | throw new Error({message: 'trapHandle should be a function'}); 47 | } 48 | 49 | /** 50 | * 定义对 微信事件消息数据的 KEY 的转换函数 51 | */ 52 | if(typeof this.attrNameProcessors === 'function'){ 53 | attrNameProcessors = this.attrNameProcessors; 54 | }else { 55 | attrNameProcessors = this.weixin.util.formatStr(this.attrNameProcessors); 56 | if(!attrNameProcessors){ 57 | console.warn('formatStr should be `keep` `lowerCase` `underscored` or a function but got a ' + this.formatStr); 58 | console.warn('change formatStr to `keep`'); 59 | attrNameProcessors = this.weixin.util.formatStr('keep'); 60 | } 61 | } 62 | 63 | /** 64 | * 获取到 ToUserName MsgType Event FromUserName 格式化后的字符串,下文要用 65 | */ 66 | var wechatidAttr = attrNameProcessors('ToUserName'), msgtypeAttr = attrNameProcessors('MsgType'), 67 | eventAttr = attrNameProcessors('Event'), openidAttr = attrNameProcessors('FromUserName'), 68 | encryptAttr = attrNameProcessors('Encrypt'); 69 | 70 | /** 71 | * 对微信事件推送的消息数据的 KEY 的转换 72 | */ 73 | var _format = function(data){ 74 | /* istanbul ignore else */ 75 | if(data){ 76 | _.each(data, function(value, p){ 77 | var prot = p; 78 | if(_.isString(prot)) prot = attrNameProcessors(prot); 79 | if(prot !== p){ 80 | data[prot] = value; 81 | delete data[p]; 82 | } 83 | if(_.isArray(value) || _.isObject(value)) { 84 | if(_.isArray(value) && value.length === 1){ 85 | data[prot] = value[0]; 86 | } 87 | _format(data[prot]); 88 | } 89 | }); 90 | } 91 | }; 92 | 93 | /** 94 | * 微信各种事件的消息回复函数 95 | */ 96 | var reply = function(req) { 97 | var message, data = req.body, query = req.query; 98 | // 组装message xml 99 | if (req.crypter) { 100 | message = function(message) { // 需要加密 101 | var encrypt = req.crypter.encrypt('' + (~~(Date.now() / 1000)) + '' + message + ''); 102 | var signature = req.crypter.getSignature(query.timestamp, query.nonce, encrypt); 103 | return '' + query.timestamp + ''; 104 | }; 105 | } else { // 不需要加密 106 | message = function(message) { 107 | return '' + (~~(Date.now() / 1000)) + '' + message + ''; 108 | }; 109 | } 110 | 111 | return { 112 | 113 | // 文本消息回复 114 | text: function(text) { 115 | return this.send(message('')); 116 | }, 117 | 118 | // 图片消息回复 119 | image: function(image) { 120 | var that = this; 121 | var send = function(image) { 122 | return that.send(message('')); 123 | }; 124 | if (typeof image === 'string' && image.match(regex_media_id)) { // image 微信素材id,则直接发送 125 | return send(image); 126 | } else { // image为文件路径地址,需要上传图片素材 127 | return self.weixin.api.uploadMedia(data[wechatidAttr], image, 'image', function(err, res) { 128 | /* istanbul ignore if */ 129 | if (image = res != null ? res.media_id : void 0) { 130 | return send(image); 131 | } else { 132 | debug(err || res); 133 | return that.status(500).end(); 134 | } 135 | }); 136 | } 137 | }, 138 | 139 | // 音频回复 140 | voice: function(voice) { 141 | var that = this; 142 | var send = function(voice) { 143 | return that.send(message('')); 144 | }; 145 | if (voice.match(regex_media_id)) { // voice 微信素材id,则直接发送 146 | return send(voice); 147 | } else { // voice 为文件路径地址,需要上传音频素材 148 | return self.weixin.api.uploadMedia(data[wechatidAttr], voice, 'voice', function(err, res) { 149 | /* istanbul ignore else */ 150 | if (!(voice = res != null ? res.media_id : void 0)) { 151 | return that.status(500).end(); 152 | } else { 153 | return send(voice); 154 | } 155 | }); 156 | } 157 | }, 158 | 159 | // 视频回复 160 | video: function(video) { 161 | var that = this; 162 | var send = function(data) { 163 | var video = data.video, title = data.title, description = data.description; 164 | return that.send(message('')); 165 | }; 166 | if (video.video.match(regex_media_id)) { // video 微信素材id,则直接发送 167 | return send(video); 168 | } else { // video 为文件路径地址,需要上传视频素材 169 | return self.weixin.api.uploadMedia(data[wechatidAttr], video.video, 'video', function(err, res) { 170 | /* istanbul ignore else */ 171 | if (!(video.video = res != null ? res.media_id : void 0)) { 172 | return that.status(500).end(); 173 | } else { 174 | return send(video); 175 | } 176 | }); 177 | } 178 | }, 179 | 180 | // 音乐回复 181 | music: function(music) { 182 | var that = this; 183 | var send = function(data) { 184 | var title = data.title, description = data.description, music_url = data.music_url, hq_music_url = data.hq_music_url, thumb_media = data.thumb_media; 185 | return that.send(message('<![CDATA[' + title + ']]>')); 186 | }; 187 | if (music.thumb_media.match(regex_media_id)) { // music 微信素材id,则直接发送 188 | return send(music); 189 | } else { // music 为文件路径地址,需要上传音频素材 190 | return self.weixin.api.uploadMedia(data[wechatidAttr], music.thumb_media, 'thumb', function(err, res) { 191 | /* istanbul ignore else */ 192 | if (!(music.thumb_media = res != null ? res.thumb_media_id : void 0)) { 193 | return that.status(500).end(); 194 | } else { 195 | return send(music); 196 | } 197 | }); 198 | } 199 | }, 200 | 201 | // 图文消息 202 | news: function(articles) { 203 | articles = [].concat(articles).map(function(a) { 204 | var title = a.title || '', description = a.description || '', pic_url = a.pic_url || '', url = a.url || ''; 205 | return '<![CDATA[' + title + ']]>'; 206 | }); 207 | return this.send(message('' + articles.length + '' + (articles.join('')) + '')); 208 | }, 209 | 210 | // 客服 211 | transfer: function() { 212 | return this.send(message('')); 213 | }, 214 | 215 | // 设备消息回复 216 | device: function(content) { 217 | content = (new Buffer(content)).toString('base64'); 218 | return this.send(message('' + data[attrNameProcessors('SessionID')] + '')); 219 | }, 220 | 221 | // 回复空消息,表示收到请求 222 | ok: function() { 223 | return this.status(200).end(); 224 | } 225 | }; 226 | }; 227 | 228 | 229 | /** 230 | * 获取 req 里面的数据 231 | */ 232 | var regexp = /^(text\/xml|application\/([\w!#\$%&\*`\-\.\^~]+\+)?xml)$/i; 233 | var getReqBody = function(req, callback) { 234 | var method = req.method.toLowerCase(); 235 | /* istanbul ignore if */ 236 | if (!self.getBody || req._body || !regexp.test(mime(req)) || method !== 'post') { 237 | return callback(); 238 | } 239 | getBody(req, { 240 | limit: '100kb', 241 | length: req.headers['content-length'], 242 | encoding: 'utf8' 243 | }, function (err, buf) { 244 | /* istanbul ignore if */ 245 | if (err) { 246 | return callback(err); 247 | } 248 | 249 | req._body = true; 250 | req.rawBuf = buf; 251 | req.body = buf; 252 | callback(); 253 | }); 254 | }; 255 | 256 | /** 257 | * 解析 xml 数据 258 | * 数据必须在 req.body 里面 259 | */ 260 | 261 | var parseXml = function(req, callback) { 262 | /* istanbul ignore if */ 263 | if (!self.parseXml) { 264 | return callback(); 265 | } 266 | 267 | if(!req.body || typeof req.body !== 'string') { 268 | debug('xml data invalid: ', req.body); 269 | return callback(); 270 | } 271 | 272 | xml2js.parseString(req.body, parserOptions, function(err, xml) { 273 | /* istanbul ignore if */ 274 | if (err) { 275 | return callback(err); 276 | } 277 | _format(xml.xml); 278 | req.body = xml.xml; 279 | callback(); 280 | }); 281 | }; 282 | 283 | /** 284 | * 获取公众号的配置数据 285 | * 如果数据加密则生成解密器 286 | */ 287 | var getConfigAndCrypter = function(req, callback){ 288 | var id = req.body ? req.body[wechatidAttr] : ''; 289 | if(!id) return callback(); 290 | self.weixin.util.getConfig(id, function(err, config){ 291 | /* istanbul ignore if */ 292 | if (err) { 293 | return callback(err); 294 | } 295 | config = config || {}; 296 | req.config = config; 297 | if (req.query.encrypt_type === 'aes' && config.token && config.encryptkey && config.appid) { 298 | req.crypter = new WXBizMsgCrypt(config.token, config.encryptkey, config.appid); 299 | } 300 | callback(); 301 | }); 302 | }; 303 | 304 | /** 305 | * 解密 xml 数据 306 | */ 307 | var decrypt = function(req, callback){ 308 | if (!self.decrypt) { 309 | return callback(); 310 | } 311 | 312 | if(req.query.encrypt_type !== 'aes'){ 313 | debug('Not need decrypt'); 314 | return callback(); 315 | } 316 | 317 | /* istanbul ignore if */ 318 | if(!req.body[encryptAttr]){ 319 | debug('No encrypt data'); 320 | return callback(); 321 | } 322 | 323 | if(!req.crypter) { 324 | debug('Not found crypter'); 325 | return callback(); 326 | } 327 | 328 | var encryptData = req.body[encryptAttr]; 329 | var message = req.crypter.decrypt(encryptData).message; 330 | xml2js.parseString(message, parserOptions, function(err, ret){ 331 | /* istanbul ignore if */ 332 | if (err) { 333 | return callback(err); 334 | } 335 | _format(ret.xml); 336 | req.body = ret.xml; 337 | callback(); 338 | }); 339 | }; 340 | 341 | /** 342 | * 343 | * 微信托管 url 路由的中间件 344 | * 检测消息是否合法(需要 to_user_name 微信号id 来获取配置信息) 345 | * 在配置微信路由规则时的校验,没有传 to_user_name 参数,这里不做校验,但其他请求都要校验 346 | * 347 | */ 348 | router.use('/', function(req, res, next) { 349 | 350 | async.waterfall([ 351 | function _getReqBody(callback) { 352 | getReqBody(req, callback); 353 | }, 354 | function _parseXml(callback) { 355 | parseXml(req, callback); 356 | }, 357 | function _getConfigAndCrypter(callback) { 358 | getConfigAndCrypter(req, callback); 359 | }, 360 | function _decrypt(callback) { 361 | decrypt(req, callback); 362 | } 363 | ], function(err) { 364 | /* istanbul ignore if */ 365 | if (err) { 366 | debug(err); 367 | return next(err); 368 | } 369 | var query = req.query; 370 | var id = req.body ? req.body[wechatidAttr] : ''; 371 | /* istanbul ignore else */ 372 | if (req.config) { // 有微信号配置数据时需要校验数据签名 373 | var token = req.config.token; 374 | if(!token) { 375 | req.weixin_signature_failure = true; 376 | return next(); 377 | } 378 | var message = [token, query.timestamp, query.nonce].sort().join(''); 379 | /* istanbul ignore else */ 380 | if (query.signature === crypto.createHash('sha1').update(message).digest('hex')) { 381 | return next(); 382 | } else { 383 | req.weixin_signature_failure = true; 384 | return next(); 385 | } 386 | } else if(req.method === 'POST') { 387 | req.weixin_signature_failure = true; 388 | return next(); 389 | } else { // 没有微信号id是直接通过 390 | return next(); 391 | } 392 | }); 393 | }); 394 | 395 | /** 396 | * 配置微信 url 规则时微信发的确认请求 397 | */ 398 | router.get('/', function(req, res) { 399 | // 数据签名错误 400 | /* istanbul ignore if */ 401 | if(req.weixin_signature_failure){ 402 | return res.status(400).send('data invalid'); 403 | } 404 | return res.send(req.query.echostr); 405 | }); 406 | 407 | var handler_action = function(req, res, type){ 408 | async.eachSeries(self['' + type + '_handlers'] || [], function(handler, callback) { 409 | return handler(req, res, callback); 410 | }, function(err) { 411 | /* istanbul ignore if */ 412 | if (err) debug(err); 413 | self.trapHandle(req, res); 414 | }); 415 | }; 416 | 417 | /** 418 | * 微信的消息推送请求入口 419 | */ 420 | router.post('/', function(req, res, next) { 421 | // 数据签名错误 422 | /* istanbul ignore if */ 423 | if(req.weixin_signature_failure){ 424 | return res.status(400).send('data invalid'); 425 | } 426 | 427 | var process_message = function(user) { 428 | 429 | // 异常处理 430 | /* istanbul ignore if */ 431 | if(!req.body || !req.body[msgtypeAttr]) { 432 | return res.status(500).end(); 433 | } 434 | 435 | var handlers, _msg_type, _event; 436 | // 把微信用户信息放在 req.user 中 437 | _.extend(req.user != null ? req.user : req.user = {}, user); 438 | // res 帮上微信的消息回复函数 439 | _.extend(res, reply(req)); 440 | 441 | // 各种消息的分类处理 442 | switch (_msg_type = req.body[msgtypeAttr].toLowerCase()) { 443 | 444 | // 文本消息 445 | case 'text': 446 | return async.eachSeries(text_handlers, function(text_handler, callback) { 447 | var pattern = text_handler[0], handlers = text_handler[1]; 448 | if (!req.body[attrNameProcessors('Content')].trim().match(pattern)) return callback(); 449 | return async.eachSeries(handlers, function(handler, cb) { 450 | return handler(req, res, cb); 451 | }, callback); 452 | }, function(err) { 453 | /* istanbul ignore if */ 454 | if (err) debug(err); 455 | self.trapHandle(req, res); 456 | }); 457 | 458 | // 图片,音频,视频,小视屏,地理位置,连接,设备消息的处理 459 | case 'image': 460 | case 'voice': 461 | case 'video': 462 | case 'shortvideo': 463 | case 'location': 464 | case 'link': 465 | return handler_action(req, res, _msg_type); 466 | 467 | //设备消息的处理 468 | case 'device_text': 469 | return handler_action(req, res, 'device'); 470 | 471 | // 时间为 event 时的处理 472 | case 'event': 473 | switch (_event = req.body[eventAttr].toLowerCase()) { 474 | 475 | // 关注,取消关注,扫描带参数二维码事件,菜单页面跳转, 476 | case 'subscribe': 477 | case 'unsubscribe': 478 | case 'scan': 479 | case 'view': 480 | return handler_action(req, res, _event); 481 | // 上报地理位置 482 | case 'location': 483 | return handler_action(req, res, 'reportedLocation'); 484 | 485 | // 模板消息 486 | case 'templatesendjobfinish': 487 | return handler_action(req, res, 'template'); 488 | // 卡券 审核通过,审核未通过,用户领取卡券,用户删除卡券,核销卡券,进入会员卡,用户从卡券进入公众号会话 489 | case 'card_pass_check': 490 | case 'card_not_pass_check': 491 | case 'user_get_card': 492 | case 'user_del_card': 493 | case 'user_consume_card': 494 | case 'user_view_card': 495 | case 'user_enter_session_from_card': 496 | return handler_action(req, res, 'card'); 497 | 498 | // 摇周边 499 | case 'shakearoundusershake': 500 | return handler_action(req, res, 'shakeAround'); 501 | 502 | // 菜单点击 503 | case 'click': 504 | if (handlers = click_handlers[req.body[attrNameProcessors('EventKey')]]) { 505 | return async.eachSeries(handlers, function(handler, callback) { 506 | return handler(req, res, callback); 507 | }, function(err) { 508 | /* istanbul ignore if */ 509 | if (err) debug(err); 510 | self.trapHandle(req, res); 511 | }); 512 | } else { 513 | self.trapHandle(req, res); 514 | } 515 | break; 516 | 517 | // 默认处理 518 | default: 519 | self.trapHandle(req, res); 520 | } 521 | break; 522 | 523 | // 默认处理 524 | default: 525 | self.trapHandle(req, res); 526 | } 527 | }; 528 | 529 | // 根据配置 populate_user 参数确定是否需要扩展出用户信息,默认为false 530 | if (self.populate_user && (req.body[eventAttr] && req.body[eventAttr] || '').toLowerCase() !== 'unsubscribe') { 531 | return self.weixin.api.getUser(req.body[wechatidAttr], req.body[openidAttr], function(err, user) { 532 | /* istanbul ignore else */ 533 | if (err) debug(err); 534 | return process_message(user); 535 | }); 536 | } else { 537 | return process_message(); 538 | } 539 | }); 540 | 541 | /** 542 | * 各种消息的处理函数接口入口 543 | */ 544 | var handlers = {}; 545 | _.each(['image', 'voice', 'video', 'shortvideo', 'location', 'link', 'device', 'subscribe', 'unsubscribe', 'scan', 'reportedLocation', 'card', 'template', 'view', 'shakeAround'], function(method){ 546 | handlers[method] = function(){ 547 | self[method + '_handlers'] = (1 <= arguments.length ? __slice.call(arguments, 0) : []); 548 | }; 549 | }); 550 | handlers.text = function(){ 551 | var handlers = 1 <= arguments.length ? __slice.call(arguments, 0) : []; 552 | var pattern = _.first(handlers); 553 | /* istanbul ignore else */ 554 | if (_.isRegExp(pattern)) { 555 | pattern = handlers.shift(); 556 | } else if (_.isFunction(pattern)) { 557 | pattern = /.*/; 558 | } else if (_.isString(pattern)) { 559 | pattern = new RegExp(handlers.shift(), 'i'); 560 | } 561 | 562 | text_handlers = _.filter(text_handlers, function(text_handler) { 563 | var pattern_exist = text_handler[0]; 564 | return pattern.toString() !== pattern_exist.toString(); 565 | }); 566 | text_handlers.push([pattern, handlers]); 567 | }; 568 | handlers.click = function(){ 569 | var key = arguments[0]; 570 | var handlers = ((2 <= arguments.length) ? __slice.call(arguments, 1) : []); 571 | click_handlers[key] = handlers; 572 | }; 573 | 574 | /** 575 | * 注册消息的处理函数 576 | */ 577 | return _.extend(router, handlers); 578 | 579 | }; 580 | --------------------------------------------------------------------------------