diff --git a/README.md b/README.md index ebc67c5b..553a5f44 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,27 @@ List.add('view', [ 如果用户触发等待回复事务后,没有按照`{}`中的进行回复,那么将会由原有的默认函数进行处理。在原有函数中,可以选择调用`res.nowait()`中断事务。`nowait()`除了能中断事务外,与`reply`的行为一致。 +### 动态List +单进程情况下,直接重新List.add()即可。多进程模式下,使用`serializeList(fn)`及`deserializeList(fn)`注册序列化和反序列化方法,将list保存在数据库或文件等外部,使进程间可以共享。需要在服务启动前注册相应序列化和反序列化方法。 + +**注意:serializeList接受的方法中的参数list,其实已经进行过序列化,是string对象,可以直接保存,这里名字用serializeList可能不太妥。相应的List.deserializeList中的方法参数list,只要对应serializeList中一致的string对象即可** + +``` +List.serializeList(function(name, list, done) { + store[name] = list; + return done && done(null); + }); + + List.deserializeList(function(name, done) { + var list = store[name]; + done(null, list); + }); +``` +当serializer和deserializer都注册之后,List的状态会自动变为dynamic,否则仍然为静态。重置动态List,移除serializer和deserializer使用`List.reset()` + +####动态列表失效问题 +当用户收到列表,发送选项这段时间之间,列表已经被修改,将返回列表过期提示,请重回重新获取列表。默认提示:“列表已过期,请重新获取列表”。修改提示`List.setInvalidListTips(tips)` + ## Show cases ### Node.js API自动回复 diff --git a/lib/list.js b/lib/list.js index a1508ce2..0c2ae958 100644 --- a/lib/list.js +++ b/lib/list.js @@ -1,4 +1,5 @@ var util = require('util'); +var serialize = require('node-serialize'); /*! * 缓存列表 */ @@ -11,6 +12,14 @@ var List = function () { this.map = {}; }; + +var serializer = null; +var deserializer = null; +var DEFAULT_INVALID_LIST_TIPS = "列表已过期,请重新获取列表"; +var invalidListTips = DEFAULT_INVALID_LIST_TIPS; +var dynamic = false; + + /** * 从List对象中根据key取出对应的handler * @param {String} key 列表中的关键词 @@ -19,6 +28,8 @@ List.prototype.get = function (key) { return this.map[key]; }; + + /** * 静态方法,根据items生成List对象,并放置到缓存中 * @param {String} name 列表名字 @@ -27,7 +38,7 @@ List.prototype.get = function (key) { * @param {String} delimiter 回复分隔符 * @param {String} foot 回复底部 */ -List.add = function (name, items, head, delimiter, foot) { +List.add = function (name, items, head, delimiter, foot, done) { var description = []; var list = new List(); list.name = name; @@ -37,25 +48,102 @@ List.add = function (name, items, head, delimiter, foot) { var replaced = text.replace(/\{(.*)\}/, function (match, key) { list.map[key] = item[1]; return key; - }); + }); description.push(replaced); }); + if (delimiter) { var lists = description.join('\n' + delimiter + '\n'); list.description = util.format('%s\n%s\n%s', head || '', lists, (foot || '')); } else { list.description = description.join('\n'); } - listCache[name] = list; + + if(dynamic) { + list.createdAt = new Date().getTime(); + var serializedList = serialize.serialize(list); + serializer(list.name, serializedList, done); + } else { + listCache[name] = list; + } }; +/** + * 设置序列化方法,保存动态List + * @param {Function} fn 序列化方法 + */ +List.serializeList = function(fn) { + serializer = fn; + if(serializer && deserializer) { + dynamic = true; + } +} +/** + * 设置反序列化方法,获取动态List + * @param {Function} fn 反序列化方法 + * @return {void} + */ +List.deserializeList = function(fn) { + deserializer = fn; + if(serializer && deserializer) { + dynamic = true; + } +} + +/** + * 静态方法,判断当前List是不是动态 + * @return {Boolean} + */ +List.isDynamic = function() { + return dynamic; +} + +/** + * 静态方法,获取列表过期提示 + * @return {string} 当前列表过期提示内容 + */ +List.getInvalidListTips = function() { + return invalidListTips; +} + +/** + * 静态方法,设置列表过期提示 + * @param {string} tips 列表过期提示内容 + */ +List.setInvalidListTips = function(tips) { + invalidListTips = tips; +} +/** + * 静态方法,重置List + */ +List.reset = function() { + serializer = null; + deserializer = null; + dynamic = false; + invalidListTips = DEFAULT_INVALID_LIST_TIPS; +} + /** * 静态方法,从缓存中根据名字取出List对象 * @param {String} name 列表名字 */ -List.get = function (name) { - return listCache[name]; +List.get = function (name, done) { + if(dynamic) { + deserializer(name, function(err,serializedList){ + if(err) return done(err); + var list = serialize.unserialize(serializedList); + var originList = new List(); + originList.map = list.map; + originList.name = list.name; + originList.description = list.description; + originList.createdAt = list.createdAt; + done(null, originList); + }); + } else { + //still return the list immediately when use static + return listCache[name]; + } }; /** diff --git a/lib/wechat.js b/lib/wechat.js index a6c428ee..dd8ea067 100644 --- a/lib/wechat.js +++ b/lib/wechat.js @@ -276,29 +276,49 @@ var respond = function (handler) { }; var done = function () { - // 如果session中有_wait标记 - if (message.MsgType === 'text' && req.wxsession && req.wxsession._wait) { - var list = List.get(req.wxsession._wait); - var handle = list.get(message.Content); - var wrapper = function (message) { + var callNext = function(handle) { + var wrapper = function(message) { return handler.handle ? function(req, res) { res.reply(message); - } : function (info, req, res) { + } : function(info, req, res) { res.reply(message); }; }; - // 如果回复命中规则,则用预置的方法回复 if (handle) { callback = typeof handle === 'string' ? wrapper(handle) : handle; } + // 兼容旧API + if (handler.handle) { + callback(req, res, next); + } else { + callback(message, req, res, next); + } } - // 兼容旧API - if (handler.handle) { - callback(req, res, next); + // 如果session中有_wait标记 + if (message.MsgType === 'text' && req.wxsession && req.wxsession._wait) { + var handle = null; + if (List.isDynamic()) { + List.get(req.wxsession._wait, function(err, list) { + var time = req.wxsession._sentAt; + var listSentAt = time; + if (!list || list.createdAt > listSentAt) { + handle = List.getInvalidListTips(); + //列表过期,清楚wait + delete req.wxsession._wait; + } else { + handle = list.get(message.Content); + } + callNext(handle); + }); + } else { + var list = List.get(req.wxsession._wait); + handle = list.get(message.Content); + callNext(handle); + } } else { - callback(message, req, res, next); + callNext(); } }; @@ -306,30 +326,46 @@ var respond = function (handler) { var storage = req.sessionStore; var _end = res.end; var openid = message.FromUserName + ':' + message.ToUserName; - res.end = function () { + res.end = function() { _end.apply(res, arguments); if (req.wxsession) { req.wxsession.save(); } }; // 等待列表 - res.wait = function (name, callback) { - var list = List.get(name); - if (list) { - req.wxsession._wait = name; - res.reply(list.description); + res.wait = function(name, callback) { + var callNext = function(err, list, dynamic) { + if (!err && list) { + req.wxsession._wait = name; + if (dynamic) { + req.wxsession._sentAt = new Date().getTime(); + } + res.reply(list.description); + } else { + err = err ? new Error(err) : new Error('Undefined list: ' + name); + err.name = 'UndefinedListError'; + res.writeHead(500); + res.end(err.name); + callback && callback(err); + } + } + if (List.isDynamic()) { + List.get(name, function(err, list) { + callNext(err, list, true); + }); } else { - var err = new Error('Undefined list: ' + name); - err.name = 'UndefinedListError'; - res.writeHead(500); - res.end(err.name); - callback && callback(err); + var list = List.get(name); + callNext(null, list); } + }; // 清除等待列表 - res.nowait = function () { + res.nowait = function() { delete req.wxsession._wait; + if (List.isDynamic()) { + delete req.wxsession._sentAt; + } res.reply.apply(res, arguments); }; diff --git a/package.json b/package.json index ac547782..fce6e7b4 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,10 @@ "wechat" ], "dependencies": { - "xml2js": "0.4.15", "ejs": ">=1.0.0", - "wechat-crypto": "0.0.2" + "node-serialize": "0.0.4", + "wechat-crypto": "0.0.2", + "xml2js": "0.4.15" }, "devDependencies": { "supertest": "*", diff --git a/test/dynamic_list.test.js b/test/dynamic_list.test.js new file mode 100644 index 00000000..82ccc05c --- /dev/null +++ b/test/dynamic_list.test.js @@ -0,0 +1,368 @@ +require('should'); +var List = require('../').List; + +var request = require('supertest'); +var template = require('./support').template; +var tail = require('./support').tail; + +var connect = require('connect'); +var wechat = require('../'); + + +beforeEach(function() { + List.reset(); +}); + + +function initDynamicList() { + var store = {}; + List.serializeList(function(name, list, done) { + store[name] = list; + return done && done(null); + }); + + List.deserializeList(function(name, done) { + var list = store[name]; + done(null, list); + }); +}; + +function sendRequest(info, cb) { + request(app) + .post('/wechat' + tail()) + .send(template(info)) + .expect(200) + .end(function(err, res) { + if (err) { + return done(err); + } + cb && cb(null, res); + }); +} + + +var app = connect(); +app.use(connect.query()); +app.use(connect.cookieParser()); +app.use(connect.session({ + secret: 'keyboard cat', + cookie: { + maxAge: 60000 + } +})); +app.use('/wechat', wechat('some token', wechat.text(function(info, req, res, next) { + // 微信输入信息都在req.weixin上 + var info = req.weixin; + if (info.Content === 'list') { + res.wait('view'); + } else { + res.reply("呵呵"); + } +}))); + + +describe('list', function() { + + it('should ok with dynamic list', function(done) { + initDynamicList(); + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ]); + var listInfo = { + sp: 'test', + user: 'dynamic1', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic1', + type: 'text', + text: 'c' + }; + + sendRequest(listInfo, function(err, res) { + nextStep(); + }); + var nextStep = function() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('这样的事情怎么好意思告诉你啦'); + done(); + }); + } + }); + + + + it('should okay with updated List', function(done) { + initDynamicList(); + var listInfo = { + sp: 'test', + user: 'dynamic2', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic2', + type: 'text', + text: 'a' + }; + + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(listInfo, function(err, res) { + nextStep(); + }); + }); + + var nextStep = function() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('this is answer a.'); + retryList(); + }); + } + + function retryList() { + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.end("this is updated answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(listInfo, function(err, res) { + retryAnswer(); + }); + }); + } + + function retryAnswer() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('this is updated answer a.'); + done(); + }); + } + }); + + + it('should get invalid list message when list is outdated ', function(done) { + initDynamicList(); + var listInfo = { + sp: 'test', + user: 'dynamic3', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic3', + type: 'text', + text: 'a' + }; + + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(listInfo, function(err, res) { + nextStep(); + }); + }); + + function nextStep() { + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.end("this is updated answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('列表已过期,请重新获取列表'); + done(); + }); + }); + } + }); + + it('should get custom invalid list message when list is outdated ', function(done) { + initDynamicList(); + List.setInvalidListTips("New Tips."); + var listInfo = { + sp: 'test', + user: 'dynamic4', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic4', + type: 'text', + text: 'a' + }; + + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(listInfo, function(err, res) { + nextStep(); + }); + }); + + function nextStep() { + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.end("this is updated answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('New Tips.'); + done(); + }); + }); + } + }); + + it('should accept user retry after invalid list', function(done) { + initDynamicList(); + var listInfo = { + sp: 'test', + user: 'dynamic5', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic5', + type: 'text', + text: 'a' + }; + + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(listInfo, function(err, res) { + nextStep(); + }); + }); + + function nextStep() { + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.end("this is updated answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('列表已过期,请重新获取列表'); + retryList(); + }); + }); + } + + function retryList() { + sendRequest(listInfo, function(err, res) { + retryAnswser(); + }); + } + + function retryAnswser() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('this is updated answer a.'); + done(); + }); + } + }); + + it('should clear status after invalid list', function(done) { + initDynamicList(); + var listInfo = { + sp: 'test', + user: 'dynamic6', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic6', + type: 'text', + text: 'a' + }; + + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(listInfo, function(err, res) { + nextStep(); + }); + }); + + function nextStep() { + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.end("this is updated answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], "header", "...", "foot", function() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('列表已过期,请重新获取列表'); + retryAnswser(); + }); + }); + } + + + function retryAnswser() { + sendRequest(answerInfo, function(err, res) { + var body = res.text.toString(); + body.should.include('呵呵'); + done(); + }); + } + }); + + +}); \ No newline at end of file