├── 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 | [](https://travis-ci.org/liuxiaodong/weixin-trap)
4 | [](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 = '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(''));
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 ' ';
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 |
--------------------------------------------------------------------------------