diff --git a/README.en.md b/README.en.md index dd29b09f..4d701052 100644 --- a/README.en.md +++ b/README.en.md @@ -250,6 +250,42 @@ List.add('view', [ if user's message is not in waiter's trigger texts. this message will be processd in the `else` way and can be stoped by `res.nowait()`, `res.nowait` method actions like `reply` method. +### Dynamic List +In single process condition, just use `List.add()` to override the existing list.In multi-process condition,use `serializeList(fn)`and `deserializeList(fn)` to register serializer and deserializer functions,to save the list in the external storage, database, file etc. Serializer and deserializer should be registered before service starts.Since handle will be serialized, the context will chanage,so you can not refer to external variables in the handle . unless you have no references of external variable, you can use function as handle directly.if you need to pass any data to the function, need to make handle as an object, and has an `action` function which will accept arguments `(message, req, res,next)`, then set the variables into the object, and access those in the funciton via `this`, meanwhile you need use absolute path for 'require()', see below: + +``` +var handle = {}; +handle.action = function(message,req, res) { + var format = require(process.cwd() + "/test/test-lib.js"); + res.reply(format(this.name) + ',this is answerc'); +} +handle.name = 'king'; +List.add('view', [ + ['reply{c} to see the anwser', handle] +],function(){ + //do something after list added +})); +``` + +**Notice:the arugment `list` which passed to the serializer has already been serialized for simplicity,it's actullay a string object. The deserialize correspondingly need the same string object for the list** + +``` +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); + }); +``` +When serializer and deserializer are both registered, the List will change to dynamic automatically, or it will still be static.Reset the dynamic List, remove serializer and deserializer use `List.reset()` + + +####Invalid List (for dynamic) +Between receive list and send the option, if the list has been updated, it will reply the invalid list tips to tell the user request the new list again.Default tips in Chinese:"列表已过期,请重新获取列表".set custom tips `List.setInvalidListTips(tips)` + ## Show cases ### Auto-reply robot based on Node.js diff --git a/README.md b/README.md index ebc67c5b..1761052c 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,41 @@ List.add('view', [ 如果用户触发等待回复事务后,没有按照`{}`中的进行回复,那么将会由原有的默认函数进行处理。在原有函数中,可以选择调用`res.nowait()`中断事务。`nowait()`除了能中断事务外,与`reply`的行为一致。 +### 动态List +单进程情况下,直接重新`List.add()`即可。多进程模式下,使用`serializeList(fn)`及`deserializeList(fn)`注册序列化和反序列化方法,将list保存在数据库或文件等外部,使进程间可以共享。需要在服务启动前注册相应序列化和反序列化方法。由于handle会被序列化,context会被改变,所以无法直接在函数内部直接引用外部变量,除非是没有任何方法外引用,可以直接使用函数作为handle。如果需要接受外部参数,需要使用对象作为handle,并使用action函数作为handle,同时在`require()`时需要使用绝对路径,如下: + +``` +var handle = {}; +handle.action = function(message,req, res) { + var format = require(process.cwd() + "/test/test-lib.js"); + res.reply(format(this.name) + ',这样的事情怎么好意思告诉你啦- -'); +} +handle.name = 'king'; +List.add('view', [ + ['回复{c}查看我的性取向', handle] +],function(){ + //do something after list added +})); +``` + +**注意: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..d6b2b322 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,17 @@ 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) { + if(typeof head === "function") { + done = head + } + if(typeof head === "object") { + var opts = head; + done = delimiter; + head = opts.head; + delimiter = opts.delimiter; + foot = opts.foot; + } var description = []; var list = new List(); list.name = name; @@ -37,25 +58,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..34a14e59 100644 --- a/lib/wechat.js +++ b/lib/wechat.js @@ -276,29 +276,53 @@ 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); + if(typeof handle === "object") { + handle.action = handle.action.bind(handle); + handle = handle.action; + } + } + 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 +330,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..7244a6f6 --- /dev/null +++ b/test/dynamic_list.test.js @@ -0,0 +1,413 @@ +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.'); + setTimeout(retryList, 10); + }); + } + + 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}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], function() { + sendRequest(listInfo, function(err, res) { + setTimeout(nextStep, 10); + }); + }); + + 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}查看我的性取向', '这样的事情怎么好意思告诉你啦- -'] + ], { head:"header",delimiter:",",foot:"footer"}, function() { + sendRequest(listInfo, function(err, res) { + setTimeout(nextStep, 10); + }); + }); + + 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) { + setTimeout(nextStep, 10); + }); + }); + + 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) { + setTimeout(nextStep, 10); + }); + }); + + 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(); + }); + } + }); + + +it('should ok with object handle', function(done) { + initDynamicList(); + + var handle = {}; + handle.action = function(message,req, res) { + var format = require(process.cwd() + "/test/test-lib.js"); + res.reply(format(this.name) + ',这样的事情怎么好意思告诉你啦- -'); + } + handle.name = 'king'; + List.add('view', [ + ['选择{a}查看啥', function(message, req, res) { + res.nowait("this is answer a."); + }], + ['选择{b}查看啥', function() {}], + ['回复{c}查看我的性取向', handle] + ],function(){ + //do something after list added + }); + var listInfo = { + sp: 'test', + user: 'dynamic7', + type: 'text', + text: 'list' + }; + + var answerInfo = { + sp: 'test', + user: 'dynamic7', + 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('King,这样的事情怎么好意思告诉你啦'); + done(); + }); + } + }); + + +}); \ No newline at end of file diff --git a/test/test-lib.js b/test/test-lib.js new file mode 100644 index 00000000..6f61b869 --- /dev/null +++ b/test/test-lib.js @@ -0,0 +1,6 @@ +module.exports = function(name) { + if(!name || typeof name !== "string") return ""; + var formattedName = name.toLowerCase().split(""); + formattedName[0] = formattedName[0].toUpperCase(); + return formattedName.join(""); +} \ No newline at end of file