diff --git a/eg/reply-async/README.md b/eg/reply-async/README.md new file mode 100644 index 0000000..12eb158 --- /dev/null +++ b/eg/reply-async/README.md @@ -0,0 +1,47 @@ +# Reply-async + +This example demonstrates using replyAsync function. You should use **replyAsync** instead of **reply** if you have subroutines that return promises. + +## Running the example + +```bash +npm install && node weatherman.js +``` + +Refer to weatherman.rive for the list of supported commmands + +## Using async subroutines + +Whenever you have a subroutine that needs to call some sort of asynchronous function in order to return a value back to the script, you should use promises: + +```javascript +var rs = new RiveScript(); +rs.setSubroutine("asyncHelper", function(rs, args) { + // Rivescript comes bundled with RSVP.js which you can + // access through RiveScript.Promise alias + // but you are free to use your own Promise implementation + return new rs.Promise(function(resolve, reject) { + resolve('hello there'); + }); +}) +``` + +Async reponses in Rive come in 2 flavors: + +```javascript +// using promises +rs.replyAsync(username, message).then(function(reply) { + // good to go +}).catch(function(error){ + // something went wrong +}); + +// or using callbacks +rs.replyAsync(username, message, this, function(error, reply) { + if (!error) { + // you can use reply here + } else { + // something went wrong, error has more info + } +}); +``` \ No newline at end of file diff --git a/eg/reply-async/package.json b/eg/reply-async/package.json new file mode 100644 index 0000000..c8655d8 --- /dev/null +++ b/eg/reply-async/package.json @@ -0,0 +1,10 @@ +{ + "name": "rive-weatherman", + "version": "1.0.0", + "description": "Your weather report", + "main": "weatherman.js", + "dependencies": { + "colors": "^1.1.2", + "request": "^2.69.0" + } +} diff --git a/eg/reply-async/weatherman.js b/eg/reply-async/weatherman.js new file mode 100644 index 0000000..aa4ebba --- /dev/null +++ b/eg/reply-async/weatherman.js @@ -0,0 +1,125 @@ +// Asynchronous Objects Example +// See the accompanying README.md for details. + +// Run this demo: `node weatherman.js` + +var readline = require("readline"); +var request = require("request"); +var colors = require('colors'); + +// This would just be require("rivescript") if not for running this +// example from within the RiveScript project. +var RiveScript = require("../../lib/rivescript"); +var rs = new RiveScript(); + +var getWeather = function(location, callback) { + request.get({ + url: "http://api.openweathermap.org/data/2.5/weather", + qs: { + q: location, + APPID: "6460241df9136925432064ac70416d05" + }, + json: true + }, function(error, response) { + if (response.statusCode !== 200) { + callback.call(this, response.body); + } else { + callback.call(this, null, response.body); + } + }); +}; + + +rs.setSubroutine("getWeather", function (rs, args) { + return new rs.Promise(function(resolve, reject) { + getWeather(args.join(' '), function(error, data){ + if(error) { + reject(error); + } else { + resolve(data.weather[0].description); + } + }); + }); +}); + +rs.setSubroutine("checkForRain", function(rs, args) { + return new rs.Promise(function(resolve, reject) { + getWeather(args.join(' '), function(error, data){ + if(error) { + console.error(''); + reject(error); + } else { + var rainStatus = data.rain ? 'yup :(' : 'nope'; + resolve(rainStatus); + } + }); + }); +}); + +// Create a prototypical class for our own chatbot. +var AsyncBot = function(onReady) { + var self = this; + + // Load the replies and process them. + rs.loadFile("weatherman.rive", function() { + rs.sortReplies(); + onReady(); + }); + + // This is a function for delivering the message to a user. Its actual + // implementation could vary; for example if you were writing an IRC chatbot + // this message could deliver a private message to a target username. + self.sendMessage = function(username, message) { + // This just logs it to the console like "[Bot] @username: message" + console.log( + ["[Brick Tamland]", message].join(": ").underline.green + ); + }; + + // This is a function for a user requesting a reply. It just proxies through + // to RiveScript. + self.getReply = function(username, message, callback) { + return rs.replyAsync(username, message, self).then(function(reply){ + callback.call(this, null, reply); + }).catch(function(error) { + callback.call(this, error); + }); + } +}; + +// Create and run the example bot. +var bot = new AsyncBot(function() { + // Drop into an interactive shell to get replies from the user. + // If this were something like an IRC bot, it would have a message + // handler from the server for when a user sends a private message + // to the bot's nick. + var rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + rl.setPrompt("> "); + rl.prompt(); + rl.on("line", function(cmd) { + // If this was an IRC bot, imagine "nick" came from the server as the + // sending user's IRC nickname. + nick = "Soandso"; + console.log("[" + nick + "] " + cmd); + + // Handle commands. + if (cmd === "/quit") { + process.exit(0); + } else { + reply = bot.getReply(nick, cmd, function(error, reply){ + if (error) { + bot.sendMessage(nick, "Oops. The weather service is not cooperating!"); + } else { + bot.sendMessage(nick, reply); + } + rl.prompt(); + }); + } + }).on("close", function() { + process.exit(0); + }); +}); diff --git a/eg/reply-async/weatherman.rive b/eg/reply-async/weatherman.rive new file mode 100644 index 0000000..9fcbea7 --- /dev/null +++ b/eg/reply-async/weatherman.rive @@ -0,0 +1,10 @@ +! sub how's = how is +! sub is it going to = will it +! sub is it gonna = will it +! sub is it raining = will it rain + ++ how is [the] weather in * +- getWeather + ++ will it rain in * +- checkForRain \ No newline at end of file diff --git a/package.json b/package.json index 38ac98b..6f3e4af 100644 --- a/package.json +++ b/package.json @@ -25,10 +25,9 @@ "lib/lang/javascript.js" ], "directories": { - "lib": "lib", - "doc": "docs" + "lib": "lib", + "doc": "docs" }, - "main": "lib/rivescript.js", "repository": { "type": "git", "url": "https://github.com/aichaos/rivescript-js.git" @@ -67,6 +66,8 @@ "scripts": { "prepublish": "grunt dist", "test": "grunt test" + }, + "dependencies": { + "rsvp": "^3.1.0" } - } diff --git a/src/brain.coffee b/src/brain.coffee index 49ece6c..403e6a9 100644 --- a/src/brain.coffee +++ b/src/brain.coffee @@ -9,6 +9,7 @@ # Brain logic for RiveScript utils = require("./utils") inherit_utils = require("./inheritance") +RSVP = require("rsvp") ## # Brain (RiveScript master) @@ -36,7 +37,7 @@ class Brain # # Fetch a reply for the user. ## - reply: (user, msg, scope) -> + reply: (user, msg, scope, async) -> @say "Asked to reply to [#{user}] #{msg}" # Store the current user's ID. @@ -60,6 +61,17 @@ class Brain else reply = @_getReply(user, msg, "normal", 0, scope) + reply = @processCallTags(reply, scope, async) + + if not utils.isAPromise(reply) + @onAfterReply(msg, user, reply) + else + reply.then (result) => + @onAfterReply(msg, user, result) + + return reply + + onAfterReply: (msg, user, reply) -> # Save their reply history @master._users[user].__history__.input.pop() @master._users[user].__history__.input.unshift(msg) @@ -69,7 +81,88 @@ class Brain # Unset the current user ID. @_currentUser = undefined - return reply + ## + # string|Promise processCallTags (string reply, Object scope, bool async) + # + # Process tags in the preprocessed reply string. + # If async is true, processCallTags can handle asynchronous subroutines + # and it returns a promise, other wise a string is returned + ## + processCallTags: (reply, scope, async) -> + reply = reply.replace(/\{__call__\}/g, "") + reply = reply.replace(/\{\/__call__\}/g, "") + callRe = /(.+?)<\/call>/ig + + giveup = 0 + matches = {} + promises = [] + + while true + giveup++ + if giveup >= 50 + @warn "Infinite loop looking for call tag!" + break + + match = callRe.exec(reply) + + if not match + break + + text = utils.strip(match[1]) + parts = text.split(/\s+/) + obj = parts.shift() + args = parts + + matches[match[1]] = + text: text + obj: obj + args: args + + # go through all the object calls and run functions + for k,data of matches + output = "" + if @master._objlangs[data.obj] + # We do. Do we have a handler for it? + lang = @master._objlangs[data.obj] + if @master._handlers[lang] + # We do. + output = @master._handlers[lang].call(@master, data.obj, data.args, scope) + else + output = "[ERR: No Object Handler]" + else + output = "[ERR: Object Not Found]" + + # if we get a promise back and we are not in the async mode, + # leave an error message to suggest using an async version of rs + # otherwise, keep promises tucked into a list where we can check on + # them later + if utils.isAPromise(output) + if async + promises.push + promise: output + text: k + continue + else + output = "[ERR: Using async routine with reply: use replyAsync instead]" + + reply = @._replaceCallTags(k, output, reply) + + if not async + return reply + else + # wait for all the promises to be resolved and + # return a resulting promise with the final reply + return new RSVP.Promise (resolve, reject) => + RSVP.all(p.promise for p in promises).then (results) => + for i in [0...results.length] + reply = @_replaceCallTags(promises[i].text, results[i], reply) + + resolve(reply) + .catch (reason) => + reject(reason) + + _replaceCallTags: (callSignature, callResult, reply) -> + return reply.replace(new RegExp("" + utils.quotemeta(callSignature) + "", "i"), callResult) ## # string _getReply (string user, string msg, string context, int step, scope) @@ -544,6 +637,8 @@ class Brain # string[] botstars, int step, scope) # # Process tags in a reply element. + # XX: All the tags get processed here except for tags that have + # a separate subroutine (refer to processCallTags for more info) ## processTags: (user, msg, reply, st, bst, step, scope) -> # Prepare the stars and botstars. @@ -746,38 +841,6 @@ class Brain reply = reply.replace(new RegExp("\\{@" + utils.quotemeta(target) + "\\}", "i"), subreply) match = reply.match(/\{@(.+?)\}/) - # Object caller - reply = reply.replace(/\{__call__\}/g, "") - reply = reply.replace(/\{\/__call__\}/g, "") - match = reply.match(/(.+?)<\/call>/i) - giveup = 0 - while match - giveup++ - if giveup >= 50 - @warn "Infinite loop looking for call tag!" - break - - text = utils.strip(match[1]) - parts = text.split(/\s+/) - obj = parts.shift() - args = parts - - # Do we know this object? - output = "" - if @master._objlangs[obj] - # We do. Do we have a handler for it? - lang = @master._objlangs[obj] - if @master._handlers[lang] - # We do. - output = @master._handlers[lang].call(@master, obj, args, scope) - else - output = "[ERR: No Object Handler]" - else - output = "[ERR: Object Not Found]" - - reply = reply.replace(new RegExp("" + utils.quotemeta(match[1]) + "", "i"), output) - match = reply.match(/(.+?)<\/call>/i) - return reply ## diff --git a/src/rivescript.coffee b/src/rivescript.coffee index 64edfa7..67800f6 100644 --- a/src/rivescript.coffee +++ b/src/rivescript.coffee @@ -16,6 +16,7 @@ utils = require "./utils" sorting = require "./sorting" inherit_utils = require "./inheritance" JSObjectHandler = require "./lang/javascript" +RSVP = require("rsvp") ## # RiveScript (hash options) @@ -115,6 +116,23 @@ class RiveScript version: -> return VERSION + ## + # Promise Promise + # + # Alias for RSVP.Promise + # + # You can use shortcut in your async subroutines + # + # ```javascript + # rs.setSubroutine("asyncHelper", function (rs, args) { + # return new rs.Promise(function (resolve, reject) { + # resolve(42); + # }); + # }); + # ``` + ## + Promise: RSVP.Promise + ## # private void runtime () # @@ -755,4 +773,41 @@ class RiveScript reply: (user, msg, scope) -> return @brain.reply(user, msg, scope) + ## + # Promise replyAsync (string username, string message [[, scope], callback]) + # + # Asyncronous version of reply. Use replyAsync if at least one of the subroutines + # used with tag returns a promise + # + # Example: using promises + # + # ```javascript + # rs.replyAsync(user, message).then(function(reply) { + # console.log("Bot>", reply); + # }).catch(function(error) { + # console.error("Error: ", error); + # }); + # ``` + # + # Example: using the callback + # + # ```javascript + # rs.replyAsync(username, msg, this, function(error, reply) { + # if (!error) { + # console.log("Bot>", reply); + # } else { + # console.error("Error: ", error); + # } + # }); + # ``` + ## + replyAsync: (user, msg, scope, callback) -> + reply = @brain.reply(user, msg, scope, true) + if callback + reply.then (result) => + callback.call @, null, result + .catch (error) => + callback.call @, error, null + return reply + module.exports = RiveScript diff --git a/src/utils.coffee b/src/utils.coffee index 7c7b5e6..569d64c 100644 --- a/src/utils.coffee +++ b/src/utils.coffee @@ -131,3 +131,14 @@ exports.clone = (obj) -> copy[key] = exports.clone(obj[key]) return copy + +## +# boolean isAPromise (object) +# +# Determines if obj looks like a promise +## +exports.isAPromise = (obj) -> + return obj and obj.then and obj.catch and obj.finally and + typeof obj.then is 'function' and + typeof obj.catch is 'function' and + typeof obj.finally is 'function' \ No newline at end of file diff --git a/test/test-rivescript.coffee b/test/test-rivescript.coffee index 441bf75..18457c9 100644 --- a/test/test-rivescript.coffee +++ b/test/test-rivescript.coffee @@ -805,17 +805,16 @@ exports.test_js_string_in_setSubroutine = (test) -> exports.test_function_in_setSubroutine = (test) -> bot = new TestCase(test, """ - + * + + my name is * - hello personhelper """) - input = "hello there" + input = "my name is Rive" bot.rs.setSubroutine("helper", (rs, args) -> - test.ok(args.length is 2) + test.ok(args.length is 1) test.equal(rs, bot.rs) - test.equal(args[0], "hello") - test.equal(args[1], "there") + test.equal(args[0], "rive") test.done() ) @@ -832,4 +831,128 @@ exports.test_function_in_setSubroutine_return_value = (test) -> ) bot.reply("hello", "hello person") - test.done() \ No newline at end of file + test.done() + +exports.test_promises_in_objects = (test) -> + bot = new TestCase(test, """ + + my name is * + - hello there helperWithPromise with a anotherHelperWithPromise + """) + + input = "my name is Rive" + + bot.rs.setSubroutine("helperWithPromise", (rs, args) -> + test.ok(args.length is 1) + test.equal(args[0], "rive") + return new rs.Promise((resolve, reject) -> + resolve("stranger") + ) + ) + + bot.rs.setSubroutine("anotherHelperWithPromise", (rs, args) -> + return new rs.Promise((resolve, reject) -> + setTimeout () -> + resolve("delay") + , 1000 + ) + ) + + bot.rs.replyAsync(bot.username, input).then (reply) -> + test.equal(reply, "hello there stranger with a delay") + test.done() + +exports.test_replyAsync_supports_callbacks = (test) -> + bot = new TestCase(test, """ + + my name is * + - hello there asyncHelper + """) + + input = "my name is Rive" + + bot.rs.setSubroutine("asyncHelper", (rs, args) -> + return new rs.Promise((resolve, reject) -> + resolve("stranger") + ) + ) + + bot.rs.replyAsync(bot.username, input, null, (error, reply) -> + test.ok(!error) + test.equal(reply, "hello there stranger") + test.done() + ) + +exports.test_use_reply_with_async_subroutines = (test) -> + bot = new TestCase(test, """ + + my name is * + - hello there asyncHelper + """) + + bot.rs.setSubroutine("asyncHelper", (rs, args) -> + return new rs.Promise((resolve, reject) -> + resolve("stranger") + ) + ) + + bot.reply("my name is Rive", "hello there [ERR: Using async routine with reply: use replyAsync instead]") + test.done() + +exports.test_errors_in_async_subroutines_with_callbacks = (test) -> + bot = new TestCase(test, """ + + my name is * + - hello there asyncHelper + """) + + errorMessage = "Something went terribly wrong" + + bot.rs.setSubroutine("asyncHelper", (rs, args) -> + return new rs.Promise((resolve, reject) -> + reject(new Error(errorMessage)) + ) + ) + + bot.rs.replyAsync(bot.username, "my name is Rive", null, (error, reply) -> + test.ok(error) + test.equal(error.message, errorMessage) + test.ok(!reply) + test.done() + ) + +exports.test_errors_in_async_subroutines_with_promises = (test) -> + bot = new TestCase(test, """ + + my name is * + - hello there asyncHelper + """) + + errorMessage = "Something went terribly wrong" + + bot.rs.setSubroutine("asyncHelper", (rs, args) -> + return new rs.Promise((resolve, reject) -> + reject(new Error(errorMessage)) + ) + ) + + bot.rs.replyAsync(bot.username, "my name is Rive").catch (error) -> + test.ok(error) + test.equal(error.message, errorMessage) + test.done() + +exports.test_async_and_sync_subroutines_together = (test) -> + bot = new TestCase(test, """ + + my name is * + - hello there asyncHelperexclaim + """) + + + bot.rs.setSubroutine("exclaim", (rs, args) -> + return "!" + ) + + bot.rs.setSubroutine("asyncHelper", (rs, args) -> + return new rs.Promise((resolve, reject) -> + resolve("stranger") + ) + ) + + bot.rs.replyAsync(bot.username, "my name is Rive").then (reply) -> + test.equal(reply, "hello there stranger!") + test.done() \ No newline at end of file