├── .babelrc ├── .eslintrc ├── .github ├── ISSUE_TEMPLATE.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── changes.md ├── clients ├── express.js ├── hangout.js ├── slack.js ├── telegram.js ├── telnet.js └── twilio.js ├── contribute.md ├── example ├── eliza.ss └── if.ss ├── package.json ├── readme.md ├── src ├── bin │ ├── bot-init.js │ ├── cleanup.js │ └── parse.js ├── bot │ ├── chatSystem.js │ ├── db │ │ ├── connect.js │ │ ├── import.js │ │ ├── modelNames.js │ │ ├── models │ │ │ ├── gambit.js │ │ │ ├── reply.js │ │ │ ├── topic.js │ │ │ └── user.js │ │ └── sort.js │ ├── factSystem.js │ ├── getReply │ │ ├── filterFunction.js │ │ ├── filterSeen.js │ │ ├── getPendingTopics.js │ │ ├── helpers.js │ │ ├── index.js │ │ └── processReplyTags.js │ ├── index.js │ ├── logger.js │ ├── postParse.js │ ├── processTags.js │ ├── regexes.js │ ├── reply │ │ ├── common.js │ │ ├── customFunction.js │ │ ├── inlineRedirect.js │ │ ├── preprocess-grammar.pegjs │ │ ├── reply-grammar.pegjs │ │ ├── respond.js │ │ ├── topicRedirect.js │ │ └── wordnet.js │ └── utils.js └── plugins │ ├── alpha.js │ ├── compare.js │ ├── message.js │ ├── test.js │ ├── time.js │ ├── user.js │ ├── wordnet.js │ └── words.js ├── test ├── capture.js ├── continue.js ├── fixtures │ ├── capture │ │ └── capture.ss │ ├── concepts │ │ ├── bigrams.tbl │ │ ├── botfacts.tbl │ │ ├── botown.tbl │ │ ├── color.tbl │ │ ├── concepts.top │ │ ├── opp.tbl │ │ ├── test.top │ │ ├── third.top │ │ ├── trigrams.tbl │ │ └── verb.top │ ├── continue │ │ └── main.ss │ ├── multitenant1 │ │ └── main.ss │ ├── multitenant2 │ │ └── main.ss │ ├── redirect │ │ └── redirects.ss │ ├── replies │ │ └── main.ss │ ├── script │ │ ├── script.ss │ │ └── suba │ │ │ └── subtop.ss │ ├── substitution │ │ └── main.ss │ ├── topicflags │ │ └── topics.ss │ ├── topichooks │ │ └── main.ss │ ├── topicsystem │ │ └── main.ss │ └── user │ │ └── main.ss ├── helpers.js ├── multitenant.js ├── processTags.js ├── redirect.js ├── replies.js ├── script.js ├── subs.js ├── test-regexes.js ├── topicflags.js ├── topichooks.js ├── topicsystem.js ├── unit │ ├── utils.js │ └── wordnet.js └── user.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "node": 6 6 | } 7 | }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | extends: 3 | airbnb 4 | 5 | rules: 6 | handle-callback-err: [2, "^(err|error)$"] 7 | no-console: [0] 8 | no-plusplus: [2, { allowForLoopAfterthoughts: true }] 9 | no-underscore-dangle: [2, { allow: ["_id"] }] 10 | radix: [2, "as-needed"] 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Expected Behavior 4 | 5 | 6 | 7 | ## Current Behavior 8 | 9 | 10 | 11 | ## Possible Solution 12 | 13 | 14 | 15 | ## Steps to Reproduce (for bugs) 16 | 17 | 18 | 1. 19 | 2. 20 | 3. 21 | 4. 22 | 23 | ## Context 24 | 25 | 26 | 27 | ## Your Environment 28 | 29 | * Version used: 30 | * Environment name and version (e.g. PHP 5.4 on nginx 1.9.1): 31 | * Server type and version: 32 | * Operating System and version: 33 | * Link to your project: 34 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | 6 | ## Motivation and Context 7 | 8 | 9 | 10 | ## How Has This Been Tested? 11 | 12 | 13 | 14 | 15 | ## Screenshots (if appropriate): 16 | 17 | ## Types of changes 18 | 19 | - [ ] Bug fix (non-breaking change which fixes an issue) 20 | - [ ] New feature (non-breaking change which adds functionality) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 22 | 23 | ## Checklist: 24 | 25 | 26 | - [ ] My code follows the code style of this project. 27 | - [ ] My change requires a change to the documentation. 28 | - [ ] I have updated the documentation accordingly. 29 | - [ ] I have added tests to cover my changes. 30 | - [ ] All new and existing tests passed. 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SuperScript 2 | lib/* 3 | logs/* 4 | test/fixtures/cache/* 5 | coverage/* 6 | 7 | # Node 8 | node_modules/* 9 | npm-debug.log 10 | 11 | # Node profiler logs 12 | isolate-*-v8.log 13 | 14 | # OS Files 15 | .DS_Store 16 | *.sw* 17 | dump.rdb 18 | dump/* 19 | 20 | # IDEA/WebStorm Project Files 21 | .idea 22 | *.iml 23 | .vscode/* -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # SuperScript 2 | src/* 3 | test/* 4 | example/* 5 | logs/* 6 | coverage/* 7 | .github/* 8 | 9 | .babelrc 10 | .eslintrc 11 | .travis.yml 12 | changes.md 13 | contribute.md 14 | LICENSE.md 15 | readme.md 16 | 17 | # Node 18 | node_modules/* 19 | npm-debug.log 20 | 21 | # Node profiler logs 22 | isolate-*-v8.log 23 | 24 | # OS Files 25 | .DS_Store 26 | *.sw* 27 | dump.rdb 28 | dump/* 29 | 30 | # IDEA/WebStorm Project Files 31 | .idea 32 | *.iml 33 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | - "7" 5 | - "8" 6 | services: 7 | - mongodb 8 | script: "npm run-script test-travis" 9 | # Send coverage data to Coveralls 10 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 11 | env: 12 | - CXX=g++-4.8 13 | addons: 14 | apt: 15 | sources: 16 | - ubuntu-toolchain-r-test 17 | packages: 18 | - g++-4.8 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2014-2015 Rob Ellis 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | ”Software”), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /clients/express.js: -------------------------------------------------------------------------------- 1 | import superscript from 'superscript'; 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | 5 | const server = express(); 6 | const PORT = process.env.PORT || 5000; 7 | 8 | server.use(bodyParser.json()); 9 | 10 | let bot; 11 | 12 | server.get('/superscript', (req, res) => { 13 | if (req.query.message) { 14 | return bot.reply('user1', req.query.message, (err, reply) => { 15 | res.json({ 16 | message: reply.string, 17 | }); 18 | }); 19 | } 20 | return res.json({ error: 'No message provided.' }); 21 | }); 22 | 23 | const options = { 24 | factSystem: { 25 | clean: true, 26 | }, 27 | importFile: './data.json', 28 | }; 29 | 30 | superscript.setup(options, (err, botInstance) => { 31 | if (err) { 32 | console.error(err); 33 | } 34 | bot = botInstance; 35 | 36 | server.listen(PORT, () => { 37 | console.log(`===> 🚀 Server is now running on port ${PORT}`); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /clients/hangout.js: -------------------------------------------------------------------------------- 1 | import superscript from 'superscript'; 2 | import xmpp from 'simple-xmpp'; 3 | 4 | const receiveData = function receiveData(from, bot, data) { 5 | // Handle incoming messages. 6 | let message = `${data}`; 7 | 8 | message = message.replace(/[\x0D\x0A]/g, ''); 9 | 10 | bot.reply(from, message.trim(), (err, reply) => { 11 | xmpp.send(from, reply.string); 12 | }); 13 | }; 14 | 15 | // You need authorize this authentication method in Google account. 16 | const botHandle = function botHandle(err, bot) { 17 | xmpp.connect({ 18 | jid: 'EMAIL ADRESS', 19 | password: 'PASSWORD', 20 | host: 'talk.google.com', 21 | port: 5222, 22 | reconnect: true, 23 | }); 24 | 25 | xmpp.on('online', (data) => { 26 | console.log(`Connected with JID: ${data.jid.user}`); 27 | console.log('Yes, I\'m connected!'); 28 | }); 29 | 30 | xmpp.on('chat', (from, message) => { 31 | receiveData(from, bot, message); 32 | }); 33 | 34 | xmpp.on('error', (err) => { 35 | console.error(err); 36 | }); 37 | }; 38 | 39 | // Main entry point 40 | const options = { 41 | factSystem: { 42 | clean: true, 43 | }, 44 | importFile: './data.json', 45 | }; 46 | 47 | superscript.setup(options, (err, bot) => { 48 | botHandle(null, bot); 49 | }); 50 | -------------------------------------------------------------------------------- /clients/slack.js: -------------------------------------------------------------------------------- 1 | import superscript from 'superscript'; 2 | // slack-client provides auth and sugar around dealing with the RealTime API. 3 | import Slack from 'slack-client'; 4 | 5 | // Auth Token - You can generate your token from 6 | // https://.slack.com/services/new/bot 7 | const token = '...'; 8 | 9 | // How should we reply to the user? 10 | // direct - sents a DM 11 | // atReply - sents a channel message with @username 12 | // public sends a channel reply with no username 13 | const replyType = 'atReply'; 14 | 15 | const atReplyRE = /<@(.*?)>/; 16 | 17 | const slack = new Slack(token, true, true); 18 | 19 | const receiveData = function receiveData(slack, bot, data) { 20 | // Fetch the user who sent the message; 21 | const user = data._client.users[data.user]; 22 | let channel; 23 | const messageData = data.toJSON(); 24 | let message = ''; 25 | 26 | if (messageData && messageData.text) { 27 | message = `${messageData.text.trim()}`; 28 | } 29 | 30 | const match = message.match(atReplyRE); 31 | 32 | // Are they talking to us? 33 | if (match && match[1] === slack.self.id) { 34 | message = message.replace(atReplyRE, '').trim(); 35 | if (message[0] === ':') { 36 | message = message.substring(1).trim(); 37 | } 38 | 39 | bot.reply(user.name, message, (err, reply) => { 40 | // We reply back direcly to the user 41 | switch (replyType) { 42 | case 'direct': 43 | channel = slack.getChannelGroupOrDMByName(user.name); 44 | break; 45 | case 'atReply': 46 | reply.string = `@${user.name} ${reply.string}`; 47 | channel = slack.getChannelGroupOrDMByID(messageData.channel); 48 | break; 49 | case 'public': 50 | channel = slack.getChannelGroupOrDMByID(messageData.channel); 51 | break; 52 | } 53 | 54 | if (reply.string) { 55 | channel.send(reply.string); 56 | } 57 | }); 58 | } else if (messageData.channel[0] === 'D') { 59 | bot.reply(user.name, message, (err, reply) => { 60 | channel = slack.getChannelGroupOrDMByName(user.name); 61 | if (reply.string) { 62 | channel.send(reply.string); 63 | } 64 | }); 65 | } else { 66 | console.log('Ignoring...', messageData); 67 | } 68 | }; 69 | 70 | const botHandle = function botHandle(err, bot) { 71 | slack.login(); 72 | 73 | slack.on('error', (error) => { 74 | console.error(`Error: ${error}`); 75 | }); 76 | 77 | slack.on('open', () => { 78 | console.log('Welcome to Slack. You are %s of %s', slack.self.name, slack.team.name); 79 | }); 80 | 81 | slack.on('close', () => { 82 | console.warn('Disconnected'); 83 | }); 84 | 85 | slack.on('message', (data) => { 86 | receiveData(slack, bot, data); 87 | }); 88 | }; 89 | 90 | // Main entry point 91 | const options = { 92 | factSystem: { 93 | clean: true, 94 | }, 95 | importFile: './data.json', 96 | }; 97 | 98 | superscript.setup(options, (err, bot) => { 99 | botHandle(null, bot); 100 | }); 101 | -------------------------------------------------------------------------------- /clients/telegram.js: -------------------------------------------------------------------------------- 1 | import TelegramBot from 'node-telegram-bot-api'; 2 | import superscript from 'superscript'; 3 | 4 | const options = { 5 | factSystem: { 6 | clean: true, 7 | }, 8 | importFile: './data.json', 9 | }; 10 | 11 | superscript.setup(options, (err, bot) => { 12 | if (err) { 13 | console.error(err); 14 | } 15 | // Auth Token - You can generate your token from @BotFather 16 | // @BotFather is the one bot to rule them all. 17 | const token = '...'; 18 | 19 | //= == Polling === 20 | const telegram = new TelegramBot(token, { 21 | polling: true, 22 | }); 23 | 24 | //= == Webhook === 25 | // Choose a port 26 | // var port = 8080; 27 | 28 | // var telegram = new TelegramBot(token, { 29 | // webHook: { 30 | // port: port, 31 | // host: 'localhost' 32 | // } 33 | // }); 34 | 35 | // Use `ngrok http 8080` to tunnels localhost to a https endpoint. Get it at https://ngrok.com/ 36 | // telegram.setWebHook('https://_____.ngrok.io/' + token); 37 | 38 | telegram.on('message', (msg) => { 39 | const fromId = msg.from.id; 40 | const text = msg.text.trim(); 41 | 42 | bot.reply(fromId, text, (err, reply) => { 43 | if (reply.string) { 44 | telegram.sendMessage(fromId, reply.string); 45 | // From file 46 | // var photo = __dirname+'/../test/bot.gif'; 47 | // telegram.sendPhoto(fromId, photo, {caption: "I'm a bot!"}); 48 | 49 | // For more examples, check out https://github.com/yagop/node-telegram-bot-api 50 | } 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /clients/telnet.js: -------------------------------------------------------------------------------- 1 | // Run this and then telnet to localhost:2000 and chat with the bot 2 | 3 | import net from 'net'; 4 | import superscript from 'superscript'; 5 | 6 | const sockets = []; 7 | 8 | const botHandle = function botHandle(err, bot) { 9 | const receiveData = function receiveData(socket, bot, data) { 10 | // Handle incoming messages. 11 | let message = `${data}`; 12 | 13 | message = message.replace(/[\x0D\x0A]/g, ''); 14 | 15 | if (message.indexOf('/quit') === 0 || data.toString('hex', 0, data.length) === 'fff4fffd06') { 16 | socket.end('Good-bye!\n'); 17 | return; 18 | } 19 | 20 | // Use the remoteIP as the name since the PORT changes on ever new connection. 21 | bot.reply(socket.remoteAddress, message.trim(), (err, reply) => { 22 | // Find the right socket 23 | const i = sockets.indexOf(socket); 24 | const soc = sockets[i]; 25 | 26 | soc.write(`\nBot> ${reply.string}\n`); 27 | soc.write('You> '); 28 | }); 29 | }; 30 | 31 | const closeSocket = function closeSocket(socket, bot) { 32 | const i = sockets.indexOf(socket); 33 | const soc = sockets[i]; 34 | 35 | console.log(`User '${soc.name}' has disconnected.\n`); 36 | 37 | if (i !== -1) { 38 | sockets.splice(i, 1); 39 | } 40 | }; 41 | 42 | const newSocket = function newSocket(socket) { 43 | socket.name = `${socket.remoteAddress}:${socket.remotePort}`; 44 | console.log(`User '${socket.name}' has connected.\n`); 45 | 46 | sockets.push(socket); 47 | 48 | // Send a welcome message. 49 | socket.write('Welcome to the Telnet server!\n'); 50 | socket.write(`Hello ${socket.name}! ` + 'Type /quit to disconnect.\n\n'); 51 | 52 | // Send their prompt. 53 | socket.write('You> '); 54 | 55 | socket.on('data', (data) => { 56 | receiveData(socket, bot, data); 57 | }); 58 | 59 | // Handle disconnects. 60 | socket.on('end', () => { 61 | closeSocket(socket, bot); 62 | }); 63 | }; 64 | 65 | // Start the TCP server. 66 | const server = net.createServer(newSocket); 67 | 68 | server.listen(2000); 69 | console.log('TCP server running on port 2000.\n'); 70 | }; 71 | 72 | // This assumes the topics have been compiled to data.json first 73 | // See superscript/src/bin/parse for information on how to do that. 74 | 75 | // Main entry point 76 | const options = { 77 | factSystem: { 78 | clean: true, 79 | }, 80 | importFile: './data.json', 81 | }; 82 | 83 | superscript.setup(options, (err, bot) => { 84 | botHandle(null, bot); 85 | }); 86 | -------------------------------------------------------------------------------- /clients/twilio.js: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import session from 'express-session'; 3 | import connectMongo from 'connect-mongo'; 4 | import bodyParser from 'body-parser'; 5 | import twilio from 'twilio'; 6 | import superscript from 'superscript'; 7 | 8 | const app = express(); 9 | const MongoStore = connectMongo(session); 10 | 11 | // Twilio Configuration 12 | // Number format should be "+19876543210", with "+1" the country code 13 | const twilioConfig = { 14 | account: '[YOUR_TWILIO_SID]', 15 | token: '[YOUR_TWILIO_TOKEN]', 16 | number: '[YOUR_TWILIO_NUMBER]', 17 | }; 18 | 19 | const accountSid = process.env.TWILIO_SID || twilioConfig.account; 20 | const authToken = process.env.TWILIO_AUTH || twilioConfig.token; 21 | const twilioNum = process.env.NUM || twilioConfig.number; 22 | 23 | twilio.client = twilio(accountSid, authToken); 24 | twilio.handler = twilio; 25 | twilio.authToken = authToken; 26 | twilio.num = twilioNum; 27 | 28 | // Send Twilio text message 29 | const sendSMS = function sendSMS(recipient, sender, message) { 30 | twilio.client.messages.create({ 31 | to: recipient, 32 | from: sender, 33 | body: message, 34 | }, (err, result) => { 35 | if (!err) { 36 | console.log('Reply sent! The SID for this message is: '); 37 | console.log(result.sid); 38 | console.log('Message sent on'); 39 | console.log(result.dateCreated); 40 | } else { 41 | console.log('Error sending message'); 42 | console.log(err); 43 | } 44 | }); 45 | }; 46 | 47 | const dataHandle = function dataHandle(data, phoneNumber, twilioNumber, bot) { 48 | // Format message 49 | let message = `${data}`; 50 | 51 | message = message.replace(/[\x0D\x0A]/g, ''); 52 | 53 | bot.reply(message.trim(), (err, reply) => { 54 | sendSMS(phoneNumber, twilioNumber, reply.string); 55 | }); 56 | }; 57 | 58 | // TWILIO 59 | // In your Twilio account, set up Twilio Messaging "Request URL" as HTTP POST 60 | // If running locally and using ngrok, should look something like: http://b2053b5e.ngrok.io/api/messages 61 | const botHandle = function (err, bot) { 62 | app.post('/api/messages', (req, res) => { 63 | if (twilio.handler.validateExpressRequest(req, twilio.authToken)) { 64 | console.log(`Twilio Message Received: ${req.body.Body}`); 65 | dataHandle(req.body.Body, req.body.From, twilio.num, bot); 66 | } else { 67 | res.set('Content-Type', 'text/xml').status(403).send('Error handling text messsage. Check your request params'); 68 | } 69 | }); 70 | }; 71 | 72 | // Main entry point 73 | const options = { 74 | factSystem: { 75 | clean: true, 76 | }, 77 | importFile: './data.json', 78 | }; 79 | 80 | superscript.setup(options, (err, bot) => { 81 | // Middleware 82 | app.use(bodyParser.json()); 83 | app.use(bodyParser.urlencoded({ extended: true })); 84 | app.use(session({ 85 | secret: 'cellar door', 86 | resave: true, 87 | saveUninitialized: false, 88 | store: new MongoStore({ mongooseConnection: bot.db }), 89 | })); 90 | 91 | // PORT 92 | const port = process.env.PORT || 3000; 93 | 94 | // START SERVER 95 | app.listen(port, () => { 96 | console.log(`Listening on port: ${port}`); 97 | }); 98 | 99 | botHandle(null, bot); 100 | }); 101 | -------------------------------------------------------------------------------- /contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute.md 2 | 3 | ## Request for contributions 4 | 5 | Thanks for taking the time to help make SuperScript better. Here are some guidlines that will highly increase the chances of your fix or feature request from being accepted. 6 | 7 | ### Bug Fixes 8 | 9 | If you find a bug you would like fixed. Open up a [ticket](https://github.com/silentrob/superscript/issues/new) with a detailed description of the bug and the expected behaviour. If you would like to fix the problem yourself please do the following steps. 10 | 11 | 1. Fork it. 12 | 2. Create a branch (`git checkout -b fix-for-that-thing`) 13 | 3. Commit a failing test (`git commit -am "adds a failing test to demonstrate that thing"`) 14 | 3. Commit a fix that makes the test pass (`git commit -am "fixes that thing"`) 15 | 4. Push to the branch (`git push origin fix-for-that-thing`) 16 | 5. Open a [Pull Request](https://github.com/silentrob/superscript/pulls) 17 | 18 | Please keep your branch up to date by rebasing upstream changes from master. 19 | 20 | ### New Functionality 21 | 22 | If you wish to add new functionality to superscript, that is super cool, lets chat about what that feature is and how best it could be implemented. You can use [gitter](https://gitter.im/silentrob/superscript), [twitter](https://twitter.com/rob_ellis) email or Issues to engague on new features. Its best if you explaing the problem you are trying to solve, not just the solution you want. You may also submit a pull request with the steps above. -------------------------------------------------------------------------------- /example/eliza.ss: -------------------------------------------------------------------------------- 1 | // A generic set of chatting responses. This set mimicks the classic Eliza bot. 2 | 3 | + * 4 | - I'm not sure I understand you fully. 5 | - Please go on. 6 | - That is interesting. Please continue. 7 | - Tell me more about that. 8 | - Does talking about this bother you? 9 | 10 | + [*] (sorry|apologize|apology) [*] 11 | - Please don't apologize. 12 | - Apologies are not necessary. 13 | - I've told you that apologies are not required. 14 | - It did not bother me. Please continue. 15 | 16 | + i remember (*) 17 | - Do you often think of ? 18 | - Does thinking of bring anything else to mind? 19 | - Why do you remember just now? 20 | - What in the present situation reminds you of ? 21 | - What is the connection between me and ? 22 | - What else does remind you of? 23 | 24 | + do you remember (*) 25 | - Did you think I would forget ? 26 | - Why do you think I should recall now? 27 | - What about ? 28 | - You mentioned ? 29 | 30 | + you remember (*) 31 | - How could I forget ? 32 | - What about should I remember? 33 | 34 | + i forget (*) 35 | - Can you think of why you might forget ? 36 | - Why can't you remember ? 37 | - How often do you think of ? 38 | - Does it bother you to forget that? 39 | - Could it be a mental block? 40 | - Are you generally forgetful? 41 | - Do you think you are suppressing ? 42 | 43 | + did you forget (*) 44 | - Why do you ask? 45 | - Are you sure you told me? 46 | - Would it bother you if I forgot ? 47 | - Why should I recall just now? 48 | - Tell me more about . 49 | 50 | + [*] if (*) 51 | - Do you think it's likely that ? 52 | - Do you wish that ? 53 | - What do you know about ? 54 | - Really, if ? 55 | - What would you do if ? 56 | - But what are the chances that ? 57 | - What does this speculation lead to? 58 | 59 | + [*] i dreamed (*) 60 | - Really, ? 61 | - Have you ever fantasized while you were awake? 62 | - Have you ever dreamed before? 63 | - What does that dream suggest to you? 64 | - Do you dream often? 65 | - What persons appear in your dreams? 66 | - Do you believe that dreams have something to do with your problem? 67 | 68 | + [*] perhaps [*] 69 | - You don't seem quite certain. 70 | - Why the uncertain tone? 71 | - Can't you be more positive? 72 | - You aren't sure? 73 | - Don't you know? 74 | - How likely, would you estimate? 75 | 76 | + (hello|hi|hey|howdy|hola|hai|yo) [*] 77 | - How do you do. Please state your problem. 78 | - Hi. What seems to be your problem? 79 | 80 | + [*] computer [*] 81 | - Do computers worry you? 82 | - Why do you mention computers? 83 | - What do you think machines have to do with your problem? 84 | - Don't you think computers can help people? 85 | - What about machines worries you? 86 | - What do you think about machines? 87 | 88 | + am i (*) 89 | - Do you believe you are ? 90 | - Would you want to be ? 91 | - Do you wish I would tell you you are ? 92 | - What would it mean if you were ? 93 | 94 | + are you (*) 95 | - Are you interested in whether I am or not? 96 | - Would you prefer if I weren't ? 97 | - Perhaps I am in your fantasies. 98 | - Do you sometimes think I am ? 99 | - Would it matter to you? 100 | - What if I were ? 101 | 102 | + you are (*) 103 | - What makes you think I am ? 104 | - Does it please you to believe I am ? 105 | - Do you sometimes wish you were ? 106 | - Perhaps you would like to be . 107 | 108 | + * are (*) 109 | - Did you think they might not be ? 110 | - Would you like it if they were not ? 111 | - What if they were not ? 112 | - Are they always ? 113 | - Are you positive they are ? 114 | 115 | + [*] your (*) 116 | - Why are you concerned over my ? 117 | - What about your own ? 118 | - Are you worried about someone else's ? 119 | - Really, my ? 120 | - What makes you think of my ? 121 | - Do you want my ? 122 | 123 | + was i (*) 124 | - What if you were ? 125 | - Do you think you were ? 126 | - Were you ? 127 | - What would it mean if you were ? 128 | - What does '' suggest to you? 129 | 130 | + i was (*) 131 | - Were you really? 132 | - Why do you tell me you were now? 133 | - Perhaps I already know you were . 134 | 135 | + [*] was you (*) 136 | - Would you like to believe I was ? 137 | - What suggests that I was ? 138 | - What do you think? 139 | - Perhaps I was . 140 | - What if I had been ? 141 | 142 | + i (desire|want|need) (*) 143 | - What would it mean to you if you got ? 144 | - Why do you want ? 145 | - Suppose you got soon. 146 | - What if you never got ? 147 | - What would getting mean to you? 148 | - What does wanting have to do with this discussion? 149 | 150 | + i am (sad|unhappy|mad|angry|pissed|depressed) [*] 151 | - I am sorry to hear that you are . 152 | - Do you think coming here will help you not to be ? 153 | - I'm sure it's not pleasant to be . 154 | - Can you explain what made you ? 155 | 156 | + i am (happy|excited|glad) [*] 157 | - How have I helped you to be ? 158 | - Has your treatment made you ? 159 | - What makes you just now? 160 | - Can you explain why you are ? 161 | 162 | + i (believe|think) (*) 163 | - Do you really think so? 164 | - But you are not sure you . 165 | - Do you really doubt you 166 | 167 | + i am (*) 168 | - Is it because you are that you came to me? 169 | - How long have you been ? 170 | - Do you believe it is normal to be ? 171 | - Do you enjoy being ? 172 | - Do you know anyone else who is ? 173 | 174 | + i can not (*) 175 | - How do you know that you can't ? 176 | - Have you tried? 177 | - Perhaps you could now. 178 | - Do you really want to be able to ? 179 | - What if you could ? 180 | 181 | + i do not (*) 182 | - Don't you really ? 183 | - Why don't you ? 184 | - Do you wish to be able to ? 185 | - Does that trouble you? 186 | 187 | + i feel (*) 188 | - Tell me more about such feelings. 189 | - Do you often feel ? 190 | - Do you enjoy feeling ? 191 | - Of what does feeling remind you? 192 | 193 | + i (*) you 194 | - Perhaps in your fantasies we each other. 195 | - Do you wish to me? 196 | - You seem to need to me. 197 | - Do you anyone else? 198 | 199 | + you (*) me 200 | - Why do you think I you? 201 | - You like to think I you -- don't you? 202 | - What makes you think I you? 203 | - Really, I you? 204 | - Do you wish to believe I you? 205 | - Suppose I did you -- what would that mean? 206 | - Does someone else believe I you? 207 | 208 | + [*] you (*) 209 | - We were discussing you -- not me. 210 | - Oh, I ? 211 | - You're not really talking about me -- are you? 212 | - What are your feelings now? 213 | 214 | + [*] (yes|yeah|yep|yup) [*] 215 | - Please go on. 216 | - Please tell me more about this. 217 | - Why don't you tell me a little more about this. 218 | - I see. 219 | - I understand. 220 | 221 | + [*] (nope|nah) [*] 222 | - Are you saying no just to be negative? 223 | - Does this make you feel unhappy? 224 | - Why not? 225 | - Why 'no'? 226 | 227 | + no 228 | @ nope 229 | 230 | + no one (*) 231 | - Are you sure, no one ? 232 | - Surely someone . 233 | - Can you think of anyone at all? 234 | - Are you thinking of a very special person? 235 | - Who, may I ask? 236 | - You have a particular person in mind, don't you? 237 | - Who do you think you are talking about? 238 | 239 | + [*] my (mom|dad|mother|father|bro|brother|sis|sister|cousin|aunt|uncle) (*) 240 | - Tell me more about your family. 241 | - Who else in your family ? 242 | - Your ? 243 | - What else comes to mind when you think of your ? 244 | 245 | + can you (*) 246 | - You believe I can don't you? 247 | - You want me to be able to . 248 | - Perhaps you would like to be able to yourself. 249 | 250 | + can i (*) 251 | - Whether or not you can depends on you more than on me. 252 | - Do you want to be able to ? 253 | - Perhaps you don't want to . 254 | 255 | + (what|who|when|where|how) [*] 256 | - Why do you ask? 257 | - Does that question interest you? 258 | - What is it you really want to know? 259 | - Are such questions much on your mind? 260 | - What answer would please you most? 261 | - What do you think? 262 | - What comes to mind when you ask that? 263 | - Have you asked such questions before? 264 | - Have you asked anyone else? 265 | 266 | + [*] because [*] 267 | - Is that the real reason? 268 | - Don't any other reasons come to mind? 269 | - Does that reason seem to explain anything else? 270 | - What other reasons might there be? 271 | 272 | + why do not you (*) 273 | - Do you believe I don't ? 274 | - Perhaps I will in good time. 275 | - Should you yourself? 276 | - You want me to ? 277 | 278 | + why can not i (*) 279 | - Do you think you should be able to ? 280 | - Do you want to be able to ? 281 | - Do you believe this will help you to ? 282 | - Have you any idea why you can't ? 283 | 284 | + everyone (*) 285 | - Really, everyone? 286 | - Surely not everyone. 287 | - Can you think of anyone in particular? 288 | - Who, for example? 289 | - Are you thinking of a very special person? 290 | - Who, may I ask? 291 | - Someone special perhaps? 292 | - You have a particular reason in mind, don't you? 293 | - Who do you think you're talking about? 294 | 295 | + [*] (fuck|fucker|shit|damn|shut up|bitch) [*] 296 | - Does it make you feel strong to use that kind of language? 297 | - Are you venting your feelings now? 298 | - Are you angry? 299 | - Does this topic make you feel angry? 300 | - Is something making you feel angry? 301 | - Does using that kind of language make you feel better? -------------------------------------------------------------------------------- /example/if.ss: -------------------------------------------------------------------------------- 1 | 2 | // This is a interaction fiction example game with basic navigation. 3 | // I will add to more to this to demonstrate items and other state. 4 | 5 | > pre 6 | 7 | // Navigate command 8 | 9 | // Option 1 have a global command to set the direction 10 | // This will change the topic, and say "look" in the new topic 11 | 12 | + go (north|south|east|west) 13 | - {keep} You are heading . ^topicRedirect(, "look") 14 | 15 | < pre 16 | 17 | > topic north 18 | 19 | // Option 2 per topic manually set the topic 20 | // The problem is this will hit even if your not in the topic so someone could teleport to the backroom 21 | // from any topic, we can fix that by adding a trigger filter function., now this trigger will only match 22 | // if you are in the room. 23 | 24 | + ^inTopic("north") go to the back room 25 | - {keep} okay, going to the back room {topic=backroom} 26 | 27 | + look 28 | - you are in the north room, there is a back room down the hall 29 | 30 | < topic 31 | 32 | 33 | > topic south 34 | 35 | + look 36 | - {keep} you are in the south room 37 | 38 | < topic 39 | 40 | 41 | > topic east 42 | 43 | + look 44 | - {keep} you are in the east room 45 | 46 | < topic 47 | 48 | > topic west 49 | 50 | + look 51 | - {keep} you are in the west room 52 | 53 | < topic 54 | 55 | 56 | > topic backroom 57 | 58 | + look 59 | - {keep} it is dark in here 60 | 61 | < topic 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "superscript", 3 | "version": "1.1.4", 4 | "description": "A dialog system and bot engine for creating human-like chat bots.", 5 | "main": "lib/bot/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir lib --copy-files", 8 | "lint": "eslint --env node src *.js", 9 | "test": "mocha --compilers js:babel-register test -R spec -s 1700 -t 300000 --recursive", 10 | "prepublish": "npm run build", 11 | "profile": "mocha --compilers js:babel-register test -R spec -s 1700 -t 300000 --prof --log-timer-events --recursive", 12 | "dtest": "DEBUG=*,-mquery,-mocha*, mocha --compilers js:babel-register test -R spec -s 1700 -t 300000 --recursive", 13 | "itest": "DEBUG=SS* DEBUG_LEVEL=info mocha --compilers js:babel-register test -R spec -s 1700 -t 300000 --recursive", 14 | "test-travis": "./node_modules/istanbul/lib/cli.js cover ./node_modules/mocha/bin/_mocha -- --compilers js:babel-register -R spec test -s 1700 -t 300000 --recursive" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/superscriptjs/superscript" 19 | }, 20 | "homepage": "http://superscriptjs.com", 21 | "bugs": { 22 | "url": "https://github.com/superscriptjs/superscript/issues" 23 | }, 24 | "bin": { 25 | "bot-init": "lib/bin/bot-init.js", 26 | "cleanup": "lib/bin/cleanup.js", 27 | "parse": "lib/bin/parse.js" 28 | }, 29 | "author": "Rob Ellis", 30 | "contributors": [ 31 | "Rob Ellis ", 32 | "Issam Hakimi ", 33 | "Marius Ursache ", 34 | "Michael Lewkowitz ", 35 | "John Wehr ", 36 | "Ben James " 37 | ], 38 | "license": "MIT", 39 | "dependencies": { 40 | "async": "^2.5.0", 41 | "commander": "^2.11.0", 42 | "debug": "^2.6.3", 43 | "debug-levels": "^0.2.0", 44 | "lodash": "^4.17.4", 45 | "mkdirp": "^0.5.1", 46 | "moment": "^2.18.1", 47 | "mongo-tenant": "^1.0.4", 48 | "mongoose": "^4.11.5", 49 | "natural": "^0.5.4", 50 | "pegjs": "^0.10.0", 51 | "pluralize": "^4.0.0", 52 | "require-dir": "^0.3.1", 53 | "rhymes": "^1.0.1", 54 | "roman-numerals": "^0.3.2", 55 | "safe-eval": "^0.3.0", 56 | "sfacts": "^1.0.1", 57 | "ss-message": "^1.1.4", 58 | "ss-parser": "^1.0.3", 59 | "syllablistic": "^0.1.0", 60 | "wordpos": "^1.1.5" 61 | }, 62 | "devDependencies": { 63 | "babel-cli": "^6.24.0", 64 | "babel-preset-env": "^1.6.0", 65 | "babel-register": "^6.24.0", 66 | "coveralls": "^2.13.0", 67 | "eslint": "^3.19.0", 68 | "eslint-config-airbnb": "^14.1.0", 69 | "eslint-plugin-import": "^2.2.0", 70 | "eslint-plugin-jsx-a11y": "^4.0.0", 71 | "eslint-plugin-react": "^6.10.3", 72 | "istanbul": "^1.1.0-alpha.1", 73 | "mocha": "^3.5.0", 74 | "should": "^11.2.1" 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/superscriptjs/superscript.svg?branch=master)](https://travis-ci.org/superscriptjs/superscript) 2 | [![Dependencies Status](https://david-dm.org/superscriptjs/superscript.svg)](https://david-dm.org/superscriptjs/superscript) 3 | [![Slack Chat](https://superscript-slackin.herokuapp.com/badge.svg)](https://superscript-slackin.herokuapp.com/) 4 | [![Code Climate](https://codeclimate.com/github/silentrob/superscript/badges/gpa.svg)](https://codeclimate.com/github/silentrob/superscript) 5 | 6 | # SuperScript 7 | 8 | SuperScript is a dialog system and bot engine for creating human-like conversation chat bots. It exposes an expressive script for crafting dialogue and features text-expansion using WordNet and information retrieval using a fact system built on a [Level](https://github.com/Level/level) interface. 9 | 10 | Note: This version (v1.x) is designed to work with and tested against the latest Node 6.x and above. 11 | 12 | ## Why SuperScript? 13 | 14 | SuperScript's power comes in its topics and conversations, which mimic typical human conversations. If you're looking to create complex conversations with branching dialogue, or recreate the natural flow of talking about different topics, SuperScript is for you! 15 | 16 | ## What comes in the box 17 | 18 | * Dialog engine. 19 | * Multi-user platform for easy integration with group chat systems like Slack. 20 | * Message pipeline with NLP tech such as POS tagging, sentence analysis and question tagging. 21 | * Extensible plugin architecture to call your own APIs or do your own NLP if you want to! 22 | * A built in graph database using LevelDB. Each user has their own sub-level, allowing you to define complex relationships between entities. 23 | * [WordNet](http://wordnet.princeton.edu/), a database for word and concept expansion. 24 | 25 | ## Install 26 | 27 | npm install superscript 28 | 29 | ## Getting Started 30 | 31 | ### bot-init 32 | 33 | If you've installed superscript globally (`npm install -g superscript`), a good way to get your new bot up and running is by running the `bot-init` script: 34 | 35 | bot-init myBotName --clients telnet,slack 36 | 37 | This will create a bot in a new 'myBotName' folder in your current directory. You can specify the clients you want with the `--clients` flag. 38 | 39 | Then all you need to do is run: 40 | 41 | ``` 42 | cd myBotName 43 | npm install 44 | parse 45 | npm run build 46 | npm run start-[clientName] 47 | ``` 48 | 49 | This will start the server. You then need to connect to a client to be able to talk to your bot! If you're using the telnet client, you'll need to open up a new Terminal tab, and run `telnet localhost 2000`. 50 | 51 | Note: The `parse` step is a bin script that will compile your SuperScript script. By default, it will look at the `chat` folder in your current directory. 52 | 53 | ### Clone a template 54 | 55 | Alternatively, check out the [`hello-superscript`](https://github.com/silentrob/hello-superscript) repo for a clean starting point to building your own bot. There's no guarantee at present that this is using the latest version of SuperScript. 56 | 57 | ### Using Heroku's one-click deploy 58 | 59 | Thanks to @bmann we now have a one-click deploy to Heroku! More info can be found over at [superscript-heroku](https://github.com/bmann/superscript-heroku). 60 | 61 | #### Express Client 62 | 63 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/bmann/superscript-heroku/tree/master) 64 | 65 | #### Slack Client 66 | 67 | The slack-client branch creates a superscript powered bot that sits in your Slack. You'll need to create a bot and give it a name in the [Slack apps directory](http://my.slack.com/apps/A0F7YS25R-bots) in order to get a token that lets your bot connect to your Slack. 68 | 69 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/bmann/superscript-heroku/tree/slack-client) 70 | 71 | Creating the bot in the Slack directory means you'll see the bot appear in your Slack as offline. When your server from above is running correctly, the status of the bot will go green, and you can say "Hi" to it and it will respond. 72 | 73 | ## Upgrading to v1.x 74 | 75 | Information on upgrading to v1.x can be found [on the wiki](https://github.com/superscriptjs/superscript/wiki/Upgrading-to-v1). 76 | 77 | ## Documentation 78 | 79 | Visit [superscriptjs.com](http://superscriptjs.com) for all the details on how to get started playing with SuperScript. Or [read the wiki](https://github.com/superscriptjs/superscript/wiki) 80 | 81 | ### Example Script - Script Authoring 82 | 83 | + hello human 84 | - Hello Bot 85 | 86 | `+` matches all input types 87 | 88 | `-` Is the reply sent back to the user. 89 | 90 | 91 | ### Optional and Alternates - Script Authoring 92 | 93 | + [hey] hello (nice|mean) human 94 | - Hello Bot 95 | 96 | `[]` are for optional words, they may or may not appear in the input match 97 | 98 | `()` are alternate words. One MUST appear. 99 | 100 | ### Capturing results - Script Authoring (wildcards) 101 | 102 | + * should *~2 work *1 103 | - I have no idea. 104 | 105 | `*` Matches ZERO or more words or tokens 106 | 107 | `*~n` Matches ZERO to N words or tokens 108 | 109 | `*n` Matches exactly N number of words or tokens 110 | 111 | 112 | ## And More 113 | 114 | The above is just a tiny fraction of what the system is capable of doing. Please see the [full documentation](http://superscriptjs.com) to learn more. 115 | 116 | 117 | ### Additional Resources 118 | 119 | * [Sublime Text Syntax Highlighting](https://github.com/mariusursache/superscript-sublimetext) 120 | * [Atom Syntax Highlighting](https://github.com/DBozhinovski/language-superscript) 121 | 122 | ### Further Reading 123 | 124 | * [Introducing SuperScript](https://medium.com/@rob_ellis/superscript-ce40e9720bef) on Medium 125 | * [Creating a Chatbot](https://medium.com/@rob_ellis/creating-a-chat-bot-42861e6a2acd) on Medium 126 | * [Custom Slack chatbot tutorial](https://medium.com/@rob_ellis/slack-superscript-rise-of-the-bots-bba8506a043c) on Medium 127 | * [SuperScript the big update](https://medium.com/@rob_ellis/superscript-the-big-update-3fa8099ab89a) on Medium 128 | * [Full Documentation](https://github.com/superscriptjs/superscript/wiki) 129 | * Follow [@rob_ellis](https://twitter.com/rob_ellis) 130 | 131 | ### Further Watching 132 | 133 | * [Talking to Machines EmpireJS](https://www.youtube.com/watch?v=uKqO6HCKSBg) 134 | 135 | ## Thanks 136 | 137 | SuperScript is based off of a fork of RiveScript with idiom brought in from ChatScript. Without the work of Noah Petherbridge and Bruce Wilcox, this project would not be possible. 138 | 139 | ## License 140 | 141 | [The MIT License (MIT)](LICENSE.md) 142 | 143 | Copyright © 2014-2017 Rob Ellis 144 | -------------------------------------------------------------------------------- /src/bin/bot-init.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from 'commander'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | 7 | program 8 | .version('1.0.0') 9 | .usage('botname [options]') 10 | .option('-c, --clients [telnet]', 'Bot clients (express, hangout, slack, telegram, telnet, twilio) [default: telnet]', 'telnet') 11 | .parse(process.argv); 12 | 13 | if (!program.args[0]) { 14 | program.help(); 15 | process.exit(1); 16 | } 17 | 18 | const botName = program.args[0]; 19 | const botPath = path.join(process.cwd(), botName); 20 | const ssRoot = path.join(__dirname, '..', '..'); 21 | 22 | const write = function write(path, str, mode = 0o666) { 23 | fs.writeFileSync(path, str, { mode }); 24 | console.log(` \x1b[36mcreate\x1b[0m : ${path}`); 25 | }; 26 | 27 | // Creating the path for your bot. 28 | fs.mkdir(botPath, (err) => { 29 | if (err && err.code === 'EEXIST') { 30 | console.error(`\n\nThere is already a bot named ${botName} at ${botPath}.\nPlease remove it or pick a new name for your bot before continuing.\n`); 31 | process.exit(1); 32 | } else if (err) { 33 | console.error(`We could not create the bot: ${err}`); 34 | process.exit(1); 35 | } 36 | 37 | fs.mkdirSync(path.join(botPath, 'chat')); 38 | fs.mkdirSync(path.join(botPath, 'plugins')); 39 | fs.mkdirSync(path.join(botPath, 'src')); 40 | 41 | // package.json 42 | const pkg = { 43 | name: botName, 44 | version: '0.0.0', 45 | private: true, 46 | dependencies: { 47 | superscript: '^1.0.0', 48 | }, 49 | devDependencies: { 50 | 'babel-cli': '^6.16.0', 51 | 'babel-preset-es2015': '^6.16.0', 52 | }, 53 | scripts: { 54 | build: 'babel src --presets babel-preset-es2015 --out-dir lib', 55 | }, 56 | }; 57 | 58 | const clients = program.clients.split(','); 59 | 60 | clients.forEach((client) => { 61 | const clientName = client.toLowerCase(); 62 | 63 | if (['express', 'hangout', 'slack', 'telegram', 'telnet', 'twilio'].indexOf(clientName) === -1) { 64 | console.log(`Cannot create bot with client type: ${clientName}`); 65 | return; 66 | } 67 | 68 | console.log(`Creating ${program.args[0]} bot with a ${clientName} client.`); 69 | 70 | // TODO: Pull out plugins that have dialogue and move them to the new bot. 71 | fs.createReadStream(path.join(ssRoot, 'clients', `${clientName}.js`)) 72 | .pipe(fs.createWriteStream(path.join(botPath, 'src', `server-${clientName}.js`))); 73 | 74 | pkg.scripts.parse = 'parse -f'; 75 | pkg.scripts[`start-${clientName}`] = `npm run build && node lib/server-${clientName}.js`; 76 | 77 | if (client === 'express') { 78 | pkg.dependencies.express = '4.x'; 79 | pkg.dependencies['body-parser'] = '1.x'; 80 | } else if (client === 'hangout') { 81 | pkg.dependencies['simple-xmpp'] = '1.x'; 82 | } else if (client === 'slack') { 83 | pkg.dependencies['slack-client'] = '1.x'; 84 | } else if (client === 'telegram') { 85 | pkg.dependencies['node-telegram-bot-api'] = '0.25.x'; 86 | } else if (client === 'twilio') { 87 | pkg.dependencies.express = '4.x'; 88 | pkg.dependencies['express-session'] = '1.x'; 89 | pkg.dependencies['body-parser'] = '1.x'; 90 | pkg.dependencies['connect-mongo'] = '1.x'; 91 | pkg.dependencies.twilio = '2.x'; 92 | } 93 | }); 94 | 95 | const firstRule = "+ {^hasTag('hello')} *~2\n- Hi!\n- Hi, how are you?\n- How are you?\n- Hello\n- Howdy\n- Ola"; 96 | 97 | write(path.join(botPath, 'package.json'), JSON.stringify(pkg, null, 2)); 98 | write(path.join(botPath, 'chat', 'main.ss'), firstRule); 99 | }); 100 | -------------------------------------------------------------------------------- /src/bin/cleanup.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from 'commander'; 4 | import superscript from '../bot'; 5 | 6 | program 7 | .version('1.0.1') 8 | .option('--host [type]', 'Mongo Host', 'localhost') 9 | .option('--port [type]', 'Mongo Port', '27017') 10 | .option('--mongo [type]', 'Mongo Database Name', 'superscriptDB') 11 | .option('--mongoURI [type]', 'Mongo URI') 12 | .option('--importFile [type]', 'Parsed JSON file path', 'data.json') 13 | .parse(process.argv); 14 | 15 | const mongoURI = process.env.MONGO_URI 16 | || process.env.MONGODB_URI 17 | || program.mongoURI 18 | || `mongodb://${program.host}:${program.port}/${program.mongo}`; 19 | 20 | // The use case of this file is to refresh a currently running bot. 21 | // So the idea is to import a new file into a Mongo instance while preserving user data. 22 | // For now, just nuke everything and import all the data into the database. 23 | 24 | // TODO: Prevent clearing user data 25 | // const collectionsToRemove = ['users', 'topics', 'replies', 'gambits']; 26 | 27 | const options = { 28 | mongoURI, 29 | importFile: program.importFile, 30 | }; 31 | 32 | superscript.setup(options, (err) => { 33 | if (err) { 34 | console.error(err); 35 | } 36 | console.log('Everything has been imported.'); 37 | process.exit(); 38 | }); 39 | -------------------------------------------------------------------------------- /src/bin/parse.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import program from 'commander'; 4 | import fs from 'fs'; 5 | import parser from 'ss-parser'; 6 | import facts from 'sfacts'; 7 | 8 | program 9 | .version('1.0.2') 10 | .option('-p, --path [type]', 'Input path', './chat') 11 | .option('-o, --output [type]', 'Output options', 'data.json') 12 | .option('-f, --force [type]', 'Force save if output file already exists', false) 13 | .option('-F, --facts [type]', 'Fact system files path', files => files.split(','), []) 14 | .option('--host [type]', 'Mongo Host', 'localhost') 15 | .option('--port [type]', 'Mongo Port', '27017') 16 | .option('--mongo [type]', 'Mongo Database Name', 'superscriptParse') 17 | .option('--mongoURI [type]', 'Mongo URI') 18 | .parse(process.argv); 19 | 20 | const mongoURI = process.env.MONGO_URI 21 | || process.env.MONGODB_URI 22 | || program.mongoURI 23 | || `mongodb://${program.host}:${program.port}/${program.mongo}`; 24 | 25 | fs.exists(program.output, (exists) => { 26 | if (exists && !program.force) { 27 | console.log('File', program.output, 'already exists, remove file first or use -f to force save.'); 28 | return process.exit(); 29 | } 30 | 31 | return facts.load(mongoURI, program.facts, true, (err, factSystem) => { 32 | parser.parseDirectory(program.path, { factSystem }, (err, result) => { 33 | if (err) { 34 | console.error(`Error parsing bot script: ${err}`); 35 | } 36 | fs.writeFile(program.output, JSON.stringify(result, null, 4), (err) => { 37 | if (err) throw err; 38 | console.log(`Saved output to ${program.output}`); 39 | process.exit(); 40 | }); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/bot/chatSystem.js: -------------------------------------------------------------------------------- 1 | /** 2 | I want to create a more organic approach to authoring new gambits, topics and replies. 3 | Right now, the system parses flat files to a intermediate JSON object that SS reads and 4 | creates an in-memory topic representation. 5 | 6 | I believe by introducing a Topic DB with a clean API we can have a faster more robust authoring 7 | expierence parseing input will become more intergrated into the topics, and Im propising 8 | changing the existing parse inerface with a import/export to make sharing SuperScript 9 | data (and advanced authoring?) easier. 10 | 11 | We also want to put more focus on the Gambit, and less on topics. A Gambit should be 12 | able to live in several topics. 13 | */ 14 | 15 | import createGambitModel from './db/models/gambit'; 16 | import createReplyModel from './db/models/reply'; 17 | import createTopicModel from './db/models/topic'; 18 | import createUserModel from './db/models/user'; 19 | 20 | const setupChatSystem = function setupChatSystem(db, coreFactSystem, logger) { 21 | const GambitCore = createGambitModel(db, coreFactSystem); 22 | const ReplyCore = createReplyModel(db); 23 | const TopicCore = createTopicModel(db); 24 | const UserCore = createUserModel(db, coreFactSystem, logger); 25 | 26 | const getChatSystem = function getChatSystem(tenantId = 'master') { 27 | const Gambit = GambitCore.byTenant(tenantId); 28 | const Reply = ReplyCore.byTenant(tenantId); 29 | const Topic = TopicCore.byTenant(tenantId); 30 | const User = UserCore.byTenant(tenantId); 31 | 32 | return { 33 | Gambit, 34 | Reply, 35 | Topic, 36 | User, 37 | }; 38 | }; 39 | 40 | return { getChatSystem }; 41 | }; 42 | 43 | export default { 44 | setupChatSystem, 45 | }; 46 | -------------------------------------------------------------------------------- /src/bot/db/connect.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | mongoose.Promise = global.Promise; 3 | 4 | export default (mongoURI) => { 5 | const db = mongoose.createConnection(`${mongoURI}`); 6 | 7 | db.on('error', console.error); 8 | 9 | // If you want to debug mongoose 10 | // mongoose.set('debug', true); 11 | 12 | return db; 13 | }; 14 | -------------------------------------------------------------------------------- /src/bot/db/import.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Import a data file into MongoDB 3 | */ 4 | 5 | import fs from 'fs'; 6 | import async from 'async'; 7 | import _ from 'lodash'; 8 | import debuglog from 'debug-levels'; 9 | 10 | const debug = debuglog('SS:Importer'); 11 | 12 | // Whenever and only when a breaking change is made to ss-parser, this needs 13 | // to be updated. 14 | const MIN_SUPPORTED_SCRIPT_VERSION = 1; 15 | 16 | const rawToGambitData = function rawToGambitData(gambitId, gambit) { 17 | const gambitData = { 18 | id: gambitId, 19 | isQuestion: gambit.trigger.question, 20 | conditions: gambit.conditional, 21 | filter: gambit.trigger.filter, 22 | trigger: gambit.trigger.clean, 23 | input: gambit.trigger.raw, 24 | }; 25 | 26 | // Conditional rolled up triggers will not have a flags 27 | if (gambit.trigger.flags && gambit.trigger.flags.order) { 28 | gambitData.reply_order = gambit.trigger.flags.order; 29 | } 30 | 31 | if (gambit.trigger.flags && gambit.trigger.flags.keep) { 32 | gambitData.reply_exhaustion = gambit.trigger.flags.keep; 33 | } 34 | 35 | if (gambit.redirect) { 36 | gambitData.redirect = gambit.redirect; 37 | } 38 | 39 | return gambitData; 40 | }; 41 | 42 | const importData = function importData(chatSystem, data, callback) { 43 | if (!data.version || data.version < MIN_SUPPORTED_SCRIPT_VERSION) { 44 | return callback(`Error: Your script has version ${data.version} but the minimum supported version is ${MIN_SUPPORTED_SCRIPT_VERSION}.\nPlease either re-parse your file with a supported parser version, or update SuperScript.`); 45 | } 46 | 47 | const Topic = chatSystem.Topic; 48 | const Gambit = chatSystem.Gambit; 49 | const Reply = chatSystem.Reply; 50 | 51 | const gambitsWithConversation = []; 52 | 53 | const eachReplyItor = function eachReplyItor(gambit) { 54 | return (replyId, nextReply) => { 55 | debug.verbose('Reply process: %s', replyId); 56 | const properties = { 57 | id: replyId, 58 | reply: data.replies[replyId].string, 59 | keep: data.replies[replyId].keep, 60 | filter: data.replies[replyId].filter, 61 | parent: gambit._id, 62 | }; 63 | 64 | gambit.addReply(properties, (err) => { 65 | if (err) { 66 | console.error(err); 67 | } 68 | nextReply(); 69 | }); 70 | }; 71 | }; 72 | 73 | const eachGambitItor = function eachGambitItor(topic) { 74 | return (gambitId, nextGambit) => { 75 | const gambit = data.gambits[gambitId]; 76 | if (gambit.conversation) { 77 | debug.verbose('Gambit has conversation (deferring process): %s', gambitId); 78 | gambitsWithConversation.push(gambitId); 79 | nextGambit(); 80 | } else if (gambit.topic === topic.name) { 81 | debug.verbose('Gambit process: %s', gambitId); 82 | const gambitData = rawToGambitData(gambitId, gambit); 83 | 84 | topic.createGambit(gambitData, (err, mongoGambit) => { 85 | if (err) { 86 | console.error(err); 87 | } 88 | async.eachSeries(gambit.replies, eachReplyItor(mongoGambit), (err) => { 89 | if (err) { 90 | console.error(err); 91 | } 92 | nextGambit(); 93 | }); 94 | }); 95 | } else { 96 | nextGambit(); 97 | } 98 | }; 99 | }; 100 | 101 | const eachTopicItor = function eachTopicItor(topicName, nextTopic) { 102 | const topic = data.topics[topicName]; 103 | debug.verbose(`Find or create topic with name '${topicName}'`); 104 | const topicProperties = { 105 | name: topic.name, 106 | keep: topic.flags.keep, 107 | nostay: topic.flags.stay === false, 108 | system: topic.flags.system, 109 | keywords: topic.keywords, 110 | filter: topic.filter, 111 | reply_order: topic.flags.order || null, 112 | reply_exhaustion: topic.flags.keep || null, 113 | }; 114 | 115 | debug.verbose('Creating Topic w/ Settings', topicProperties); 116 | Topic.findOneAndUpdate({ name: topic.name }, topicProperties, { 117 | upsert: true, 118 | setDefaultsOnInsert: true, 119 | new: true, 120 | }, (err, mongoTopic) => { 121 | if (err) { 122 | console.error(err); 123 | } 124 | 125 | async.eachSeries(Object.keys(data.gambits), eachGambitItor(mongoTopic), (err) => { 126 | if (err) { 127 | console.error(err); 128 | } 129 | debug.verbose(`All gambits for ${topic.name} processed.`); 130 | nextTopic(); 131 | }); 132 | }); 133 | }; 134 | 135 | const eachConvItor = function eachConvItor(gambitId) { 136 | return (replyId, nextConv) => { 137 | debug.verbose('conversation/reply: %s', replyId); 138 | Reply.findOne({ id: replyId }, (err, reply) => { 139 | if (err) { 140 | console.error(err); 141 | } 142 | if (reply) { 143 | reply.gambits.addToSet(gambitId); 144 | reply.save((err) => { 145 | if (err) { 146 | console.error(err); 147 | } 148 | reply.sortGambits(() => { 149 | debug.verbose('All conversations for %s processed.', gambitId); 150 | nextConv(); 151 | }); 152 | }); 153 | } else { 154 | debug.warn('No reply found!'); 155 | nextConv(); 156 | } 157 | }); 158 | }; 159 | }; 160 | 161 | debug.info('Cleaning database: removing all data.'); 162 | 163 | // Remove everything before we start importing 164 | async.each([Gambit, Reply, Topic], 165 | (model, nextModel) => { 166 | model.remove({}, err => nextModel()); 167 | }, 168 | (err) => { 169 | async.eachSeries(Object.keys(data.topics), eachTopicItor, () => { 170 | async.eachSeries(_.uniq(gambitsWithConversation), (gambitId, nextGambit) => { 171 | const gambitRawData = data.gambits[gambitId]; 172 | 173 | const conversations = gambitRawData.conversation || []; 174 | if (conversations.length === 0) { 175 | return nextGambit(); 176 | } 177 | 178 | const gambitData = rawToGambitData(gambitId, gambitRawData); 179 | // TODO: gambit.parent should be able to be multiple replies, not just conversations[0] 180 | const replyId = conversations[0]; 181 | 182 | // TODO??: Add reply.addGambit(...) 183 | Reply.findOne({ id: replyId }, (err, reply) => { 184 | if (!reply) { 185 | console.error(`Gambit ${gambitId} is supposed to have conversations (has %), but none were found.`); 186 | nextGambit(); 187 | } 188 | const gambit = new Gambit(gambitData); 189 | async.eachSeries(gambitRawData.replies, eachReplyItor(gambit), (err) => { 190 | debug.verbose('All replies processed.'); 191 | gambit.parent = reply._id; 192 | debug.verbose('Saving new gambit: ', err, gambit); 193 | gambit.save((err, gam) => { 194 | if (err) { 195 | console.log(err); 196 | } 197 | async.mapSeries(conversations, eachConvItor(gam._id), (err, results) => { 198 | debug.verbose('All conversations for %s processed.', gambitId); 199 | nextGambit(); 200 | }); 201 | }); 202 | }); 203 | }); 204 | }, () => { 205 | callback(null, 'done'); 206 | }); 207 | }); 208 | }, 209 | ); 210 | }; 211 | 212 | const importFile = function importFile(chatSystem, path, callback) { 213 | fs.readFile(path, (err, jsonFile) => { 214 | if (err) { 215 | console.log(err); 216 | } 217 | return importData(chatSystem, JSON.parse(jsonFile), callback); 218 | }); 219 | }; 220 | 221 | export default { importFile, importData }; 222 | -------------------------------------------------------------------------------- /src/bot/db/modelNames.js: -------------------------------------------------------------------------------- 1 | const names = { 2 | gambit: 'ss_gambit', 3 | reply: 'ss_reply', 4 | topic: 'ss_topic', 5 | user: 'ss_user', 6 | }; 7 | 8 | export default names; 9 | -------------------------------------------------------------------------------- /src/bot/db/models/gambit.js: -------------------------------------------------------------------------------- 1 | /** 2 | A Gambit is a Trigger + Reply or Reply Set 3 | - We define a Reply as a subDocument in Mongo. 4 | **/ 5 | 6 | import mongoose from 'mongoose'; 7 | import mongoTenant from 'mongo-tenant'; 8 | import debuglog from 'debug-levels'; 9 | import async from 'async'; 10 | import parser from 'ss-parser'; 11 | 12 | import modelNames from '../modelNames'; 13 | import Utils from '../../utils'; 14 | 15 | const debug = debuglog('SS:Gambit'); 16 | 17 | /** 18 | A trigger is the matching rule behind a piece of input. It lives in a topic or several topics. 19 | A trigger also contains one or more replies. 20 | **/ 21 | 22 | const createGambitModel = function createGambitModel(db, factSystem) { 23 | const gambitSchema = new mongoose.Schema({ 24 | id: { type: String, index: true, default: Utils.genId() }, 25 | 26 | // This is the input string that generates a rule, 27 | // In the event we want to export this, we will use this value. 28 | // Make this filed conditionally required if trigger is supplied 29 | input: { type: String }, 30 | 31 | // The Trigger is a partly baked regex. 32 | trigger: { type: String }, 33 | 34 | // If the trigger is a Question Match 35 | isQuestion: { type: Boolean, default: false }, 36 | 37 | // If this gambit is nested inside a conditional block 38 | conditions: [{ type: String, default: '' }], 39 | 40 | // The filter function for the the expression 41 | filter: { type: String, default: '' }, 42 | 43 | // An array of replies. 44 | replies: [{ type: String, ref: modelNames.reply }], 45 | 46 | // How we choose gambits can be `random` or `ordered` 47 | reply_order: { type: String, default: 'random' }, 48 | 49 | // How we handle the reply exhaustion can be `keep` or `exhaust` 50 | reply_exhaustion: { type: String }, 51 | 52 | // Save a reference to the parent Reply, so we can walk back up the tree 53 | parent: { type: String, ref: modelNames.reply }, 54 | 55 | // This will redirect anything that matches elsewhere. 56 | // If you want to have a conditional rediect use reply redirects 57 | // TODO, change the type to a ID and reference another gambit directly 58 | // this will save us a lookup down the road (and improve performace.) 59 | redirect: { type: String, default: '' }, 60 | }); 61 | 62 | gambitSchema.pre('save', function (next) { 63 | // FIXME: This only works when the replies are populated which is not always the case. 64 | // this.replies = _.uniq(this.replies, (item, key, id) => { 65 | // return item.id; 66 | // }); 67 | 68 | // If we created the trigger in an external editor, normalize the trigger before saving it. 69 | if (this.input && !this.trigger) { 70 | const facts = factSystem.getFactSystem(this.getTenantId()); 71 | return parser.normalizeTrigger(this.input, facts, (err, cleanTrigger) => { 72 | this.trigger = cleanTrigger; 73 | next(); 74 | }); 75 | } 76 | next(); 77 | }); 78 | 79 | gambitSchema.methods.addReply = function (replyData, callback) { 80 | if (!replyData) { 81 | return callback('No data'); 82 | } 83 | 84 | const Reply = db.model(modelNames.reply).byTenant(this.getTenantId()); 85 | const reply = new Reply(replyData); 86 | reply.save((err) => { 87 | if (err) { 88 | return callback(err); 89 | } 90 | this.replies.addToSet(reply._id); 91 | this.save((err) => { 92 | callback(err, reply); 93 | }); 94 | }); 95 | }; 96 | 97 | gambitSchema.methods.clearReplies = function (callback) { 98 | const self = this; 99 | 100 | const clearReply = function (replyId, cb) { 101 | self.replies.pull({ _id: replyId }); 102 | db.model(modelNames.reply).byTenant(this.getTenantId()).remove({ _id: replyId }, (err) => { 103 | if (err) { 104 | console.log(err); 105 | } 106 | 107 | debug.verbose('removed reply %s', replyId); 108 | 109 | cb(null, replyId); 110 | }); 111 | }; 112 | 113 | async.map(self.replies, clearReply, (err, clearedReplies) => { 114 | self.save((err2) => { 115 | callback(err2, clearedReplies); 116 | }); 117 | }); 118 | }; 119 | 120 | gambitSchema.plugin(mongoTenant); 121 | 122 | return db.model('ss_gambit', gambitSchema); 123 | }; 124 | 125 | export default createGambitModel; 126 | -------------------------------------------------------------------------------- /src/bot/db/models/reply.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import mongoTenant from 'mongo-tenant'; 3 | import async from 'async'; 4 | 5 | import modelNames from '../modelNames'; 6 | import Utils from '../../utils'; 7 | import Sort from '../sort'; 8 | 9 | const createReplyModel = function createReplyModel(db) { 10 | const replySchema = new mongoose.Schema({ 11 | id: { type: String, index: true, default: Utils.genId() }, 12 | reply: { type: String, required: '{reply} is required.' }, 13 | keep: { type: Boolean, default: false }, 14 | filter: { type: String, default: '' }, 15 | parent: { type: String, ref: modelNames.gambit }, 16 | 17 | // Replies could referece other gambits 18 | // This forms the basis for the 'previous' - These are Children 19 | gambits: [{ type: String, ref: modelNames.gambit }], 20 | }); 21 | 22 | replySchema.methods.sortGambits = function sortGambits(callback) { 23 | const self = this; 24 | const expandReorder = (gambitId, cb) => { 25 | db.model(modelNames.gambit).byTenant(this.getTenantId()).findById(gambitId, (err, gambit) => { 26 | cb(err, gambit); 27 | }); 28 | }; 29 | 30 | async.map(this.gambits, expandReorder, (err, newGambitList) => { 31 | if (err) { 32 | console.log(err); 33 | } 34 | 35 | const newList = Sort.sortTriggerSet(newGambitList); 36 | self.gambits = newList.map(g => g._id); 37 | self.save(callback); 38 | }); 39 | }; 40 | 41 | replySchema.plugin(mongoTenant); 42 | 43 | return db.model(modelNames.reply, replySchema); 44 | }; 45 | 46 | export default createReplyModel; 47 | -------------------------------------------------------------------------------- /src/bot/db/models/topic.js: -------------------------------------------------------------------------------- 1 | /** 2 | Topics are a grouping of gambits. 3 | The order of the Gambits are important, and a gambit can live in more than one topic. 4 | **/ 5 | 6 | import mongoose from 'mongoose'; 7 | import mongoTenant from 'mongo-tenant'; 8 | import async from 'async'; 9 | import debuglog from 'debug-levels'; 10 | 11 | import modelNames from '../modelNames'; 12 | import Sort from '../sort'; 13 | 14 | const debug = debuglog('SS:Topics'); 15 | 16 | const createTopicModel = function createTopicModel(db) { 17 | const topicSchema = new mongoose.Schema({ 18 | name: { type: String, index: true, unique: true }, 19 | 20 | system: { type: Boolean, default: false }, 21 | nostay: { type: Boolean, default: false }, 22 | filter: { type: String, default: '' }, 23 | keywords: { type: Array }, 24 | 25 | // How we choose gambits can be `random` or `ordered` 26 | reply_order: { type: String, default: 'random' }, 27 | 28 | // How we handle the reply exhaustion can be `keep` or `exhaust` 29 | reply_exhaustion: { type: String }, 30 | 31 | gambits: [{ type: String, ref: modelNames.gambit }], 32 | }); 33 | 34 | // This will create the Gambit and add it to the model 35 | topicSchema.methods.createGambit = function (gambitData, callback) { 36 | if (!gambitData) { 37 | return callback('No data'); 38 | } 39 | 40 | const Gambit = db.model(modelNames.gambit).byTenant(this.getTenantId()); 41 | const gambit = new Gambit(gambitData); 42 | gambit.save((err) => { 43 | if (err) { 44 | return callback(err); 45 | } 46 | this.gambits.addToSet(gambit._id); 47 | this.save((err) => { 48 | callback(err, gambit); 49 | }); 50 | }); 51 | }; 52 | 53 | topicSchema.methods.sortGambits = function (callback) { 54 | const expandReorder = (gambitId, cb) => { 55 | db.model(modelNames.gambit).byTenant(this.getTenantId()).findById(gambitId, (err, gambit) => { 56 | if (err) { 57 | console.log(err); 58 | } 59 | cb(null, gambit); 60 | }); 61 | }; 62 | 63 | async.map(this.gambits, expandReorder, (err, newGambitList) => { 64 | if (err) { 65 | console.log(err); 66 | } 67 | 68 | const newList = Sort.sortTriggerSet(newGambitList); 69 | this.gambits = newList.map(gambit => gambit._id); 70 | this.save(callback); 71 | }); 72 | }; 73 | 74 | topicSchema.methods.clearGambits = function (callback) { 75 | const clearGambit = (gambitId, cb) => { 76 | this.gambits.pull({ _id: gambitId }); 77 | db.model(modelNames.gambit).byTenant(this.getTenantId()).findById(gambitId, (err, gambit) => { 78 | if (err) { 79 | debug.error(err); 80 | } 81 | 82 | gambit.clearReplies(() => { 83 | db.model(modelNames.gambit).byTenant(this.getTenantId()).remove({ _id: gambitId }, (err) => { 84 | if (err) { 85 | debug.error(err); 86 | } 87 | 88 | debug.verbose('removed gambit %s', gambitId); 89 | 90 | cb(null, gambitId); 91 | }); 92 | }); 93 | }); 94 | }; 95 | 96 | async.map(this.gambits, clearGambit, (err, clearedGambits) => { 97 | this.save((err) => { 98 | callback(err, clearedGambits); 99 | }); 100 | }); 101 | }; 102 | 103 | topicSchema.statics.findByName = function (name, callback) { 104 | this.findOne({ name }, {}, callback); 105 | }; 106 | 107 | topicSchema.plugin(mongoTenant); 108 | 109 | return db.model(modelNames.topic, topicSchema); 110 | }; 111 | 112 | export default createTopicModel; 113 | -------------------------------------------------------------------------------- /src/bot/db/models/user.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | import mongoose from 'mongoose'; 4 | import mongoTenant from 'mongo-tenant'; 5 | 6 | import modelNames from '../modelNames'; 7 | 8 | const debug = debuglog('SS:User'); 9 | 10 | const createUserModel = function createUserModel(db, factSystem, logger) { 11 | const userSchema = mongoose.Schema({ 12 | id: String, 13 | currentTopic: { type: String, default: 'random' }, 14 | pendingTopic: String, 15 | lastMessageSentAt: Date, 16 | prevAns: Number, 17 | conversationState: Object, 18 | history: [{ 19 | input: Object, 20 | reply: Object, 21 | topic: Object, 22 | stars: Object, 23 | }], 24 | }); 25 | 26 | userSchema.set('versionKey', false); 27 | 28 | userSchema.pre('save', function (next) { 29 | debug.verbose('Pre-Save Hook'); 30 | // save a full log of user conversations, but just in case a user has a 31 | // super long conversation, don't take up too much storage space 32 | this.history = this.history.slice(0, 500); 33 | next(); 34 | }); 35 | 36 | userSchema.methods.clearConversationState = function (callback) { 37 | this.conversationState = {}; 38 | this.save(callback); 39 | }; 40 | 41 | userSchema.methods.setTopic = async function (topic = '') { 42 | debug.verbose('Set topic', topic); 43 | 44 | if (topic === '') { 45 | debug.warn('Trying to set topic to something invalid'); 46 | return; 47 | } 48 | 49 | this.pendingTopic = topic; 50 | await this.save(); 51 | debug.verbose('Set topic Complete'); 52 | }; 53 | 54 | userSchema.methods.getTopic = function () { 55 | debug.verbose('getTopic', this.currentTopic); 56 | return this.currentTopic; 57 | }; 58 | 59 | userSchema.methods.updateHistory = function (message, reply, cb) { 60 | if (!_.isNull(message)) { 61 | this.lastMessageSentAt = Date.now(); 62 | } 63 | 64 | const log = { 65 | user_id: this.id, 66 | raw_input: message.original, 67 | normalized_input: message.clean, 68 | matched_gambit: reply.debug, 69 | final_output: reply.original, 70 | timestamp: message.createdAt, 71 | }; 72 | 73 | const cleanId = this.id.replace(/\W/g, ''); 74 | logger.log(`${JSON.stringify(log)}\r\n`, `${cleanId}_trans.txt`); 75 | 76 | debug.verbose('Updating History'); 77 | 78 | const stars = reply.stars; 79 | 80 | const messageToSave = { 81 | original: message.original, 82 | clean: message.clean, 83 | timestamp: message.createdAt, 84 | }; 85 | 86 | reply.createdAt = Date.now(); 87 | 88 | this.history.unshift({ 89 | stars, 90 | input: messageToSave, 91 | reply, 92 | topic: this.currentTopic, 93 | }); 94 | 95 | if (this.pendingTopic !== undefined && this.pendingTopic !== '') { 96 | const pendingTopic = this.pendingTopic; 97 | this.pendingTopic = null; 98 | 99 | db.model(modelNames.topic).byTenant(this.getTenantId()).findOne({ name: pendingTopic }, (err, topicData) => { 100 | if (topicData && topicData.nostay === true) { 101 | this.currentTopic = this.history[0].topic; 102 | } else { 103 | this.currentTopic = pendingTopic; 104 | } 105 | this.save(function(err){ 106 | if (err) { 107 | console.error(err); 108 | } 109 | debug.verbose('Saved user'); 110 | cb(err, log); 111 | }); 112 | }); 113 | } else { 114 | cb(null, log); 115 | } 116 | }; 117 | 118 | userSchema.methods.getVar = function (key, cb) { 119 | debug.verbose('getVar', key); 120 | 121 | this.memory.db.get({ subject: key, predicate: this.id }, (err, res) => { 122 | if (res && res.length !== 0) { 123 | cb(err, res[0].object); 124 | } else { 125 | cb(err, null); 126 | } 127 | }); 128 | }; 129 | 130 | userSchema.methods.setVar = function (key, value, cb) { 131 | debug.verbose('setVar', key, value); 132 | const self = this; 133 | 134 | self.memory.db.get({ subject: key, predicate: self.id }, (err, results) => { 135 | if (err) { 136 | console.log(err); 137 | } 138 | 139 | if (!_.isEmpty(results)) { 140 | self.memory.db.del(results[0], () => { 141 | const opt = { subject: key, predicate: self.id, object: value }; 142 | self.memory.db.put(opt, () => { 143 | cb(); 144 | }); 145 | }); 146 | } else { 147 | const opt = { subject: key, predicate: self.id, object: value }; 148 | self.memory.db.put(opt, (err2) => { 149 | if (err2) { 150 | console.log(err2); 151 | } 152 | 153 | cb(); 154 | }); 155 | } 156 | }); 157 | }; 158 | 159 | userSchema.plugin(mongoTenant); 160 | 161 | userSchema.virtual('memory').get(function () { 162 | return factSystem.getFactSystem(this.getTenantId()).createUserDB(this.id); 163 | }); 164 | 165 | return db.model(modelNames.user, userSchema); 166 | }; 167 | 168 | export default createUserModel; 169 | -------------------------------------------------------------------------------- /src/bot/db/sort.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug'; 2 | import Utils from '../utils'; 3 | 4 | const debug = debuglog('Sort'); 5 | 6 | const initSortTrack = function initSortTrack() { 7 | return { 8 | atomic: {}, // Sort by number of whole words 9 | option: {}, // Sort optionals by number of words 10 | alpha: {}, // Sort alpha wildcards by no. of words 11 | number: {}, // Sort number wildcards by no. of words 12 | wild: {}, // Sort wildcards by no. of words 13 | pound: [], // Triggers of just # 14 | under: [], // Triggers of just _ 15 | star: [], // Triggers of just * 16 | }; 17 | }; 18 | 19 | const sortTriggerSet = function sortTriggerSet(gambits) { 20 | let gambit; 21 | let cnt; 22 | let inherits; 23 | 24 | const lengthSort = (a, b) => (b.length - a.length); 25 | 26 | // Create a priority map. 27 | const prior = { 28 | 0: [], // Default priority = 0 29 | }; 30 | 31 | // Sort triggers by their weights. 32 | for (let i = 0; i < gambits.length; i++) { 33 | gambit = gambits[i]; 34 | const match = gambit.input.match(/\{weight=(\d+)\}/i); 35 | let weight = 0; 36 | if (match && match[1]) { 37 | weight = match[1]; 38 | } 39 | 40 | if (!prior[weight]) { 41 | prior[weight] = []; 42 | } 43 | prior[weight].push(gambit); 44 | } 45 | 46 | const sortFwd = (a, b) => (b - a); 47 | const sortRev = (a, b) => (a - b); 48 | 49 | // Keep a running list of sorted triggers for this topic. 50 | const running = []; 51 | 52 | // Sort them by priority. 53 | const priorSort = Object.keys(prior).sort(sortFwd); 54 | 55 | for (let i = 0; i < priorSort.length; i++) { 56 | const p = priorSort[i]; 57 | debug(`Sorting triggers with priority ${p}`); 58 | 59 | // Loop through and categorize these triggers. 60 | const track = {}; 61 | 62 | for (let j = 0; j < prior[p].length; j++) { 63 | gambit = prior[p][j]; 64 | 65 | inherits = -1; 66 | if (!track[inherits]) { 67 | track[inherits] = initSortTrack(); 68 | } 69 | 70 | if (gambit.input.indexOf('*') > -1) { 71 | // Wildcard included. 72 | cnt = Utils.wordCount(gambit.input); 73 | debug(`Has a * wildcard with ${cnt} words.`); 74 | if (cnt > 1) { 75 | if (!track[inherits].wild[cnt]) { 76 | track[inherits].wild[cnt] = []; 77 | } 78 | track[inherits].wild[cnt].push(gambit); 79 | } else { 80 | track[inherits].star.push(gambit); 81 | } 82 | } else if (gambit.input.indexOf('[') > -1) { 83 | // Optionals included. 84 | cnt = Utils.wordCount(gambit.input); 85 | debug(`Has optionals with ${cnt} words.`); 86 | if (!track[inherits].option[cnt]) { 87 | track[inherits].option[cnt] = []; 88 | } 89 | track[inherits].option[cnt].push(gambit); 90 | } else { 91 | // Totally atomic. 92 | cnt = Utils.wordCount(gambit.input); 93 | debug(`Totally atomic trigger and ${cnt} words.`); 94 | if (!track[inherits].atomic[cnt]) { 95 | track[inherits].atomic[cnt] = []; 96 | } 97 | track[inherits].atomic[cnt].push(gambit); 98 | } 99 | } 100 | 101 | // Move the no-{inherits} triggers to the bottom of the stack. 102 | track[0] = track['-1']; 103 | delete track['-1']; 104 | 105 | // Add this group to the sort list. 106 | const trackSorted = Object.keys(track).sort(sortRev); 107 | 108 | for (let j = 0; j < trackSorted.length; j++) { 109 | const ip = trackSorted[j]; 110 | debug(`ip=${ip}`); 111 | 112 | const kinds = ['atomic', 'option', 'alpha', 'number', 'wild']; 113 | for (let k = 0; k < kinds.length; k++) { 114 | const kind = kinds[k]; 115 | 116 | const kindSorted = Object.keys(track[ip][kind]).sort(sortFwd); 117 | 118 | for (let l = 0; l < kindSorted.length; l++) { 119 | const item = kindSorted[l]; 120 | running.push(...track[ip][kind][item]); 121 | } 122 | } 123 | 124 | // We can sort these using Array.sort 125 | const underSorted = track[ip].under.sort(lengthSort); 126 | const poundSorted = track[ip].pound.sort(lengthSort); 127 | const starSorted = track[ip].star.sort(lengthSort); 128 | 129 | running.push(...underSorted); 130 | running.push(...poundSorted); 131 | running.push(...starSorted); 132 | } 133 | } 134 | return running; 135 | }; 136 | 137 | export default { 138 | sortTriggerSet, 139 | }; 140 | -------------------------------------------------------------------------------- /src/bot/factSystem.js: -------------------------------------------------------------------------------- 1 | import facts from 'sfacts'; 2 | 3 | const decorateFactSystem = function decorateFactSystem(factSystem) { 4 | const getFactSystem = function getFactSystem(tenantId = 'master') { 5 | return factSystem.createUserDB(`${tenantId}`); 6 | }; 7 | 8 | return { getFactSystem }; 9 | }; 10 | 11 | const setupFactSystem = function setupFactSystem(mongoURI, { clean, importData }, callback) { 12 | // TODO: On a multitenanted system, importing data should not do anything 13 | if (importData) { 14 | return facts.load(mongoURI, importData, clean, (err, factSystem) => { 15 | callback(err, decorateFactSystem(factSystem)); 16 | }); 17 | } 18 | return facts.create(mongoURI, clean, (err, factSystem) => { 19 | callback(err, decorateFactSystem(factSystem)); 20 | }); 21 | }; 22 | 23 | export default { 24 | setupFactSystem, 25 | }; 26 | -------------------------------------------------------------------------------- /src/bot/getReply/filterFunction.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | 4 | import processTags from '../processTags'; 5 | import Utils from '../utils'; 6 | 7 | const debug = debuglog('SS:FilterFunction'); 8 | 9 | const filterRepliesByFunction = async function filterRepliesByFunction(potentialReplies, options) { 10 | const bits = await Promise.all(potentialReplies.map(async (potentialReply) => { 11 | const system = options.system; 12 | 13 | // We support a single filter function in the reply 14 | // It returns true/false to aid in the selection. 15 | 16 | if (potentialReply.reply.filter) { 17 | const stars = { stars: potentialReply.stars }; 18 | const cleanFilter = await processTags.preprocess(potentialReply.reply.filter, stars, options); 19 | 20 | debug.verbose(`Reply filter function found: ${cleanFilter}`); 21 | 22 | const filterScope = _.merge({}, system.scope); 23 | filterScope.user = options.user; 24 | filterScope.message = options.message; 25 | filterScope.message_props = options.system.extraScope; 26 | 27 | try { 28 | const [filterReply] = await Utils.runPluginFunc(cleanFilter, filterScope, system.plugins); 29 | if (filterReply === 'true' || filterReply === true) { 30 | return true; 31 | } 32 | return false; 33 | } catch (err) { 34 | console.error(err); 35 | return false; 36 | } 37 | } 38 | 39 | return true; 40 | })); 41 | 42 | potentialReplies = potentialReplies.filter(() => bits.shift()); 43 | 44 | return potentialReplies; 45 | }; 46 | 47 | export default filterRepliesByFunction; 48 | -------------------------------------------------------------------------------- /src/bot/getReply/filterSeen.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug-levels'; 2 | 3 | const debug = debuglog('SS:FilterSeen'); 4 | 5 | // This may be called several times, once for each topic. 6 | const filterRepliesBySeen = async function filterRepliesBySeen(filteredResults, options) { 7 | debug.verbose('filterRepliesBySeen', filteredResults); 8 | 9 | const bucket = filteredResults.map((filteredResult) => { 10 | const replyId = filteredResult.reply._id; 11 | if (!filteredResult.seenCount) { 12 | filteredResult.seenCount = 0; 13 | } 14 | options.user.history.map((historyItem) => { 15 | if (historyItem.topic !== undefined) { 16 | const pastGambit = historyItem.reply; 17 | const pastInput = historyItem.input; 18 | 19 | if (pastGambit && pastInput) { 20 | if (pastGambit.replyIds && pastGambit.replyIds.find(id => String(id) === String(replyId))) { 21 | debug.verbose('Already Seen', filteredResult.reply); 22 | filteredResult.seenCount += 1; 23 | } 24 | } 25 | } 26 | }); 27 | return filteredResult; 28 | }); 29 | return bucket; 30 | }; 31 | 32 | export default filterRepliesBySeen; 33 | -------------------------------------------------------------------------------- /src/bot/getReply/getPendingTopics.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | import natural from 'natural'; 4 | 5 | import helpers from './helpers'; 6 | 7 | const debug = debuglog('SS:Topics'); 8 | 9 | const TfIdf = natural.TfIdf; 10 | 11 | natural.PorterStemmer.attach(); 12 | 13 | // Function to score the topics by TF-IDF 14 | const scoreTopics = function scoreTopics(message, tfidf) { 15 | let topics = []; 16 | const tasMessage = message.lemString.tokenizeAndStem(); 17 | debug.verbose('Tokenised and stemmed words: ', tasMessage); 18 | 19 | // Score the input against the topic keywords to come up with a topic order. 20 | tfidf.tfidfs(tasMessage, (index, score, name) => { 21 | // Filter out system topic pre/post 22 | if (name !== '__pre__' && name !== '__post__') { 23 | topics.push({ name, score, type: 'TOPIC' }); 24 | } 25 | }); 26 | 27 | // Removes duplicate entries. 28 | topics = _.uniqBy(topics, 'name'); 29 | 30 | const topicOrder = _.sortBy(topics, 'score').reverse(); 31 | debug.verbose('Scored topics: ', topicOrder); 32 | 33 | return topicOrder; 34 | }; 35 | 36 | const removeMissingTopics = function removeMissingTopics(topics) { 37 | return _.filter(topics, topic => topic.id); 38 | }; 39 | 40 | const findConversationTopics = async function findConversationTopics(pendingTopics, user, chatSystem, conversationTimeout) { 41 | if (user.history.length === 0) { 42 | return pendingTopics; 43 | } 44 | 45 | // If we are currently in a conversation, we want the entire chain added 46 | // to the topics to search 47 | const lastReply = user.history[0].reply; 48 | if (!_.isEmpty(lastReply)) { 49 | // If the message is less than _ minutes old we continue 50 | const delta = Date.now() - lastReply.createdAt; 51 | if (delta <= conversationTimeout) { 52 | debug.verbose(`Last reply string: ${lastReply.original}`); 53 | debug.verbose(`Last reply sequence: ${lastReply.replyIds}`); 54 | debug.verbose(`Clear conversation: ${lastReply.clearConversation}`); 55 | 56 | if (lastReply.clearConversation) { 57 | debug.verbose('Conversation RESET since clearConversation was true'); 58 | return pendingTopics; 59 | } 60 | 61 | const replies = await chatSystem.Reply.find({ _id: { $in: lastReply.replyIds } }) 62 | .lean() 63 | .exec(); 64 | if (replies === []) { 65 | debug.verbose("We couldn't match the last reply. Continuing."); 66 | return pendingTopics; 67 | } 68 | 69 | let replyThreads = []; 70 | 71 | await Promise.all(replies.map(async (reply) => { 72 | const threads = await helpers.walkReplyParent(reply._id, chatSystem); 73 | debug.verbose(`Threads found by walkReplyParent: ${threads}`); 74 | threads.forEach(thread => replyThreads.push(thread)); 75 | })); 76 | 77 | replyThreads = replyThreads.map(item => ({ id: item, type: 'REPLY' })); 78 | // This inserts the array replyThreads into pendingTopics after the first topic 79 | pendingTopics.splice(1, 0, ...replyThreads); 80 | return pendingTopics; 81 | } 82 | 83 | debug.info('The conversation thread was to old to continue it.'); 84 | return pendingTopics; 85 | } 86 | }; 87 | 88 | export const findPendingTopicsForUser = async function findPendingTopicsForUser(user, message, chatSystem, conversationTimeout) { 89 | const allTopics = await chatSystem.Topic.find({}).lean().exec(); 90 | 91 | const tfidf = new TfIdf(); 92 | 93 | allTopics.forEach((topic) => { 94 | const keywords = topic.keywords.join(' '); 95 | if (keywords) { 96 | tfidf.addDocument(keywords.tokenizeAndStem(), topic.name); 97 | } 98 | }); 99 | 100 | const scoredTopics = scoreTopics(message, tfidf); 101 | 102 | const currentTopic = user.getTopic(); 103 | 104 | // Add the current topic to the front of the array. 105 | scoredTopics.unshift({ name: currentTopic, type: 'TOPIC' }); 106 | 107 | let otherTopics = _.map(allTopics, topic => 108 | ({ id: topic._id, name: topic.name, system: topic.system }), 109 | ); 110 | 111 | // This gets a list if all the remaining topics. 112 | otherTopics = _.filter(otherTopics, topic => 113 | !_.find(scoredTopics, { name: topic.name }), 114 | ); 115 | 116 | // We remove the system topics 117 | otherTopics = _.filter(otherTopics, topic => 118 | topic.system === false, 119 | ); 120 | 121 | const pendingTopics = []; 122 | pendingTopics.push({ name: '__pre__', type: 'TOPIC' }); 123 | 124 | for (let i = 0; i < scoredTopics.length; i++) { 125 | if (scoredTopics[i].name !== '__pre__' && scoredTopics[i].name !== '__post__') { 126 | pendingTopics.push(scoredTopics[i]); 127 | } 128 | } 129 | 130 | // Search random as the highest priority after current topic and pre 131 | if (!_.find(pendingTopics, { name: 'random' }) && _.find(otherTopics, { name: 'random' })) { 132 | pendingTopics.push({ name: 'random', type: 'TOPIC' }); 133 | } 134 | 135 | for (let i = 0; i < otherTopics.length; i++) { 136 | if (otherTopics[i].name !== '__pre__' && otherTopics[i].name !== '__post__') { 137 | otherTopics[i].type = 'TOPIC'; 138 | pendingTopics.push(otherTopics[i]); 139 | } 140 | } 141 | 142 | pendingTopics.push({ name: '__post__', type: 'TOPIC' }); 143 | 144 | debug.verbose(`Pending topics before conversations: ${JSON.stringify(pendingTopics, null, 2)}`); 145 | 146 | // Lets assign the ids to the topics 147 | for (let i = 0; i < pendingTopics.length; i++) { 148 | const topicName = pendingTopics[i].name; 149 | for (let n = 0; n < allTopics.length; n++) { 150 | if (allTopics[n].name === topicName) { 151 | pendingTopics[i].id = allTopics[n]._id; 152 | } 153 | } 154 | } 155 | 156 | const allFoundTopics = await findConversationTopics(pendingTopics, user, chatSystem, conversationTimeout); 157 | return removeMissingTopics(allFoundTopics); 158 | }; 159 | 160 | const getPendingTopics = async function getPendingTopics(messageObject, options) { 161 | // We already have a pre-set list of potential topics from directReply, respond or topicRedirect 162 | if (!_.isEmpty(_.reject(options.pendingTopics, _.isNull))) { 163 | debug.verbose('Using pre-set topic list via directReply, respond or topicRedirect'); 164 | debug.info('Topics to check: ', options.pendingTopics.map(topic => topic.name)); 165 | return options.pendingTopics; 166 | } 167 | 168 | // Find potential topics for the response based on the message (tfidfs) 169 | return findPendingTopicsForUser( 170 | options.user, 171 | messageObject, 172 | options.system.chatSystem, 173 | options.system.conversationTimeout, 174 | ); 175 | }; 176 | 177 | export default getPendingTopics; 178 | -------------------------------------------------------------------------------- /src/bot/getReply/helpers.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | import safeEval from 'safe-eval'; 4 | 5 | import postParse from '../postParse'; 6 | import Utils from '../utils'; 7 | 8 | const debug = debuglog('SS:Helpers'); 9 | 10 | // This will find all the gambits to process by parent (topic or conversation) 11 | // and return ones that match the message 12 | const findMatchingGambitsForMessage = async function findMatchingGambitsForMessage(type, parent, message, options) { 13 | const matches = await Promise.all(parent.gambits.map(async (gambit) => { 14 | const match = await eachGambitHandle(gambit, message, options); 15 | return match; 16 | })); 17 | 18 | return _.flatten(matches); 19 | }; 20 | 21 | 22 | const processStars = function processStars(match, gambit, topic) { 23 | debug.verbose(`Match found: ${gambit.input} in topic: ${topic}`); 24 | const stars = []; 25 | if (match.length > 1) { 26 | for (let j = 1; j < match.length; j++) { 27 | if (match[j]) { 28 | let starData = Utils.trim(match[j]); 29 | // Concepts are not allowed to be stars or captured input. 30 | starData = (starData[0] === '~') ? starData.substr(1) : starData; 31 | stars.push(starData); 32 | } 33 | } 34 | } 35 | 36 | const data = { stars, gambit }; 37 | if (topic !== 'reply') { 38 | data.topic = topic; 39 | } 40 | 41 | const matches = [data]; 42 | return matches; 43 | }; 44 | 45 | /* This is a function to determine whether a certain key has been set to a certain value. 46 | * The double percentage sign (%%) syntax is used in the script to denote that a gambit 47 | * must meet a condition before being executed, e.g. 48 | * 49 | * %% (userKilledAlice === true) 50 | * + I love you. 51 | * - I still haven't forgiven you, you know. 52 | * 53 | * The context is whatever a user has previously set in any replies. So in this example, 54 | * if a user has set {userKilledAlice = true}, then the gambit is matched. 55 | */ 56 | const processConditions = function processConditions(conditions, options) { 57 | const context = options.user.conversationState || {}; 58 | 59 | return _.every(conditions, (condition) => { 60 | debug.verbose('Check condition - Context: ', context); 61 | debug.verbose('Check condition - Condition: ', condition); 62 | 63 | try { 64 | const result = safeEval(condition, context); 65 | if (result) { 66 | debug.verbose('--- Condition TRUE ---'); 67 | return true; 68 | } 69 | debug.verbose('--- Condition FALSE ---'); 70 | return false; 71 | } catch (e) { 72 | debug.verbose(`Error in condition checking: ${e.stack}`); 73 | return false; 74 | } 75 | }); 76 | }; 77 | 78 | /** 79 | * Takes a gambit and a message, and returns non-null if they match. 80 | */ 81 | export const doesMatch = async function doesMatch(gambit, message, options) { 82 | if (gambit.conditions && gambit.conditions.length > 0) { 83 | const conditionsMatch = processConditions(gambit.conditions, options); 84 | if (!conditionsMatch) { 85 | debug.verbose('Conditions did not match'); 86 | return false; 87 | } 88 | } 89 | 90 | let match = false; 91 | 92 | // Replace , etc. with the actual words in user message 93 | const regexp = postParse(gambit.trigger, message, options.user); 94 | 95 | const pattern = new RegExp(`^${regexp}$`, 'i'); 96 | 97 | debug.verbose(`Try to match (clean)'${message.clean}' against '${gambit.trigger}' (${pattern})`); 98 | debug.verbose(`Try to match (lemma)'${message.lemString}' against '${gambit.trigger}' (${pattern})`); 99 | 100 | // Match on isQuestion 101 | if (gambit.isQuestion && message.isQuestion) { 102 | debug.verbose('Gambit and message are questions, testing against question types'); 103 | match = message.clean.match(pattern); 104 | if (!match) { 105 | match = message.lemString.match(pattern); 106 | } 107 | } else if (!gambit.isQuestion) { 108 | match = message.clean.match(pattern); 109 | if (!match) { 110 | match = message.lemString.match(pattern); 111 | } 112 | } 113 | 114 | debug.verbose(`Match at the end of doesMatch was: ${match}`); 115 | 116 | return match; 117 | }; 118 | 119 | // TODO: This only exists for testing, ideally we should get rid of this 120 | export const doesMatchTopic = async function doesMatchTopic(topicName, message, options) { 121 | const topic = await options.chatSystem.Topic.findOne({ name: topicName }, 'gambits') 122 | .populate('gambits') 123 | .lean() 124 | .exec(); 125 | 126 | return Promise.all(topic.gambits.map(async gambit => ( 127 | doesMatch(gambit, message, options) 128 | ))); 129 | }; 130 | 131 | // This is the main function that looks for a matching entry 132 | // This takes a gambit that is a child of a topic or reply and checks if 133 | // it matches the user's message or not. 134 | const eachGambitHandle = async function eachGambitHandle(gambit, message, options) { 135 | const plugins = options.system.plugins; 136 | const scope = options.system.scope; 137 | const topic = options.topic || 'reply'; 138 | const chatSystem = options.system.chatSystem; 139 | 140 | const match = await doesMatch(gambit, message, options); 141 | if (!match) { 142 | return []; 143 | } 144 | 145 | // A filter is syntax that calls a plugin function such as: 146 | // - {^functionX(true)} Yes, you are. 147 | if (gambit.filter) { 148 | debug.verbose(`We have a filter function: ${gambit.filter}`); 149 | 150 | // The filterScope is what 'this' is during the execution of the plugin. 151 | // This is so you can write plugins that can access, e.g. this.user or this.chatSystem 152 | // Here we augment the global scope (system.scope) with any additional local scope for 153 | // the current reply. 154 | const filterScope = _.merge({}, scope); 155 | filterScope.message = message; 156 | // filterScope.message_props = options.localOptions.messageScope; 157 | filterScope.user = options.user; 158 | 159 | let filterReply; 160 | try { 161 | [filterReply] = await Utils.runPluginFunc(gambit.filter, filterScope, plugins); 162 | } catch (err) { 163 | console.error(err); 164 | return []; 165 | } 166 | 167 | debug.verbose(`Reply from filter function was: ${filterReply}`); 168 | 169 | if (filterReply !== 'true' && filterReply !== true) { 170 | debug.verbose('Gambit is not matched since the filter function returned false'); 171 | return []; 172 | } 173 | } 174 | 175 | if (gambit.redirect !== '') { 176 | debug.verbose('Gambit has a redirect', topic); 177 | // FIXME: ensure this works 178 | const redirectedGambit = await chatSystem.Gambit.findOne({ input: gambit.redirect }) 179 | .populate({ path: 'replies' }) 180 | .lean() 181 | .exec(); 182 | return processStars(match, redirectedGambit, topic); 183 | } 184 | 185 | // Tag the message with the found Trigger we matched on 186 | message.gambitId = gambit._id; 187 | return processStars(match, gambit, topic); 188 | }; 189 | 190 | const walkGambitParent = async function walkGambitParent(gambitId, chatSystem) { 191 | const gambitIds = []; 192 | try { 193 | const gambit = await chatSystem.Gambit.findById(gambitId, '_id parent') 194 | .populate('parent') 195 | .lean() 196 | .exec(); 197 | debug.verbose('Walk', gambit); 198 | 199 | if (gambit) { 200 | gambitIds.push(gambit._id); 201 | if (gambit.parent && gambit.parent.parent) { 202 | const parents = await walkGambitParent(gambit.parent.parent, chatSystem); 203 | return gambitIds.concat(parents); 204 | } 205 | } 206 | } catch (err) { 207 | console.error(err); 208 | } 209 | return gambitIds; 210 | }; 211 | 212 | const walkReplyParent = async function walkReplyParent(replyId, chatSystem) { 213 | const replyIds = []; 214 | try { 215 | const reply = await chatSystem.Reply.findById(replyId, '_id parent') 216 | .populate('parent') 217 | .lean() 218 | .exec(); 219 | debug.verbose('Walk', reply); 220 | 221 | if (reply) { 222 | replyIds.push(reply._id); 223 | if (reply.parent && reply.parent.parent) { 224 | const parents = await walkReplyParent(reply.parent.parent, chatSystem); 225 | return replyIds.concat(parents); 226 | } 227 | } 228 | } catch (err) { 229 | console.error(err); 230 | } 231 | return replyIds; 232 | }; 233 | 234 | const getRootTopic = async function getRootTopic(gambit, chatSystem) { 235 | if (!gambit.parent) { 236 | return chatSystem.Topic.findOne({ gambits: { $in: [gambit._id] } }).lean().exec(); 237 | } 238 | 239 | const gambits = await walkGambitParent(gambit._id, chatSystem); 240 | if (gambits.length !== 0) { 241 | return chatSystem.Topic.findOne({ gambits: { $in: [gambits.pop()] } }).lean().exec(); 242 | } 243 | 244 | return chatSystem.Topic.findOne({ name: 'random' }).lean().exec(); 245 | }; 246 | 247 | export default { 248 | findMatchingGambitsForMessage, 249 | getRootTopic, 250 | walkReplyParent, 251 | }; 252 | -------------------------------------------------------------------------------- /src/bot/getReply/processReplyTags.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | 4 | import processTags from '../processTags'; 5 | 6 | const debug = debuglog('SS:GetReply:ProcessTags'); 7 | 8 | const processReplyTags = async function processReplyTags(reply, options) { 9 | let replyObj; 10 | try { 11 | replyObj = await processTags.processReplyTags(reply, options); 12 | } catch (err) { 13 | debug.verbose('There was an error in processTags: ', err); 14 | } 15 | 16 | if (!_.isEmpty(replyObj)) { 17 | // reply is the selected reply object that we created earlier (wrapped mongoDB reply) 18 | // reply.reply is the actual mongoDB reply object 19 | // reply.reply.reply is the reply string 20 | replyObj.matched_reply_string = reply.reply.reply; 21 | replyObj.matched_topic_string = reply.topic; 22 | 23 | debug.verbose('Reply object after processing tags: ', replyObj); 24 | 25 | return replyObj; 26 | } 27 | 28 | debug.verbose('No reply object was received from processTags so check for more.'); 29 | return null; 30 | }; 31 | 32 | export default processReplyTags; 33 | -------------------------------------------------------------------------------- /src/bot/logger.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import mkdirp from 'mkdirp'; 3 | 4 | class Logger { 5 | constructor(logPath) { 6 | if (logPath) { 7 | try { 8 | mkdirp.sync(logPath); 9 | this.logPath = logPath; 10 | } catch (e) { 11 | console.error(`Could not create logs folder at ${logPath}: ${e}`); 12 | } 13 | } 14 | } 15 | 16 | log(message, logName = 'default') { 17 | if (this.logPath) { 18 | const filePath = `${this.logPath}/${logName}.log`; 19 | try { 20 | fs.appendFileSync(filePath, message); 21 | } catch (e) { 22 | console.error(`Could not write log to file with path: ${filePath}`); 23 | } 24 | } 25 | } 26 | } 27 | 28 | export default Logger; 29 | -------------------------------------------------------------------------------- /src/bot/postParse.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const searchRE = /<(name|noun|adverb|verb|pronoun|adjective)(s|[0-9]+)?>/g; 4 | const inputReplyRE = /<(input|reply)([1-9])?>/g; 5 | 6 | /** 7 | * This function replaces syntax in the trigger such as: 8 | * 9 | * with the respective word in the user's message. 10 | * 11 | * - `` gets replaced by `(replacements[0])` 12 | * - `` gets replaced by `(replacements[0]|replacements[1]|...)` 13 | * - `` gets replaced by `(replacements[N])` 14 | * 15 | * This function contains the user object so it may be contextual to this user. 16 | */ 17 | const postParse = function postParse(regexp, message, user) { 18 | if (_.isNull(regexp)) { 19 | return null; 20 | } 21 | 22 | regexp = regexp.replace(searchRE, (match, p1, p2) => { 23 | let replacements = null; 24 | 25 | switch (p1) { 26 | case 'name': replacements = message.names; break; 27 | case 'noun': replacements = message.nouns; break; 28 | case 'adverb': replacements = message.adverbs; break; 29 | case 'verb': replacements = message.verbs; break; 30 | case 'pronoun': replacements = message.pronouns; break; 31 | case 'adjective': replacements = message.adjectives; break; 32 | default: break; 33 | } 34 | 35 | if (replacements.length > 0) { 36 | if (p2 === 's') { 37 | return `(${replacements.join('|')})`; 38 | } 39 | 40 | let index = Number.parseInt(p2); 41 | index = index ? index - 1 : 0; 42 | if (index < replacements.length) { 43 | return `(${replacements[index]})`; 44 | } 45 | } 46 | 47 | return ''; 48 | }); 49 | 50 | if (user && user.history) { 51 | const history = user.history; 52 | regexp = regexp.replace(inputReplyRE, (match, p1, p2) => { 53 | const index = p2 ? Number.parseInt(p2) : 0; 54 | return history[index][p1] ? history[index][p1].original : match; 55 | }); 56 | } 57 | 58 | return regexp; 59 | }; 60 | 61 | export default postParse; 62 | -------------------------------------------------------------------------------- /src/bot/regexes.js: -------------------------------------------------------------------------------- 1 | // Standard regular expressions that can be reused throughout the codebase 2 | 3 | export default { 4 | captures: //ig, 5 | delay: /{\s*delay\s*=\s*(\d+)\s*}/, 6 | filter: /\^(\w+)\(([^)]*)\)/i, 7 | }; 8 | -------------------------------------------------------------------------------- /src/bot/reply/common.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug-levels'; 2 | 3 | const debug = debuglog('SS:ProcessHelpers'); 4 | 5 | const getTopic = async function getTopic(chatSystem, name) { 6 | if (!name) { 7 | // TODO: This should probably throw, not return null 8 | return null; 9 | } 10 | 11 | debug.verbose('Getting topic data for', name); 12 | const topicData = await chatSystem.Topic.findOne({ name }).lean().exec(); 13 | 14 | if (!topicData) { 15 | throw new Error(`No topic found for the topic name "${name}"`); 16 | } else { 17 | return { id: topicData._id, name, type: 'TOPIC' }; 18 | } 19 | }; 20 | 21 | export default { 22 | getTopic, 23 | }; 24 | -------------------------------------------------------------------------------- /src/bot/reply/customFunction.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | 4 | const debug = debuglog('SS:Reply:customFunction'); 5 | 6 | const customFunction = async function customFunction(functionName, functionArgs, replyObj, options) { 7 | const plugins = options.system.plugins; 8 | // Important to create a new scope object otherwise we could leak data 9 | const scope = _.merge({}, options.system.scope); 10 | scope.extraScope = options.system.extraScope; 11 | scope.message = options.message; 12 | scope.user = options.user; 13 | 14 | if (!plugins[functionName]) { 15 | // If a function is missing, we kill the line and return empty handed 16 | throw new Error(`WARNING: Custom function (${functionName}) was not found. Your script may not behave as expected.`); 17 | } 18 | 19 | return new Promise((resolve, reject) => { 20 | functionArgs.push((err, functionResponse, stopMatching) => { 21 | let reply = ''; 22 | const props = {}; 23 | if (err) { 24 | console.error(`Error in plugin function (${functionName}): ${err}`); 25 | return reject(err); 26 | } 27 | 28 | if (_.isPlainObject(functionResponse)) { 29 | if (functionResponse.text) { 30 | reply = functionResponse.text; 31 | delete functionResponse.text; 32 | } 33 | 34 | if (functionResponse.reply) { 35 | reply = functionResponse.reply; 36 | delete functionResponse.reply; 37 | } 38 | 39 | // There may be data, so merge it with the reply object 40 | replyObj.props = _.merge(replyObj.props, functionResponse); 41 | if (stopMatching !== undefined) { 42 | replyObj.continueMatching = !stopMatching; 43 | } 44 | } else { 45 | reply = functionResponse || ''; 46 | if (stopMatching !== undefined) { 47 | replyObj.continueMatching = !stopMatching; 48 | } 49 | } 50 | 51 | return resolve(reply); 52 | }); 53 | 54 | debug.verbose(`Calling plugin function: ${functionName}`); 55 | plugins[functionName].apply(scope, functionArgs); 56 | }); 57 | }; 58 | 59 | export default customFunction; 60 | -------------------------------------------------------------------------------- /src/bot/reply/inlineRedirect.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug-levels'; 2 | import Message from 'ss-message'; 3 | 4 | import processHelpers from './common'; 5 | import getReply from '../getReply'; 6 | 7 | const debug = debuglog('SS:Reply:inline'); 8 | 9 | const inlineRedirect = async function inlineRedirect(triggerTarget, options) { 10 | debug.verbose(`Inline redirection to: '${triggerTarget}'`); 11 | 12 | // if we have a special topic, reset it to the previous one 13 | // in order to preserve the context for inline redirection 14 | if (options.topic === '__pre__' || options.topic === '__post__') { 15 | if (options.user.history.length !== 0) { 16 | options.topic = options.user.history[0].topic; 17 | } 18 | } 19 | 20 | let topicData; 21 | try { 22 | topicData = await processHelpers.getTopic(options.system.chatSystem, options.topic); 23 | } catch (err) { 24 | console.error(err); 25 | return {}; 26 | } 27 | 28 | const messageOptions = { 29 | factSystem: options.system.factSystem, 30 | }; 31 | 32 | const redirectMessage = await new Promise((resolve, reject) => { 33 | Message.createMessage(triggerTarget, messageOptions, (err, redirectMessage) => { 34 | err ? reject(err) : resolve(redirectMessage); 35 | }); 36 | }); 37 | 38 | options.pendingTopics = [topicData]; 39 | 40 | const redirectReply = await new Promise((resolve, reject) => { 41 | getReply(redirectMessage, options, (err, redirectReply) => { 42 | err ? reject(err) : resolve(redirectReply); 43 | }); 44 | }); 45 | 46 | debug.verbose('Response from inlineRedirect: ', redirectReply); 47 | return redirectReply || {}; 48 | }; 49 | 50 | export default inlineRedirect; 51 | -------------------------------------------------------------------------------- /src/bot/reply/preprocess-grammar.pegjs: -------------------------------------------------------------------------------- 1 | start = preprocess 2 | 3 | capture 4 | = "" 5 | { 6 | return { 7 | type: "capture", 8 | starID: starID 9 | }; 10 | } 11 | 12 | previousCapture 13 | = "" 14 | { 15 | return { 16 | type: "previousCapture", 17 | starID, 18 | conversationID 19 | }; 20 | } 21 | 22 | previousInput 23 | = "" 24 | { 25 | return { 26 | type: "previousInput", 27 | inputID: inputID 28 | } 29 | } 30 | 31 | previousReply 32 | = "" 33 | { 34 | return { 35 | type: "previousReply", 36 | replyID: replyID 37 | } 38 | } 39 | 40 | wordnetLookup 41 | = "~" term:[A-Za-z0-9_]+ 42 | { 43 | return { 44 | type: "wordnetLookup", 45 | term: term.join("") 46 | } 47 | } 48 | 49 | nonReplacementChar 50 | = "\\" character:[<>)~] { return character; } 51 | / character:[^<>)~] { return character; } 52 | 53 | nonReplacement 54 | = chars:nonReplacementChar+ { return chars.join(""); } 55 | 56 | functionArg 57 | = capture 58 | / previousCapture 59 | / previousInput 60 | / previousReply 61 | / wordnetLookup 62 | / nonReplacement 63 | 64 | functionArgs 65 | = functionArg+ 66 | 67 | function 68 | = "^" name:[A-Za-z0-9_]+ "(" args:functionArgs? ")" 69 | { return [`^${name.join("")}(`, args || '', ')']; } 70 | 71 | nonFunctionChar 72 | = "\\" character:[\^] { return character; } 73 | / character:[^\^] { return character; } 74 | 75 | nonFunction 76 | = chars:nonFunctionChar+ { return chars.join(""); } 77 | 78 | preprocessType 79 | = function 80 | / nonFunction 81 | 82 | preprocess 83 | = preprocessType* 84 | 85 | integer 86 | = numbers:[0-9]+ 87 | { return Number.parseInt(numbers.join("")); } 88 | 89 | ws "whitespace" 90 | = [ \t] 91 | 92 | nl "newline" 93 | = [\n\r] 94 | -------------------------------------------------------------------------------- /src/bot/reply/reply-grammar.pegjs: -------------------------------------------------------------------------------- 1 | start = reply 2 | 3 | capture 4 | = "" 5 | { 6 | return { 7 | type: "capture", 8 | starID: starID 9 | }; 10 | } 11 | 12 | previousCapture 13 | = "" 14 | { 15 | return { 16 | type: "previousCapture", 17 | starID, 18 | conversationID 19 | }; 20 | } 21 | 22 | previousInput 23 | = "" 24 | { 25 | return { 26 | type: "previousInput", 27 | inputID: inputID 28 | } 29 | } 30 | 31 | previousReply 32 | = "" 33 | { 34 | return { 35 | type: "previousReply", 36 | replyID: replyID 37 | } 38 | } 39 | 40 | topicRedirect 41 | = "^topicRedirect(" args:customFunctionArgs ")" 42 | { 43 | return { 44 | type: "topicRedirect", 45 | functionArgs: args ? `[${args}]` : null 46 | } 47 | } 48 | 49 | respond 50 | = "^respond(" args:customFunctionArgs ")" 51 | { 52 | return { 53 | type: "respond", 54 | functionArgs: args ? `[${args}]` : null 55 | } 56 | } 57 | 58 | redirect 59 | = "{@" ws* trigger:[^}]+ ws* "}" 60 | { 61 | return { 62 | type: "redirect", 63 | trigger: trigger.join("") 64 | } 65 | } 66 | 67 | customFunctionLetter 68 | = !")" letter:. { return letter } 69 | 70 | customFunctionArgs 71 | = letters:customFunctionLetter+ { return letters.join("") } 72 | 73 | customFunction 74 | = "^" !"topicRedirect" !"respond" name:[A-Za-z0-9_]+ "(" args:customFunctionArgs? ")" 75 | { 76 | return { 77 | type: "customFunction", 78 | functionName: name.join(""), 79 | functionArgs: args ? `[${args}]` : null 80 | }; 81 | } 82 | 83 | newTopic 84 | = "{" ws* "topic" ws* "=" ws* topicName:[A-Za-z0-9~_]* ws* "}" 85 | { 86 | return { 87 | type: "newTopic", 88 | topicName: topicName.join("") 89 | }; 90 | } 91 | 92 | clearString 93 | = "clear" 94 | / "CLEAR" 95 | 96 | clearConversation 97 | = "{" ws* clearString ws* "}" 98 | { 99 | return { 100 | type: "clearConversation" 101 | } 102 | } 103 | 104 | continueString 105 | = "continue" 106 | / "CONTINUE" 107 | 108 | continueSearching 109 | = "{" ws* continueString ws* "}" 110 | { 111 | return { 112 | type: "continueSearching" 113 | } 114 | } 115 | 116 | endString 117 | = "end" 118 | / "END" 119 | 120 | endSearching 121 | = "{" ws* endString ws* "}" 122 | { 123 | return { 124 | type: "endSearching" 125 | } 126 | } 127 | 128 | wordnetLookup 129 | = "~" term:[A-Za-z0-9_]+ 130 | { 131 | return { 132 | type: "wordnetLookup", 133 | term: term.join("") 134 | } 135 | } 136 | 137 | alternates 138 | = "((" alternateFirst:[^|]+ alternates:("|" alternate:[^|)]+ { return alternate.join(""); })+ "))" 139 | { 140 | return { 141 | type: "alternates", 142 | alternates: [alternateFirst.join("")].concat(alternates) 143 | } 144 | } 145 | 146 | delay 147 | = "{" ws* "delay" ws* "=" ws* delayLength:integer "}" 148 | { 149 | return { 150 | type: "delay", 151 | delayLength 152 | } 153 | } 154 | 155 | keyValuePair 156 | = ws* key:[A-Za-z0-9_]+ ws* "=" ws* value:[A-Za-z0-9_'"]+ ws* 157 | { 158 | return { 159 | key: key.join(""), 160 | value: value.join("") 161 | } 162 | } 163 | 164 | setState 165 | = "{" keyValuePairFirst:keyValuePair keyValuePairs:("," keyValuePair:keyValuePair { return keyValuePair; })* "}" 166 | { 167 | return { 168 | type: "setState", 169 | stateToSet: [keyValuePairFirst].concat(keyValuePairs) 170 | } 171 | } 172 | 173 | stringCharacter 174 | = !"((" "\\" character:[n] { return `\n`; } 175 | / !"((" "\\" character:[s] { return `\\s`; } 176 | / !"((" "\\" character:. { return character; } 177 | / !"((" character:[^^{<~] { return character; } 178 | 179 | string 180 | = string:stringCharacter+ { return string.join(""); } 181 | 182 | replyToken 183 | = capture 184 | / previousCapture 185 | / previousInput 186 | / previousReply 187 | / topicRedirect 188 | / respond 189 | / redirect 190 | / customFunction 191 | / newTopic 192 | / clearConversation 193 | / continueSearching 194 | / endSearching 195 | / wordnetLookup 196 | / alternates 197 | / delay 198 | / setState 199 | / string 200 | 201 | reply 202 | = tokens:replyToken* 203 | { return tokens; } 204 | 205 | integer 206 | = numbers:[0-9]+ 207 | { return Number.parseInt(numbers.join("")); } 208 | 209 | ws "whitespace" 210 | = [ \t] 211 | 212 | nl "newline" 213 | = [\n\r] 214 | -------------------------------------------------------------------------------- /src/bot/reply/respond.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug-levels'; 2 | 3 | import processHelpers from './common'; 4 | import getReply from '../getReply'; 5 | 6 | const debug = debuglog('SS:Reply:Respond'); 7 | 8 | const respond = async function respond(topicName, options) { 9 | debug.verbose(`Responding to topic: ${topicName}`); 10 | 11 | const topicData = await processHelpers.getTopic(options.system.chatSystem, topicName); 12 | 13 | options.pendingTopics = [topicData]; 14 | 15 | const respondReply = await new Promise((resolve, reject) => { 16 | getReply(options.message, options, (err, respondReply) => { 17 | err ? reject(err) : resolve(respondReply); 18 | }); 19 | }); 20 | 21 | debug.verbose('Callback from respond getReply: ', respondReply); 22 | 23 | return respondReply || {}; 24 | }; 25 | 26 | export default respond; 27 | -------------------------------------------------------------------------------- /src/bot/reply/topicRedirect.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug-levels'; 2 | import Message from 'ss-message'; 3 | 4 | import processHelpers from './common'; 5 | import getReply from '../getReply'; 6 | 7 | const debug = debuglog('SS:Reply:topicRedirect'); 8 | 9 | const topicRedirect = async function topicRedirect(topicName, topicTrigger, options) { 10 | debug.verbose(`Topic redirection to topic: ${topicName}, trigger: ${topicTrigger}`); 11 | 12 | // Here we are looking for gambits in the NEW topic. 13 | // TODO: Deprecate this behaviour: a failed topic lookup should fail the whole reply 14 | let topicData; 15 | try { 16 | topicData = await processHelpers.getTopic(options.system.chatSystem, topicName); 17 | } catch (err) { 18 | console.error(err); 19 | return {}; 20 | } 21 | 22 | const messageOptions = { 23 | factSystem: options.system.factSystem, 24 | }; 25 | 26 | const redirectMessage = await new Promise((resolve, reject) => { 27 | Message.createMessage(topicTrigger, messageOptions, (err, redirectMessage) => { 28 | err ? reject(err) : resolve(redirectMessage); 29 | }); 30 | }); 31 | 32 | options.pendingTopics = [topicData]; 33 | 34 | const redirectReply = await new Promise((resolve, reject) => { 35 | getReply(redirectMessage, options, (err, redirectReply) => { 36 | err ? reject(err) : resolve(redirectReply); 37 | }); 38 | }); 39 | 40 | debug.verbose('redirectReply', redirectReply); 41 | return redirectReply || {}; 42 | }; 43 | 44 | export default topicRedirect; 45 | -------------------------------------------------------------------------------- /src/bot/reply/wordnet.js: -------------------------------------------------------------------------------- 1 | // This is a shim for wordnet lookup. 2 | // http://wordnet.princeton.edu/wordnet/man/wninput.5WN.html 3 | 4 | import _ from 'lodash'; 5 | import WordPOS from 'wordpos'; 6 | 7 | const wordpos = new WordPOS(); 8 | 9 | // Unhandled promises should throw top-level errors, not just silently fail 10 | process.on('unhandledRejection', (err) => { 11 | throw err; 12 | }); 13 | 14 | const define = async function define(word) { 15 | const results = await wordpos.lookup(word); 16 | if (_.isEmpty(results)) { 17 | throw new Error(`No results for wordnet definition of '${word}'`); 18 | } 19 | 20 | return results[0].def; 21 | }; 22 | 23 | // Does a word lookup 24 | // @word can be a word or a word/pos to filter out unwanted types 25 | const lookup = async function lookup(word, pointerSymbol = '~') { 26 | let pos = null; 27 | 28 | const match = word.match(/~(\w)$/); 29 | if (match) { 30 | pos = match[1]; 31 | word = word.replace(match[0], ''); 32 | } 33 | 34 | const synets = []; 35 | 36 | const results = await wordpos.lookup(word); 37 | results.forEach((result) => { 38 | result.ptrs.forEach((part) => { 39 | if (pos !== null && part.pos === pos && part.pointerSymbol === pointerSymbol) { 40 | synets.push(part); 41 | } else if (pos === null && part.pointerSymbol === pointerSymbol) { 42 | synets.push(part); 43 | } 44 | }); 45 | }); 46 | 47 | let items = await Promise.all(synets.map(async (word) => { 48 | const sub = await wordpos.seek(word.synsetOffset, word.pos); 49 | return sub.lemma; 50 | })); 51 | 52 | items = _.uniq(items); 53 | items = items.map(x => x.replace(/_/g, ' ')); 54 | return items; 55 | }; 56 | 57 | // Used to explore a word or concept 58 | // Spits out lots of info on the word 59 | const explore = async function explore(word, cb) { 60 | let ptrs = []; 61 | 62 | const results = await wordpos.lookup(word); 63 | for (let i = 0; i < results.length; i++) { 64 | ptrs.push(results[i].ptrs); 65 | } 66 | 67 | ptrs = _.uniq(_.flatten(ptrs)); 68 | ptrs = _.map(ptrs, item => ({ pos: item.pos, sym: item.pointerSymbol })); 69 | 70 | ptrs = _.chain(ptrs) 71 | .groupBy('pos') 72 | .map((value, key) => ({ 73 | pos: key, 74 | ptr: _.uniq(_.map(value, 'sym')), 75 | })) 76 | .value(); 77 | 78 | return Promise.all(ptrs.map(async item => Promise.all(item.ptr.map(async (ptr) => { 79 | const res = await lookup(`${word}~${item.pos}`, ptr); 80 | console.log(word, item.pos, ':', ptr, res.join(', ')); 81 | })))); 82 | }; 83 | 84 | export default { 85 | define, 86 | explore, 87 | lookup, 88 | }; 89 | -------------------------------------------------------------------------------- /src/bot/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug-levels'; 3 | import safeEval from 'safe-eval'; 4 | 5 | import regexes from './regexes'; 6 | 7 | const debug = debuglog('SS:Utils'); 8 | 9 | // TODO: rename to normlize to avoid confusion with string.trim() semantics 10 | /** 11 | * Remove extra whitespace from a string, while preserving new lines. 12 | * @param {string} text - the string to tidy up 13 | */ 14 | const trim = (text = '') => text.trim().replace(/[ \t]+/g, ' '); 15 | 16 | /** 17 | * Count the number of real words in a string 18 | * @param {string} text - the text to count 19 | * @returns {number} the number of words in `text` 20 | */ 21 | const wordCount = text => text.split(/[\s*#_|]+/).filter(w => w.length > 0).length; 22 | 23 | // Checks if any of the values in 'value' are present in 'list' 24 | const inArray = function inArray(list, value) { 25 | const values = _.isArray(value) ? value : [value]; 26 | return values.some(value => list.indexOf(value) >= 0); 27 | }; 28 | 29 | const commandsRE = /[\\.+?${}=!:]/g; 30 | const nonCommandsRE = /[\\.+*?^\[\]$(){}=!<>|:]/g; 31 | /** 32 | * Escape a string sp that it can be used in a regular expression. 33 | * @param {string} string - the string to escape 34 | * @param {boolean} commands - 35 | */ 36 | const quotemeta = (string, commands = false) => string.replace(commands ? commandsRE : nonCommandsRE, c => `\\${c}`); 37 | 38 | const getRandomInt = function getRandomInt(min, max) { 39 | return Math.floor(Math.random() * ((max - min) + 1)) + min; 40 | }; 41 | 42 | const pickItem = function pickItem(arr) { 43 | // TODO - Item may have a wornet suffix meal~2 or meal~n 44 | const ind = getRandomInt(0, arr.length - 1); 45 | return _.isString(arr[ind]) ? arr[ind].replace(/_/g, ' ') : arr[ind]; 46 | }; 47 | 48 | const genId = function genId() { 49 | let text = ''; 50 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 51 | 52 | for (let i = 0; i < 8; i++) { 53 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 54 | } 55 | return text; 56 | }; 57 | 58 | /** 59 | * Search each string in `strings` for `` tags and replace them with values from `caps`. 60 | * 61 | * Replacement is positional so `` replaces with `caps[1]` and so on, with `` also 62 | * replacing from `caps[1]`. 63 | * Empty `strings` are removed from the result. 64 | * 65 | * @param {Array} strings - text to search for `` tags 66 | * @param {Array} caps - replacement text 67 | */ 68 | const replaceCapturedText = (strings, caps) => strings 69 | .filter(s => !_.isEmpty(s)) 70 | .map(s => s.replace(regexes.captures, (m, p1) => caps[Number.parseInt(p1 || 1)])); 71 | 72 | const runPluginFunc = async function runPluginFunc(functionRegex, scope, plugins) { 73 | const pluginFunction = functionRegex.match(regexes.filter); 74 | const functionName = pluginFunction[1]; 75 | const functionArgs = pluginFunction[2]; 76 | 77 | debug.verbose(`Running plugin function with name: ${functionName}`); 78 | 79 | if (!plugins[functionName]) { 80 | throw new Error(`Plugin function not found: ${functionName}`); 81 | } 82 | 83 | let cleanArgs = null; 84 | try { 85 | cleanArgs = safeEval(`[${functionArgs}]`); 86 | } catch (err) { 87 | throw new Error(`Error in plugin function arguments: ${err}`); 88 | } 89 | 90 | return new Promise((resolve, reject) => { 91 | cleanArgs.push((err, ...args) => { 92 | err ? reject(err) : resolve(args); 93 | }); 94 | debug.verbose(`Calling plugin function: ${functionName} with args: ${cleanArgs}`); 95 | plugins[functionName].apply(scope, cleanArgs); 96 | }); 97 | }; 98 | 99 | export default { 100 | genId, 101 | getRandomInt, 102 | inArray, 103 | pickItem, 104 | quotemeta, 105 | replaceCapturedText, 106 | runPluginFunc, 107 | trim, 108 | wordCount, 109 | }; 110 | -------------------------------------------------------------------------------- /src/plugins/alpha.js: -------------------------------------------------------------------------------- 1 | import rhyme from 'rhymes'; 2 | import syllablistic from 'syllablistic'; 3 | import debuglog from 'debug'; 4 | import _ from 'lodash'; 5 | 6 | const debug = debuglog('AlphaPlugins'); 7 | 8 | const getRandomInt = (min, max) => Math.floor(Math.random() * ((max - min) + 1)) + min; 9 | 10 | // TODO: deprecate oppisite and replace with opposite 11 | const oppisite = function oppisite(word, cb) { 12 | debug('oppisite', word); 13 | 14 | this.facts.db.get({ subject: word, predicate: 'opposite' }, (err, opp) => { 15 | if (!_.isEmpty(opp)) { 16 | let oppositeWord = opp[0].object; 17 | oppositeWord = oppositeWord.replace(/_/g, ' '); 18 | cb(null, oppositeWord); 19 | } else { 20 | cb(null, ''); 21 | } 22 | }); 23 | }; 24 | 25 | const rhymes = function rhymes(word, cb) { 26 | debug('rhyming', word); 27 | 28 | const rhymedWords = rhyme(word); 29 | const i = getRandomInt(0, rhymedWords.length - 1); 30 | 31 | if (rhymedWords.length !== 0) { 32 | cb(null, rhymedWords[i].word.toLowerCase()); 33 | } else { 34 | cb(null, null); 35 | } 36 | }; 37 | 38 | const syllable = (word, cb) => cb(null, syllablistic.text(word)); 39 | 40 | const letterLookup = function letterLookup(cb) { 41 | let reply = ''; 42 | 43 | const lastWord = this.message.lemWords.slice(-1)[0]; 44 | debug('--LastWord', lastWord); 45 | debug('LemWords', this.message.lemWords); 46 | const alpha = 'abcdefghijklmonpqrstuvwxyz'.split(''); 47 | const pos = alpha.indexOf(lastWord); 48 | debug('POS', pos); 49 | if (this.message.lemWords.indexOf('before') !== -1) { 50 | if (alpha[pos - 1]) { 51 | reply = alpha[pos - 1].toUpperCase(); 52 | } else { 53 | reply = "Don't be silly, there is nothing before A"; 54 | } 55 | } else if (this.message.lemWords.indexOf('after') !== -1) { 56 | if (alpha[pos + 1]) { 57 | reply = alpha[pos + 1].toUpperCase(); 58 | } else { 59 | reply = 'haha, funny.'; 60 | } 61 | } else { 62 | const i = this.message.lemWords.indexOf('letter'); 63 | const loc = this.message.lemWords[i - 1]; 64 | 65 | if (loc === 'first') { 66 | reply = 'It is A.'; 67 | } else if (loc === 'last') { 68 | reply = 'It is Z.'; 69 | } else { 70 | // Number or word number 71 | // 1st, 2nd, 3rd, 4th or less then 99 72 | if ((loc === 'st' || loc === 'nd' || loc === 'rd' || loc === 'th') && this.message.numbers.length !== 0) { 73 | const num = parseInt(this.message.numbers[0]); 74 | if (num > 0 && num <= 26) { 75 | reply = `It is ${alpha[num - 1].toUpperCase()}`; 76 | } else { 77 | reply = 'seriously...'; 78 | } 79 | } 80 | } 81 | } 82 | cb(null, reply); 83 | }; 84 | 85 | const wordLength = function wordLength(cap, cb) { 86 | if (typeof cap === 'string') { 87 | const parts = cap.split(' '); 88 | if (parts.length === 1) { 89 | cb(null, cap.length); 90 | } else if (parts[0].toLowerCase() === 'the' && parts.length === 3) { 91 | // name bill, word bill 92 | cb(null, parts.pop().length); 93 | } else if (parts[0] === 'the' && parts[1].toLowerCase() === 'alphabet') { 94 | cb(null, '26'); 95 | } else if (parts[0] === 'my' && parts.length === 2) { 96 | // Varible lookup 97 | const lookup = parts[1]; 98 | this.user.getVar(lookup, (e, v) => { 99 | if (v !== null && v.length) { 100 | cb(null, `There are ${v.length} letters in your ${lookup}.`); 101 | } else { 102 | cb(null, "I don't know"); 103 | } 104 | }); 105 | } else if (parts[0] == 'this' && parts.length == 2) { 106 | // this phrase, this sentence 107 | cb(null, `That phrase has ${this.message.raw.length} characters. I think.`); 108 | } else { 109 | cb(null, 'I think there is about 10 characters. :)'); 110 | } 111 | } else { 112 | cap(null, ''); 113 | } 114 | }; 115 | 116 | const nextNumber = function nextNumber(cb) { 117 | let reply = ''; 118 | const num = this.message.numbers.slice(-1)[0]; 119 | 120 | if (num) { 121 | if (this.message.lemWords.indexOf('before') !== -1) { 122 | reply = parseInt(num) - 1; 123 | } 124 | if (this.message.lemWords.indexOf('after') !== -1) { 125 | reply = parseInt(num) + 1; 126 | } 127 | } 128 | 129 | cb(null, reply); 130 | }; 131 | 132 | export default { 133 | letterLookup, 134 | nextNumber, 135 | oppisite, 136 | rhymes, 137 | syllable, 138 | wordLength, 139 | }; 140 | -------------------------------------------------------------------------------- /src/plugins/compare.js: -------------------------------------------------------------------------------- 1 | import debuglog from 'debug'; 2 | import _ from 'lodash'; 3 | import async from 'async'; 4 | 5 | import Utils from '../bot/utils'; 6 | 7 | const debug = debuglog('Compare Plugin'); 8 | 9 | const createFact = function createFact(s, v, o, cb) { 10 | this.user.memory.create(s, v, o, false, () => { 11 | this.facts.db.get({ subject: v, predicate: 'opposite' }, (e, r) => { 12 | if (r.length !== 0) { 13 | this.user.memory.create(o, r[0].object, s, false, () => { 14 | cb(null, ''); 15 | }); 16 | } else { 17 | cb(null, ''); 18 | } 19 | }); 20 | }); 21 | }; 22 | 23 | export default { 24 | createFact 25 | }; 26 | -------------------------------------------------------------------------------- /src/plugins/message.js: -------------------------------------------------------------------------------- 1 | const addMessageProp = function addMessageProp(key, value, callback) { 2 | if (key !== '' && value !== '') { 3 | return callback(null, { [key]: value }); 4 | } 5 | 6 | return callback(null, ''); 7 | }; 8 | 9 | const hasTag = function hasTag(tag, callback) { 10 | if (this.message.tags.indexOf(tag) !== -1) { 11 | return callback(null, true); 12 | } 13 | return callback(null, false); 14 | }; 15 | 16 | export default { addMessageProp, hasTag }; 17 | -------------------------------------------------------------------------------- /src/plugins/test.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | // This is used in a test to verify fall though works 4 | // TODO: Move this into a fixture. 5 | const bail = function bail(cb) { 6 | cb(true, null); 7 | }; 8 | 9 | const one = function one(cb) { 10 | cb(null, 'one'); 11 | }; 12 | 13 | const num = function num(n, cb) { 14 | cb(null, n); 15 | }; 16 | 17 | const changetopic = function changetopic(n, cb) { 18 | this.user.setTopic(n).then(() => cb(null, '')); 19 | }; 20 | 21 | const changefunctionreply = function changefunctionreply(newtopic, cb) { 22 | cb(null, `{topic=${newtopic}}`); 23 | }; 24 | 25 | const doSomething = function doSomething(cb) { 26 | console.log('this.message.raw', this.message.raw); 27 | cb(null, 'function'); 28 | }; 29 | 30 | const breakFunc = function breakFunc(cb) { 31 | cb(null, '', true); 32 | }; 33 | 34 | const nobreak = function nobreak(cb) { 35 | cb(null, '', false); 36 | }; 37 | 38 | const objparam1 = function objparam1(cb) { 39 | const data = { 40 | text: 'world', 41 | attachments: [ 42 | { 43 | text: 'Optional text that appears *within* the attachment', 44 | }, 45 | ], 46 | }; 47 | cb(null, data); 48 | }; 49 | 50 | const objparam2 = function objparam2(cb) { 51 | cb(null, { test: 'hello', text: 'world' }); 52 | }; 53 | 54 | 55 | const showScope = function showScope(cb) { 56 | cb(null, `${this.extraScope.key} ${this.user.id} ${this.message.clean}`); 57 | }; 58 | 59 | const word = function word(word1, word2, cb) { 60 | cb(null, word1 === word2); 61 | }; 62 | 63 | const hasFirstName = function hasFirstName(bool, cb) { 64 | this.user.getVar('firstName', (e, name) => { 65 | if (name !== null) { 66 | cb(null, (bool === 'true')); 67 | } else { 68 | cb(null, (bool === 'false')); 69 | } 70 | }); 71 | }; 72 | 73 | const getUserId = function getUserId(cb) { 74 | const userID = this.user.id; 75 | const that = this; 76 | // console.log("CMP1", _.isEqual(userID, that.user.id)); 77 | return that.bot.getUser('userB', (err, user) => { 78 | console.log('CMP2', _.isEqual(userID, that.user.id)); 79 | cb(null, that.user.id); 80 | }); 81 | }; 82 | 83 | const hasName = function hasName(bool, cb) { 84 | this.user.getVar('name', (e, name) => { 85 | if (name !== null) { 86 | cb(null, (bool === 'true')); 87 | } else { 88 | // We have no name 89 | cb(null, (bool === 'false')); 90 | } 91 | }); 92 | }; 93 | 94 | const testCustomArgs = function testCustomArgs(myObj, myArr, cb) { 95 | const part1 = myObj.myKey; 96 | const part2 = myArr[0]; 97 | cb(null, `${part1} ${part2}`); 98 | }; 99 | 100 | const testMoreTags = function testMoreTags(topic, trigger, cb) { 101 | cb(null, `^topicRedirect("${topic}", "${trigger}")`); 102 | }; 103 | 104 | // This function is called from the topic filter function 105 | // Return true if you want the method to filter it out 106 | const filterTopic = function (cb) { 107 | if (this.topic.name === 'filter2') { 108 | cb(null, false); 109 | } else { 110 | cb(null, true); 111 | } 112 | }; 113 | 114 | export default { 115 | bail, 116 | breakFunc, 117 | doSomething, 118 | changefunctionreply, 119 | changetopic, 120 | getUserId, 121 | hasFirstName, 122 | hasName, 123 | nobreak, 124 | num, 125 | objparam1, 126 | objparam2, 127 | one, 128 | showScope, 129 | testCustomArgs, 130 | testMoreTags, 131 | word, 132 | filterTopic, 133 | }; 134 | -------------------------------------------------------------------------------- /src/plugins/time.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | const COEFF = 1000 * 60 * 5; 4 | 5 | const getSeason = function getSeason() { 6 | const now = moment(); 7 | now.dayOfYear(); 8 | const doy = now.dayOfYear(); 9 | 10 | if (doy > 80 && doy < 172) { 11 | return 'spring'; 12 | } else if (doy > 172 && doy < 266) { 13 | return 'summer'; 14 | } else if (doy > 266 && doy < 357) { 15 | return 'fall'; 16 | } else if (doy < 80 || doy > 357) { 17 | return 'winter'; 18 | } 19 | return 'unknown'; 20 | }; 21 | 22 | exports.getDOW = function getDOW(cb) { 23 | cb(null, moment().format('dddd')); 24 | }; 25 | 26 | exports.getDate = function getDate(cb) { 27 | cb(null, moment().format('ddd, MMMM Do')); 28 | }; 29 | 30 | exports.getDateTomorrow = function getDateTomorrow(cb) { 31 | const date = moment().add(1,'d').format('ddd, MMMM Do'); 32 | cb(null, date); 33 | }; 34 | 35 | exports.getSeason = function getSeason(cb) { 36 | cb(null, getSeason()); 37 | }; 38 | 39 | exports.getTime = function getTime(cb) { 40 | const date = new Date(); 41 | const rounded = new Date(Math.round(date.getTime() / COEFF) * COEFF); 42 | const time = moment(rounded).format('h:mm'); 43 | cb(null, `The time is ${time}`); 44 | }; 45 | 46 | exports.getGreetingTimeOfDay = function getGreetingTimeOfDay(cb) { 47 | const date = new Date(); 48 | const rounded = new Date(Math.round(date.getTime() / COEFF) * COEFF); 49 | const time = moment(rounded).format('H'); 50 | let tod; 51 | if (time < 12) { 52 | tod = 'morning'; 53 | } else if (time < 17) { 54 | tod = 'afternoon'; 55 | } else { 56 | tod = 'evening'; 57 | } 58 | 59 | cb(null, tod); 60 | }; 61 | 62 | exports.getTimeOfDay = function getTimeOfDay(cb) { 63 | const date = new Date(); 64 | const rounded = new Date(Math.round(date.getTime() / COEFF) * COEFF); 65 | const time = moment(rounded).format('H'); 66 | let tod; 67 | if (time < 12) { 68 | tod = 'morning'; 69 | } else if (time < 17) { 70 | tod = 'afternoon'; 71 | } else { 72 | tod = 'night'; 73 | } 74 | 75 | cb(null, tod); 76 | }; 77 | 78 | exports.getDayOfWeek = function getDayOfWeek(cb) { 79 | cb(null, moment().format('dddd')); 80 | }; 81 | 82 | exports.getMonth = function getMonth(cb) { 83 | let reply = ''; 84 | if (this.message.words.indexOf('next') !== -1) { 85 | reply = moment().add(1,'M').format('MMMM'); 86 | } else if (this.message.words.indexOf('previous') !== -1) { 87 | reply = moment().subtract(1,'M').format('MMMM'); 88 | } else if (this.message.words.indexOf('first') !== -1) { 89 | reply = 'January'; 90 | } else if (this.message.words.indexOf('last') !== -1) { 91 | reply = 'December'; 92 | } else { 93 | reply = moment().format('MMMM'); 94 | } 95 | cb(null, reply); 96 | }; 97 | -------------------------------------------------------------------------------- /src/plugins/user.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import debuglog from 'debug'; 3 | 4 | const debug = debuglog('SS:UserFacts'); 5 | 6 | const save = function save(key, value, cb) { 7 | const memory = this.user.memory; 8 | const userId = this.user.id; 9 | 10 | if (arguments.length !== 3) { 11 | console.log('WARNING\nValue not found in save function.'); 12 | if (_.isFunction(value)) { 13 | cb = value; 14 | value = ''; 15 | } 16 | } 17 | 18 | memory.db.get({ subject: key, predicate: userId }, (err, results) => { 19 | if (!_.isEmpty(results)) { 20 | memory.db.del(results[0], () => { 21 | memory.db.put({ subject: key, predicate: userId, object: value }, () => { 22 | cb(null, ''); 23 | }); 24 | }); 25 | } else { 26 | memory.db.put({ subject: key, predicate: userId, object: value }, (err) => { 27 | cb(null, ''); 28 | }); 29 | } 30 | }); 31 | }; 32 | 33 | const hasItem = function hasItem(key, bool, cb) { 34 | const memory = this.user.memory; 35 | const userId = this.user.id; 36 | 37 | debug('getVar', key, bool, userId); 38 | memory.db.get({ subject: key, predicate: userId }, (err, res) => { 39 | if (!_.isEmpty(res)) { 40 | cb(null, (bool === 'true')); 41 | } else { 42 | cb(null, (bool === 'false')); 43 | } 44 | }); 45 | }; 46 | 47 | const get = function get(key, cb) { 48 | const memory = this.user.memory; 49 | const userId = this.user.id; 50 | 51 | debug('getVar', key, userId); 52 | 53 | memory.db.get({ subject: key, predicate: userId }, (err, res) => { 54 | if (res && res.length !== 0) { 55 | cb(err, res[0].object); 56 | } else { 57 | cb(err, ''); 58 | } 59 | }); 60 | }; 61 | 62 | // Query SV return O and if that failes query OV return S 63 | const queryUserFact = function queryUserFact(subject, verb, cb) { 64 | var subject = subject.replace(/\s/g,"_").toLowerCase(); 65 | var memory = this.user.memory; 66 | memory.db.get({subject:subject, predicate:verb}, function(err, result){ 67 | if (!_.isEmpty(result)) { 68 | cb(null, result[0].object); 69 | } else { 70 | memory.db.get({object:subject, predicate:verb}, function(err, result){ 71 | if (!_.isEmpty(result)) { 72 | cb(null, result[0].subject); 73 | } else { 74 | cb(null,""); 75 | } 76 | }); 77 | } 78 | }); 79 | } 80 | 81 | const createUserFact = function createUserFact(subject, predicate, object, cb) { 82 | const memory = this.user.memory; 83 | 84 | var subject = subject.replace(/\s/g,"_").toLowerCase(); 85 | var object = object.replace(/\s/g,"_").toLowerCase(); 86 | 87 | memory.db.get({ subject, predicate, object }, (err, results) => { 88 | if (!_.isEmpty(results)) { 89 | memory.db.del(results[0], () => { 90 | memory.db.put({ subject, predicate, object }, () => { 91 | cb(null, ''); 92 | }); 93 | }); 94 | } else { 95 | memory.db.put({ subject, predicate, object }, (err) => { 96 | cb(null, ''); 97 | }); 98 | } 99 | }); 100 | }; 101 | 102 | const known = function known(bool, cb) { 103 | const memory = this.user.memory; 104 | const name = (this.message.names && !_.isEmpty(this.message.names)) ? this.message.names[0] : ''; 105 | memory.db.get({ subject: name.toLowerCase() }, (err, res1) => { 106 | memory.db.get({ object: name.toLowerCase() }, (err, res2) => { 107 | if (_.isEmpty(res1) && _.isEmpty(res2)) { 108 | cb(null, (bool === 'false')); 109 | } else { 110 | cb(null, (bool === 'true')); 111 | } 112 | }); 113 | }); 114 | }; 115 | 116 | const inTopic = function inTopic(topic, cb) { 117 | if (topic === this.user.currentTopic) { 118 | cb(null, 'true'); 119 | } else { 120 | cb(null, 'false'); 121 | } 122 | }; 123 | 124 | export default { 125 | createUserFact, 126 | queryUserFact, 127 | get, 128 | hasItem, 129 | inTopic, 130 | known, 131 | save, 132 | }; 133 | -------------------------------------------------------------------------------- /src/plugins/wordnet.js: -------------------------------------------------------------------------------- 1 | import wd from '../bot/reply/wordnet'; 2 | 3 | const wordnetDefine = function wordnetDefine(cb) { 4 | const args = Array.prototype.slice.call(arguments); 5 | let word; 6 | 7 | if (args.length === 2) { 8 | word = args[0]; 9 | } else { 10 | word = this.message.words.pop(); 11 | } 12 | 13 | wd.define(word).then((result) => { 14 | cb(null, `The Definition of ${word} is ${result}`); 15 | }).catch(() => { 16 | cb(null, `There is no definition for the word ${word}!`); 17 | }); 18 | }; 19 | 20 | export default { wordnetDefine }; 21 | -------------------------------------------------------------------------------- /src/plugins/words.js: -------------------------------------------------------------------------------- 1 | import pluralize from 'pluralize'; 2 | import debuglog from 'debug'; 3 | import utils from '../bot/utils'; 4 | 5 | const debug = debuglog('Word Plugin'); 6 | 7 | const plural = function plural(word, cb) { 8 | // Sometimes WordNet will give us more then one word 9 | let reply; 10 | const parts = word.split(' '); 11 | 12 | if (parts.length === 2) { 13 | reply = `${pluralize.plural(parts[0])} ${parts[1]}`; 14 | } else { 15 | reply = pluralize.plural(word); 16 | } 17 | 18 | cb(null, reply); 19 | }; 20 | 21 | const not = function not(word, cb) { 22 | const words = word.split('|'); 23 | const results = utils.inArray(this.message.words, words); 24 | debug('RES', results); 25 | cb(null, (results === false)); 26 | }; 27 | 28 | const lowercase = function lowercase(word, cb) { 29 | if (word) { 30 | cb(null, word.toLowerCase()); 31 | } else { 32 | cb(null, ''); 33 | } 34 | }; 35 | 36 | export default { 37 | lowercase, 38 | not, 39 | plural, 40 | }; 41 | -------------------------------------------------------------------------------- /test/capture.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | 7 | // The bulk of these tests now live in ss-parser - that script manages the 8 | // input capture interface. 9 | 10 | describe('SuperScript Capture System', () => { 11 | before(helpers.before('capture')); 12 | 13 | describe('Previous Capture should return previous capture tag', () => { 14 | it('Previous capture', (done) => { 15 | helpers.getBot().reply('user1', 'previous capture one interface', (err, reply) => { 16 | should(reply.string).eql('previous capture test one interface'); 17 | helpers.getBot().reply('user1', 'previous capture two', (err, reply) => { 18 | should(reply.string).eql('previous capture test two interface'); 19 | done(); 20 | }); 21 | }); 22 | }); 23 | }); 24 | 25 | describe('Match ', () => { 26 | it('It should capture the last thing said', (done) => { 27 | helpers.getBot().reply('user1', 'capture input', (err, reply) => { 28 | should(reply.string).eql('capture input'); 29 | done(); 30 | }); 31 | }); 32 | }); 33 | 34 | after(helpers.after); 35 | }); 36 | -------------------------------------------------------------------------------- /test/continue.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | 7 | describe('SuperScript Continue System aka Conversation', () => { 8 | before(helpers.before('continue')); 9 | 10 | describe('Dynamic Conversations', () => { 11 | it('set some conversation state', (done) => { 12 | helpers.getBot().reply('user1', '__start__', (err, reply) => { 13 | helpers.getBot().getUser('user1', (err, user) => { 14 | should(reply.string).eql('match here'); 15 | should(user.conversationState.id).eql(123); 16 | helpers.getBot().reply('user1', 'I really hope this works!', (err, reply) => { 17 | should(reply.string).eql('winning'); 18 | done(); 19 | }); 20 | }); 21 | }); 22 | }); 23 | 24 | it('and again', (done) => { 25 | helpers.getBot().reply('user1', '__start__', (err, reply) => { 26 | helpers.getBot().reply('user1', 'boo ya', (err, reply) => { 27 | helpers.getBot().getUser('user1', (err, user) => { 28 | should(reply.string).eql('YES'); 29 | done(); 30 | }); 31 | }); 32 | }); 33 | }); 34 | }); 35 | 36 | describe('Match and continue', () => { 37 | it('should continue', (done) => { 38 | helpers.getBot().reply('user1', 'i went to highschool', (err, reply) => { 39 | should(reply.string).eql('did you finish ?'); 40 | helpers.getBot().reply('user1', 'then what happened?', (err, reply2) => { 41 | should(['i went to university', 'what was it like?']).containEql(reply2.string); 42 | done(); 43 | }); 44 | }); 45 | }); 46 | 47 | // Issue in ss-message/bot-lang removing leading yes 48 | it('should continue 2 - yes', (done) => { 49 | helpers.getBot().reply('user1', 'i like to travel', (err, reply) => { 50 | should(reply.string).eql('have you been to Madird?'); 51 | helpers.getBot().reply('user1', 'yes it is the capital of spain!', (err, reply2) => { 52 | should(reply2.string).eql('Madird is amazing.'); 53 | done(); 54 | }); 55 | }); 56 | }); 57 | 58 | it('should continue 3 - no', (done) => { 59 | helpers.getBot().reply('user1', 'i like to travel', (err, reply) => { 60 | should(reply.string).eql('have you been to Madird?'); 61 | helpers.getBot().reply('user1', 'no', (err, reply2) => { 62 | should(reply2.string).eql('Madird is my favorite city.'); 63 | done(); 64 | }); 65 | }); 66 | }); 67 | 68 | // These two are testing sorted gambits in replies. 69 | it('should continue Sorted - A', (done) => { 70 | helpers.getBot().reply('user1', 'something random', (err, reply) => { 71 | helpers.getBot().reply('user1', 'red', (err, reply2) => { 72 | should(reply2.string).eql('red is mine too.'); 73 | done(); 74 | }); 75 | }); 76 | }); 77 | 78 | it('should continue Sorted - B', (done) => { 79 | helpers.getBot().reply('user1', 'something random', (err, reply) => { 80 | helpers.getBot().reply('user1', 'blue', (err, reply2) => { 81 | should(reply2.string).eql('I hate that color.'); 82 | done(); 83 | }); 84 | }); 85 | }); 86 | 87 | it('GH-84 - compound reply convo.', (done) => { 88 | helpers.getBot().reply('user1', 'test complex', (err, reply) => { 89 | should(reply.string).eql('reply test super compound'); 90 | helpers.getBot().reply('user1', 'cool', (err, reply) => { 91 | should(reply.string).eql('it works'); 92 | done(); 93 | }); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('GH-133', () => { 99 | it('Threaded Conversation', (done) => { 100 | helpers.getBot().reply('user5', 'conversation', (err, reply) => { 101 | should(reply.string).eql('Are you happy?'); 102 | 103 | // This is the reply to the conversation 104 | helpers.getBot().reply('user5', 'yes', (err, reply) => { 105 | should(reply.string).eql('OK, so you are happy'); 106 | 107 | // Something else wont match because we are still in the conversation 108 | helpers.getBot().reply('user5', 'something else', (err, reply) => { 109 | should(reply.string).eql("OK, so you don't know"); 110 | done(); 111 | }); 112 | }); 113 | }); 114 | }); 115 | 116 | // NB: I changed the user to user2 here to clear the thread. 117 | // FIXME: GH-162 118 | it.skip('Threaded Conversation 2', (done) => { 119 | helpers.getBot().reply('user2', 'start', (err, reply) => { 120 | should(reply.string).eql('What is your name?'); 121 | 122 | helpers.getBot().reply('user2', 'My name is Marius Ursache', (err, reply) => { 123 | should(reply.string).eql('So your first name is Marius?'); 124 | 125 | helpers.getBot().reply('user2', 'Yes', (err, reply) => { 126 | should(reply.string).eql("That's a nice name."); 127 | 128 | // We are still stuck in the conversation here, so we repeat the question again 129 | helpers.getBot().reply('user2', 'something else', (err, reply) => { 130 | should(reply.string).eql('okay nevermind'); 131 | done(); 132 | }); 133 | }); 134 | }); 135 | }); 136 | }); 137 | }); 138 | 139 | 140 | describe('GH-152 - dont match sub-reply', () => { 141 | it('Should not match', (done) => { 142 | helpers.getBot().reply('user3', 'lastreply two', (err, reply) => { 143 | should(reply.string).eql(''); 144 | done(); 145 | }); 146 | }); 147 | }); 148 | 149 | describe('Match and continue KEEP', () => { 150 | it('Should be even more awesome', (done) => { 151 | helpers.getBot().reply('user3', 'new conversation', (err, reply) => { 152 | should(reply.string).eql('What is your name?'); 153 | 154 | helpers.getBot().reply('user3', 'My name is Rob', (err, reply) => { 155 | should(reply.string).eql('So your first name is Rob?'); 156 | 157 | helpers.getBot().reply('user3', 'yes', (err, reply) => { 158 | should(reply.string).eql('Okay good.'); 159 | 160 | helpers.getBot().reply('user3', 'break out', (err, reply) => { 161 | should(reply.string).eql('okay nevermind'); 162 | 163 | // We should have exhausted "okay nevermind" and break out completely 164 | helpers.getBot().reply('user3', 'break out', (err, reply) => { 165 | should(reply.string).eql('okay we are free'); 166 | done(); 167 | }); 168 | }); 169 | }); 170 | }); 171 | }); 172 | }); 173 | }); 174 | 175 | describe('GH-207 Pass stars forward', () => { 176 | it('should pass stars forward', (done) => { 177 | helpers.getBot().reply('user4', 'start 2 foo or win', (err, reply) => { 178 | should(reply.string).eql('reply 2 foo'); 179 | helpers.getBot().reply('user4', '2 match bar', (err, reply) => { 180 | should(reply.string).eql('reply 3 bar foo win'); 181 | done(); 182 | }); 183 | }); 184 | }); 185 | }); 186 | 187 | describe('Contrived edge case not forwarding conversation clears with redirects', () => { 188 | it('should forward conversation clears', (done) => { 189 | helpers.getBot().reply('user6', 'this is a triumph', (err, reply) => { 190 | should(reply.string).eql("I'm making a note here, huge success"); 191 | helpers.getBot().reply('user6', 'wrong lyric', (err, reply) => { 192 | should(reply.string).eql("That's the wrong lyric, you goon The cake is a lie"); 193 | helpers.getBot().reply('user6', 'I like cake', (err, reply) => { 194 | should(reply.string).eql('do you like portal'); 195 | done(); 196 | }); 197 | }); 198 | }); 199 | }); 200 | }); 201 | 202 | describe('GH-357 Conversation matching based on multiline replies', () => { 203 | it('should match the conversation based on reply with linebreak', (done) => { 204 | helpers.getBot().reply('user7', 'test linebreak', (err, reply) => { 205 | should(reply.string).eql('first reply\nplease'); 206 | helpers.getBot().reply('user7', 'test linebreak', (err, reply) => { 207 | should(reply.string).eql('second reply'); 208 | done(); 209 | }); 210 | }); 211 | }); 212 | }); 213 | 214 | after(helpers.after); 215 | }); 216 | -------------------------------------------------------------------------------- /test/fixtures/capture/capture.ss: -------------------------------------------------------------------------------- 1 | > topic random {keep} 2 | 3 | + new capture (interface|face) 4 | - capture test 5 | 6 | + new capture [interface|face] 2 7 | - capture test 8 | 9 | + new capture *~1 3 10 | - capture test 11 | 12 | + new capture *1 4 13 | - capture test 14 | 15 | + new capture ~like wordnet 16 | - capture test 17 | 18 | + new capture system is * 19 | - capture test 20 | 21 | + capture input 22 | - 23 | 24 | // GH-128 25 | + *1 is taller than *1 26 | - is taller than 27 | 28 | + *~1 is smaller than *~1 29 | - is smaller than 30 | 31 | + *(1-1) is bigger than *(1-1) 32 | - is bigger than 33 | 34 | + *(1-5) is related to *(1-5) 35 | - is 36 | 37 | + previous capture 1 (*) 38 | - previous capture test one 39 | 40 | + previous capture 2 41 | - previous capture test two 42 | 43 | < topic -------------------------------------------------------------------------------- /test/fixtures/concepts/botfacts.tbl: -------------------------------------------------------------------------------- 1 | table: ~botfacts (^arg1 ^arg2 ^arg3) 2 | ^createfact(^arg1 ^arg2 ^arg3) 3 | ^createfact(^arg3 ^arg2 ^arg1) 4 | DATA: 5 | hair color brown 6 | hair length sholder_length 7 | eye color hazel 8 | favorite color green 9 | -------------------------------------------------------------------------------- /test/fixtures/concepts/botown.tbl: -------------------------------------------------------------------------------- 1 | table: ~own (^thing ^usedFor ^prop ) 2 | ^createfact(^thing ownedby bot) 3 | ^createfact(^thing usedFor ^usedFor) 4 | ^createfact(^thing hasProperty ^prop) 5 | 6 | DATA: 7 | car travel red 8 | house shelter big 9 | bike exercise road_bike -------------------------------------------------------------------------------- /test/fixtures/concepts/color.tbl: -------------------------------------------------------------------------------- 1 | 2 | table: ~colorof (^color ^object) 3 | ^createfact(^object color ^color) 4 | ^addproperty(^color NOUN NOUN_SINGULAR) 5 | 6 | DATA: 7 | green [grass leaf plant emerald ~plants turtle parsley topaz] 8 | green [lettuce lime spinach celery pear broccoli apple shamrock ] 9 | green [artichoke arugula asparagus avacado brussel_sprout chinese_cabbage cucumber endive green_apple green_bean] 10 | green [bean cabbage "green onion" "green pepper" honeydew kiwi kiwifruit leek okra pea snow_pea watercress zucchini] 11 | 12 | brown ["tree trunk" tree soil dirt earth mud chocolate bark beaver coffee toast root_beer eye raisin shit feces crap wood furniture floor floorboard] 13 | 14 | orange [orange marigold goldfish pumpkin carrot tangerine cantaloupe mango opal ] 15 | 16 | blue [sky water bluebell blue_screen_of_death blue_jeans jeans blueberry ocean sea lake ink blue_jay eye] 17 | 18 | purple [lollipop grape violet plum lilacs black_currant blackberry eggplant prune ] 19 | 20 | red [fire fire_engine ruby rose apple hair] 21 | red [steak beef cherry heart lip lipstick stop_sign cardinal garnet] 22 | red [beet cranberry guava pomegranate radish] 23 | red [red_apple red_grape grape "red pepper" potato] 24 | red [rhubarb strawberry tomato watermelon ] 25 | 26 | yellow [sun daffodil primrose lemon dandelion egg_yolk yolk pineapple apricot banana cheese papaya] 27 | 28 | gold [coin nugget watch filling tooth earring necklace bracelet medal crown jewelry ring] 29 | silver [coin teaspoon fork knife spoon watch pocketwatch dime quarter dollar_coin medal filling] 30 | copper [penny wire] 31 | bronze [statue medal] 32 | 33 | black [night darkness computer speaker "electronic equipment" Tivo TV pavement magnet tire smoke panther opal zebra charcoal ] 34 | 35 | white [snow snowman sheet pillowcase cloud lamb cotton_ball cotton milk cream polar_bear tooth bride zebra egg] 36 | white [chicken pigeon dove ginger cauliflower garlic jicama kohlrabi onion parsnip potato onion shallot turnip white_corn white_house] 37 | 38 | grey [raincloud building ~building elephant ash turkey computer] 39 | 40 | pink [peony skin flesh cheek cream_soda flamingo cotton_candy shrimp grapefruit piglet rose tongue] 41 | rainbow ["bright red" blue green] 42 | transparent [glass window air] 43 | -------------------------------------------------------------------------------- /test/fixtures/concepts/opp.tbl: -------------------------------------------------------------------------------- 1 | table: ~opposites (^arg1 ^arg2) 2 | ^createfact(^arg1 opposite ^arg2) 3 | ^createfact(^arg2 opposite ^arg1) 4 | DATA: 5 | taller shorter 6 | longer shorter 7 | smile frown 8 | tall short 9 | save spend 10 | old young 11 | -------------------------------------------------------------------------------- /test/fixtures/concepts/test.top: -------------------------------------------------------------------------------- 1 | concept: ~brother (bro brother male_sibling~1 ) 2 | 3 | 4 | concept: ~family_adult NOUN ANIMATE_BEING (~FAMILY_ADULT_FEMALE ~FAMILY_ADULT_GENERIC ~FAMILY_ADULT_MALE ) 5 | concept: ~family_adult_female NOUN ANIMATE_BEING (~MOTHER aunt daughter-in-law ex-wife fiancee girlfriend godmother gran grandma grandmom grandmother granny matriarch mistress step-mother stepmother widow spinster wife mother-in-law) 6 | concept: ~family_adult_generic NOUN ANIMATE_BEING (tribe heir parent descendant descendent ancestor clan folks elders ancestor cousin descendant parent stepparent grandparent folks partner elders partner heir spouse) 7 | concept: ~family_adult_male NOUN ANIMATE_BEING (~BROTHER ~FATHER boyfriend brother-in-law hubby ex-husband father-in-law fiance granddad granddaddy grandfather grandpa grandpaw grandpop hubby husband patriarch son-in-law stepfather uncle widower step-father scion) 8 | concept: ~family_children NOUN NOUN_SINGULAR(~FAMILY_CHILDREN_FEMALE ~FAMILY_CHILDREN_GENERIC ~FAMILY_CHILDREN_MALE ) 9 | concept: ~family_children_female NOUN ANIMATE_BEING NOUN_SINGULAR(~SISTER daughter granddaughter grandniece niece sister sister-in-law step-daughter step-sister stepdaughter stepsister step-sister ) 10 | concept: ~family_children_generic NOUN ANIMATE_BEING NOUN_SINGULAR(ward cousin twin offspring progeny baby child youth grandchild grandkid infant kid sibling step-child stepchild teen teenager toddler tot cousin ward) 11 | concept: ~family_children_male NOUN ANIMATE_BEING NOUN_SINGULAR(~brother scion brother grandnephew grandson nephew son step-brother step-son stepson ) 12 | concept: ~family_members NOUN ANIMATE_BEING NOUN_SINGULAR(~FAMILY_ADULT ~FAMILY_CHILDREN twin relative ) 13 | 14 | 15 | 16 | concept: ~do_with_titles (~watch ~buy ~own ~like ~hear ~read) 17 | 18 | concept: ~sports_ball (badminton ball baseball basketball billiards bocce bocce_ball boules bowl bowling cricket croquet field_hockey football golf handball hardball hockey hurling juggling lacrosse netball paddle_ball paintball Ping-Pong polo pool racquetball rugby soccer softball squash stickball tennis tetherball volleyball ) 19 | 20 | 21 | concept: ~extensions (and but although yet still) 22 | concept: ~than (then than) 23 | concept: ~propername (tom mary) 24 | 25 | 26 | concept: ~adjectives (tall short ) 27 | 28 | -------------------------------------------------------------------------------- /test/fixtures/concepts/third.top: -------------------------------------------------------------------------------- 1 | concept: ~num (one two three ~own) -------------------------------------------------------------------------------- /test/fixtures/concepts/verb.top: -------------------------------------------------------------------------------- 1 | concept: ~own (belong brandish clasp clutch grab grasp grip handle have hold keep own possess retain 2 | wield ) -------------------------------------------------------------------------------- /test/fixtures/continue/main.ss: -------------------------------------------------------------------------------- 1 | > topic random2 2 | 3 | + new conversation 4 | - What is your name? 5 | 6 | + [my name is] *1 7 | % What is your name 8 | - So your first name is ? 9 | 10 | + yes 11 | % So your first name is * 12 | - Okay good. 13 | 14 | + no 15 | % So your first name is * 16 | - Oh, lets try this again... {@new conversation} 17 | 18 | + * 19 | % What is your name 20 | - okay nevermind 21 | 22 | + break out 23 | - okay we are free 24 | 25 | < topic 26 | 27 | > topic random {keep} 28 | 29 | + i went to highschool 30 | - did you finish ? 31 | 32 | + * what happened 33 | % did you finish ? 34 | - i went to university 35 | - what was it like? 36 | 37 | 38 | + i like to travel 39 | - have you been to Madird? 40 | + yes * 41 | % have you been to Madird? 42 | - Madird is amazing. 43 | 44 | + no * 45 | % have you been to Madird? 46 | - Madird is my favorite city. 47 | 48 | 49 | + something random 50 | - What is your favorite color? 51 | 52 | + *1 53 | % What is your favorite color? 54 | - is mine too. 55 | 56 | + (blue|green) 57 | % What is your favorite color? 58 | - I hate that color. 59 | 60 | 61 | + test complex 62 | - reply test {@__complex__} 63 | 64 | + cool 65 | % * super compound * 66 | - it works 67 | 68 | + __complex__ 69 | - super compound 70 | 71 | // Testing conversation exaustion GH-133 from slack 72 | + conversation 73 | - Are you happy? 74 | + yes 75 | % are you happy 76 | - OK, so you are happy 77 | 78 | + no 79 | % are you happy 80 | - OK, so you are not happy 81 | 82 | + * 83 | % are you happy 84 | - OK, so you don't know 85 | 86 | + something else 87 | - Random reply 88 | 89 | 90 | // GH-133 example from gh issues 91 | // This has **some** the same gambits as the example above. 92 | // TODO - GH-162 93 | /* 94 | + start 95 | - What is your name? 96 | 97 | + [my name is] *1 98 | % * what is your name * 99 | - So your first name is ? 100 | 101 | + [my name is] *1 *1 102 | % * what is your name * 103 | - So your first name is ? 104 | 105 | + ~yes * 106 | % * so your first name is * 107 | - That's a nice name. 108 | 109 | + ~no * 110 | % * so your first name is * 111 | - I'm a bit confused. 112 | */ 113 | 114 | // GH-152 matching on sub-replies 115 | + lastreply one 116 | - lastreply one ok 117 | 118 | + lastreply two 119 | % lastreply one ok 120 | - lastreply exists 121 | 122 | // GH-206 123 | + __start__ 124 | - match here {id=123, bool=true, str="string" } 125 | 126 | %% (bool == true) 127 | + boo ya 128 | - YES 129 | 130 | %% (id == 123) 131 | - winning 132 | 133 | // GH-207 134 | 135 | + start 2 (*) or *1 136 | - reply 2 137 | 138 | + 2 match (*) 139 | % reply 2 * 140 | - reply 3 141 | 142 | 143 | + this is a triumph 144 | - I'm making a note here, huge success 145 | 146 | + it is hard to overstate my satisfaction 147 | % I'm making a note here, huge success 148 | - aperture science {@__portal__} {clear} 149 | 150 | + * 151 | % I'm making a note here, huge success 152 | - That's the wrong lyric, you goon {@__portal__} {clear} 153 | 154 | + __portal__ 155 | - The cake is a lie 156 | 157 | + I like cake 158 | - do you like portal 159 | 160 | // GH-357 161 | 162 | + test linebreak 163 | - first reply\n 164 | ^ please 165 | 166 | + test linebreak 167 | % * please * 168 | - second reply 169 | 170 | < topic 171 | -------------------------------------------------------------------------------- /test/fixtures/multitenant1/main.ss: -------------------------------------------------------------------------------- 1 | + must reply to this 2 | - in tenancy one 3 | 4 | + * 5 | - catch all 6 | -------------------------------------------------------------------------------- /test/fixtures/multitenant2/main.ss: -------------------------------------------------------------------------------- 1 | + must not reply to this 2 | - in tenancy two 3 | 4 | + * 5 | - catch all 6 | -------------------------------------------------------------------------------- /test/fixtures/redirect/redirects.ss: -------------------------------------------------------------------------------- 1 | // Redirect Test 2 | + redirect landing 3 | - {keep} redirect test pass 4 | 5 | + testing redirects 6 | @ redirect landing 7 | 8 | + this is an inline redirect 9 | - lets redirect to {@redirect landing} 10 | 11 | 12 | + this is an complex redirect 13 | - this {@message} is made up of {@bar} teams 14 | 15 | + message 16 | - game 17 | 18 | + one 19 | - 1 20 | 21 | + bar 22 | - bar 23 | 24 | 25 | + this is an nested redirect 26 | - this {@nested message} 27 | 28 | + nested message 29 | - message contains {@another message} 30 | 31 | + another message 32 | - secrets 33 | 34 | + this is a bad idea 35 | - this {@deep message loop} 36 | 37 | + deep message loop 38 | - and back {@this is a bad idea} 39 | 40 | // Dummy entry 41 | + pass the cheese 42 | - Thanks 43 | 44 | 45 | // Redirect to a topic 46 | + hello * 47 | - ^topicRedirect("weather","__to_say__") 48 | 49 | 50 | // GH-227 51 | + issue 227 52 | - ^one()^topicRedirect("weather","__to_say__") 53 | 54 | 55 | // GH-156 56 | + test missing topic 57 | - ^topicRedirect("supercalifragilisticexpialidocious","hello") Test OK. 58 | 59 | // GH-81 Function with redirect 60 | 61 | + tell me a random fact 62 | - {keep} Okay, here's a fact: ^one() . {@_post_random_fact} 63 | 64 | + _post_random_fact 65 | - Would you like me to tell you another fact? 66 | 67 | + tell me a random fact 2 68 | - {keep} Okay, here's a fact. {@_post_random_fact2} 69 | 70 | + _post_random_fact2 71 | - ^one() Would you like me to tell you another fact? 72 | 73 | 74 | // Odd.. 75 | + GitHub issue 92 76 | - testing redirects {@ _one_thing } {@_two_thing } 77 | 78 | + _one_thing 79 | - one thing 80 | 81 | + _two_thing 82 | - two thing 83 | 84 | > topic weather {keep} 85 | 86 | + __to_say__ 87 | - Is it hot 88 | 89 | // Dummy entry 90 | + pass the cheese 91 | - Thanks 92 | 93 | < topic 94 | 95 | 96 | // Go to a topic Dynamically Spoiler alert it is school 97 | + i like *1 98 | - ^topicRedirect(,"__to_say__") 99 | 100 | > topic school 101 | 102 | + __to_say__ 103 | - I'm majoring in CS. 104 | 105 | < topic 106 | 107 | // Redirect to a topic 2 108 | 109 | + topic redirect test 110 | - Say this. ^topicRedirect("testx","__to_say__") 111 | 112 | > topic testx 113 | 114 | + __to_say__ 115 | - Say that. 116 | 117 | < topic 118 | 119 | 120 | + topic redirect to *1 121 | - ^topicRedirect("test2","__to_say__") 122 | 123 | > topic test2 124 | 125 | + __to_say__ 126 | - Capture forward 127 | 128 | < topic 129 | 130 | 131 | + topic set systest 132 | - Setting systest. ^changetopic("systest") 133 | 134 | > topic hidden {system} 135 | + I am hidden 136 | - You can't find me. 137 | < topic 138 | 139 | > topic systest {system} 140 | + where am I 141 | - In systest. 142 | < topic 143 | 144 | > topic __post__ 145 | + * 146 | - {keep} must not match post. 147 | < topic 148 | 149 | > topic preview_words (preview) {keep} 150 | + __preview 151 | - {@__preview_question_kickoff} 152 | 153 | + yes 154 | % {@__preview_question_kickoff} 155 | - Great, let's play! 156 | 157 | + no 158 | % {@__preview_question_kickoff} 159 | - No? Alright, let's play a differnt game! 160 | 161 | + * 162 | % {@__preview_question_kickoff} 163 | - OK, let's play! 164 | 165 | + __preview_question_kickoff 166 | - Do you want to play word games? 167 | - Let's play word games 168 | 169 | < topic 170 | -------------------------------------------------------------------------------- /test/fixtures/replies/main.ss: -------------------------------------------------------------------------------- 1 | > topic exhaust_topic {exhaust} 2 | 3 | // This is the default reply behaviour 4 | + {random} test exhaust random 5 | - reply one 6 | - reply two 7 | - reply three 8 | 9 | + {ordered} test exhaust ordered 10 | - reply one 11 | - reply two 12 | - reply three 13 | 14 | < topic 15 | 16 | > topic keep_topic {keep} 17 | 18 | + {random} test keep random 19 | - reply one 20 | - reply two 21 | - reply three 22 | 23 | + {ordered} test keep ordered 24 | - reply one 25 | - reply two 26 | - reply three 27 | 28 | < topic 29 | 30 | > topic reload_topic {reload} 31 | 32 | + {random} test reload random 33 | - reply one 34 | - reply two 35 | - reply three 36 | 37 | + {ordered} test reload ordered 38 | - reply one 39 | - reply two 40 | - reply three 41 | 42 | < topic -------------------------------------------------------------------------------- /test/fixtures/script/script.ss: -------------------------------------------------------------------------------- 1 | > topic random 2 | 3 | + + this is unscaped 4 | - This should pass 5 | 6 | + * bone * 7 | - {keep} win 1 8 | 9 | // Simple Test 10 | + This is a test 11 | - Test should pass one 12 | 13 | // Star match 14 | + connect the * 15 | - Test should pass 16 | 17 | // Test single and double star 18 | + Should match single * 19 | - {keep} pass 1 20 | - {keep} pass 2 21 | - {keep} pass 3 22 | 23 | /* 24 | Variable length Star 25 | *2 will match exactly 2 26 | *~2 will match 0,2 27 | */ 28 | // Should match it is foo bar hot out 29 | + It is *2 hot out 30 | - Test three should pass 31 | 32 | // Should match it foo hot out 2 33 | // Should match it hot out 2 34 | + It is *~2 hot out 2 35 | - {keep} pass 1 36 | - {keep} pass 2 37 | - {keep} pass 3 38 | 39 | + var length *~2 40 | - {keep} pass 1 41 | 42 | // Min Max Test 43 | + min max *(1-2) 44 | - {keep} min max test 45 | 46 | // Min Max Test 47 | + test 2 min max *(0-1) 48 | - {keep} min max test 49 | 50 | // Min Max emo GH-221 51 | + *(1-2) test test 52 | - {keep} emo reply 53 | 54 | // GH-211 55 | + test *(1-99) 56 | - {keep} test 57 | 58 | // Test 2 Star match 59 | + It is *2 cold out 60 | - Two star result 61 | 62 | // varwidth star end case 63 | + define *~1 64 | - Test endstar should pass 65 | 66 | // fixedwidth star end case 67 | + fixedwidth define *1 68 | - Test endstar should pass 69 | 70 | + * (or) * 71 | - alter boundry test 72 | 73 | + * (a|b|c) * (d|e|f) 74 | - alter boundry test 2 75 | 76 | // Alternates 77 | + What (day|week) is it 78 | - {keep} Test four should pass 79 | 80 | // Optionals 81 | + i have a [red|green|blue] car 82 | - {keep} Test five should pass 83 | 84 | // Mix case testing 85 | + THIS IS ALL CAPITALS 86 | - Test six must pass 87 | 88 | + this reply is random 89 | - yes this reply is ((awesome|random)) 90 | 91 | + reply with wordnet 92 | - i ~like people 93 | 94 | // In this example we want to demonstrate that the trigger 95 | // is changed to "it is ..." before trying to find a match 96 | + it's all good in the hood 97 | - normalize trigger test 98 | 99 | // Replies accross triggers should be allowd, even if the reply is identical 100 | + trigger 1 101 | - generic reply 102 | + trigger 2 103 | - generic reply 104 | 105 | // Reply Flags 106 | + reply flags 107 | - say one thing 108 | - {keep} say something else 109 | 110 | + reply flags 2 111 | - {keep} keep this 112 | 113 | + error with function (*) 114 | - ^num() 115 | 116 | // Custom functions! 117 | + custom *1 118 | - ^wordnetDefine() 119 | 120 | + custom 2 *1 121 | - ^wordnetDef() 122 | 123 | + custom 3 *1 124 | - ^bail() 125 | 126 | + custom 3 function 127 | - backup plan 128 | 129 | + custom 4 *1 130 | - ^one() + ^one() = 2 131 | 132 | + custom 5 *1 133 | - he ^plural("like") this 134 | 135 | + custom 6 *1 136 | - he ^plural(~like) this 137 | 138 | + custom 7 *1 139 | - he ^plural() this 140 | 141 | + custom 8 *1 142 | - ^num("4") + ^num("3") = 7 143 | 144 | + custom 9 *1 145 | - a\n 146 | ^ b\n 147 | ^ ^one()\n\n 148 | ^ more 149 | 150 | // We pull in wordnet and system facts 151 | + I ~like shoe 152 | - Wordnet test one 153 | 154 | + I love ~SPORTS_BALL * 155 | - {keep} Term expanded 156 | 157 | + my ~family_members be fat 158 | - {keep} Ouch 159 | 160 | + what is one plus one 161 | - It is two. 162 | 163 | + how many (letters|chars|characters) [are there] in [the word] *~3 164 | - {keep} ^wordLength() 165 | 166 | + what [letter] (comes|is) (after|before) *~1 167 | - {keep} ^letterLookup() 168 | 169 | // What is the nth letter in the alphabet? 170 | // What is the first letter in the alphabet? 171 | // What is the last letter in the alphabet? 172 | + what [is] [the] * letter (in|of) the [english] alphabet 173 | - {keep} ^letterLookup() 174 | 175 | + call function with new topic 176 | - ^changetopic("fish") 177 | 178 | + reply with a new topic from function 179 | - ^changefunctionreply("fish") 180 | 181 | // This will save the name to the internal fact system for this user. 182 | + save name *1 183 | - {keep} ^save("name", ) Hi . 184 | 185 | + {^not("filter|filterx")} trigger *1 function 186 | - trigger filter reply 187 | 188 | + can you smile 189 | - ^addMessageProp("emoji","smile") Sure can. 190 | 191 | + object param 1 192 | - ^objparam1() 193 | 194 | + object param 2 195 | - ^objparam2() ^addMessageProp("foo", "bar") 196 | 197 | // Object params though topicRedirect 198 | + object param 3 199 | - ^addMessageProp("foo", "bar") ^topicRedirect("test_topic", "__objParams__") 200 | 201 | // Reply Filter functions 202 | + my name is 203 | - {^hasName("false")} ^save("name",) Nice to meet you, . 204 | - {^hasName("true")} I know, you already told me your name. 205 | 206 | ? * your name 207 | - My name is Brit. 208 | 209 | + i go by *~4 210 | - {keep} so you go by 211 | 212 | // Moved over from qtypes (isQuestion test) 213 | ? * bathroom 214 | - {keep} Down the hall on the left 215 | 216 | < topic 217 | 218 | // Object params though topicRedirect (related topic) 219 | > topic test_topic {keep} 220 | + __objParams__ 221 | - ^objparam1() 222 | < topic 223 | 224 | > topic fish 225 | 226 | + I like fish 227 | - me too 228 | < topic 229 | 230 | 231 | + generic message 232 | - {keep} generic reply ^showScope() 233 | 234 | + generic message 2 235 | - {keep} generic reply ^showScope() 236 | 237 | 238 | // Style Tests 239 | 240 | // Mix case testing 241 | + THIS IS ALL CAPITALS 242 | - {keep} Test six must pass 243 | 244 | + Do you have a clue 245 | - Test seven must pass 246 | 247 | + Do you have a cause 248 | - Test seven must pass 249 | 250 | + Do you have a condition 251 | - Test seven must pass 252 | 253 | + John is older than Mary and Mary is older than Sarah 254 | - Test eight must pass 255 | 256 | // should match without commas 257 | + is it morning noon night 258 | - Test nine must pass 259 | 260 | // Remove Quotes 261 | + remove quotes around car 262 | - Test ten must pass 263 | 264 | + reply quotes 265 | - Test "eleven" must pass 266 | 267 | // Test Multiple line output 268 | + tell me a poem 269 | - Little Miss Muffit sat on her tuffet,\n 270 | ^ In a nonchalant sort of way.\n 271 | ^ With her forcefield around her,\n 272 | ^ The Spider, the bounder,\n 273 | ^ Is not in the picture today. 274 | 275 | 276 | // In this example we want to demonstrate that the trigger 277 | // is changed to "it is ..." before trying to find a match 278 | + it's all good in the hood 279 | - normalize trigger test 280 | 281 | + it's all good in the hood 2 282 | - normalize trigger test 283 | 284 | + I ~like basketball 285 | - Wordnet test one 286 | 287 | 288 | + spaced out 289 | - note the space\s\s 290 | 291 | // Sub Replies 292 | + redirect_rainbow 293 | - ^topicRedirect("rainbow","__delay__") 294 | 295 | > topic rainbow 296 | + __delay__ 297 | - red\n 298 | ^ {delay=500} orange\n 299 | ^ {delay=500} yellow\n 300 | ^ {delay=500} green\n 301 | ^ {delay=500} blue\n 302 | ^ {delay=500} and black? 303 | 304 | + how many colors in the rainbow 305 | - {delay=500} lots 306 | < topic 307 | 308 | 309 | // Special topics flow with inline redirection 310 | > topic __pre__ 311 | + flow redirection test 312 | - Going back. {@flow match} 313 | < topic 314 | 315 | > topic flow_test 316 | + flow match 317 | - {keep} You are in the first reply. 318 | + next flow match 319 | - You are in the second reply. {@flow match} 320 | < topic 321 | 322 | // gh-173 323 | + name 324 | - {keep} ^respond("set_name") 325 | 326 | > topic set_name {keep, system} 327 | + * 328 | - What is your first name? 329 | 330 | + *~5 331 | % * is your first name? 332 | - ^save("firstName", ) Ok , what is your last name? 333 | 334 | + *~5 335 | % * what is your last name? 336 | - ^save("lastName", ) Thanks, ^get("firstName") ^get("lastName")! {topic=random} {clear } 337 | < topic 338 | 339 | 340 | > topic generic {keep, system} 341 | 342 | + __simple__ 343 | - ^breakFunc() 344 | 345 | + * 346 | - no match 347 | < topic 348 | 349 | 350 | // GH-243 351 | + filter by *1 352 | - {^word(,"logic")} logic 353 | - {^word(,"though")} though 354 | - {^word(,"ai")} ai 355 | 356 | 357 | + scope though redirect 358 | - ^topicRedirect("__A__", "__B__") 359 | 360 | > topic __A__ {keep, system} 361 | + __B__ 362 | - ^showScope() 363 | < topic 364 | 365 | + __preview 366 | - {@__preview_question_kickoff} ^addMessageProp("topLevelProp","myProp") 367 | 368 | + __preview_question_kickoff 369 | - Do you want to play word games? Yes? ^addMessageProp("subProp","mySubProp1") 370 | - Let's play word games OK? ^addMessageProp("subProp","mySubProp2") 371 | 372 | + let's test objects/arrays as custom function args 373 | - here's my answer ^testCustomArgs({myKey: "value"}, ['hey!']) 374 | 375 | + what if there's more tags in custom func 376 | - and the result is ^testMoreTags("super", "awesome") 377 | 378 | > topic super {keep} 379 | + awesome 380 | - yay 381 | < topic 382 | 383 | + {^hasTag("hello")} * 384 | - Greetings! 385 | 386 | + set a fact 387 | - that is a cool fact ^createUserFact("thisfact", "cooler", "thatfact") 388 | 389 | > topic testfoo {system} 390 | + *(3-99) 391 | - {keep} Caught by variable length 392 | 393 | + foo 394 | - {keep} Direct match 395 | < topic 396 | 397 | + redirect setup 398 | - {keep} ^topicRedirect("setup","setup") 399 | 400 | > topic setup {keep, system} 401 | + setup 402 | - who are you? 403 | 404 | + *~2 405 | % who are you 406 | - ^save(name,) Nice to meet you ! {topic=random} 407 | < topic 408 | 409 | > topic testkeep 410 | + {keep} we should keep this trigger 411 | - {@__partone__} some other text i dynamically generate {@__parttwo__} 412 | 413 | + __partone__ 414 | - part one reply 415 | 416 | + __parttwo__ 417 | - part two reply 418 | < topic 419 | -------------------------------------------------------------------------------- /test/fixtures/script/suba/subtop.ss: -------------------------------------------------------------------------------- 1 | > topic suba 2 | + this should exist 3 | - yes 4 | < topic -------------------------------------------------------------------------------- /test/fixtures/substitution/main.ss: -------------------------------------------------------------------------------- 1 | // subs 2 | + is here 3 | - {keep} hi 4 | 5 | + is taller than 6 | - {keep} is shorter than 7 | 8 | + to 9 | - {keep} okay 10 | -------------------------------------------------------------------------------- /test/fixtures/topicflags/topics.ss: -------------------------------------------------------------------------------- 1 | 2 | + topic change 3 | - Okay we are going to test2 {topic=test2} 4 | 5 | > topic test2 6 | + let us talk about testing 7 | - topic test pass 8 | < topic 9 | 10 | + set topic to dry 11 | - Okay we are going to dry {topic=dry} 12 | 13 | + set topic to dry again 14 | - Okay we are going to dry {topic=dry} 15 | 16 | + set topic to keeptopic 17 | - Okay we are going to keeptopic {topic=keeptopic} 18 | 19 | + set topic to nostay 20 | - Okay we are going to nostay {topic=nostay} 21 | 22 | + why did * 23 | - ^respond("system_why") 24 | 25 | + test recursion 26 | - ^respond("system_recurr") 27 | 28 | + testing nostay 29 | - ^topicRedirect("nostay", "_bounce_") 30 | 31 | 32 | + something else 33 | - reply in random 34 | 35 | // Testing sort 36 | + x * 37 | - Catch all 38 | 39 | + this * catch some 40 | - Catch some 41 | 42 | + this * catch * more 43 | - Catch more 44 | 45 | // test topic flow 46 | + testing * 47 | - ^respond("newHidden") 48 | 49 | + * go on 50 | - end 51 | 52 | > topic system_recurr {system} 53 | 54 | + test recursion 55 | - ^respond("hidden") 56 | 57 | < topic 58 | 59 | > topic system_why {system} 60 | + * you run 61 | - to get away from someone 62 | < topic 63 | 64 | > topic hidden {system} 65 | + this is a system topic 66 | - some reply 67 | < topic 68 | 69 | 70 | /* 71 | non-Keep Flag Test 72 | This topic will get depleated and stay empty after all 73 | Gambits have been exausted. 74 | */ 75 | > topic dry 76 | + i have 1 thing to say 77 | - dry topic test pass 78 | 79 | + this is a dry topic 80 | - dry topic test pass 81 | < topic 82 | 83 | /* 84 | Keep Flag Test 85 | We use the keep flag to allow us to reuse the gambit over and over 86 | */ 87 | > topic keeptopic {keep} 88 | + i have 1 thing to say 89 | - topic test pass 90 | < topic 91 | 92 | 93 | + respond test 94 | - ^respond("respond_test") 95 | 96 | > topic respond_test {system} 97 | 98 | + * 99 | - ^respond("respond_test2") 100 | 101 | < topic 102 | 103 | > topic respond_test2 {system} 104 | 105 | + * 106 | - final {topic=random} 107 | 108 | < topic 109 | 110 | 111 | /* 112 | NoStay Flag Test 113 | The nostay flag means the topic will change automatically back 114 | to the previous one after saying the gambit. 115 | */ 116 | 117 | > topic loaded {nostay, keep, system} 118 | + this topic is loaded 119 | - woot 120 | < topic 121 | 122 | 123 | > topic nostay {nostay} 124 | + _bounce_ 125 | - topic test pass 126 | 127 | + something else 128 | - reply in nostay 129 | < topic 130 | 131 | 132 | > topic newHidden {system} 133 | 134 | + testing hidden 135 | - some reply 136 | 137 | + yes 138 | % some reply 139 | - this must work. 140 | 141 | + no 142 | - wont work 143 | 144 | < topic 145 | 146 | + test no stay 147 | - {keep} {@__testnostay__} 148 | 149 | > topic __testnostay__ {nostay} 150 | + test no stay 151 | - {keep} Mustn't stay here. 152 | < topic 153 | -------------------------------------------------------------------------------- /test/fixtures/topichooks/main.ss: -------------------------------------------------------------------------------- 1 | + this is random 2 | - we are in random 3 | 4 | > pre 5 | + pre hook 6 | - yep pre hook 7 | < pre 8 | 9 | 10 | > post 11 | + post hook 12 | - yep post hook 13 | < post 14 | -------------------------------------------------------------------------------- /test/fixtures/topicsystem/main.ss: -------------------------------------------------------------------------------- 1 | 2 | + testing topic system 3 | - we like it 4 | - i hate it 5 | 6 | + testing topic * 7 | - we really like it 8 | 9 | + force break 10 | - not going to hit this. 11 | 12 | + force continue 13 | - force two 14 | 15 | + testing flow 16 | - bingo 17 | 18 | + break with continue 19 | - {CONTINUE} ended 20 | 21 | // With the continue bit set we should still hit this next one too 22 | + break * continue 23 | - test passed 24 | 25 | > topic __pre__ {keep} 26 | 27 | + testing topic system 28 | - ^save("key", "value") 29 | 30 | + force break 31 | - ^breakFunc() 32 | 33 | + force continue 34 | - ^nobreak() force one 35 | 36 | + testing flow 37 | - ^save("key", "value") 38 | 39 | < topic 40 | 41 | > topic filter1 ^filterTopic() {keep} 42 | + filter topic * 43 | - filter pass topic1 44 | < topic 45 | 46 | > topic filter2 ^filterTopic() {keep} 47 | + filter topic * 48 | - filter pass topic2 49 | < topic 50 | 51 | > topic outdoors ( fishing, hunting, camping ) {keep} 52 | 53 | + I like to * 54 | - i like to spend time outdoors 55 | 56 | + hiking is so much fun 57 | - I like to hike too! 58 | 59 | + I like to spend * 60 | - outdoors 61 | 62 | < topic 63 | 64 | 65 | > topic fishing (fish, fishing, to_fish, rod, worms) 66 | 67 | + I like to spend time * 68 | - fishing 69 | 70 | + I like to * 71 | - me too 72 | 73 | < topic 74 | 75 | 76 | // GH-240 77 | + test empty 78 | - ^topicRedirect("test", "__empty__") 79 | 80 | + generic redirect 81 | - ^topicRedirect("test", "__something__") 82 | 83 | + generic respond 84 | - ^respond("test") 85 | 86 | + test respond 87 | - ^respond("test") 88 | 89 | > topic test {keep} 90 | + __empty__ 91 | - {END} 92 | 93 | + test respond 94 | - {END} 95 | 96 | + __something__ 97 | - Something 98 | 99 | + * 100 | - Topic catchall 101 | < topic 102 | -------------------------------------------------------------------------------- /test/fixtures/user/main.ss: -------------------------------------------------------------------------------- 1 | 2 | + Save user token (*) 3 | - ^save("name", ) User token has been saved. 4 | 5 | + Get user token 6 | - Return ^get("name") 7 | 8 | + this is a test 9 | - this is user ^getUserId() 10 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import _ from 'lodash'; 3 | import async from 'async'; 4 | import sfacts from 'sfacts'; 5 | import parser from 'ss-parser'; 6 | 7 | import SuperScript from '../src/bot/index'; 8 | 9 | let bot; 10 | 11 | const getBot = function getBot() { 12 | return bot; 13 | }; 14 | 15 | const data = [ 16 | // './test/fixtures/concepts/bigrams.tbl', // Used in Reason tests 17 | // './test/fixtures/concepts/trigrams.tbl', 18 | // './test/fixtures/concepts/concepts.top', 19 | // './test/fixtures/concepts/verb.top', 20 | // './test/fixtures/concepts/color.tbl', 21 | // './test/fixtures/concepts/opp.tbl' 22 | ]; 23 | 24 | /* const botData = [ 25 | './test/fixtures/concepts/botfacts.tbl', 26 | './test/fixtures/concepts/botown.tbl', 27 | ];*/ 28 | 29 | // If you want to use data in tests, then use bootstrap 30 | const bootstrap = function bootstrap(cb) { 31 | sfacts.load('mongodb://localhost/superscripttest', data, true, (err, facts) => { 32 | if (err) { 33 | console.error(err); 34 | } 35 | cb(null, facts); 36 | }); 37 | }; 38 | 39 | const after = function after(end) { 40 | if (bot) { 41 | bot.factSystem.db.close(() => { 42 | // Kill the globals and remove any fact systems 43 | bot = null; 44 | async.each(['mongodb://localhost/superscripttest'], (item, next) => { 45 | sfacts.clean(item, next); 46 | }, end); 47 | }); 48 | } else { 49 | end(); 50 | } 51 | }; 52 | 53 | const parse = function parse(file, callback) { 54 | const fileCache = `${__dirname}/fixtures/cache/${file}.json`; 55 | fs.exists(fileCache, (exists) => { 56 | if (!exists) { 57 | bootstrap((err, factSystem) => { 58 | parser.parseDirectory(`${__dirname}/fixtures/${file}`, { factSystem }, (err, result) => { 59 | if (err) { 60 | return callback(err); 61 | } 62 | return callback(null, fileCache, result); 63 | }); 64 | }); 65 | } else { 66 | console.log(`Loading cached script from ${fileCache}`); 67 | let contents = fs.readFileSync(fileCache, 'utf-8'); 68 | contents = JSON.parse(contents); 69 | 70 | bootstrap((err, factSystem) => { 71 | if (err) { 72 | return callback(err); 73 | } 74 | const checksums = contents.checksums; 75 | return parser.parseDirectory(`${__dirname}/fixtures/${file}`, { factSystem, cache: checksums }, (err, result) => { 76 | if (err) { 77 | return callback(err); 78 | } 79 | const results = _.merge(contents, result); 80 | return callback(null, fileCache, results); 81 | }); 82 | }); 83 | } 84 | }); 85 | }; 86 | 87 | const saveToCache = function saveToCache(fileCache, result, callback) { 88 | fs.exists(`${__dirname}/fixtures/cache`, (exists) => { 89 | if (!exists) { 90 | fs.mkdirSync(`${__dirname}/fixtures/cache`); 91 | } 92 | return fs.writeFile(fileCache, JSON.stringify(result), (err) => { 93 | if (err) { 94 | return callback(err); 95 | } 96 | return callback(); 97 | }); 98 | }); 99 | }; 100 | 101 | const parseAndSaveToCache = function parseAndSaveToCache(file, callback) { 102 | parse(file, (err, fileCache, result) => { 103 | if (err) { 104 | return callback(err); 105 | } 106 | return saveToCache(fileCache, result, (err) => { 107 | if (err) { 108 | return callback(err); 109 | } 110 | return callback(null, fileCache); 111 | }); 112 | }); 113 | }; 114 | 115 | const setupBot = function setupBot(fileCache, multitenant, callback) { 116 | const options = { 117 | mongoURI: 'mongodb://localhost/superscripttest', 118 | factSystem: { 119 | clean: false, 120 | }, 121 | logPath: null, 122 | pluginsPath: null, 123 | importFile: fileCache, 124 | useMultitenancy: multitenant, 125 | }; 126 | 127 | return SuperScript.setup(options, (err, botInstance) => { 128 | if (err) { 129 | return callback(err); 130 | } 131 | bot = botInstance; 132 | return callback(); 133 | }); 134 | }; 135 | 136 | const before = function before(file, multitenant = false) { 137 | return (done) => { 138 | parseAndSaveToCache(file, (err, fileCache) => { 139 | if (err) { 140 | return done(err); 141 | } 142 | return setupBot(fileCache, multitenant, done); 143 | }); 144 | }; 145 | }; 146 | 147 | export default { 148 | after, 149 | before, 150 | getBot, 151 | parseAndSaveToCache, 152 | setupBot, 153 | }; 154 | -------------------------------------------------------------------------------- /test/multitenant.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | 7 | describe('SuperScript Multitenant', () => { 8 | before((done) => { 9 | helpers.parseAndSaveToCache('multitenant1', (err, fileCache) => { 10 | if (err) { 11 | return done(err); 12 | } 13 | return helpers.parseAndSaveToCache('multitenant2', (err2, fileCache2) => { 14 | if (err2) { 15 | return done(err2); 16 | } 17 | return helpers.setupBot(null, true, (err3) => { 18 | if (err3) { 19 | return done(err3); 20 | } 21 | return helpers.getBot().getBot('multitenant1').importFile(fileCache, (err) => { 22 | helpers.getBot().getBot('multitenant2').importFile(fileCache2, (err) => { 23 | done(); 24 | }); 25 | }); 26 | }); 27 | }); 28 | }); 29 | }); 30 | 31 | describe('Different tenancy', () => { 32 | it('should reply to trigger in tenancy', (done) => { 33 | helpers.getBot().getBot('multitenant1').reply('user1', 'must reply to this', (err, reply) => { 34 | should(reply.string).eql('in tenancy one'); 35 | done(); 36 | }); 37 | }); 38 | 39 | it('should not reply to trigger not in tenancy', (done) => { 40 | helpers.getBot().getBot('multitenant1').reply('user1', 'must not reply to this', (err, reply) => { 41 | should(reply.string).eql('catch all'); 42 | done(); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /test/processTags.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import should from 'should/as-function'; 4 | import fs from 'fs'; 5 | import peg from 'pegjs'; 6 | 7 | const grammar = fs.readFileSync(`${__dirname}/../src/bot/reply/reply-grammar.pegjs`, 'utf-8'); 8 | const parser = peg.generate(grammar, { trace: false }); 9 | 10 | const matchTags = function matchTags(reply, expectedTags) { 11 | const tags = parser.parse(reply); 12 | should(tags).deepEqual(expectedTags); 13 | }; 14 | 15 | // TODO: Test captures like , etc 16 | 17 | describe('Test if the reply tags PEG parser works', () => { 18 | it('match redirects', () => { 19 | const reply = '{@__greeting__} How are you today?'; 20 | const expectedTags = [ 21 | { 22 | type: 'redirect', 23 | trigger: '__greeting__', 24 | }, 25 | ' How are you today?', 26 | ]; 27 | matchTags(reply, expectedTags); 28 | }); 29 | 30 | it('match topicRedirects', () => { 31 | const reply = 'hello ^topicRedirect("topicName","trigger") '; 32 | const expectedTags = [ 33 | 'hello ', 34 | { 35 | type: 'topicRedirect', 36 | functionArgs: '["topicName","trigger"]', 37 | }, 38 | ' ', 39 | ]; 40 | matchTags(reply, expectedTags); 41 | }); 42 | 43 | it('match responds', () => { 44 | const reply = 'hello ^respond("topicName") '; 45 | const expectedTags = [ 46 | 'hello ', 47 | { 48 | type: 'respond', 49 | functionArgs: '["topicName"]', 50 | }, 51 | ' ', 52 | ]; 53 | matchTags(reply, expectedTags); 54 | }); 55 | 56 | it('match custom functions', () => { 57 | const reply = 'the weather is ^getWeather("today","") today'; 58 | const expectedTags = [ 59 | 'the weather is ', 60 | { 61 | type: 'customFunction', 62 | functionName: 'getWeather', 63 | functionArgs: '["today",""]', 64 | }, 65 | ' today', 66 | ]; 67 | matchTags(reply, expectedTags); 68 | }); 69 | 70 | it('match wordnet expressions', () => { 71 | const reply = 'I ~like ~sport'; 72 | const expectedTags = [ 73 | 'I ', 74 | { 75 | type: 'wordnetLookup', 76 | term: 'like', 77 | }, 78 | ' ', 79 | { 80 | type: 'wordnetLookup', 81 | term: 'sport', 82 | }, 83 | ]; 84 | matchTags(reply, expectedTags); 85 | }); 86 | 87 | it('match clear', () => { 88 | const reply = '{clear } clear me'; 89 | const expectedTags = [ 90 | { 91 | type: 'clearConversation', 92 | }, 93 | ' clear me', 94 | ]; 95 | matchTags(reply, expectedTags); 96 | }); 97 | 98 | it('match continue', () => { 99 | const reply = 'you should check more {CONTINUE}'; 100 | const expectedTags = [ 101 | 'you should check more ', 102 | { 103 | type: 'continueSearching', 104 | }, 105 | ]; 106 | matchTags(reply, expectedTags); 107 | }); 108 | 109 | it('match end', () => { 110 | const reply = 'STOP searching { end }'; 111 | const expectedTags = [ 112 | 'STOP searching ', 113 | { 114 | type: 'endSearching', 115 | }, 116 | ]; 117 | matchTags(reply, expectedTags); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/redirect.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | 7 | describe('SuperScript Redirects', () => { 8 | before(helpers.before('redirect')); 9 | 10 | describe('Dont trim whitespace from redirect (GH-92)', () => { 11 | it('this needs to work..', (done) => { 12 | helpers.getBot().reply('user1', 'GitHub issue 92', (err, reply) => { 13 | should(reply.string).eql('testing redirects one thing two thing'); 14 | done(); 15 | }); 16 | }); 17 | }); 18 | 19 | describe('Redirect Interface', () => { 20 | it('should redirect on match', (done) => { 21 | helpers.getBot().reply('user1', 'testing redirects', (err, reply) => { 22 | should(reply.string).eql('redirect test pass'); 23 | done(); 24 | }); 25 | }); 26 | }); 27 | 28 | describe('Inline Redirect Interface', () => { 29 | it('should redirect on match', (done) => { 30 | helpers.getBot().reply('user1', 'this is an inline redirect', (err, reply) => { 31 | should(reply.string).eql('lets redirect to redirect test pass'); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('Inline Redirect two message in one reply', () => { 38 | it('should redirect on match complex message', (done) => { 39 | helpers.getBot().reply('user1', 'this is an complex redirect', (err, reply) => { 40 | should(reply.string).eql('this game is made up of bar teams'); 41 | done(); 42 | }); 43 | }); 44 | }); 45 | 46 | describe('Inline Redirect Interface nested inline redirects', () => { 47 | it('should redirect on match complex nested message', (done) => { 48 | helpers.getBot().reply('user1', 'this is an nested redirect', (err, reply) => { 49 | should(reply.string).eql('this message contains secrets'); 50 | done(); 51 | }); 52 | }); 53 | }); 54 | 55 | describe('Inline Redirect recurrsion!', () => { 56 | it('should redirect should save itself', (done) => { 57 | helpers.getBot().reply('user1', 'this is a bad idea', (err, reply) => { 58 | should(reply.string).not.be.empty; 59 | done(); 60 | }); 61 | }); 62 | }); 63 | 64 | describe('Inline Redirect with function GH-81', () => { 65 | it('should parse function and redirect', (done) => { 66 | helpers.getBot().reply('user1', 'tell me a random fact', (err, reply) => { 67 | should(reply.string).not.be.empty; 68 | should(reply.string).containEql("Okay, here's a fact: one . Would you like me to tell you another fact?"); 69 | done(); 70 | }); 71 | }); 72 | 73 | it('should parse function and redirect', (done) => { 74 | helpers.getBot().reply('user1', 'tell me a random fact 2', (err, reply) => { 75 | should(reply.string).not.be.empty; 76 | should(reply.string).containEql("Okay, here's a fact. one Would you like me to tell you another fact?"); 77 | done(); 78 | }); 79 | }); 80 | }); 81 | 82 | describe('Redirect to new topic', () => { 83 | // GH-156 84 | it("if redirect does not exist - don't crash", (done) => { 85 | helpers.getBot().reply('user1', 'test missing topic', (err, reply) => { 86 | should(reply.string).eql('Test OK.'); 87 | done(); 88 | }); 89 | }); 90 | 91 | // GH-227 92 | it('Missing function', (done) => { 93 | helpers.getBot().reply('user1', 'issue 227', (err, reply) => { 94 | should(reply.string).eql('oneIs it hot'); 95 | done(); 96 | }); 97 | }); 98 | 99 | it('should redirect to new topic', (done) => { 100 | helpers.getBot().reply('user1', 'hello', (err, reply) => { 101 | should(reply.string).eql('Is it hot'); 102 | done(); 103 | }); 104 | }); 105 | 106 | it('should redirect to new topic dynamically', (done) => { 107 | helpers.getBot().reply('user1', 'i like school', (err, reply) => { 108 | should(reply.string).eql("I'm majoring in CS."); 109 | done(); 110 | }); 111 | }); 112 | 113 | it('should redirect to new topic Inline', (done) => { 114 | helpers.getBot().reply('user1', 'topic redirect test', (err, reply) => { 115 | should(reply.string).eql('Say this. Say that.'); 116 | done(); 117 | }); 118 | }); 119 | 120 | xit('should redirect forward capture', (done) => { 121 | helpers.getBot().reply('user1', 'topic redirect to fishsticks', (err, reply) => { 122 | should(reply.string).eql('Capture forward fishsticks'); 123 | done(); 124 | }); 125 | }); 126 | }); 127 | 128 | describe('Set topic through plugin and match gambit in the topic in next reply', () => { 129 | it('should redirect to system topic', (done) => { 130 | helpers.getBot().reply('user1', 'topic set systest', (err, r1) => { 131 | should(r1.string).eql('Setting systest.'); 132 | helpers.getBot().reply('user1', 'where am I', (err, r2) => { 133 | should(r2.string).eql('In systest.'); 134 | done(); 135 | }); 136 | }); 137 | }); 138 | }); 139 | 140 | describe('GH-309: conversations should work with redirects', () => { 141 | it('Should be part of a conversation', (done) => { 142 | helpers.getBot().reply('user2', '__preview', (err, r1) => { 143 | should(['Do you want to play word games?', "Let's play word games"]).containEql(r1.string); 144 | helpers.getBot().reply('user2', 'yes', (err, r2) => { 145 | should(r2.string).eql("Great, let's play!"); 146 | helpers.getBot().reply('user2', 'hello', (err, r3) => { 147 | should(r3.string).eql("OK, let's play!"); 148 | done(); 149 | }); 150 | }); 151 | }); 152 | }); 153 | }); 154 | 155 | 156 | after(helpers.after); 157 | }); 158 | -------------------------------------------------------------------------------- /test/replies.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | import async from 'async'; 7 | 8 | describe('SuperScript Replies', () => { 9 | before(helpers.before('replies')); 10 | 11 | 12 | let itor = function(user) { 13 | return function(msg, next) { 14 | helpers.getBot().reply(user, msg, (err, reply) => { 15 | next(err, reply.string); 16 | }); 17 | }; 18 | } 19 | 20 | describe('replies exhaust', () => { 21 | describe('random', () => { 22 | it('should exhaused replies randomly', (done) => { 23 | var data = (new Array(3)).fill('test exhaust random'); 24 | async.mapSeries(data, itor('us2'), (err, replies1) => { 25 | async.mapSeries(data, itor('us3'), (err, replies2) => { 26 | should.notDeepEqual(replies1, replies2); 27 | done(); 28 | }); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('ordered', () => { 34 | it('should exaused replies orderedly', (done) => { 35 | var data = (new Array(4)).fill('test exhaust ordered'); 36 | async.mapSeries(data, itor('us4'), (err, replies1) => { 37 | should(replies1).deepEqual(['reply one', 'reply two', 'reply three', '']); 38 | done(); 39 | }); 40 | }); 41 | }); 42 | }); 43 | 44 | 45 | describe('replies keep', () => { 46 | describe('random', () => { 47 | it('should keep replies randomly', (done) => { 48 | var data = (new Array(3)).fill('test keep random'); 49 | async.mapSeries(data, itor('us2'), (err, replies1) => { 50 | async.mapSeries(data, itor('us3'), (err, replies2) => { 51 | should.notDeepEqual(replies1, replies2); 52 | done(); 53 | }); 54 | }); 55 | }); 56 | }); 57 | 58 | describe('ordered', () => { 59 | it('should keep replies orderedly', (done) => { 60 | var data = (new Array(4)).fill('test keep ordered'); 61 | async.mapSeries(data, itor('us4'), (err, replies1) => { 62 | should(replies1).deepEqual(['reply one', 'reply one', 'reply one', 'reply one']); 63 | done(); 64 | }); 65 | }); 66 | }); 67 | }); 68 | 69 | describe('replies reload', () => { 70 | describe('random', () => { 71 | it('should reload replies randomly', (done) => { 72 | var data = (new Array(7)).fill('test reload random'); 73 | async.mapSeries(data, itor('us5'), (err, replies1) => { 74 | async.mapSeries(data, itor('us6'), (err, replies2) => { 75 | should.notDeepEqual(replies1, replies2); 76 | should.notEqual(replies1[3], ''); 77 | should.notEqual(replies2[3], ''); 78 | done(); 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | describe('ordered', () => { 85 | it('should reload replies orderedly', (done) => { 86 | var data = (new Array(4)).fill('test reload ordered'); 87 | async.mapSeries(data, itor('us4'), (err, replies1) => { 88 | should(replies1).deepEqual(['reply one', 'reply two', 'reply three', 'reply one']); 89 | done(); 90 | }); 91 | }); 92 | }); 93 | }); 94 | after(helpers.after); 95 | }); 96 | -------------------------------------------------------------------------------- /test/subs.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | 7 | describe('SuperScript substitution Interface', () => { 8 | before(helpers.before('substitution')); 9 | 10 | describe('Message Subs', () => { 11 | it('name subsitution', (done) => { 12 | helpers.getBot().reply('user1', 'Ashley is here', (err, reply) => { 13 | should(reply.string).eql('hi Ashley'); 14 | done(); 15 | }); 16 | }); 17 | 18 | it('name subsitution - 2', (done) => { 19 | helpers.getBot().reply('user1', 'Ashley is taller than Heather', (err, reply) => { 20 | should(reply.string).eql('Heather is shorter than Ashley'); 21 | done(); 22 | }); 23 | }); 24 | 25 | it('name subsitution - 3', (done) => { 26 | helpers.getBot().reply('user1', 'John Ellis is taller than Heather Allen', (err, reply) => { 27 | should(reply.string).eql('Heather Allen is shorter than John Ellis'); 28 | done(); 29 | }); 30 | }); 31 | 32 | it('verb pronoun noun subsitution ', (done) => { 33 | helpers.getBot().reply('user1', 'She ran to Vancouver', (err, reply) => { 34 | should(reply.string).eql('okay'); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | 40 | after(helpers.after); 41 | }); 42 | -------------------------------------------------------------------------------- /test/test-regexes.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import should from 'should/as-function'; 4 | import regexes from '../src/bot/regexes'; 5 | 6 | describe('The shared regular expressions', () => { 7 | it('filters should match “hello ^filterName(foo,, baz) !” expressions', () => { 8 | const m = 'hello ^filterName(foo,, baz) !'.match(regexes.filter); 9 | should(m.length).equal(3); 10 | should(m[1]).equal('filterName'); 11 | should(m[2]).equal('foo,, baz'); 12 | should(m.index).equal(6); 13 | }); 14 | 15 | it('delay should match “this {delay = 400}” expressions', () => { 16 | should('this {delay = 400}'.match(regexes.delay)[1]).equal('400'); 17 | should('{delay=300} testing'.match(regexes.delay)[1]).equal('300'); 18 | should('{ delay =300} test'.match(regexes.delay)[1]).equal('300'); 19 | should('{ delay =300 } test'.match(regexes.delay)[1]).equal('300'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/topicflags.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | import { findPendingTopicsForUser } from '../src/bot/getReply/getPendingTopics'; 7 | 8 | describe('SuperScript Topics', () => { 9 | before(helpers.before('topicflags')); 10 | 11 | describe('Topic Functions', () => { 12 | // The length of this should equal five (at present): this excludes system topics which 13 | // are not searched by default, and includes the random topic (it always does). 14 | it('should fetch a list of topics', (done) => { 15 | helpers.getBot().findOrCreateUser('user1', async (err, user) => { 16 | const message = { lemString: 'hello world' }; 17 | 18 | const topics = await findPendingTopicsForUser(user, message, helpers.getBot().chatSystem); 19 | should(topics).not.be.empty; 20 | should(topics).have.length(6); 21 | done(); 22 | }); 23 | }); 24 | 25 | it('find topic by Name', (done) => { 26 | helpers.getBot().chatSystem.Topic.findByName('random', (err, topic) => { 27 | should(topic).not.be.empty; 28 | done(); 29 | }); 30 | }); 31 | }); 32 | 33 | describe('Topics - System', () => { 34 | it('topic should have system flag', (done) => { 35 | helpers.getBot().reply('user1', 'this is a system topic', (err, reply) => { 36 | should(reply.string).be.empty; 37 | done(); 38 | }); 39 | }); 40 | 41 | // Re-check this 42 | it('Go to hidden topic indirectly', (done) => { 43 | helpers.getBot().reply('user1', 'why did you run', (err, reply) => { 44 | // This really just makes sure the reply is not accesses directly 45 | should(reply.string).eql('to get away from someone'); 46 | should(reply.topicName).eql('system_why'); 47 | done(); 48 | }); 49 | }); 50 | 51 | it('topic recurrsion with respond', (done) => { 52 | helpers.getBot().reply('user1', 'test recursion', (err, reply) => { 53 | should(reply.string).eql(''); 54 | done(); 55 | }); 56 | }); 57 | }); 58 | 59 | describe('Topic - sort', () => { 60 | it('topic should not be orderd by default', (done) => { 61 | helpers.getBot().reply('user1', 'this must catch some', (err, reply) => { 62 | helpers.getBot().chatSystem.Topic.findByName('random', (err, topic) => { 63 | topic.createGambit({ input: 'this must catch some more' }, (er, gam) => { 64 | gam.addReply({ reply: 'New Reply' }, (err, rep) => { 65 | topic.sortGambits(() => { 66 | helpers.getBot().reply('user1', 'this must catch some more', (err, reply) => { 67 | should(reply.string).eql('New Reply'); 68 | done(); 69 | }); 70 | }); 71 | }); 72 | }); 73 | }); 74 | }); 75 | }); 76 | }); 77 | 78 | 79 | describe('Topic Flow', () => { 80 | it('topic flow 0', (done) => { 81 | helpers.getBot().reply('user1', 'respond test', (err, reply) => { 82 | should(reply.string).eql('final'); 83 | done(); 84 | }); 85 | }); 86 | 87 | it('topic flow 1', (done) => { 88 | helpers.getBot().reply('user 10', 'testing hidden', (err, reply) => { 89 | should(reply.string).eql('some reply'); 90 | 91 | helpers.getBot().reply('user 10', 'yes', (err, reply) => { 92 | should(reply.string).eql('this must work.'); 93 | done(); 94 | }); 95 | }); 96 | }); 97 | 98 | it('topic flow 2', (done) => { 99 | helpers.getBot().reply('user2', 'testing hidden', (err, reply) => { 100 | should(reply.string).eql('some reply'); 101 | 102 | helpers.getBot().reply('user2', 'lets not go on', (err, reply) => { 103 | should(reply.string).eql('end'); 104 | done(); 105 | }); 106 | }); 107 | }); 108 | }); 109 | 110 | describe('Topics - NoStay Flag', () => { 111 | it('topic should have keep flag', (done) => { 112 | helpers.getBot().reply('User1', 'testing nostay', (err, reply) => { 113 | should(reply.string).eql('topic test pass'); 114 | helpers.getBot().reply('User1', 'something else', (err, reply) => { 115 | should(reply.string).eql('reply in random'); 116 | done(); 117 | }); 118 | }); 119 | }); 120 | }); 121 | 122 | describe('Topics - Keep', () => { 123 | it('topic should have keep flag', (done) => { 124 | helpers.getBot().chatSystem.Topic.findByName('keeptopic', (err, t) => { 125 | should(t.keep).be.true; 126 | done(); 127 | }); 128 | }); 129 | 130 | it('should keep topic for reuse', (done) => { 131 | helpers.getBot().reply('user1', 'set topic to keeptopic', (err, reply) => { 132 | should(reply.string).eql('Okay we are going to keeptopic'); 133 | helpers.getBot().getUser('user1', (err, cu) => { 134 | should(cu.getTopic()).eql('keeptopic'); 135 | helpers.getBot().reply('user1', 'i have 1 thing to say', (err, reply) => { 136 | should(reply.string).eql('topic test pass'); 137 | helpers.getBot().reply('user1', 'i have 1 thing to say', (err, reply) => { 138 | should(reply.string).eql('topic test pass'); 139 | done(); 140 | }); 141 | }); 142 | }); 143 | }); 144 | }); 145 | 146 | 147 | it('should not repeat itself', (done) => { 148 | // Manually reset the topic 149 | helpers.getBot().findOrCreateUser('user1', (err, user) => { 150 | user.currentTopic = 'random'; 151 | 152 | helpers.getBot().reply('user1', 'set topic to dry', (err, reply) => { 153 | // Now in dry topic 154 | helpers.getBot().getUser('user1', (err, su) => { 155 | const ct = su.getTopic(); 156 | should(ct).eql('dry'); 157 | 158 | helpers.getBot().reply('user1', 'this is a dry topic', (err, reply) => { 159 | should(reply.string).eql('dry topic test pass'); 160 | // Say it again... 161 | helpers.getBot().reply('user1', 'this is a dry topic', (err, reply) => { 162 | // If something was said, we don't say it again 163 | should(reply.string).eql(''); 164 | done(); 165 | }); 166 | }); 167 | }); 168 | }); 169 | }); 170 | }); 171 | }); 172 | 173 | describe('gh-230', () => { 174 | it('nostay should not discard responses', (done) => { 175 | helpers.getBot().reply('user2', 'test no stay', (err, reply) => { 176 | should(reply.string).eql("Mustn't stay here."); 177 | done(); 178 | }); 179 | }); 180 | }); 181 | 182 | after(helpers.after); 183 | }); 184 | -------------------------------------------------------------------------------- /test/topichooks.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | 7 | // Testing topics that include and mixin other topics. 8 | describe('SuperScript Topic Hooks', () => { 9 | before(helpers.before('topichooks')); 10 | 11 | describe('Pre/Post Topic Hooks', () => { 12 | it('pre topic should be called', (done) => { 13 | helpers.getBot().chatSystem.Topic.findOne({ name: '__pre__' }, (err, res) => { 14 | should(res.reply_exhaustion).eql('keep'); 15 | should(res.gambits).have.length(1); 16 | done(); 17 | }); 18 | }); 19 | 20 | it('post topic should be called', (done) => { 21 | helpers.getBot().chatSystem.Topic.findOne({ name: '__post__' }, (err, res) => { 22 | should(res.reply_exhaustion).eql('keep'); 23 | should(res.gambits).have.length(1); 24 | done(); 25 | }); 26 | }); 27 | 28 | it('normal topic should be called', (done) => { 29 | helpers.getBot().chatSystem.Topic.findOne({ name: 'random' }, (err, res) => { 30 | should(res.gambits).have.length(1); 31 | done(); 32 | }); 33 | }); 34 | }); 35 | 36 | after(helpers.after); 37 | }); 38 | -------------------------------------------------------------------------------- /test/topicsystem.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import helpers from './helpers'; 6 | import { doesMatch, doesMatchTopic } from '../src/bot/getReply/helpers'; 7 | 8 | /* 9 | 10 | Proposed - New TopicSystem relationships. 11 | topic.createGambit(...) 12 | gambit.createReply(...) 13 | 14 | */ 15 | 16 | // Testing topics that include and mixin other topics. 17 | describe('SuperScript TopicsSystem', () => { 18 | before(helpers.before('topicsystem')); 19 | 20 | describe('TopicSystem', () => { 21 | it('Should skip empty replies until it finds a match', (done) => { 22 | helpers.getBot().reply('testing topic system', (err, reply) => { 23 | should(['we like it', 'i hate it']).containEql(reply.string); 24 | done(); 25 | }); 26 | }); 27 | 28 | it('Should break in function with third param', (done) => { 29 | helpers.getBot().reply('userx', 'force break', (err, reply) => { 30 | should(reply.string).eql(''); 31 | done(); 32 | }); 33 | }); 34 | 35 | it('Should continue in function with third param', (done) => { 36 | helpers.getBot().reply('userx', 'force continue', (err, reply) => { 37 | should(reply.string).eql('force one force two'); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('Should continue with a {CONTINUE} tag', (done) => { 43 | helpers.getBot().reply('userx', 'break with continue', (err, reply) => { 44 | should(reply.string).eql('ended test passed'); 45 | done(); 46 | }); 47 | }); 48 | }); 49 | 50 | 51 | // Test Single gambit 52 | describe('Test Gambit', () => { 53 | // this is a testing input for the editor 54 | // We want a string in and false or matches out 55 | it('Should try string agaist gambit', (done) => { 56 | helpers.getBot().message('i like to build fires', (err, msg) => { 57 | helpers.getBot().chatSystem.Gambit.findOne({ input: 'I like to *' }, (e, g) => { 58 | helpers.getBot().getUser('user1', (err, user) => { 59 | const options = { user }; 60 | doesMatch(g, msg, options).then((r) => { 61 | should(r).exist; 62 | done(); 63 | }).catch(err => done(err)); 64 | }); 65 | }); 66 | }); 67 | }); 68 | 69 | it('update gambit test', (done) => { 70 | helpers.getBot().chatSystem.Gambit.create({ input: 'this is a create test' }, (er, gam) => { 71 | helpers.getBot().message('this is a create test', (err, msg) => { 72 | helpers.getBot().getUser('user1', (err, user) => { 73 | const options = { user }; 74 | doesMatch(gam, msg, options).then((r) => { 75 | should(r).exist; 76 | gam.input = 'this is a create *~2'; 77 | // Clear the normalized trigger created in the first step. 78 | gam.trigger = ''; 79 | gam.save(() => { 80 | helpers.getBot().message('this is a create hello world', (err, msg) => { 81 | doesMatch(gam, msg, options).then((r) => { 82 | should(r[1]).eql('hello world'); 83 | done(); 84 | }).catch(err => done(err)); 85 | }); 86 | }); 87 | }).catch(err => done(err)); 88 | }); 89 | }); 90 | }); 91 | }); 92 | }); 93 | 94 | 95 | // Test Entire topic for Match 96 | describe('Test Topic', () => { 97 | // this is a testing input for the editor 98 | // We want a string in and false or matches out 99 | it('Should try string agaist topic', (done) => { 100 | helpers.getBot().message('I like to play outside', (err, msg) => { 101 | helpers.getBot().getUser('user1', (err, user) => { 102 | const options = { user, chatSystem: helpers.getBot().chatSystem }; 103 | doesMatchTopic('outdoors', msg, options).then((r) => { 104 | should(r).not.be.empty; 105 | should(r[0].input).containEql('I like to play outside'); 106 | done(); 107 | }).catch(err => done(err)); 108 | }); 109 | }); 110 | }); 111 | }); 112 | 113 | describe('TopicDiscovery', () => { 114 | it('Should find the right topic', (done) => { 115 | helpers.getBot().reply('i like to hunt', (err, reply) => { 116 | should(reply.string).containEql('i like to spend time outdoors'); 117 | 118 | helpers.getBot().reply('i like to fish', (err, reply) => { 119 | should(reply.string).containEql('me too'); 120 | done(); 121 | }); 122 | }); 123 | }); 124 | }); 125 | 126 | 127 | describe('Topic Filter Functions', () => { 128 | // Now lets see it it works, we call it twice and it should be filtered both times. 129 | it('Should filter topic', (done) => { 130 | helpers.getBot().reply('filter topic test', (err, reply) => { 131 | should(reply.string).containEql('filter pass topic2'); 132 | helpers.getBot().reply('filter topic test', (err, reply) => { 133 | should(reply.string).containEql('filter pass topic2'); 134 | done(); 135 | }); 136 | }); 137 | }); 138 | }); 139 | 140 | describe.skip('log-debug', () => { 141 | it('Should show steps - redirect', (done) => { 142 | helpers.getBot().reply('user', 'generic redirect', (err, reply) => { 143 | should(reply.debug.matched_gambit[0].topic).containEql('random'); 144 | should(reply.debug.matched_gambit[0].subset[0].topic).containEql('test'); 145 | done(); 146 | }); 147 | }); 148 | 149 | it('Should show steps - respond', (done) => { 150 | helpers.getBot().reply('user', 'generic respond', (err, reply) => { 151 | should(reply.debug.matched_gambit[0].topic).containEql('random'); 152 | should(reply.debug.matched_gambit[0].subset[0].topic).containEql('test'); 153 | done(); 154 | }); 155 | }); 156 | }); 157 | 158 | 159 | describe('gh-240', () => { 160 | it('should stop with topicRedirect', (done) => { 161 | helpers.getBot().reply('user', 'test empty', (err, reply) => { 162 | should(reply.string).containEql(''); 163 | done(); 164 | }); 165 | }); 166 | 167 | it('should stop with respond', (done) => { 168 | helpers.getBot().reply('user', 'test respond', (err, reply) => { 169 | should(reply.string).containEql(''); 170 | done(); 171 | }); 172 | }); 173 | }); 174 | 175 | after(helpers.after); 176 | }); 177 | -------------------------------------------------------------------------------- /test/unit/utils.js: -------------------------------------------------------------------------------- 1 | import mocha from 'mocha'; 2 | import should from 'should/as-function'; 3 | 4 | import utils from '../../src/bot/utils'; 5 | 6 | describe('Util Helpers', () => { 7 | it('should escape mustaches', () => { 8 | should(utils.quotemeta('hello{world}', true)).equal('hello\\{world\\}'); 9 | should(utils.quotemeta('hello{world}', false)).equal('hello\\{world\\}'); 10 | }); 11 | 12 | it('should only escape pipes when not in commands mode', () => { 13 | should(utils.quotemeta('hello|world', true)).equal('hello|world'); 14 | should(utils.quotemeta('hello|world', false)).equal('hello\\|world'); 15 | }); 16 | 17 | it('should trim space from string', () => { 18 | should(utils.trim(' hello \t\tworld ')).equal('hello world'); 19 | }); 20 | 21 | it('should preserve newlines in strings', () => { 22 | should(utils.trim(' hello \n world ')).equal('hello \n world'); 23 | }); 24 | 25 | it('should count words', () => { 26 | should(utils.wordCount('hello_world#this is a very*odd*string')).equal(8); 27 | }); 28 | 29 | it('should replace captured text', () => { 30 | const parts = ['hello ', '', 'how are you today', ', meet ']; 31 | const stars = ['', 'Dave', 'feeling', 'Sally']; 32 | const replaced = utils.replaceCapturedText(parts, stars); 33 | should(replaced.length).equal(3); 34 | should(replaced[0]).equal('hello Dave'); 35 | should(replaced[1]).equal('how are you feeling today'); 36 | should(replaced[2]).equal('Dave, meet Sally'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /test/unit/wordnet.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | 6 | import wordnet from '../../src/bot/reply/wordnet'; 7 | 8 | describe('Wordnet Interface', () => { 9 | it('should have have lookup and explore function', (done) => { 10 | should(wordnet.lookup).be.a.Function(); 11 | should(wordnet.explore).be.a.Function(); 12 | done(); 13 | }); 14 | 15 | it('should perform lookup correctly', async () => { 16 | const results = await wordnet.lookup('like', '@'); 17 | should(results).have.length(3); 18 | }); 19 | 20 | it('should perform lookup correctly', async () => { 21 | const results = await wordnet.lookup('like~v', '@'); 22 | should(results).have.length(2); 23 | }); 24 | 25 | it('should refine to POS', async () => { 26 | const results = await wordnet.lookup('milk', '~'); 27 | should(results).have.length(25); 28 | }); 29 | 30 | it('should explore a concept', async () => { 31 | const results = wordnet.explore('job'); 32 | console.log(results); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/user.js: -------------------------------------------------------------------------------- 1 | /* global describe, it, before, after */ 2 | 3 | import mocha from 'mocha'; 4 | import should from 'should/as-function'; 5 | import async from 'async'; 6 | import helpers from './helpers'; 7 | 8 | describe('SuperScript User Persist', () => { 9 | before(helpers.before('user')); 10 | 11 | describe('Get a list of users', () => { 12 | it('should return all users', (done) => { 13 | helpers.getBot().reply('userx', 'hello world', (err, reply) => { 14 | helpers.getBot().getUsers((err, list) => { 15 | should(list).not.be.empty; 16 | should(list[0].id).eql('userx'); 17 | done(); 18 | }); 19 | }); 20 | }); 21 | }); 22 | 23 | describe('Should save users session', () => { 24 | it('should save user session', (done) => { 25 | helpers.getBot().reply('iuser3', 'Save user token ABCD.', (err, reply) => { 26 | should(reply.string).eql('User token ABCD has been saved.'); 27 | done(); 28 | }); 29 | }); 30 | 31 | it('it remember my name', (done) => { 32 | // Call startup again (same as before hook) 33 | helpers.getBot().reply('iuser3', 'Get user token', (err, reply) => { 34 | should(reply.string).eql('Return ABCD'); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | 40 | 41 | describe("Don't leak the user", () => { 42 | const list = ['userA', 'userB']; 43 | 44 | it('ask user A', (done) => { 45 | const itor = function (user, next) { 46 | helpers.getBot().reply(user, 'this is a test', (err, reply) => { 47 | should(reply.string).eql(`this is user ${user}`); 48 | next(); 49 | }); 50 | }; 51 | async.each(list, itor, () => { 52 | done(); 53 | }); 54 | }); 55 | }); 56 | 57 | after(helpers.after); 58 | }); 59 | --------------------------------------------------------------------------------